mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
## Why PR #6660 introduced four PostgreSQL-backed persistence tests and a focused CI selector, but an exact test-name list does not automatically cover future database tests. Broad ignored-test execution also exposed shared-schema races and ambiguity between desired-state and migration-applied schema expectations. ## What - Establish a discoverable convention: PostgreSQL unit modules use `postgres_tests`, PostgreSQL integration binaries use a `postgres_` prefix, and unrelated external-infrastructure tests use an `external_infra_` prefix. - Add a dedicated nextest PostgreSQL profile and archive-backed CI job covering the relevant crates without enumerating test names. - Create a run-scoped desired-state source database and a unique PostgreSQL database per test process for parallel-safe isolation, with cleanup on success, failure, or interruption. - Route destructive migration and migration-parity tests to clean `template0` databases while desired-state tests clone the desired-state database. - Document discovery, schema modes, required role privileges, and a bounded portable local runner. - Keep the existing infrastructure-free unit-test jobs unchanged. This is intentionally limited to test and CI harness behavior. It does not move production database code, change persistence semantics, or implement issue #20's broader shared test-utility refactor. ## Risk Low production risk because all changes are confined to tests, CI configuration, documentation, and test harness scripts. Remaining operational risks: - Cleanup retries `dropdb --force` five times across roughly four seconds. Exhaustion warns with the database name but deliberately does not mask the test result; individual PostgreSQL diagnostics are suppressed. - The seven-package boundary is duplicated between the runner and CI archive and must remain synchronized if PostgreSQL tests move to a new crate. - Portable SHA fallback branches were exercised on Linux; no macOS Blox workstation was needed for this Linux CI artifact. - GitHub's unchanged generic Unit Tests job currently fails while cold-linking `buzz-voice` because `sherpa-onnx-c-api` is absent. The branch changes no voice/build/toolchain inputs; the exact parent previously passed that job, and Blox passes the same infrastructure-free suite (1,450/1,450). A prior-head one-job retry and the final-head run both reproduced the hosted-runner failure. ## Verification Author Blox workstation `2020088`: - `cargo fmt --all -- --check` - `just clippy` - `just test-unit`: 1,450 passed; PostgreSQL tests remain skipped in fast jobs - PR #6660 focused tests: 4/4 passed through the final runner - Full lane: 282/282 passed in 10.087s with a non-superuser role limited to `CREATEDB`, `CREATEROLE`, and `pg_read_all_stats` - 272 desired-state tests - 10 migration-applied tests - 6 explicitly filtered external-infrastructure tests - Cleanup fault injection: a deliberately underprivileged 282-test run produced the expected 277 passes and 5 failures, and the post-run catalog audit found zero lane databases after retry cleanup. The restored successful run also left zero lane databases. GitHub exact final head `92c231e48f299ea2af23817763f5ae80ac013d68`: - [PostgreSQL Tests](https://github.com/block/buzz/actions/runs/32789030203/job/97630827823): 282/282 passed across 10 binaries in 98.886s; 1,240 skipped, including 6 via the profile filter - The shared relay/PostgreSQL archive predecessor completed successfully - [Unit Tests](https://github.com/block/buzz/actions/runs/32789030203/job/97626930420): unrelated `sherpa-onnx-c-api` native-link failure described above - Independent exact-head review on separate Blox workstation `2022762`: no substantive findings after all initial findings were addressed. The reviewer independently verified cleanup retry behavior, exit-status preservation, shell portability, syntax, and a clean exact-head worktree. ## References - Stacked on #6660 at exact head `561de54be4d9c2b622b7c2aa5b61bc3068f47e2a` - https://github.com/TheSentinel454/buzz/issues/20 - https://github.com/TheSentinel454/buzz/issues/20#issuecomment-5398526792 Generated with Codex. ## Update — August 24, 2026 review follow-up - Centralized the repeated PostgreSQL test URL resolution in crate-local, test-only helpers without introducing the broader shared utility refactor from issue #20. - Restored descriptive hybrid/Redis test function names. Structural `external_infra_*_tests` modules now own exclusion, and the nextest filter only recognizes module path segments. - Added a three-second source guard that scans every Rust file and fails CI when an ignored PostgreSQL test would be omitted or an external-infrastructure test would be included. Fixture tests cover accepted modules/binaries and both failure modes. - Removed the two implementation-plan documents. Performance profiling on Blox workstation `2027352` (same prebuilt archive and cargo-nextest 0.9.143 for every comparison): - Current per-test database model, 8 workers: 10.15–10.40s across three runs. The 283 `createdb` calls and 284 `dropdb` calls consumed 28.85–29.46 aggregate seconds, about 39–40% of aggregate test-process duration. - Reusing one database per worker without cleanup: 6.55–6.84s, about 35% faster, but all three runs failed because global matcher-queue tests inherited a quiesced community from an earlier test; the failing test varied with scheduling. - Reusing workers with `TRUNCATE … RESTART IDENTITY CASCADE`: all 282 tests passed, but total time regressed to 10.47–10.67s. The 265 truncate calls consumed 24.34–25.45 aggregate seconds, so truncation merely replaced most clone/drop cost. - Raising concurrency to 16 workers reduced one isolated-database run to 8.79s, but 24 workers exposed a cluster-global `pg_stat_activity` race. The profile remains at the proven-safe 8 workers. Decision: retain unique per-test databases. Harness cleanup uses `dropdb --if-exists --force`; it drops the database rather than truncating tables or deleting rows. Verification at local branch head `60086c1f6ee34a193c342255a5e8e6293b60e988`: - Discovery inventory: exactly 282 intended tests; all 6 hybrid/Redis tests present and structurally excluded. - PR #6660 focused persistence tests: 4/4 passed. - Full PostgreSQL lane: 282/282 passed three times; 1,240 skipped each run; wall time 9.72–9.96s without profiling shims. - Non-ignored tests with a desired-state database: 1,234/1,234 passed; one mesh-demo timeout from the first run passed immediately in isolation. - `cargo fmt --all -- --check` and all-target/all-feature clippy for `buzz-db`, `buzz-deletion`, and `buzz-relay` passed. Final independent re-review at exact head `42097c0136ab3fa8f0efe5720ae829c04575b9b6` on separate Blox workstation `2028455`: no substantive residual findings. The reviewer independently exercised ordinary strings, zero/one/three-hash raw strings, line/block comments, and string-contained lookalikes, then reran the full 353-file scan, shell syntax, Python AST parsing, and diff check. Independent review found that the source guard's initial regular expression could miss raw-string ignore reasons and treat a commented attribute as real. Final head `42097c0136ab3fa8f0efe5720ae829c04575b9b6` parses valid ordinary/raw Rust string literals only at attributes found in comment-sanitized source; regression fixtures cover both cases. The fixture suite, Python compilation, shell syntax, diff check, and full 353-file repository scan pass on Blox. ## Update — August 25, 2026 restack PR #6660 merged, so this follow-up was rebased from its former exact parent onto current `origin/main` (`8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8`). The final candidate head is `05dcc2ab28948e3ab79bb44839a69f2ba44648a2`; the PR no longer carries #6660's pre-squash history. Fresh exact-head verification on Blox workstation `2028572`: - Discovery guard passed across 353 Rust files; inventory remained exactly 282 intended PostgreSQL tests. - PR #6660 focused persistence tests: 4/4 passed. - Full PostgreSQL lane: 282/282 passed three times; 1,243 skipped each run; wall time 9.70–9.87s. - `cargo fmt --all -- --check` and all-target/all-feature clippy for `buzz-db`, `buzz-deletion`, and `buzz-relay` passed. ## Update — August 25, 2026 final hosted verification - [GitHub PostgreSQL Tests](https://github.com/block/buzz/actions/runs/32801679226/job/97667835080): 282/282 passed in 53.787s; 1,243 skipped. - The final guard scanned all 353 Rust files successfully before the hosted lane ran. - The shared relay/archive prerequisite and infrastructure-free Unit Tests job both passed at exact final head `42097c0136ab3fa8f0efe5720ae829c04575b9b6`. --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: tornquist <tornquist@squareup.com>
1318 lines
46 KiB
Rust
1318 lines
46 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 bridge::VerifiedBridgeAuth {
|
|
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 postgres_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 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 = crate::test_support::database_url();
|
|
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(&crate::test_support::database_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);
|
|
}
|
|
|
|
/// Regression for the RELAY_OPERATOR_API_ORIGIN decoupling: with the
|
|
/// operator allowlist set but no origin configured (the shape an
|
|
/// admin-console-only operator boots in), the provisioning endpoints must
|
|
/// fail closed with a clean 500 — never a panic, and never a silent
|
|
/// success. This exercises the request-time guard that replaced the boot
|
|
/// hard-error. It uses a lazy pool and needs no Postgres, because the
|
|
/// origin check in `authorize_operator_request` runs before any DB access.
|
|
#[tokio::test]
|
|
async fn provisioning_fails_closed_when_origin_unset_but_pubkeys_set() {
|
|
let operator = Keys::generate();
|
|
|
|
let mut config = crate::config::Config::from_env().expect("default config loads");
|
|
config.require_relay_membership = false;
|
|
config.redis_url = "redis://127.0.0.1:1".to_string();
|
|
config.relay_operator_pubkeys = vec![operator.public_key().to_hex()];
|
|
config.relay_operator_api_origin = None;
|
|
|
|
let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool");
|
|
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))
|
|
.expect("redis pool");
|
|
let pubsub = Arc::new(
|
|
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
|
|
.await
|
|
.expect("pubsub manager"),
|
|
);
|
|
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).expect("media storage");
|
|
let (state, _audit_shutdown) = AppState::new(
|
|
config,
|
|
db,
|
|
redis_pool,
|
|
audit,
|
|
pubsub,
|
|
auth,
|
|
search,
|
|
workflow_engine,
|
|
Keys::generate(),
|
|
media_storage,
|
|
);
|
|
let state = Arc::new(state);
|
|
|
|
let response =
|
|
provision_community(state, &operator, "acme.example", &Keys::generate()).await;
|
|
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"provisioning must reject fail-closed when the operator API origin is unset"
|
|
);
|
|
let body = read_json(response).await;
|
|
assert_eq!(body["error"], "internal server error");
|
|
}
|
|
}
|