9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
1790 lines
64 KiB
Rust
1790 lines
64 KiB
Rust
//! Relay invite HTTP API — mint and claim stateless invite codes.
|
|
//!
|
|
//! Routes (both NIP-98 signed, outside the Nostr event data plane):
|
|
//!
|
|
//! - `POST /api/invites` — mint an invite code. Caller must hold the `owner`
|
|
//! or `admin` role in the tenant community (mirrors the kind:9030 authz).
|
|
//! - `POST /api/invites/claim` — claim an invite code. Deliberately **exempt
|
|
//! from the relay-membership gate**: the whole point is that the caller is
|
|
//! not a member yet. NIP-98 proves control of the joining pubkey; the HMAC
|
|
//! on the code proves an admin authorized the join.
|
|
//!
|
|
//! Token format, key derivation, and security trade-offs live in
|
|
//! [`crate::invite_token`].
|
|
|
|
use std::sync::atomic::Ordering;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use axum::{
|
|
extract::State,
|
|
http::{HeaderMap, StatusCode},
|
|
response::{Html, Json},
|
|
};
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
|
|
use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list};
|
|
use buzz_core::invite::{
|
|
hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES,
|
|
MIN_INVITE_TTL_SECS, V2_PREFIX,
|
|
};
|
|
|
|
use crate::invite_token;
|
|
use crate::state::AppState;
|
|
|
|
use super::{api_error, bridge, internal_error};
|
|
|
|
/// Fixed-window size for the per-pubkey claim rate limiter.
|
|
pub(crate) const CLAIM_RATE_WINDOW: Duration = Duration::from_secs(60);
|
|
/// Max claim attempts per pubkey per window. Claims are idempotent and a real
|
|
/// user performs exactly one, so this only bounds brute-force probing.
|
|
const CLAIM_RATE_LIMIT: u32 = 10;
|
|
/// Maximum distinct pubkeys retained by the process-local claim limiter.
|
|
/// NIP-98 proves key ownership, not that a key is costly to create, so this
|
|
/// bound is required in addition to expiry.
|
|
pub(crate) const CLAIM_RATE_CACHE_CAPACITY: u64 = 10_000;
|
|
|
|
/// Body for `POST /api/invites`.
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub struct MintInviteRequest {
|
|
/// Requested lifetime in seconds. Must be between
|
|
/// [`MIN_INVITE_TTL_SECS`] and
|
|
/// [`invite_token::MAX_INVITE_TTL_SECS`]; defaults to 72 h.
|
|
#[serde(default)]
|
|
pub ttl_secs: Option<u64>,
|
|
/// Maximum number of uses before the invite is exhausted. `None` (omitted
|
|
/// or `null`) means unlimited — preserves current behavior. When present,
|
|
/// must be an integer from 1 through [`MAX_INVITE_USES`].
|
|
#[serde(default)]
|
|
pub max_uses: Option<i32>,
|
|
}
|
|
|
|
fn validate_mint_request(
|
|
request: &MintInviteRequest,
|
|
) -> Result<(u64, Option<i32>), (StatusCode, Json<Value>)> {
|
|
let ttl = request.ttl_secs.unwrap_or(DEFAULT_INVITE_TTL_SECS);
|
|
if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&ttl) {
|
|
return Err(api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!(
|
|
"ttl_secs must be between {} and {MAX_INVITE_TTL_SECS}",
|
|
MIN_INVITE_TTL_SECS
|
|
),
|
|
));
|
|
}
|
|
|
|
if let Some(max_uses) = request.max_uses {
|
|
if !(1..=MAX_INVITE_USES).contains(&max_uses) {
|
|
return Err(api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("max_uses must be between 1 and {MAX_INVITE_USES}"),
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok((ttl, request.max_uses))
|
|
}
|
|
|
|
/// Body for `POST /api/invites/claim`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ClaimInviteRequest {
|
|
/// The invite code to redeem.
|
|
pub code: String,
|
|
/// Relay-issued proof of accepting the configured terms, when required.
|
|
#[serde(default)]
|
|
pub policy_receipt: Option<String>,
|
|
}
|
|
|
|
/// Body for `POST /api/invites/accept-policy`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AcceptPolicyRequest {
|
|
/// Invite code the acceptance receipt will be bound to.
|
|
pub code: String,
|
|
/// Policy revision displayed by the client.
|
|
pub policy_version: String,
|
|
/// Minimum-age assertion, required only when configured by the operator.
|
|
#[serde(default)]
|
|
pub age_confirmed: bool,
|
|
}
|
|
|
|
/// Public join policy shared by every client-side join surface.
|
|
pub async fn join_policy(State(state): State<Arc<AppState>>) -> Json<Value> {
|
|
match &state.config.join_policy {
|
|
Some(policy) => Json(serde_json::json!({
|
|
"policy": {
|
|
"terms_markdown": policy.terms_markdown,
|
|
"privacy_markdown": policy.privacy_markdown,
|
|
"age_attestation_required": policy.age_attestation_required,
|
|
"version": policy.version
|
|
}
|
|
})),
|
|
None => Json(serde_json::json!({})),
|
|
}
|
|
}
|
|
|
|
/// `GET /api/join-policy/terms` — Terms of Service as a standalone HTML page.
|
|
///
|
|
/// Serves the operator-configured Markdown as a real browser page so desktop
|
|
/// clients can hand the link to the system browser instead of rendering the
|
|
/// document inside the webview (which requires app chrome the onboarding
|
|
/// surfaces don't have). 404 when no terms document is configured.
|
|
pub async fn join_policy_terms(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Result<Html<String>, (StatusCode, Json<Value>)> {
|
|
policy_document_page(&state, "Terms of Service", |policy| {
|
|
policy.terms_markdown.as_deref()
|
|
})
|
|
}
|
|
|
|
/// `GET /api/join-policy/privacy` — Privacy Policy as a standalone HTML page.
|
|
pub async fn join_policy_privacy(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Result<Html<String>, (StatusCode, Json<Value>)> {
|
|
policy_document_page(&state, "Privacy Policy", |policy| {
|
|
policy.privacy_markdown.as_deref()
|
|
})
|
|
}
|
|
|
|
fn policy_document_page(
|
|
state: &AppState,
|
|
title: &str,
|
|
select: impl Fn(&crate::config::JoinPolicyConfig) -> Option<&str>,
|
|
) -> Result<Html<String>, (StatusCode, Json<Value>)> {
|
|
let markdown = state
|
|
.config
|
|
.join_policy
|
|
.as_ref()
|
|
.and_then(select)
|
|
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "join_policy_not_configured"))?;
|
|
Ok(Html(render_policy_document(title, markdown)))
|
|
}
|
|
|
|
/// Render operator Markdown into a minimal self-contained HTML page.
|
|
///
|
|
/// Raw HTML embedded in the Markdown is escaped and rendered as text — the
|
|
/// operator authors a policy document, not a web page, and this keeps the
|
|
/// endpoint from serving arbitrary operator-controlled markup.
|
|
fn render_policy_document(title: &str, markdown: &str) -> String {
|
|
use pulldown_cmark::{html, Event, Parser};
|
|
|
|
let mut body = String::new();
|
|
html::push_html(
|
|
&mut body,
|
|
Parser::new(markdown).map(|event| match event {
|
|
Event::Html(raw) => Event::Text(raw.into_string().into()),
|
|
Event::InlineHtml(raw) => Event::Text(raw.into_string().into()),
|
|
other => other,
|
|
}),
|
|
);
|
|
|
|
// Titles are fixed literals today; escape anyway so a future caller
|
|
// can't accidentally inject markup through this seam.
|
|
let escaped_title = title
|
|
.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">");
|
|
|
|
format!(
|
|
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
|
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
|
|
<title>{escaped_title}</title>\n\
|
|
<style>body{{max-width:42rem;margin:2rem auto;padding:0 1rem;\
|
|
font-family:system-ui,sans-serif;line-height:1.6}}</style>\n\
|
|
</head>\n<body>\n{body}</body>\n</html>\n"
|
|
)
|
|
}
|
|
|
|
/// Exchange explicit policy acceptance for a short-lived, invite-bound receipt.
|
|
pub async fn accept_policy(
|
|
State(state): State<Arc<AppState>>,
|
|
body: axum::body::Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let Some(policy) = &state.config.join_policy else {
|
|
return Err(api_error(
|
|
StatusCode::NOT_FOUND,
|
|
"join_policy_not_configured",
|
|
));
|
|
};
|
|
let request: AcceptPolicyRequest = serde_json::from_slice(&body).map_err(|e| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("invalid policy acceptance JSON: {e}"),
|
|
)
|
|
})?;
|
|
if request.policy_version != policy.version
|
|
|| (policy.age_attestation_required && !request.age_confirmed)
|
|
{
|
|
return Err(api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"join_policy_not_accepted",
|
|
));
|
|
}
|
|
let key = invite_token::derive_invite_key(&state.relay_keypair);
|
|
let receipt = invite_token::mint_policy_acceptance(&key, &request.code, &policy.version);
|
|
Ok(Json(serde_json::json!({ "receipt": receipt })))
|
|
}
|
|
|
|
/// Shared prelude: bind the tenant from the Host header and verify the NIP-98
|
|
/// signature + replay for `path`.
|
|
async fn authenticate(
|
|
state: &Arc<AppState>,
|
|
headers: &HeaderMap,
|
|
path: &str,
|
|
body: &[u8],
|
|
) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json<Value>)> {
|
|
let raw_host = headers
|
|
.get(axum::http::header::HOST)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
let tenant = crate::tenant::bind_community(&state.db, raw_host)
|
|
.await
|
|
.map_err(|_| {
|
|
api_error(
|
|
StatusCode::NOT_FOUND,
|
|
"relay: no community is configured for this host",
|
|
)
|
|
})?;
|
|
|
|
let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path);
|
|
let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options(
|
|
headers,
|
|
"POST",
|
|
&url,
|
|
Some(body),
|
|
true, // invites always require NIP-98; no X-Pubkey dev fallback
|
|
true, // POST bodies must be covered by a payload tag
|
|
)?;
|
|
bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?;
|
|
|
|
Ok((tenant, pubkey))
|
|
}
|
|
|
|
/// Mint an invite code — `POST /api/invites`, NIP-98 signed by an owner/admin.
|
|
///
|
|
/// Returns the code, its expiry, and a shareable landing-page URL on the
|
|
/// tenant host.
|
|
pub async fn mint_invite(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
body: axum::body::Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites", &body).await?;
|
|
|
|
// Authz mirrors kind:9030 (add member): owner or admin only.
|
|
let sender_hex = pubkey.to_hex();
|
|
let member = state
|
|
.db
|
|
.get_relay_member(tenant.community(), &sender_hex)
|
|
.await
|
|
.map_err(|e| internal_error(&format!("invite mint role lookup: {e}")))?;
|
|
let role = member.map(|m| m.role).unwrap_or_default();
|
|
if role != "owner" && role != "admin" {
|
|
return Err(api_error(
|
|
StatusCode::FORBIDDEN,
|
|
"only relay owners and admins can create invites",
|
|
));
|
|
}
|
|
|
|
let request: MintInviteRequest = if body.is_empty() {
|
|
MintInviteRequest::default()
|
|
} else {
|
|
serde_json::from_slice(&body).map_err(|e| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("invalid invite JSON: {e}"),
|
|
)
|
|
})?
|
|
};
|
|
|
|
let (ttl, max_uses) = validate_mint_request(&request)?;
|
|
|
|
// Mint a v2 opaque, database-backed invite.
|
|
let invite = state
|
|
.db
|
|
.mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses)
|
|
.await
|
|
.map_err(|error| match error {
|
|
buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message),
|
|
error => internal_error(&format!("invite mint: {error}")),
|
|
})?;
|
|
|
|
// Same TLS-posture logic as nip98_expected_url: wss deployments get an
|
|
// https landing page URL, ws dev/test deployments get http.
|
|
let scheme = if state.config.relay_url.trim_start().starts_with("wss://") {
|
|
"https"
|
|
} else {
|
|
"http"
|
|
};
|
|
|
|
tracing::info!(
|
|
community = %tenant.community(),
|
|
minted_by = %sender_hex,
|
|
invite_id = %invite.invite_id,
|
|
expires_at = %invite.expires_at,
|
|
max_uses = ?invite.max_uses,
|
|
"relay invite minted"
|
|
);
|
|
|
|
// expires_at as unix seconds for the response contract.
|
|
let expires_at_unix = invite.expires_at.timestamp() as u64;
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"code": invite.code,
|
|
"expires_at": expires_at_unix,
|
|
"max_uses": invite.max_uses,
|
|
"uses_remaining": invite.uses_remaining,
|
|
"url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code),
|
|
})))
|
|
}
|
|
|
|
/// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the
|
|
/// *joining* pubkey. Exempt from the relay-membership gate by design.
|
|
///
|
|
/// Routing is by exact prefix: `v2.` codes go to the database-backed
|
|
/// redemption path; every other code goes to the v1 HMAC verifier. A `v2.`
|
|
/// code is never fallen back to v1 verification.
|
|
pub async fn claim_invite(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
body: axum::body::Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites/claim", &body).await?;
|
|
|
|
if claim_rate_limited(&state, tenant.community(), &pubkey) {
|
|
return Err(api_error(
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"too many invite claim attempts, slow down",
|
|
));
|
|
}
|
|
|
|
let request: ClaimInviteRequest = serde_json::from_slice(&body)
|
|
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid claim JSON: {e}")))?;
|
|
|
|
let claimer_hex = pubkey.to_hex();
|
|
let key = invite_token::derive_invite_key(&state.relay_keypair);
|
|
|
|
// --- v2 database-backed path ---
|
|
//
|
|
// Route by exact prefix: v2. codes use the durable invite table. No
|
|
// fallback to v1 HMAC verification for malformed v2 input.
|
|
if request.code.starts_with(V2_PREFIX) {
|
|
validate_v2_code(&request.code)
|
|
.map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_invalid"))?;
|
|
|
|
// Join-policy receipt verification, same mechanism as v1: the receipt
|
|
// is bound to the code string by SHA-256, so it works for v2 codes.
|
|
if let Some(policy) = &state.config.join_policy {
|
|
let receipt = request
|
|
.policy_receipt
|
|
.as_deref()
|
|
.ok_or_else(|| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
|
|
invite_token::verify_policy_acceptance(&key, receipt, &request.code, &policy.version)
|
|
.map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
|
|
}
|
|
|
|
let token_hash = hash_v2_code(&request.code);
|
|
let outcome = state
|
|
.db
|
|
.claim_relay_invite(
|
|
tenant.community(),
|
|
&token_hash,
|
|
&claimer_hex,
|
|
state
|
|
.config
|
|
.join_policy
|
|
.as_ref()
|
|
.map(|policy| policy.version.as_str()),
|
|
)
|
|
.await
|
|
.map_err(|e| internal_error(&format!("v2 invite claim: {e}")))?;
|
|
|
|
return match outcome {
|
|
buzz_db::relay_invite::ClaimOutcome::Joined { .. } => {
|
|
tracing::info!(
|
|
community = %tenant.community(),
|
|
member = %claimer_hex,
|
|
"relay member added via v2 invite"
|
|
);
|
|
// NIP-43 side effects only on Joined, never on other outcomes.
|
|
if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await {
|
|
tracing::warn!(
|
|
"failed to publish NIP-43 member-added delta after v2 claim: {e}"
|
|
);
|
|
}
|
|
if let Err(e) = publish_nip43_membership_list(&tenant, &state).await {
|
|
tracing::warn!("failed to publish NIP-43 membership list after v2 claim: {e}");
|
|
}
|
|
Ok(Json(serde_json::json!({
|
|
"status": "joined",
|
|
"community_id": tenant.community().to_string(),
|
|
"host": tenant.host(),
|
|
"role": "member",
|
|
})))
|
|
}
|
|
buzz_db::relay_invite::ClaimOutcome::AlreadyMember { .. } => {
|
|
Ok(Json(serde_json::json!({
|
|
"status": "already_member",
|
|
"community_id": tenant.community().to_string(),
|
|
"host": tenant.host(),
|
|
"role": "member",
|
|
})))
|
|
}
|
|
buzz_db::relay_invite::ClaimOutcome::Expired => {
|
|
Err(api_error(StatusCode::FORBIDDEN, "invite_expired"))
|
|
}
|
|
buzz_db::relay_invite::ClaimOutcome::Exhausted => {
|
|
Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted"))
|
|
}
|
|
buzz_db::relay_invite::ClaimOutcome::Invalid => {
|
|
Err(api_error(StatusCode::FORBIDDEN, "invite_invalid"))
|
|
}
|
|
};
|
|
}
|
|
|
|
// --- v1 HMAC path (stateless tokens, drain window) ---
|
|
let payload = invite_token::verify_invite(&key, tenant.community(), &request.code).map_err(
|
|
|e| match e {
|
|
// Expired is post-MAC: revealing it helps the UX without helping a forger.
|
|
invite_token::InviteError::Expired => {
|
|
api_error(StatusCode::FORBIDDEN, "invite_expired")
|
|
}
|
|
// Everything else stays coarse so the endpoint is a poor oracle.
|
|
_ => api_error(StatusCode::FORBIDDEN, "invite_invalid"),
|
|
},
|
|
)?;
|
|
|
|
if let Some(policy) = &state.config.join_policy {
|
|
let receipt = request
|
|
.policy_receipt
|
|
.as_deref()
|
|
.ok_or_else(|| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
|
|
invite_token::verify_policy_acceptance(&key, receipt, &request.code, &policy.version)
|
|
.map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
|
|
}
|
|
|
|
let was_inserted = state
|
|
.db
|
|
.claim_relay_membership(
|
|
tenant.community(),
|
|
&claimer_hex,
|
|
&payload.r,
|
|
state
|
|
.config
|
|
.join_policy
|
|
.as_ref()
|
|
.map(|policy| policy.version.as_str()),
|
|
)
|
|
.await
|
|
.map_err(|e| internal_error(&format!("invite claim insert: {e}")))?;
|
|
|
|
if was_inserted {
|
|
tracing::info!(
|
|
community = %tenant.community(),
|
|
member = %claimer_hex,
|
|
"relay member added via invite"
|
|
);
|
|
if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await {
|
|
tracing::warn!("failed to publish NIP-43 member-added delta after claim: {e}");
|
|
}
|
|
if let Err(e) = publish_nip43_membership_list(&tenant, &state).await {
|
|
tracing::warn!("failed to publish NIP-43 membership list after claim: {e}");
|
|
}
|
|
}
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"status": if was_inserted { "joined" } else { "already_member" },
|
|
"community_id": tenant.community().to_string(),
|
|
"host": tenant.host(),
|
|
"role": payload.r,
|
|
})))
|
|
}
|
|
|
|
/// Fixed-window rate limit on claim attempts, keyed by community and claimer
|
|
/// pubkey so traffic for one tenant cannot consume another tenant's allowance.
|
|
///
|
|
/// Entries expire after one window and the cache has a hard capacity. Both are
|
|
/// important because a pre-membership caller can cheaply create fresh Nostr
|
|
/// keypairs; retaining one immortal entry per key would make the limiter itself
|
|
/// an unbounded-memory denial-of-service vector.
|
|
fn claim_rate_limited(
|
|
state: &AppState,
|
|
community: buzz_core::tenant::CommunityId,
|
|
pubkey: &nostr::PublicKey,
|
|
) -> bool {
|
|
claim_key_rate_limited(
|
|
&state.invite_claim_rate_limiter,
|
|
(community, pubkey.to_bytes()),
|
|
)
|
|
}
|
|
|
|
fn claim_key_rate_limited(
|
|
cache: &moka::sync::Cache<crate::state::ScopedPubkeyKey, Arc<std::sync::atomic::AtomicU32>>,
|
|
key: crate::state::ScopedPubkeyKey,
|
|
) -> bool {
|
|
let counter = cache.get_with(key, || Arc::new(std::sync::atomic::AtomicU32::new(0)));
|
|
counter.fetch_add(1, Ordering::Relaxed) >= CLAIM_RATE_LIMIT
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use super::{claim_key_rate_limited, CLAIM_RATE_LIMIT, MAX_INVITE_USES, MIN_INVITE_TTL_SECS};
|
|
use axum::{
|
|
body::{to_bytes, Body},
|
|
http::{header, Request, StatusCode},
|
|
};
|
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
|
use hmac::{Hmac, KeyInit, Mac};
|
|
use nostr::{EventBuilder, EventId, Keys, Kind, Tag};
|
|
use serde_json::Value;
|
|
use sha2::{Digest, Sha256};
|
|
use std::sync::Mutex;
|
|
use tower::ServiceExt;
|
|
use uuid::Uuid;
|
|
|
|
use crate::invite_token::{derive_invite_key, InvitePayload, MAX_INVITE_TTL_SECS};
|
|
|
|
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
|
|
|
|
fn claim_cache(
|
|
capacity: u64,
|
|
ttl: Duration,
|
|
) -> moka::sync::Cache<crate::state::ScopedPubkeyKey, Arc<std::sync::atomic::AtomicU32>> {
|
|
moka::sync::Cache::builder()
|
|
.max_capacity(capacity)
|
|
.time_to_live(ttl)
|
|
.build()
|
|
}
|
|
|
|
#[test]
|
|
fn claim_limiter_rejects_after_limit() {
|
|
let cache = claim_cache(100, Duration::from_secs(60));
|
|
let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), [7; 32]);
|
|
|
|
for _ in 0..CLAIM_RATE_LIMIT {
|
|
assert!(!claim_key_rate_limited(&cache, key));
|
|
}
|
|
assert!(claim_key_rate_limited(&cache, key));
|
|
}
|
|
|
|
#[test]
|
|
fn claim_limiter_expires_entries() {
|
|
let cache = claim_cache(100, Duration::from_millis(10));
|
|
let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), [8; 32]);
|
|
assert!(!claim_key_rate_limited(&cache, key));
|
|
assert!(cache.get(&key).is_some());
|
|
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
cache.run_pending_tasks();
|
|
|
|
assert!(cache.get(&key).is_none());
|
|
assert!(!claim_key_rate_limited(&cache, key));
|
|
}
|
|
|
|
#[test]
|
|
fn claim_limiter_isolates_communities_for_same_pubkey() {
|
|
let cache = claim_cache(100, Duration::from_secs(60));
|
|
let pubkey = [9; 32];
|
|
let community_a = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xAAAA));
|
|
let community_b = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xBBBB));
|
|
|
|
for _ in 0..CLAIM_RATE_LIMIT {
|
|
assert!(!claim_key_rate_limited(&cache, (community_a, pubkey)));
|
|
}
|
|
assert!(claim_key_rate_limited(&cache, (community_a, pubkey)));
|
|
assert!(!claim_key_rate_limited(&cache, (community_b, pubkey)));
|
|
}
|
|
|
|
#[test]
|
|
fn claim_limiter_bounds_distinct_pubkeys() {
|
|
let capacity = 10;
|
|
let cache = claim_cache(capacity, Duration::from_secs(60));
|
|
for id in 0..100_u64 {
|
|
let mut pubkey = [0; 32];
|
|
pubkey[..8].copy_from_slice(&id.to_le_bytes());
|
|
let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), pubkey);
|
|
assert!(!claim_key_rate_limited(&cache, key));
|
|
}
|
|
cache.run_pending_tasks();
|
|
|
|
assert!(cache.entry_count() <= capacity);
|
|
}
|
|
|
|
fn nip98_auth_header(keys: &Keys, url: &str, body: &[u8]) -> String {
|
|
let hash: [u8; 32] = Sha256::digest(body).into();
|
|
let tags = vec![
|
|
Tag::parse(["u", url]).expect("u tag"),
|
|
Tag::parse(["method", "POST"]).expect("method tag"),
|
|
Tag::parse(["payload", hex::encode(hash).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}")
|
|
}
|
|
|
|
/// Build a closed-relay (`require_relay_membership = true`) test state with
|
|
/// a fresh community on `host`; returns `None` when Postgres is unavailable.
|
|
async fn invite_test_state(host: &str) -> Option<Arc<AppState>> {
|
|
let mut config = crate::config::Config::from_env().ok()?;
|
|
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
|
.unwrap_or_else(|_| TEST_DB_URL.to_string());
|
|
config.database_url = database_url.clone();
|
|
config.redis_url = "redis://127.0.0.1:1".to_string();
|
|
config.relay_url = format!("wss://{host}");
|
|
// The claim route must work on relays where membership is enforced —
|
|
// that is the entire point of an invite.
|
|
config.require_relay_membership = true;
|
|
|
|
let pool = sqlx::PgPool::connect(&database_url).await.ok()?;
|
|
let db = buzz_db::Db::from_pool(pool.clone());
|
|
db.ensure_configured_community(host).await.ok()?;
|
|
|
|
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 post_json(
|
|
state: Arc<AppState>,
|
|
host: &str,
|
|
path: &str,
|
|
keys: &Keys,
|
|
body: String,
|
|
) -> axum::response::Response {
|
|
let url = format!("https://{host}{path}");
|
|
let auth = nip98_auth_header(keys, &url, body.as_bytes());
|
|
build_router(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(path)
|
|
.header(header::HOST, host)
|
|
.header(header::AUTHORIZATION, auth)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(body))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response")
|
|
}
|
|
|
|
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 mint_code(state: Arc<AppState>, host: &str, owner: &Keys, request: Value) -> String {
|
|
let response = post_json(state, host, "/api/invites", owner, request.to_string()).await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
read_json(response)
|
|
.await
|
|
.get("code")
|
|
.and_then(Value::as_str)
|
|
.expect("minted code")
|
|
.to_string()
|
|
}
|
|
|
|
async fn event_count(state: &AppState, community: buzz_core::CommunityId, kind: i32) -> i64 {
|
|
state
|
|
.db
|
|
.count_events(&buzz_db::EventQuery {
|
|
kinds: Some(vec![kind]),
|
|
global_only: true,
|
|
..buzz_db::EventQuery::for_community(community)
|
|
})
|
|
.await
|
|
.expect("count side-effect events")
|
|
}
|
|
|
|
#[test]
|
|
fn mint_request_deserialization_is_strict() {
|
|
for valid in [
|
|
serde_json::json!({}),
|
|
serde_json::json!({ "max_uses": null }),
|
|
serde_json::json!({ "max_uses": 1 }),
|
|
serde_json::json!({ "max_uses": MAX_INVITE_USES }),
|
|
] {
|
|
serde_json::from_value::<super::MintInviteRequest>(valid).expect("valid request");
|
|
}
|
|
|
|
for invalid in [
|
|
serde_json::json!({ "max_uses": 1.5 }),
|
|
serde_json::json!({ "max_uses": "10" }),
|
|
serde_json::json!({ "ttl_secs": -1 }),
|
|
serde_json::json!({ "ttl_secs": 1.5 }),
|
|
serde_json::json!({ "ttl_secs": "3600" }),
|
|
] {
|
|
assert!(
|
|
serde_json::from_value::<super::MintInviteRequest>(invalid.clone()).is_err(),
|
|
"accepted wrong JSON type: {invalid}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mint_request_validation_enforces_bounds_without_a_database() {
|
|
use super::validate_mint_request;
|
|
|
|
for (request, expected) in [
|
|
(
|
|
super::MintInviteRequest::default(),
|
|
(crate::invite_token::DEFAULT_INVITE_TTL_SECS, None),
|
|
),
|
|
(
|
|
super::MintInviteRequest {
|
|
ttl_secs: Some(MIN_INVITE_TTL_SECS),
|
|
max_uses: Some(1),
|
|
},
|
|
(MIN_INVITE_TTL_SECS, Some(1)),
|
|
),
|
|
(
|
|
super::MintInviteRequest {
|
|
ttl_secs: Some(MAX_INVITE_TTL_SECS),
|
|
max_uses: Some(MAX_INVITE_USES),
|
|
},
|
|
(MAX_INVITE_TTL_SECS, Some(MAX_INVITE_USES)),
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
validate_mint_request(&request).expect("valid request"),
|
|
expected
|
|
);
|
|
}
|
|
|
|
for request in [
|
|
super::MintInviteRequest {
|
|
ttl_secs: None,
|
|
max_uses: Some(0),
|
|
},
|
|
super::MintInviteRequest {
|
|
ttl_secs: None,
|
|
max_uses: Some(-1),
|
|
},
|
|
super::MintInviteRequest {
|
|
ttl_secs: None,
|
|
max_uses: Some(MAX_INVITE_USES + 1),
|
|
},
|
|
super::MintInviteRequest {
|
|
ttl_secs: Some(MIN_INVITE_TTL_SECS - 1),
|
|
max_uses: None,
|
|
},
|
|
super::MintInviteRequest {
|
|
ttl_secs: Some(MAX_INVITE_TTL_SECS + 1),
|
|
max_uses: None,
|
|
},
|
|
] {
|
|
assert_eq!(
|
|
validate_mint_request(&request)
|
|
.expect_err("invalid request")
|
|
.0,
|
|
StatusCode::BAD_REQUEST
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn mint_validates_max_uses_and_ttl_bounds() {
|
|
let host = format!("invites-validation-{}.example", Uuid::new_v4().simple());
|
|
let owner = Keys::generate();
|
|
let state = invite_test_state(&host)
|
|
.await
|
|
.expect("requires reachable Postgres and relay test state");
|
|
let community = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("lookup")
|
|
.expect("community exists");
|
|
state
|
|
.db
|
|
.add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None)
|
|
.await
|
|
.expect("seed owner");
|
|
|
|
for body in [
|
|
serde_json::json!({ "max_uses": 0 }),
|
|
serde_json::json!({ "max_uses": -1 }),
|
|
serde_json::json!({ "max_uses": MAX_INVITE_USES + 1 }),
|
|
serde_json::json!({ "ttl_secs": MIN_INVITE_TTL_SECS - 1 }),
|
|
serde_json::json!({ "ttl_secs": MAX_INVITE_TTL_SECS + 1 }),
|
|
] {
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites",
|
|
&owner,
|
|
body.to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{body}");
|
|
}
|
|
|
|
for body in [
|
|
serde_json::json!({}),
|
|
serde_json::json!({ "max_uses": null }),
|
|
serde_json::json!({ "max_uses": 1 }),
|
|
serde_json::json!({ "max_uses": MAX_INVITE_USES }),
|
|
serde_json::json!({ "ttl_secs": MIN_INVITE_TTL_SECS }),
|
|
serde_json::json!({ "ttl_secs": MAX_INVITE_TTL_SECS }),
|
|
] {
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites",
|
|
&owner,
|
|
body.to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK, "{body}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn malformed_and_unknown_v2_codes_are_forbidden_without_v1_fallback() {
|
|
let host = format!("invites-v2-invalid-{}.example", Uuid::new_v4().simple());
|
|
let joiner = Keys::generate();
|
|
let state = invite_test_state(&host)
|
|
.await
|
|
.expect("requires reachable Postgres and relay test state");
|
|
let unknown = format!("v2.{}", URL_SAFE_NO_PAD.encode([9_u8; 32]));
|
|
|
|
for code in [
|
|
"v2.".to_string(),
|
|
"v2.not-base64!".to_string(),
|
|
format!("v2.{}", URL_SAFE_NO_PAD.encode([9_u8; 31])),
|
|
unknown,
|
|
] {
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&joiner,
|
|
serde_json::json!({ "code": code }).to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{code}");
|
|
assert_eq!(
|
|
read_json(response)
|
|
.await
|
|
.get("error")
|
|
.and_then(Value::as_str),
|
|
Some("invite_invalid")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn bounded_v2_claims_publish_side_effects_only_for_joined() {
|
|
let host = format!(
|
|
"invites-v2-side-effects-{}.example",
|
|
Uuid::new_v4().simple()
|
|
);
|
|
let owner = Keys::generate();
|
|
let first = Keys::generate();
|
|
let second = Keys::generate();
|
|
let state = invite_test_state(&host)
|
|
.await
|
|
.expect("requires reachable Postgres and relay test state");
|
|
let community = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("lookup")
|
|
.expect("community exists");
|
|
state
|
|
.db
|
|
.add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None)
|
|
.await
|
|
.expect("seed owner");
|
|
let before_delta_count = event_count(
|
|
&state,
|
|
community.id,
|
|
buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32,
|
|
)
|
|
.await;
|
|
let before_list_count = event_count(
|
|
&state,
|
|
community.id,
|
|
buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32,
|
|
)
|
|
.await;
|
|
let code = mint_code(
|
|
state.clone(),
|
|
&host,
|
|
&owner,
|
|
serde_json::json!({ "max_uses": 1 }),
|
|
)
|
|
.await;
|
|
let claim_body = serde_json::json!({ "code": code }).to_string();
|
|
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&first,
|
|
claim_body.clone(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
read_json(response)
|
|
.await
|
|
.get("status")
|
|
.and_then(Value::as_str),
|
|
Some("joined")
|
|
);
|
|
let delta_count = event_count(
|
|
&state,
|
|
community.id,
|
|
buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32,
|
|
)
|
|
.await;
|
|
let list_count = event_count(
|
|
&state,
|
|
community.id,
|
|
buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32,
|
|
)
|
|
.await;
|
|
assert_eq!(delta_count, before_delta_count + 1);
|
|
assert_eq!(list_count, before_list_count + 1);
|
|
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&first,
|
|
claim_body.clone(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
read_json(response)
|
|
.await
|
|
.get("status")
|
|
.and_then(Value::as_str),
|
|
Some("already_member")
|
|
);
|
|
assert_eq!(
|
|
event_count(
|
|
&state,
|
|
community.id,
|
|
buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32,
|
|
)
|
|
.await,
|
|
delta_count
|
|
);
|
|
assert_eq!(
|
|
event_count(
|
|
&state,
|
|
community.id,
|
|
buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32,
|
|
)
|
|
.await,
|
|
list_count
|
|
);
|
|
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&second,
|
|
claim_body,
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
assert_eq!(
|
|
read_json(response)
|
|
.await
|
|
.get("error")
|
|
.and_then(Value::as_str),
|
|
Some("invite_exhausted")
|
|
);
|
|
assert_eq!(
|
|
event_count(
|
|
&state,
|
|
community.id,
|
|
buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32,
|
|
)
|
|
.await,
|
|
delta_count
|
|
);
|
|
assert_eq!(
|
|
event_count(
|
|
&state,
|
|
community.id,
|
|
buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32,
|
|
)
|
|
.await,
|
|
list_count
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn owner_mints_and_new_pubkey_claims() {
|
|
let host = format!("invites-{}.example", Uuid::new_v4().simple());
|
|
let owner = Keys::generate();
|
|
let joiner = Keys::generate();
|
|
let Some(state) = invite_test_state(&host).await else {
|
|
return;
|
|
};
|
|
let community = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("lookup")
|
|
.expect("community exists");
|
|
let community_id = community.id;
|
|
state
|
|
.db
|
|
.add_relay_member(community_id, &owner.public_key().to_hex(), "owner", None)
|
|
.await
|
|
.expect("seed owner");
|
|
|
|
// Mint.
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites",
|
|
&owner,
|
|
"{}".to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let json = read_json(response).await;
|
|
let code = json.get("code").and_then(Value::as_str).expect("code");
|
|
let url = json.get("url").and_then(Value::as_str).expect("url");
|
|
assert!(url.contains("/invite/"), "unexpected url: {url}");
|
|
|
|
// Claim on a closed relay by a pubkey that is not yet a member.
|
|
let claim_body = serde_json::json!({ "code": code }).to_string();
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&joiner,
|
|
claim_body.clone(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let json = read_json(response).await;
|
|
assert_eq!(json.get("status").and_then(Value::as_str), Some("joined"));
|
|
assert_eq!(json.get("role").and_then(Value::as_str), Some("member"));
|
|
|
|
let member = state
|
|
.db
|
|
.get_relay_member(community_id, &joiner.public_key().to_hex())
|
|
.await
|
|
.expect("member lookup")
|
|
.expect("joiner is now a member");
|
|
assert_eq!(member.role, "member");
|
|
|
|
// Second claim is idempotent.
|
|
let response = post_json(state, &host, "/api/invites/claim", &joiner, claim_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("already_member")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn join_policy_gate_end_to_end() {
|
|
let host = format!("invites-policy-{}.example", Uuid::new_v4().simple());
|
|
let owner = Keys::generate();
|
|
let joiner = Keys::generate();
|
|
let Some(state) = invite_test_state(&host).await else {
|
|
return;
|
|
};
|
|
// Force the join policy on regardless of env.
|
|
let mut state_inner = (*state).clone();
|
|
let mut config = state_inner.config.as_ref().clone();
|
|
config.join_policy = Some(crate::config::JoinPolicyConfig {
|
|
terms_markdown: Some("# Terms".to_string()),
|
|
privacy_markdown: Some("# Privacy".to_string()),
|
|
age_attestation_required: true,
|
|
version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
|
|
});
|
|
state_inner.config = Arc::new(config);
|
|
let state = Arc::new(state_inner);
|
|
|
|
let community = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("lookup")
|
|
.expect("community exists");
|
|
state
|
|
.db
|
|
.add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None)
|
|
.await
|
|
.expect("seed owner");
|
|
|
|
// Mint an invite.
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites",
|
|
&owner,
|
|
"{}".to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let json = read_json(response).await;
|
|
let code = json
|
|
.get("code")
|
|
.and_then(Value::as_str)
|
|
.expect("code")
|
|
.to_string();
|
|
|
|
// 1. Claim WITHOUT receipt -> 403 (checkbox bypass).
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&joiner,
|
|
serde_json::json!({ "code": code }).to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"no-receipt claim must fail"
|
|
);
|
|
|
|
// 2. Forged receipt (wrong key) -> 403.
|
|
let forged = crate::invite_token::mint_policy_acceptance(
|
|
&[9u8; 32],
|
|
&code,
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
);
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&joiner,
|
|
serde_json::json!({ "code": code, "policy_receipt": forged }).to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"forged receipt must fail"
|
|
);
|
|
|
|
// 3. Receipt bound to a DIFFERENT invite code -> 403.
|
|
let key = crate::invite_token::derive_invite_key(&state.relay_keypair);
|
|
let other = crate::invite_token::mint_policy_acceptance(
|
|
&key,
|
|
"some-other-code",
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
);
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&joiner,
|
|
serde_json::json!({ "code": code, "policy_receipt": other }).to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"cross-invite receipt must fail"
|
|
);
|
|
|
|
// 4. Receipt for a STALE policy version -> 403.
|
|
let stale = crate::invite_token::mint_policy_acceptance(
|
|
&key,
|
|
&code,
|
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
);
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&joiner,
|
|
serde_json::json!({ "code": code, "policy_receipt": stale }).to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"stale-version receipt must fail"
|
|
);
|
|
|
|
// 5. accept-policy without age confirmation -> 400.
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/accept-policy",
|
|
&joiner,
|
|
serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": false })
|
|
.to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"age not confirmed must be rejected when required"
|
|
);
|
|
|
|
// 5b. accept-policy with stale version -> 400.
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/accept-policy",
|
|
&joiner,
|
|
serde_json::json!({ "code": code, "policy_version": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "age_confirmed": true })
|
|
.to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
// 6. Legit flow: accept-policy -> receipt -> claim OK.
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/accept-policy",
|
|
&joiner,
|
|
serde_json::json!({ "code": code, "policy_version": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "age_confirmed": true })
|
|
.to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let receipt = read_json(response)
|
|
.await
|
|
.get("receipt")
|
|
.and_then(Value::as_str)
|
|
.expect("receipt")
|
|
.to_string();
|
|
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&joiner,
|
|
serde_json::json!({ "code": code, "policy_receipt": receipt }).to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::OK,
|
|
"legit receipt claim must succeed"
|
|
);
|
|
let json = read_json(response).await;
|
|
assert_eq!(json.get("status").and_then(Value::as_str), Some("joined"));
|
|
|
|
let member = state
|
|
.db
|
|
.get_relay_member(community.id, &joiner.public_key().to_hex())
|
|
.await
|
|
.expect("member lookup")
|
|
.expect("joiner is now a member");
|
|
assert_eq!(member.role, "member");
|
|
assert!(
|
|
state
|
|
.db
|
|
.has_join_policy_acceptance(
|
|
community.id,
|
|
&joiner.public_key().to_hex(),
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
)
|
|
.await
|
|
.expect("policy acceptance lookup"),
|
|
"accepted policy version must be persisted",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn non_admin_cannot_mint() {
|
|
let host = format!("invites-{}.example", Uuid::new_v4().simple());
|
|
let member = Keys::generate();
|
|
let outsider = Keys::generate();
|
|
let Some(state) = invite_test_state(&host).await else {
|
|
return;
|
|
};
|
|
let community = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("lookup")
|
|
.expect("community exists");
|
|
let community_id = community.id;
|
|
state
|
|
.db
|
|
.add_relay_member(community_id, &member.public_key().to_hex(), "member", None)
|
|
.await
|
|
.expect("seed member");
|
|
|
|
for keys in [&member, &outsider] {
|
|
let response =
|
|
post_json(state.clone(), &host, "/api/invites", keys, "{}".to_string()).await;
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn claim_rejects_invalid_code() {
|
|
let host = format!("invites-{}.example", Uuid::new_v4().simple());
|
|
let joiner = Keys::generate();
|
|
let Some(state) = invite_test_state(&host).await else {
|
|
return;
|
|
};
|
|
|
|
let body = serde_json::json!({ "code": "garbage.code" }).to_string();
|
|
let response = post_json(state.clone(), &host, "/api/invites/claim", &joiner, body).await;
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let json = read_json(response).await;
|
|
assert_eq!(
|
|
json.get("error").and_then(Value::as_str),
|
|
Some("invite_invalid")
|
|
);
|
|
|
|
let community = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("lookup")
|
|
.expect("community exists");
|
|
let is_member = state
|
|
.db
|
|
.is_relay_member(community.id, &joiner.public_key().to_hex())
|
|
.await
|
|
.expect("member check");
|
|
assert!(!is_member, "invalid code must not admit anyone");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn code_minted_for_one_community_fails_on_another() {
|
|
let host_a = format!("invites-a-{}.example", Uuid::new_v4().simple());
|
|
let host_b = format!("invites-b-{}.example", Uuid::new_v4().simple());
|
|
let owner = Keys::generate();
|
|
let joiner = Keys::generate();
|
|
let Some(state) = invite_test_state(&host_a).await else {
|
|
return;
|
|
};
|
|
state
|
|
.db
|
|
.ensure_configured_community(&host_b)
|
|
.await
|
|
.expect("second community");
|
|
let community_a = state
|
|
.db
|
|
.lookup_community_by_host(&host_a)
|
|
.await
|
|
.expect("lookup")
|
|
.expect("community a");
|
|
state
|
|
.db
|
|
.add_relay_member(community_a.id, &owner.public_key().to_hex(), "owner", None)
|
|
.await
|
|
.expect("seed owner");
|
|
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host_a,
|
|
"/api/invites",
|
|
&owner,
|
|
"{}".to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let json = read_json(response).await;
|
|
let code = json.get("code").and_then(Value::as_str).expect("code");
|
|
|
|
// Present community A's code on community B's host.
|
|
let body = serde_json::json!({ "code": code }).to_string();
|
|
let response = post_json(state, &host_b, "/api/invites/claim", &joiner, body).await;
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
/// Forge an already-expired invite payload signed with the relay's derived
|
|
/// invite key. `mint_invite` clamps ttl to 60s minimum, so the only way to
|
|
/// produce an expired code is to build the payload by hand at the token
|
|
/// layer.
|
|
fn forge_expired_invite_code(
|
|
state: &AppState,
|
|
community: buzz_core::CommunityId,
|
|
seconds_ago: u64,
|
|
) -> String {
|
|
let key = derive_invite_key(&state.relay_keypair);
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("time")
|
|
.as_secs();
|
|
let payload = InvitePayload {
|
|
c: community.as_uuid().to_string(),
|
|
r: "member".to_string(),
|
|
e: now.saturating_sub(seconds_ago),
|
|
n: "test-nonce".to_string(),
|
|
};
|
|
let payload_bytes = serde_json::to_vec(&payload).expect("payload serializes");
|
|
let mut mac =
|
|
<Hmac<Sha256> as KeyInit>::new_from_slice(&key).expect("HMAC accepts any key size");
|
|
mac.update(&payload_bytes);
|
|
let mac_bytes = mac.finalize().into_bytes();
|
|
format!(
|
|
"{}.{}",
|
|
URL_SAFE_NO_PAD.encode(&payload_bytes),
|
|
URL_SAFE_NO_PAD.encode(mac_bytes),
|
|
)
|
|
}
|
|
|
|
/// Endpoint-level proof that expired codes (with a valid MAC) are
|
|
/// rejected by `/api/invites/claim` with the distinguishable
|
|
/// `invite_expired` body, and do not admit the caller.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn claim_rejects_expired_code() {
|
|
let host = format!("invites-{}.example", Uuid::new_v4().simple());
|
|
let joiner = Keys::generate();
|
|
let state = invite_test_state(&host)
|
|
.await
|
|
.expect("requires reachable Postgres and relay test state");
|
|
let community = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("lookup")
|
|
.expect("community exists");
|
|
let code = forge_expired_invite_code(&state, community.id, 10);
|
|
|
|
let body = serde_json::json!({ "code": code }).to_string();
|
|
let response = post_json(state.clone(), &host, "/api/invites/claim", &joiner, body).await;
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let json = read_json(response).await;
|
|
// The expired branch is deliberately distinguishable from the generic
|
|
// `invite_invalid` so the UX can prompt the user for a fresh link
|
|
// without becoming a MAC oracle.
|
|
assert_eq!(
|
|
json.get("error").and_then(Value::as_str),
|
|
Some("invite_expired"),
|
|
"expired branch must be distinguishable from generic invalid",
|
|
);
|
|
|
|
let is_member = state
|
|
.db
|
|
.is_relay_member(community.id, &joiner.public_key().to_hex())
|
|
.await
|
|
.expect("member check");
|
|
assert!(!is_member, "expired code must not admit anyone");
|
|
}
|
|
|
|
/// NIP-98 replay guard that returns `Ok(true)` the first time a given
|
|
/// event id is seen and `Ok(false)` on every subsequent call — mirrors
|
|
/// what the Redis guard does after a `SET NX` succeeds and then fails.
|
|
struct SeenOnceReplayGuard {
|
|
seen: Mutex<std::collections::HashSet<[u8; 32]>>,
|
|
}
|
|
|
|
impl SeenOnceReplayGuard {
|
|
fn new() -> Self {
|
|
Self {
|
|
seen: Mutex::new(std::collections::HashSet::new()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl buzz_auth::Nip98ReplayGuard for SeenOnceReplayGuard {
|
|
fn try_mark_in_scope<'a>(
|
|
&'a self,
|
|
_scope: &'a str,
|
|
event_id: &'a EventId,
|
|
_ttl_secs: u64,
|
|
) -> std::pin::Pin<
|
|
Box<dyn std::future::Future<Output = Result<bool, buzz_auth::AuthError>> + Send + 'a>,
|
|
> {
|
|
let bytes = *event_id.as_bytes();
|
|
let inserted = self.seen.lock().expect("replay set").insert(bytes);
|
|
Box::pin(async move { Ok(inserted) })
|
|
}
|
|
}
|
|
|
|
/// Endpoint-level proof that a replayed NIP-98 auth event on a claim POST
|
|
/// is rejected — the first claim succeeds, but reusing the exact same
|
|
/// Authorization header (same signed NIP-98 event id) is rejected as
|
|
/// replay before the invite verification ever runs.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn claim_rejects_replayed_nip98_auth() {
|
|
let host = format!("invites-{}.example", Uuid::new_v4().simple());
|
|
let owner = Keys::generate();
|
|
let joiner = Keys::generate();
|
|
let state_arc = invite_test_state(&host)
|
|
.await
|
|
.expect("requires reachable Postgres and relay test state");
|
|
// Swap the always-fresh guard for one that fires the second time the
|
|
// same event id is presented — the code path we're pinning.
|
|
let mut state_owned =
|
|
Arc::try_unwrap(state_arc).unwrap_or_else(|_| panic!("sole owner of AppState"));
|
|
state_owned.nip98_replay = Arc::new(SeenOnceReplayGuard::new());
|
|
let state = Arc::new(state_owned);
|
|
|
|
let community = state
|
|
.db
|
|
.lookup_community_by_host(&host)
|
|
.await
|
|
.expect("lookup")
|
|
.expect("community exists");
|
|
state
|
|
.db
|
|
.add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None)
|
|
.await
|
|
.expect("seed owner");
|
|
|
|
// Mint a valid code so the replay under test is on the claim path.
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites",
|
|
&owner,
|
|
"{}".to_string(),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let json = read_json(response).await;
|
|
let code = json.get("code").and_then(Value::as_str).expect("code");
|
|
|
|
// Build one NIP-98 header and reuse it verbatim on two claim POSTs.
|
|
let claim_body = serde_json::json!({ "code": code }).to_string();
|
|
let claim_url = format!("https://{host}/api/invites/claim");
|
|
let claim_auth = nip98_auth_header(&joiner, &claim_url, claim_body.as_bytes());
|
|
|
|
let send_claim = |auth: String, body: String| {
|
|
let state = state.clone();
|
|
let host = host.clone();
|
|
async move {
|
|
build_router(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/invites/claim")
|
|
.header(header::HOST, host.as_str())
|
|
.header(header::AUTHORIZATION, auth)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(body))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response")
|
|
}
|
|
};
|
|
|
|
let first = send_claim(claim_auth.clone(), claim_body.clone()).await;
|
|
assert_eq!(first.status(), StatusCode::OK);
|
|
|
|
// Same signed auth event, sent again → replay guard fires.
|
|
let second = send_claim(claim_auth, claim_body).await;
|
|
assert_eq!(second.status(), StatusCode::UNAUTHORIZED);
|
|
let json = read_json(second).await;
|
|
assert_eq!(
|
|
json.get("error").and_then(Value::as_str),
|
|
Some("NIP-98: replay detected"),
|
|
);
|
|
}
|
|
|
|
/// Endpoint-level proof that `/api/invites/claim` enforces the per-pubkey
|
|
/// fixed-window rate limit — the same joiner probing the endpoint hits
|
|
/// 429 on the `CLAIM_RATE_LIMIT + 1`th attempt inside the window.
|
|
///
|
|
/// We use invalid codes throughout so no membership state can change; the
|
|
/// limiter runs before code verification, so the transition from
|
|
/// `invite_invalid` (403) to `too many invite claim attempts` (429) proves
|
|
/// the limiter guard is on the request path and fires on repeat pubkey.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn claim_rate_limit_fires_on_repeat_pubkey() {
|
|
let host = format!("invites-{}.example", Uuid::new_v4().simple());
|
|
let joiner = Keys::generate();
|
|
let state_arc = invite_test_state(&host)
|
|
.await
|
|
.expect("requires reachable Postgres and relay test state");
|
|
// Fresh limiter with the production limit so the assertion pins the
|
|
// in-endpoint threshold, not a test-only budget.
|
|
let mut state_owned =
|
|
Arc::try_unwrap(state_arc).unwrap_or_else(|_| panic!("sole owner of AppState"));
|
|
state_owned.invite_claim_rate_limiter = Arc::new(claim_cache(
|
|
super::CLAIM_RATE_CACHE_CAPACITY,
|
|
super::CLAIM_RATE_WINDOW,
|
|
));
|
|
let state = Arc::new(state_owned);
|
|
|
|
let body = serde_json::json!({ "code": "garbage.code" }).to_string();
|
|
for _ in 0..CLAIM_RATE_LIMIT {
|
|
let response = post_json(
|
|
state.clone(),
|
|
&host,
|
|
"/api/invites/claim",
|
|
&joiner,
|
|
body.clone(),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"attempts up to the limit should reach code verification and be rejected as invalid",
|
|
);
|
|
let json = read_json(response).await;
|
|
assert_eq!(
|
|
json.get("error").and_then(Value::as_str),
|
|
Some("invite_invalid"),
|
|
);
|
|
}
|
|
|
|
let over_limit = post_json(state, &host, "/api/invites/claim", &joiner, body).await;
|
|
assert_eq!(over_limit.status(), StatusCode::TOO_MANY_REQUESTS);
|
|
let json = read_json(over_limit).await;
|
|
assert_eq!(
|
|
json.get("error").and_then(Value::as_str),
|
|
Some("too many invite claim attempts, slow down"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn policy_document_renders_markdown_and_escapes_raw_html() {
|
|
let page = super::render_policy_document(
|
|
"Terms of Service",
|
|
"# Terms\n\nBe kind & honest.\n\n<script>alert(1)</script>",
|
|
);
|
|
assert!(page.contains("<title>Terms of Service</title>"), "{page}");
|
|
assert!(page.contains("<h1>Terms</h1>"), "{page}");
|
|
// `&` inside prose must be entity-encoded by the HTML writer.
|
|
assert!(page.contains("Be kind & honest."), "{page}");
|
|
// Raw HTML in operator Markdown renders as escaped text, never markup.
|
|
assert!(!page.contains("<script>"), "{page}");
|
|
assert!(
|
|
page.contains("<script>alert(1)</script>"),
|
|
"{page}"
|
|
);
|
|
}
|
|
|
|
/// The document routes are public (no NIP-98) and 404 until configured,
|
|
/// exactly like the JSON policy endpoint they sit beside.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn join_policy_document_pages_serve_configured_markdown() {
|
|
let host = format!("invites-docs-{}.example", Uuid::new_v4().simple());
|
|
let Some(state) = invite_test_state(&host).await else {
|
|
return;
|
|
};
|
|
|
|
let get_page = |state: Arc<crate::state::AppState>, path: &'static str| {
|
|
let host = host.clone();
|
|
async move {
|
|
build_router(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(path)
|
|
.header(header::HOST, host)
|
|
.body(Body::empty())
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response")
|
|
}
|
|
};
|
|
|
|
// Unconfigured relay: both documents 404.
|
|
let response = get_page(state.clone(), "/api/join-policy/terms").await;
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
let response = get_page(state.clone(), "/api/join-policy/privacy").await;
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
|
|
// Configure terms only — terms serves HTML, privacy still 404s.
|
|
let mut state_inner = (*state).clone();
|
|
let mut config = state_inner.config.as_ref().clone();
|
|
config.join_policy = Some(crate::config::JoinPolicyConfig {
|
|
terms_markdown: Some("# Terms\n\nNo funny business.".to_string()),
|
|
privacy_markdown: None,
|
|
age_attestation_required: false,
|
|
version: "v".repeat(64),
|
|
});
|
|
state_inner.config = Arc::new(config);
|
|
let state = Arc::new(state_inner);
|
|
|
|
let response = get_page(state.clone(), "/api/join-policy/terms").await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let content_type = response
|
|
.headers()
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
assert!(content_type.starts_with("text/html"), "{content_type}");
|
|
let bytes = to_bytes(response.into_body(), 1024 * 1024)
|
|
.await
|
|
.expect("read body");
|
|
let page = String::from_utf8(bytes.to_vec()).expect("utf8");
|
|
assert!(page.contains("<h1>Terms</h1>"), "{page}");
|
|
assert!(page.contains("No funny business."), "{page}");
|
|
|
|
let response = get_page(state, "/api/join-policy/privacy").await;
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
}
|