mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
1253 lines
44 KiB
Rust
1253 lines
44 KiB
Rust
//! Deployment-operator HTTP APIs.
|
|
//!
|
|
//! These routes are outside the Nostr event data plane. They still use NIP-98
|
|
//! request signing and replay protection, but they do not run through event
|
|
//! ingest, relay membership, channel scoping, storage, or fan-out.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
extract::{Query, RawQuery, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::Json,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use uuid::Uuid;
|
|
|
|
use buzz_core::{CommunityId, TenantContext};
|
|
|
|
use crate::handlers::community_provisioning::{
|
|
normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest,
|
|
};
|
|
use crate::state::AppState;
|
|
|
|
use super::{api_error, bridge, internal_error};
|
|
|
|
/// Query parameters for `GET /operator/communities`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListCommunitiesQuery {
|
|
owner_pubkey: String,
|
|
}
|
|
|
|
/// Query parameters for `GET /operator/communities/availability`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CommunityAvailabilityQuery {
|
|
host: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TransferCommunityRequest {
|
|
community_id: String,
|
|
new_owner_pubkey: String,
|
|
expected_owner_pubkey: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct TransferCommunityResponse {
|
|
community_id: String,
|
|
new_owner_pubkey: String,
|
|
status: &'static str,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
previous_owner: Option<String>,
|
|
}
|
|
|
|
const OPERATOR_REPLAY_SCOPE: &str = "operator-management";
|
|
|
|
/// Shared deployment-global operator auth prelude. The canonical management
|
|
/// origin and replay namespace are configuration, never tenant registry state
|
|
/// or an inbound proxy `Host` header.
|
|
async fn authorize_operator_request(
|
|
state: &Arc<AppState>,
|
|
headers: &HeaderMap,
|
|
method: &str,
|
|
path: &str,
|
|
raw_query: Option<&str>,
|
|
body: Option<&[u8]>,
|
|
) -> Result<nostr::PublicKey, (StatusCode, Json<Value>)> {
|
|
let origin = state
|
|
.config
|
|
.relay_operator_api_origin
|
|
.as_deref()
|
|
.ok_or_else(|| internal_error("operator API origin is not configured"))?;
|
|
let path_with_query = match raw_query {
|
|
Some(q) if !q.is_empty() => format!("{path}?{q}"),
|
|
_ => path.to_string(),
|
|
};
|
|
let url = format!("{origin}{path_with_query}");
|
|
let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options(
|
|
headers,
|
|
method,
|
|
&url,
|
|
body,
|
|
true, // operator endpoints always require NIP-98; no X-Pubkey dev fallback
|
|
body.is_some(),
|
|
)?;
|
|
check_operator_replay(state, event_id_bytes).await?;
|
|
|
|
let pubkey_hex = pubkey.to_hex();
|
|
if !state
|
|
.config
|
|
.relay_operator_pubkeys
|
|
.iter()
|
|
.any(|pk| pk == &pubkey_hex)
|
|
{
|
|
return Err(api_error(
|
|
StatusCode::FORBIDDEN,
|
|
"actor not authorized: not a relay operator",
|
|
));
|
|
}
|
|
|
|
Ok(pubkey)
|
|
}
|
|
|
|
async fn check_operator_replay(
|
|
state: &AppState,
|
|
event_id_bytes: [u8; 32],
|
|
) -> Result<(), (StatusCode, Json<Value>)> {
|
|
let event_id = nostr::EventId::from_byte_array(event_id_bytes);
|
|
match state
|
|
.nip98_replay
|
|
.try_mark_in_scope(
|
|
OPERATOR_REPLAY_SCOPE,
|
|
&event_id,
|
|
buzz_auth::DEFAULT_REPLAY_TTL_SECS,
|
|
)
|
|
.await
|
|
{
|
|
Ok(true) => Ok(()),
|
|
Ok(false) => Err(api_error(
|
|
StatusCode::UNAUTHORIZED,
|
|
"NIP-98: replay detected",
|
|
)),
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
scope = OPERATOR_REPLAY_SCOPE,
|
|
error = %error,
|
|
"operator NIP-98 replay guard failed; rejecting request fail-closed"
|
|
);
|
|
Err(api_error(
|
|
StatusCode::UNAUTHORIZED,
|
|
"NIP-98: replay check unavailable",
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a community host and atomically bootstrap its initial owner.
|
|
///
|
|
/// `POST /operator/communities`, NIP-98 signed by a pubkey in
|
|
/// `RELAY_OPERATOR_PUBKEYS`, body:
|
|
///
|
|
/// ```json
|
|
/// { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "<hex>" }
|
|
/// ```
|
|
///
|
|
/// The request is authenticated against `RELAY_OPERATOR_API_ORIGIN` and does
|
|
/// not bind the inbound host to a tenant. The operator allowlist is the
|
|
/// authority for this deployment-root control-plane surface.
|
|
pub async fn provision_community(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
body: axum::body::Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let pubkey = authorize_operator_request(
|
|
&state,
|
|
&headers,
|
|
"POST",
|
|
"/operator/communities",
|
|
None,
|
|
Some(&body),
|
|
)
|
|
.await?;
|
|
|
|
let request: ProvisionCommunityRequest = serde_json::from_slice(&body).map_err(|e| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("invalid provision-community JSON: {e}"),
|
|
)
|
|
})?;
|
|
|
|
match crate::handlers::community_provisioning::provision_community(&state, &pubkey, request)
|
|
.await
|
|
{
|
|
Ok(response) => Ok(Json(serde_json::to_value(response).map_err(|e| {
|
|
tracing::error!("failed to serialize provision-community response: {e}");
|
|
internal_error("operator provision response serialization failed")
|
|
})?)),
|
|
Err(msg) if msg.starts_with("actor not authorized") => {
|
|
Err(api_error(StatusCode::FORBIDDEN, &msg))
|
|
}
|
|
Err(msg) if msg == "community already exists" || msg.starts_with("limit_reached:") => {
|
|
Err(api_error(StatusCode::CONFLICT, &msg))
|
|
}
|
|
Err(msg)
|
|
if msg.starts_with("failed to create community:")
|
|
|| msg.starts_with("community provisioned but owner bootstrap failed:") =>
|
|
{
|
|
tracing::error!(error = %msg, "operator community persistence failed");
|
|
Err(internal_error("operator community persistence failed"))
|
|
}
|
|
Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)),
|
|
}
|
|
}
|
|
|
|
/// Owner assertion supplied by the trusted operator client.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ArchiveCommunityRequest {
|
|
host: String,
|
|
owner_pubkey: String,
|
|
}
|
|
|
|
/// Idempotently archive a community owned by the asserted end-user identity.
|
|
pub async fn archive_community(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
body: axum::body::Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
const PATH: &str = "/operator/communities/archive";
|
|
authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?;
|
|
let request: ArchiveCommunityRequest = serde_json::from_slice(&body).map_err(|e| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("invalid archive-community JSON: {e}"),
|
|
)
|
|
})?;
|
|
let normalized_host = normalize_candidate_host(&request.host)
|
|
.map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?;
|
|
let deployment_host = buzz_core::tenant::relay_url_authority(&state.config.relay_url);
|
|
if normalized_host == deployment_host {
|
|
return Err(api_error(
|
|
StatusCode::CONFLICT,
|
|
"the deployment community cannot be archived",
|
|
));
|
|
}
|
|
let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"invalid owner_pubkey: expected 64-char hex pubkey",
|
|
)
|
|
})?;
|
|
let record = state
|
|
.db
|
|
.archive_community_owned_by(&normalized_host, &owner, &deployment_host)
|
|
.await
|
|
.map_err(|e| internal_error(&format!("archive community: {e}")))?
|
|
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?;
|
|
let tenant = TenantContext::resolved(record.id, &record.host);
|
|
let closed = match state.disconnect_community_clusterwide(&tenant).await {
|
|
Ok(closed) => closed,
|
|
Err(error) => {
|
|
tracing::warn!(community = %record.id, host = %record.host, %error, "community archived but disconnect propagation is pending");
|
|
return Err((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
Json(serde_json::json!({
|
|
"community_id": record.id.to_string(),
|
|
"host": record.host,
|
|
"archived_at": record.archived_at,
|
|
"status": "archived",
|
|
"propagation": "pending",
|
|
"error": "connection propagation pending — retry this request",
|
|
})),
|
|
));
|
|
}
|
|
};
|
|
tracing::info!(community = %record.id, host = %record.host, local_connections_closed = closed, "community archived");
|
|
Ok(Json(serde_json::json!({
|
|
"community_id": record.id.to_string(),
|
|
"host": record.host,
|
|
"archived_at": record.archived_at,
|
|
"status": "archived",
|
|
})))
|
|
}
|
|
|
|
/// Idempotently restore an archived community owned by the asserted end-user identity.
|
|
pub async fn unarchive_community(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
body: axum::body::Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
const PATH: &str = "/operator/communities/unarchive";
|
|
authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?;
|
|
let request: ArchiveCommunityRequest = serde_json::from_slice(&body).map_err(|e| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("invalid unarchive-community JSON: {e}"),
|
|
)
|
|
})?;
|
|
let normalized_host = normalize_candidate_host(&request.host)
|
|
.map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?;
|
|
let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"invalid owner_pubkey: expected 64-char hex pubkey",
|
|
)
|
|
})?;
|
|
let record = state
|
|
.db
|
|
.unarchive_community_owned_by(&normalized_host, &owner)
|
|
.await
|
|
.map_err(|e| internal_error(&format!("unarchive community: {e}")))?
|
|
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?;
|
|
tracing::info!(community = %record.id, host = %record.host, "community unarchived");
|
|
Ok(Json(serde_json::json!({
|
|
"community_id": record.id.to_string(),
|
|
"host": record.host,
|
|
"archived_at": null,
|
|
"status": "active",
|
|
})))
|
|
}
|
|
|
|
/// List communities where a pubkey currently holds the `owner` role.
|
|
pub async fn list_owned_communities(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
RawQuery(raw_query): RawQuery,
|
|
Query(query): Query<ListCommunitiesQuery>,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
authorize_operator_request(
|
|
&state,
|
|
&headers,
|
|
"GET",
|
|
"/operator/communities",
|
|
raw_query.as_deref(),
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
let owner_pubkey = validate_pubkey_hex(&query.owner_pubkey).ok_or_else(|| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"invalid owner_pubkey: expected 64-char hex pubkey",
|
|
)
|
|
})?;
|
|
|
|
let rows = state
|
|
.db
|
|
.list_communities_owned_by(&owner_pubkey)
|
|
.await
|
|
.map_err(|e| internal_error(&format!("list owned communities: {e}")))?;
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"owner_pubkey": owner_pubkey,
|
|
"communities": rows.into_iter().map(|row| serde_json::json!({
|
|
"community_id": row.id.to_string(),
|
|
"host": row.host,
|
|
"created_at": row.created_at,
|
|
"archived_at": row.archived_at,
|
|
})).collect::<Vec<_>>(),
|
|
})))
|
|
}
|
|
|
|
/// Transfer ownership of a community to a new owner pubkey.
|
|
///
|
|
/// `POST /operator/communities/transfer`, NIP-98 signed by a pubkey in
|
|
/// `RELAY_OPERATOR_PUBKEYS`, body:
|
|
///
|
|
/// ```json
|
|
/// { "community_id": "<uuid>", "new_owner_pubkey": "<hex>" }
|
|
/// ```
|
|
///
|
|
/// The previous owner is demoted to `member` (not `admin`). The transfer is
|
|
/// instant and atomic at the database layer. Publication of the updated
|
|
/// NIP-43 membership list is best-effort, matching the provision path.
|
|
pub async fn transfer_community(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
body: axum::body::Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let _pubkey = authorize_operator_request(
|
|
&state,
|
|
&headers,
|
|
"POST",
|
|
"/operator/communities/transfer",
|
|
None,
|
|
Some(&body),
|
|
)
|
|
.await?;
|
|
|
|
let request: TransferCommunityRequest = serde_json::from_slice(&body).map_err(|e| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("invalid transfer-community JSON: {e}"),
|
|
)
|
|
})?;
|
|
|
|
let community_uuid = Uuid::parse_str(&request.community_id).map_err(|e| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("invalid community_id: {e}"),
|
|
)
|
|
})?;
|
|
|
|
let new_owner_pubkey = validate_pubkey_hex(&request.new_owner_pubkey).ok_or_else(|| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"invalid new_owner_pubkey: expected 64-char hex pubkey",
|
|
)
|
|
})?;
|
|
|
|
let expected_owner_pubkey =
|
|
validate_pubkey_hex(&request.expected_owner_pubkey).ok_or_else(|| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"invalid expected_owner_pubkey: expected 64-char hex pubkey",
|
|
)
|
|
})?;
|
|
|
|
let community = CommunityId::from_uuid(community_uuid);
|
|
|
|
let result = state
|
|
.db
|
|
.transfer_ownership(community, &new_owner_pubkey, &expected_owner_pubkey)
|
|
.await
|
|
.map_err(|e| internal_error(&format!("transfer ownership: {e}")))?;
|
|
|
|
let (status, previous_owner) = match result {
|
|
buzz_db::relay_members::TransferResult::Transferred { previous_owner } => {
|
|
("transferred", previous_owner)
|
|
}
|
|
buzz_db::relay_members::TransferResult::AlreadyOwner => ("already_owner", None),
|
|
buzz_db::relay_members::TransferResult::NoOwner => {
|
|
return Err(api_error(
|
|
StatusCode::NOT_FOUND,
|
|
"community has no owner to transfer from",
|
|
));
|
|
}
|
|
buzz_db::relay_members::TransferResult::OwnerConflict => {
|
|
return Err(api_error(
|
|
StatusCode::CONFLICT,
|
|
"owner_conflict: the current owner no longer matches expected_owner_pubkey",
|
|
));
|
|
}
|
|
buzz_db::relay_members::TransferResult::LimitReached => {
|
|
return Err(api_error(
|
|
StatusCode::CONFLICT,
|
|
"limit_reached: transferee already owns the maximum number of communities",
|
|
));
|
|
}
|
|
};
|
|
|
|
// Best-effort NIP-43 membership snapshot publication — same pattern as
|
|
// provision_community. The DB mutation is already committed; a publication
|
|
// failure must not turn a success into an HTTP error.
|
|
if state.config.require_relay_membership {
|
|
if let Some(host) = state
|
|
.db
|
|
.lookup_community_host(community)
|
|
.await
|
|
.map_err(|e| internal_error(&format!("lookup community host: {e}")))?
|
|
{
|
|
let tenant = TenantContext::resolved(community, host);
|
|
if let Err(error) =
|
|
crate::handlers::side_effects::publish_nip43_membership_list(&tenant, &state).await
|
|
{
|
|
tracing::warn!(
|
|
community = %community,
|
|
error = %error,
|
|
"ownership transferred but NIP-43 membership snapshot publication failed"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let response = TransferCommunityResponse {
|
|
community_id: request.community_id,
|
|
new_owner_pubkey,
|
|
status,
|
|
previous_owner,
|
|
};
|
|
|
|
Ok(Json(serde_json::to_value(response).map_err(|e| {
|
|
tracing::error!("failed to serialize transfer-community response: {e}");
|
|
internal_error("operator transfer response serialization failed")
|
|
})?))
|
|
}
|
|
/// Check whether a community host is available, returning the relay-canonical
|
|
/// normalized authority used by create.
|
|
pub async fn community_availability(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
RawQuery(raw_query): RawQuery,
|
|
Query(query): Query<CommunityAvailabilityQuery>,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
authorize_operator_request(
|
|
&state,
|
|
&headers,
|
|
"GET",
|
|
"/operator/communities/availability",
|
|
raw_query.as_deref(),
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
let normalized_host = normalize_candidate_host(&query.host)
|
|
.map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?;
|
|
let existing = state
|
|
.db
|
|
.lookup_community_by_host_for_management(&normalized_host)
|
|
.await
|
|
.map_err(|e| internal_error(&format!("check community availability: {e}")))?;
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"host": query.host,
|
|
"normalized_host": normalized_host,
|
|
"available": existing.is_none(),
|
|
"community_id": existing.map(|record| record.id.to_string()),
|
|
})))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
body::{to_bytes, Body},
|
|
http::{header, Request, StatusCode},
|
|
};
|
|
use base64::Engine;
|
|
use nostr::{EventBuilder, Keys, Kind, Tag};
|
|
use serde_json::Value;
|
|
use sha2::{Digest, Sha256};
|
|
use tower::ServiceExt;
|
|
use uuid::Uuid;
|
|
|
|
use buzz_core::{kind::KIND_NIP43_MEMBERSHIP_LIST, CommunityId};
|
|
use buzz_db::event::EventQuery;
|
|
|
|
use crate::router::build_router;
|
|
use crate::state::AppState;
|
|
|
|
struct AlwaysFreshReplayGuard;
|
|
|
|
impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard {
|
|
fn try_mark_in_scope<'a>(
|
|
&'a self,
|
|
_scope: &'a str,
|
|
_event_id: &'a nostr::EventId,
|
|
_ttl_secs: u64,
|
|
) -> std::pin::Pin<
|
|
Box<dyn std::future::Future<Output = Result<bool, buzz_auth::AuthError>> + Send + 'a>,
|
|
> {
|
|
Box::pin(async { Ok(true) })
|
|
}
|
|
}
|
|
|
|
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
|
|
const INGRESS_HOST: &str = "operator-ingress.example";
|
|
|
|
fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String {
|
|
let mut tags = vec![
|
|
Tag::parse(["u", url]).expect("u tag"),
|
|
Tag::parse(["method", method]).expect("method tag"),
|
|
];
|
|
if let Some(body) = body {
|
|
let hash: [u8; 32] = Sha256::digest(body).into();
|
|
let hash_hex = hex::encode(hash);
|
|
tags.push(Tag::parse(["payload", hash_hex.as_str()]).expect("payload tag"));
|
|
}
|
|
let event = EventBuilder::new(Kind::HttpAuth, "")
|
|
.tags(tags)
|
|
.sign_with_keys(keys)
|
|
.expect("sign NIP-98 event");
|
|
let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event");
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes());
|
|
format!("Nostr {encoded}")
|
|
}
|
|
|
|
fn nip98_auth_header_without_payload(keys: &Keys, url: &str, method: &str) -> String {
|
|
let tags = vec![
|
|
Tag::parse(["u", url]).expect("u tag"),
|
|
Tag::parse(["method", method]).expect("method tag"),
|
|
];
|
|
let event = EventBuilder::new(Kind::HttpAuth, "")
|
|
.tags(tags)
|
|
.sign_with_keys(keys)
|
|
.expect("sign NIP-98 event");
|
|
let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event");
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes());
|
|
format!("Nostr {encoded}")
|
|
}
|
|
|
|
async fn operator_test_state(operator_keys: &[Keys]) -> Option<Arc<AppState>> {
|
|
let mut config = crate::config::Config::from_env().ok()?;
|
|
config.database_url = TEST_DB_URL.to_string();
|
|
config.redis_url = "redis://127.0.0.1:1".to_string();
|
|
config.relay_url = "wss://tenant.example".to_string();
|
|
config.relay_operator_api_origin = Some(format!("http://{INGRESS_HOST}"));
|
|
config.relay_operator_pubkeys = operator_keys
|
|
.iter()
|
|
.map(|keys| keys.public_key().to_hex())
|
|
.collect();
|
|
config.require_relay_membership = true;
|
|
|
|
let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?;
|
|
let db = buzz_db::Db::from_pool(pool.clone());
|
|
|
|
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
|
|
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
|
.ok()?;
|
|
let pubsub = Arc::new(
|
|
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
|
|
.await
|
|
.ok()?,
|
|
);
|
|
let audit = buzz_audit::AuditService::new(pool.clone());
|
|
let auth = buzz_auth::AuthService::new(config.auth.clone());
|
|
let search = buzz_search::SearchService::new(pool.clone());
|
|
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
|
|
db.clone(),
|
|
buzz_workflow::WorkflowConfig::default(),
|
|
));
|
|
let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?;
|
|
let (mut state, _audit_shutdown) = AppState::new(
|
|
config,
|
|
db,
|
|
redis_pool,
|
|
audit,
|
|
pubsub,
|
|
auth,
|
|
search,
|
|
workflow_engine,
|
|
Keys::generate(),
|
|
media_storage,
|
|
);
|
|
state.nip98_replay = Arc::new(AlwaysFreshReplayGuard);
|
|
Some(Arc::new(state))
|
|
}
|
|
|
|
async fn read_json(response: axum::response::Response) -> Value {
|
|
let bytes = to_bytes(response.into_body(), 1024 * 1024)
|
|
.await
|
|
.expect("read response body");
|
|
serde_json::from_slice(&bytes).expect("response JSON")
|
|
}
|
|
|
|
async fn signed_operator_request(
|
|
state: Arc<AppState>,
|
|
keys: &Keys,
|
|
method: &str,
|
|
path: &str,
|
|
body: Option<String>,
|
|
) -> axum::response::Response {
|
|
let url = format!("http://{INGRESS_HOST}{path}");
|
|
let auth = nip98_auth_header(keys, &url, method, body.as_deref().map(str::as_bytes));
|
|
let mut request = Request::builder()
|
|
.method(method)
|
|
.uri(path)
|
|
.header(header::HOST, INGRESS_HOST)
|
|
.header(header::AUTHORIZATION, auth);
|
|
if body.is_some() {
|
|
request = request.header(header::CONTENT_TYPE, "application/json");
|
|
}
|
|
build_router(state)
|
|
.oneshot(
|
|
request
|
|
.body(body.map_or_else(Body::empty, Body::from))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response")
|
|
}
|
|
|
|
async fn provision_community(
|
|
state: Arc<AppState>,
|
|
operator: &Keys,
|
|
host: &str,
|
|
owner: &Keys,
|
|
) -> axum::response::Response {
|
|
let body = serde_json::json!({
|
|
"host": host,
|
|
"initial_owner_pubkey": owner.public_key().to_hex(),
|
|
"create_only": true,
|
|
})
|
|
.to_string();
|
|
signed_operator_request(state, operator, "POST", "/operator/communities", Some(body)).await
|
|
}
|
|
|
|
fn is_member_tag(tag: &Tag, pubkey: &str, role: &str) -> bool {
|
|
let values = tag.as_slice();
|
|
values.first().is_some_and(|value| value == "member")
|
|
&& values.get(1).is_some_and(|value| value == pubkey)
|
|
&& values.get(2).is_some_and(|value| value == role)
|
|
}
|
|
|
|
async fn assert_snapshot_roles(
|
|
state: &AppState,
|
|
community: CommunityId,
|
|
expected: &[(&str, &str)],
|
|
) {
|
|
let snapshot = state
|
|
.db
|
|
.query_events(&EventQuery {
|
|
kinds: Some(vec![KIND_NIP43_MEMBERSHIP_LIST as i32]),
|
|
global_only: true,
|
|
limit: Some(1),
|
|
..EventQuery::for_community(community)
|
|
})
|
|
.await
|
|
.expect("query membership snapshot")
|
|
.into_iter()
|
|
.next()
|
|
.expect("membership snapshot published");
|
|
for &(pubkey, role) in expected {
|
|
assert!(
|
|
snapshot
|
|
.event
|
|
.tags
|
|
.iter()
|
|
.any(|tag| is_member_tag(tag, pubkey, role)),
|
|
"missing {role} snapshot tag for {pubkey}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn non_allowlisted_operator_key_gets_403() {
|
|
let operator = Keys::generate();
|
|
let outsider = Keys::generate();
|
|
let Some(state) = operator_test_state(&[operator]).await else {
|
|
return;
|
|
};
|
|
let body = format!(
|
|
r#"{{"host":"community-{}.example"}}"#,
|
|
Uuid::new_v4().simple()
|
|
);
|
|
let url = format!("http://{INGRESS_HOST}/operator/communities");
|
|
let auth = nip98_auth_header(&outsider, &url, "POST", Some(body.as_bytes()));
|
|
|
|
let response = build_router(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/operator/communities")
|
|
.header(header::HOST, INGRESS_HOST)
|
|
.header(header::AUTHORIZATION, auth)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(body))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn post_operator_body_requires_payload_tag() {
|
|
let operator = Keys::generate();
|
|
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
|
|
return;
|
|
};
|
|
let body = format!(
|
|
r#"{{"host":"community-{}.example"}}"#,
|
|
Uuid::new_v4().simple()
|
|
);
|
|
let url = format!("http://{INGRESS_HOST}/operator/communities");
|
|
let auth = nip98_auth_header_without_payload(&operator, &url, "POST");
|
|
|
|
let response = build_router(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/operator/communities")
|
|
.header(header::HOST, INGRESS_HOST)
|
|
.header(header::AUTHORIZATION, auth)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(body))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
let json = read_json(response).await;
|
|
assert!(
|
|
json.get("error")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.contains("missing payload tag"),
|
|
"unexpected response: {json:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn unmapped_management_host_can_check_availability() {
|
|
let operator = Keys::generate();
|
|
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
|
|
return;
|
|
};
|
|
let host = format!("community-{}.example", Uuid::new_v4().simple());
|
|
let query = format!("host={host}");
|
|
let url = format!("http://{INGRESS_HOST}/operator/communities/availability?{query}");
|
|
let auth = nip98_auth_header(&operator, &url, "GET", None);
|
|
|
|
let response = build_router(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("/operator/communities/availability?{query}"))
|
|
.header(header::HOST, INGRESS_HOST)
|
|
.header(header::AUTHORIZATION, auth)
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let json = read_json(response).await;
|
|
assert_eq!(json.get("available").and_then(Value::as_bool), Some(true));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn unmapped_management_host_can_list_owned_communities() {
|
|
let operator = Keys::generate();
|
|
let owner = Keys::generate();
|
|
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
|
|
return;
|
|
};
|
|
let owner_hex = owner.public_key().to_hex();
|
|
let query = format!("owner_pubkey={owner_hex}");
|
|
let url = format!("http://{INGRESS_HOST}/operator/communities?{query}");
|
|
let auth = nip98_auth_header(&operator, &url, "GET", None);
|
|
|
|
let response = build_router(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("/operator/communities?{query}"))
|
|
.header(header::HOST, INGRESS_HOST)
|
|
.header(header::AUTHORIZATION, auth)
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let json = read_json(response).await;
|
|
assert_eq!(
|
|
json.get("owner_pubkey").and_then(Value::as_str),
|
|
Some(owner_hex.as_str())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn unarchive_restores_admission_and_is_idempotent_without_changing_ownership() {
|
|
let operator = Keys::generate();
|
|
let owner = Keys::generate();
|
|
let outsider = Keys::generate();
|
|
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
|
|
return;
|
|
};
|
|
let host = format!("community-{}.example", Uuid::new_v4().simple());
|
|
assert_eq!(
|
|
provision_community(Arc::clone(&state), &operator, &host, &owner)
|
|
.await
|
|
.status(),
|
|
StatusCode::OK
|
|
);
|
|
let owner_hex = owner.public_key().to_hex();
|
|
let archived = state
|
|
.db
|
|
.archive_community_owned_by(&host, &owner_hex, "protected.example")
|
|
.await
|
|
.expect("archive community")
|
|
.expect("owned community");
|
|
assert!(state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("archived admission lookup")
|
|
.is_none());
|
|
|
|
let request = |host: &str, owner_pubkey: String| {
|
|
serde_json::json!({
|
|
"host": host,
|
|
"owner_pubkey": owner_pubkey,
|
|
})
|
|
.to_string()
|
|
};
|
|
let wrong_owner = signed_operator_request(
|
|
Arc::clone(&state),
|
|
&operator,
|
|
"POST",
|
|
"/operator/communities/unarchive",
|
|
Some(request(&host, outsider.public_key().to_hex())),
|
|
)
|
|
.await;
|
|
assert_eq!(wrong_owner.status(), StatusCode::NOT_FOUND);
|
|
assert_eq!(read_json(wrong_owner).await["error"], "community not found");
|
|
let unknown = signed_operator_request(
|
|
Arc::clone(&state),
|
|
&operator,
|
|
"POST",
|
|
"/operator/communities/unarchive",
|
|
Some(request("missing.example", owner_hex.clone())),
|
|
)
|
|
.await;
|
|
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
|
|
assert_eq!(read_json(unknown).await["error"], "community not found");
|
|
|
|
for attempt in 0..2 {
|
|
let response = signed_operator_request(
|
|
Arc::clone(&state),
|
|
&operator,
|
|
"POST",
|
|
"/operator/communities/unarchive",
|
|
Some(request(&host, owner_hex.clone())),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK, "attempt {attempt}");
|
|
let json = read_json(response).await;
|
|
assert_eq!(json["community_id"], archived.id.to_string());
|
|
assert_eq!(json["host"], host);
|
|
assert!(json["archived_at"].is_null());
|
|
assert_eq!(json["status"], "active");
|
|
}
|
|
|
|
let active = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("restored admission lookup")
|
|
.expect("active community");
|
|
assert_eq!(active.id, archived.id);
|
|
let owner_member = state
|
|
.db
|
|
.get_relay_member(active.id, &owner_hex)
|
|
.await
|
|
.expect("owner lookup")
|
|
.expect("owner remains");
|
|
assert_eq!(owner_member.role, "owner");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn archive_publish_failure_is_retryable_and_preserves_timestamp() {
|
|
let operator = Keys::generate();
|
|
let owner = Keys::generate();
|
|
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
|
|
return;
|
|
};
|
|
let host = format!("community-{}.example", Uuid::new_v4().simple());
|
|
let owner_hex = owner.public_key().to_hex();
|
|
let create_body = serde_json::json!({
|
|
"host": host,
|
|
"initial_owner_pubkey": owner_hex,
|
|
"create_only": true,
|
|
})
|
|
.to_string();
|
|
let create_url = format!("http://{INGRESS_HOST}/operator/communities");
|
|
let create_auth =
|
|
nip98_auth_header(&operator, &create_url, "POST", Some(create_body.as_bytes()));
|
|
let create_response = build_router(Arc::clone(&state))
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/operator/communities")
|
|
.header(header::HOST, INGRESS_HOST)
|
|
.header(header::AUTHORIZATION, create_auth)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(create_body))
|
|
.expect("create request"),
|
|
)
|
|
.await
|
|
.expect("create response");
|
|
assert_eq!(create_response.status(), StatusCode::OK);
|
|
|
|
let archive_body = serde_json::json!({
|
|
"host": host,
|
|
"owner_pubkey": owner.public_key().to_hex(),
|
|
})
|
|
.to_string();
|
|
let archive_url = format!("http://{INGRESS_HOST}/operator/communities/archive");
|
|
let archive_once = |state: Arc<AppState>| {
|
|
let auth = nip98_auth_header(
|
|
&operator,
|
|
&archive_url,
|
|
"POST",
|
|
Some(archive_body.as_bytes()),
|
|
);
|
|
let body = archive_body.clone();
|
|
async move {
|
|
build_router(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/operator/communities/archive")
|
|
.header(header::HOST, INGRESS_HOST)
|
|
.header(header::AUTHORIZATION, auth)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(body))
|
|
.expect("archive request"),
|
|
)
|
|
.await
|
|
.expect("archive response")
|
|
}
|
|
};
|
|
|
|
let first = archive_once(Arc::clone(&state)).await;
|
|
assert_eq!(first.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let first_json = read_json(first).await;
|
|
assert_eq!(first_json["status"], "archived");
|
|
assert_eq!(first_json["propagation"], "pending");
|
|
let first_archived_at = first_json["archived_at"].clone();
|
|
assert!(!first_archived_at.is_null());
|
|
assert!(state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("active lookup")
|
|
.is_none());
|
|
|
|
assert_eq!(
|
|
state
|
|
.community_disconnect_publish_attempts
|
|
.load(std::sync::atomic::Ordering::Relaxed),
|
|
1
|
|
);
|
|
let second = archive_once(Arc::clone(&state)).await;
|
|
assert_eq!(second.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let second_json = read_json(second).await;
|
|
assert_eq!(second_json["archived_at"], first_archived_at);
|
|
assert_eq!(
|
|
state
|
|
.community_disconnect_publish_attempts
|
|
.load(std::sync::atomic::Ordering::Relaxed),
|
|
2,
|
|
"idempotent archive retry must republish the disconnect"
|
|
);
|
|
|
|
let owned = state
|
|
.db
|
|
.list_communities_owned_by(&owner.public_key().to_hex())
|
|
.await
|
|
.expect("owned communities");
|
|
let row = owned
|
|
.iter()
|
|
.find(|row| row.host == host)
|
|
.expect("archived row");
|
|
assert_eq!(
|
|
serde_json::to_value(row.archived_at).expect("timestamp JSON"),
|
|
first_archived_at
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn happy_path_create_returns_created_and_bootstraps_owner() {
|
|
let operator = Keys::generate();
|
|
let owner = Keys::generate();
|
|
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
|
|
return;
|
|
};
|
|
let host = format!("community-{}.example", Uuid::new_v4().simple());
|
|
let response = provision_community(state.clone(), &operator, &host, &owner).await;
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let json = read_json(response).await;
|
|
assert_eq!(json.get("status").and_then(Value::as_str), Some("created"));
|
|
assert_eq!(
|
|
json.get("host").and_then(Value::as_str),
|
|
Some(host.as_str())
|
|
);
|
|
let community = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("lookup community")
|
|
.expect("community exists");
|
|
let owner_hex = owner.public_key().to_hex();
|
|
let member = state
|
|
.db
|
|
.get_relay_member(community.id, &owner_hex)
|
|
.await
|
|
.expect("lookup owner role")
|
|
.expect("owner member exists");
|
|
assert_eq!(member.role, "owner");
|
|
|
|
assert_snapshot_roles(&state, community.id, &[(&owner_hex, "owner")]).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn fresh_host_at_owner_limit_returns_limit_reached_conflict() {
|
|
let operator = Keys::generate();
|
|
let owner = Keys::generate();
|
|
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
|
|
return;
|
|
};
|
|
|
|
for _ in 0..buzz_db::relay_members::MAX_COMMUNITIES_PER_OWNER {
|
|
let host = format!("community-{}.example", Uuid::new_v4().simple());
|
|
assert_eq!(
|
|
provision_community(state.clone(), &operator, &host, &owner)
|
|
.await
|
|
.status(),
|
|
StatusCode::OK
|
|
);
|
|
}
|
|
|
|
let host = format!("community-{}.example", Uuid::new_v4().simple());
|
|
let response = provision_community(state.clone(), &operator, &host, &owner).await;
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
let json = read_json(response).await;
|
|
assert!(json["error"]
|
|
.as_str()
|
|
.is_some_and(|error| error.starts_with("limit_reached:")));
|
|
assert!(state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("look up rejected fresh host")
|
|
.is_none());
|
|
}
|
|
|
|
/// Happy path: POST /operator/communities/transfer swaps ownership, demotes
|
|
/// the old owner to `member`, and publishes a NIP-43 snapshot reflecting the
|
|
/// new roles.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn happy_path_transfer_swaps_owner_and_demotes_old_to_member() {
|
|
let operator = Keys::generate();
|
|
let initial_owner = Keys::generate();
|
|
let new_owner = Keys::generate();
|
|
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
|
|
return;
|
|
};
|
|
|
|
let host = format!("community-{}.example", Uuid::new_v4().simple());
|
|
let create_response =
|
|
provision_community(state.clone(), &operator, &host, &initial_owner).await;
|
|
assert_eq!(create_response.status(), StatusCode::OK);
|
|
|
|
let community = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("lookup community")
|
|
.expect("community exists");
|
|
let community_id = community.id.to_string();
|
|
let initial_owner_hex = initial_owner.public_key().to_hex();
|
|
let new_owner_hex = new_owner.public_key().to_hex();
|
|
|
|
let transfer_body = serde_json::json!({
|
|
"community_id": community_id,
|
|
"new_owner_pubkey": new_owner_hex,
|
|
"expected_owner_pubkey": initial_owner_hex,
|
|
})
|
|
.to_string();
|
|
let response = signed_operator_request(
|
|
state.clone(),
|
|
&operator,
|
|
"POST",
|
|
"/operator/communities/transfer",
|
|
Some(transfer_body),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let json = read_json(response).await;
|
|
assert_eq!(
|
|
json.get("status").and_then(Value::as_str),
|
|
Some("transferred")
|
|
);
|
|
assert_eq!(
|
|
json.get("new_owner_pubkey").and_then(Value::as_str),
|
|
Some(new_owner_hex.as_str())
|
|
);
|
|
assert_eq!(
|
|
json.get("previous_owner").and_then(Value::as_str),
|
|
Some(initial_owner_hex.as_str())
|
|
);
|
|
|
|
// New owner is owner.
|
|
assert_eq!(
|
|
state
|
|
.db
|
|
.get_relay_member(community.id, &new_owner_hex)
|
|
.await
|
|
.expect("get new owner")
|
|
.expect("new owner exists")
|
|
.role,
|
|
"owner"
|
|
);
|
|
// Old owner is member (not admin).
|
|
assert_eq!(
|
|
state
|
|
.db
|
|
.get_relay_member(community.id, &initial_owner_hex)
|
|
.await
|
|
.expect("get old owner")
|
|
.expect("old owner exists")
|
|
.role,
|
|
"member"
|
|
);
|
|
|
|
assert_snapshot_roles(
|
|
&state,
|
|
community.id,
|
|
&[(&new_owner_hex, "owner"), (&initial_owner_hex, "member")],
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Transfer with an invalid community_id returns 400.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn transfer_with_invalid_community_id_returns_400() {
|
|
let operator = Keys::generate();
|
|
let new_owner = Keys::generate();
|
|
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
|
|
return;
|
|
};
|
|
let body = serde_json::json!({
|
|
"community_id": "not-a-uuid",
|
|
"new_owner_pubkey": new_owner.public_key().to_hex(),
|
|
"expected_owner_pubkey": new_owner.public_key().to_hex(),
|
|
})
|
|
.to_string();
|
|
let response = signed_operator_request(
|
|
state,
|
|
&operator,
|
|
"POST",
|
|
"/operator/communities/transfer",
|
|
Some(body),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
/// Transfer with an invalid new_owner_pubkey returns 400.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn transfer_with_invalid_pubkey_returns_400() {
|
|
let operator = Keys::generate();
|
|
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
|
|
return;
|
|
};
|
|
let body = serde_json::json!({
|
|
"community_id": Uuid::new_v4().to_string(),
|
|
"new_owner_pubkey": "not-a-pubkey",
|
|
"expected_owner_pubkey": "not-a-pubkey",
|
|
})
|
|
.to_string();
|
|
let response = signed_operator_request(
|
|
state,
|
|
&operator,
|
|
"POST",
|
|
"/operator/communities/transfer",
|
|
Some(body),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
}
|