mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
Relay staff (the `RELAY_OPERATOR_PUBKEYS` config roster, the owner fallback, and DB operator/moderator rows) can now ban, time out, and delete without a report through the admin API. Staff never join the community transport: these are admin-host, NIP-98 signed routes that drive the existing report enforcement state machine. Community admission, bans, and right-click menus are unchanged from `main`. Stack: this PR → #7904 ## Routes | Route | Body | |---|---| | `POST /api/admin/v1/members/{pubkey}/ban?communityHost=` | `{requestId, reason?}` | | `POST /api/admin/v1/members/{pubkey}/timeout?communityHost=` | `{requestId, expirationSecs, reason?}` | | `POST /api/admin/v1/events/{eventId}/delete?communityHost=` | `{requestId, reason?}` | Responses: `200 {actionId, state: "succeeded", replayed}`, `202 {state: "pending"}` when the action is accepted but not yet converged (the recovery worker finishes it; the same `requestId` replays), `409 request_id_conflict`, `409 target_is_staff`, `404 event_not_in_community`, `400 invalid_community_host` / `unknown_community_host`, and `422 enforcement_failed`. `422` is now the single failed-enforcement code for both the report and direct paths (shared `enforcement_failed()` mapping). ## Design - **Migration 0055.** `relay_admin_actions.report_id` becomes nullable. A direct row has `report_id NULL`, and `report_community_id` stays the tenant. New columns: `enforcement_target_event_id` and `timeout_secs`. The `relay_admin_actions_direct_shape` CHECK spells out each direct action: timeout needs both `timeout_secs` and `timeout_until`, ban and delete need neither, and delete needs both the event id and the author. A partial unique index on `(report_community_id, request_id) WHERE report_id IS NULL` makes direct actions idempotent. `pgschema` drops multi-column CHECKs, so `scripts/reconcile-schema-after-pgschema.sql` re-adds `relay_admin_actions_direct_shape` on desired-state databases. - **Atomic acceptance.** `claim_direct_action` inserts the action row and its `moderation_actions` audit row (`ban` / `timeout` / `delete_message`, `relay_operator` / `relay_moderator`) in one transaction. `ON CONFLICT DO NOTHING` sends a concurrent same-key request to the comparison. Retries compare actor, action, reason, `timeout_secs`, and the client-named target: the event id for delete, the pubkey otherwise. The event author, channel, and `timeout_until` are derived at acceptance and never compared. - **Retry racing its own first attempt.** Handler order: stored-action lookup → validation → staff guard → accept. If validation fails (for example B's event lookup runs after A already deleted the event), the handler re-reads the stored action. It returns a replay or 409 rather than 404. - **Report-less enforcement.** `finalize_success(report_id: Option<Uuid>)` skips the report CAS and the `reporter_notice` for direct actions. Tombstone and affected-user notices are unchanged. `recover_one` drives a direct action from its persisted row. `deliver_reporter_notice` treats a missing `report_id` as a terminal error instead of panicking. - **Permanent bans clear old expiry.** The ban upsert shared by direct and report-backed bans sets `ban_expires_at = NULL`, matching the kind-9040 path, so a permanent ban over an active or lapsed timed ban is in effect with no deadline. Timeouts are untouched. - **Community purge removes admin-action rows.** `purge_postgres` deletes the community's `relay_admin_outbox` rows (joined through their action), then its `relay_admin_actions` rows by `report_community_id`, before `moderation_reports`. That order satisfies both FKs and covers report-backed and direct (`report_id NULL`) rows in one pass. The same keyed row sources (`RELAY_ADMIN_TABLES`) feed the deletion inventory, which now records row counts for both tables, and post-purge logical verification, which now fails if either table still has a row for the community. Before this, a community with any admin-resolved report could never finish deletion (23503 on `relay_admin_actions_report_community_id_report_id_fkey`, stuck at `bindings_removed`). The `_operator_global_tables` registry reasons for both tables (seeded in migration 0036) are updated in 0055 and `schema.sql` to say community deletion purges them explicitly. - **Staff bans disconnect clusterwide.** The shared enforcement driver (`drive_enforcement`, used by the report and direct paths, fresh and recovery) calls `disconnect_pubkey_clusterwide` for `ban` before finalizing, matching the kind-9040 ban. A crash before finalize leaves the action non-terminal, so recovery re-runs the disconnect (idempotent) even when the ban step marker is already set. Timeouts do not disconnect, matching in-community timeout. - **Staff guard (D2).** `lookup_admin_principal` returns `Result<Option<AdminPrincipal>>`, with the same precedence as `resolve_admin_principal` (now a wrapper). `Ok(None)` means not staff. A roster lookup error is a 500, so the guard fails closed. Ban and timeout (direct) and ban/timeout/kick (via `resolve_report`) refuse any effective-roster target with `409 target_is_staff`, including the actor. The report path refuses before claim, so the report stays `open`. Delete is not guarded on either path. The check runs only when an action is first accepted: a replay or crash recovery completes an accepted action even if its target became staff afterwards. - **`communityHost` grammar (D4).** `buzz_core::tenant::validate_community_host` accepts only DNS labels and an optional port, and rejects schemes, paths, queries, userinfo, IP brackets, and whitespace before tenant binding. ## Tests binding fixes (red under mutation → green) Postgres tests are in `api/admin/mod.rs` `postgres_tests`, run against a DB migrated through 0055. Every rejection test seeds an existing restriction and asserts, through the shared `assert_no_effects` helper, that restriction values and action/audit/outbox/restriction/event counts are unchanged. | Fix | Test | Mutation → red | |---|---|---| | Racing retry replays | `direct_delete_retry_racing_first_attempt_replays_not_404` (barrier-paused HTTP interleaving; same-intent → 200 replay, conflicting reason → 409; the retry is checked with `assert_no_effects` against a snapshot taken after the first attempt, with the event author's restriction seeded) | drop the post-failure re-read → `left: 404 right: 200`; conflicting retry updates a restriction on the re-read path → `raced retry` snapshot mismatch (`ban_reason: "mutated"` vs `"seeded ban"`) | | Per-action retry comparison | `direct_ban_replays_same_request_and_conflicts_on_changed_intent` | drop `reason` from the comparison → `left: (200, None) right: (409, Some("request_id_conflict"))` | | Lookup fails closed (helper-level, calls `refuse_staff_target` directly) | `staff_lookup_failure_fails_closed` | `.unwrap_or(None)` on lookup error → panics on `unwrap_err` (guard passed) | | Direct staff guard | `direct_ban_and_timeout_refuse_staff_targets` (config/self and DB staff over HTTP with seeded restrictions unchanged; owner fallback checked at helper level via `refuse_staff_target`) | remove `refuse_staff_target` → `left: (200, None) right: (409, Some("target_is_staff"))` | | Report-path guard incl. kick | `resolve_report_refuses_kick_and_ban_of_staff_target` | remove report-path guard → `left: (200, None) right: (409, Some("target_is_staff"))` | | Accept-time-only check | `recovery_completes_accepted_ban_after_target_became_staff` | re-check staff in `drive_direct_action` → `left: "pending" right: "succeeded"` | | Timeout CHECK | `migration_0055_rejects_half_filled_direct_timeout` (buzz-db, migrated schema) and `direct_timeout_shape_check_holds_on_desired_state_schema` (buzz-relay, `pgschema` + reconcile schema) | loosen timeout branch of the CHECK, or drop the reconcile block → half-filled insert accepted | | No reporter notice for direct | `direct_ban_replays_…` (outbox assertion) | enqueue `reporter_notice` unconditionally → assertion at the `reporter_notice` check fails | | Delete unguarded | `direct_delete_removes_staff_authored_event` | — (behavioral control) | | Unauthorized direct requests have no effect | `direct_routes_refuse_unauthorized_requests_without_effects` (nonstaff, community owner, disabled auth, wrong host, wrong URL, altered body; restrictions on the targeted member and on the delete target's author, plus action/audit/outbox rows, unchanged) | roster bypass / drop mutation-principal check / admit owner / skip host check → `200 vs 403`; drop URL binding / payload hash → `200 vs 401` | | Foreign-community delete | `direct_delete_of_foreign_community_event_has_no_effect` | unscoped event lookup → `(200, None) vs (404, "event_not_in_community")` | | Banned staff actor still acts | `community_banned_staff_actor_still_acts_under_membership_enforcement` (membership enforcement on) | gate the actor on community bans → `403 vs 200` | | Timeout idempotency | `direct_timeout_keeps_expiry_on_retry_and_conflicts_on_changed_intent` | compare derived `timeout_until` → `409 vs 200`; compare action only → code `None vs Some("request_id_conflict")` | | Competing claims | `competing_direct_claims_accept_once` (identical → existing, conflicting → conflict; one action + one audit row each) | join skips compare → `[claimed, existing] vs [claimed, conflict]`; join always conflicts → the reverse | | Aborted acceptance rolls back | `aborted_direct_acceptance_rolls_back` | accept outside the transaction → effects `(1,0,0,0,0) vs (0,0,0,0,0)` | | Recovery exactly once | `recovery_after_committed_mutation_finalizes_exactly_once` (one recovery, `succeeded`, one `affected_user_notice`; each `recover_one` bounded by a 30s timeout) | ignore the enforcement step marker → `recovery did not converge: Elapsed(())` after 30s | | Permanent ban over a timed ban | `direct_ban_clears_prior_ban_expiry` (active and expired prior ban; asserts `ban_expires_at` NULL, admission `moderation_restriction_state` banned, timeout unchanged) | drop `ban_expires_at = NULL` → active: `left: Some(+1 day) right: None`; expired: `left: Some(-1 day) right: None` | | Rejections never rewrite restrictions | `direct_ban_and_timeout_refuse_staff_targets`, `resolve_report_refuses_kick_and_ban_of_staff_target` (value snapshot via `assert_no_effects`) | staff guard updates the target's existing restriction before returning 409 → counts equal but `ban_reason: "mutated"` vs `"seeded ban"` | | Purge with admin-action rows | `purge_postgres_succeeds_with_relay_admin_action_rows` (buzz-db; report-backed and direct rows with outbox rows; inventory counts both tables; deleted community left with none, second community untouched; a straggler direct row fails logical verification) | remove the new purge deletes → `23503 … violates foreign key constraint "relay_admin_actions_report_community_id_report_id_fkey"`; drop the relay admin tables from verification → `straggler relay admin action must fail verification` | | Ban disconnects, timeout doesn't | `direct_ban_disconnects_target_and_timeout_does_not` | remove the disconnect call (run alone) → `ban must close the target's socket` | | Recovery still disconnects | `recovery_past_ban_step_still_disconnects_target` | remove the disconnect call → `recovery must disconnect the banned target` | | Purge result keyed by table | `checkpointed_resume_is_idempotent_and_tombstone_blocks_name_reuse` compares returned keys to the scoped tables plus the two relay admin tables | — (existing regression, updated) | | Failed replay | `direct_replay_of_failed_action_returns_422_without_effects` | — (coverage) | | Pending under contention | `direct_action_under_contention_returns_202_pending` | — (coverage) | | Timeout/delete recovery | `recovery_completes_accepted_direct_timeout_and_delete` (test-only `claim_stranded` acquires just the requested action through `acquire_action_lease`, then runs `recover_one`, so tests don't claim each other's rows) | — (coverage) | ## Rollout note Before rolling the relay back to a binary without this PR, drain non-terminal direct rows (`report_id IS NULL AND state IN ('pending','enforcing')`). An older binary cannot decode `report_id NULL`, and recovery batches containing such rows stall. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
353 lines
13 KiB
Rust
353 lines
13 KiB
Rust
//! Tenant identity: the server-resolved community key carried on every scoped path.
|
||
//!
|
||
//! These types live in `buzz-core` (zero I/O deps) so the DB, auth, pub/sub,
|
||
//! search, audit, media, and relay-wiring layers all name a community the same
|
||
//! way without depending on each other.
|
||
//!
|
||
//! ## The fence
|
||
//!
|
||
//! The whole multi-tenant safety story rests on one invariant from the formal
|
||
//! model (conformance "row zero"): a request's community is *resolved from the
|
||
//! connection host by the server*, never supplied or influenced by the client.
|
||
//!
|
||
//! [`TenantContext`] expresses that invariant in the type system as far as the
|
||
//! type system can carry it: there is no `Default`, no `Deserialize`, and no
|
||
//! way to *parse* a community from client input. A `CommunityId` only ever
|
||
//! comes from host resolution or from a DB row the server already scoped.
|
||
//!
|
||
//! This is a **lint-and-review fence, not a compiler fence.**
|
||
//! [`TenantContext::resolved`] and [`CommunityId::from_uuid`] are public so the
|
||
//! host-resolution path (in another crate) can call them — which means a
|
||
//! determined caller elsewhere *could* call them too. The migration-lint
|
||
//! harness forbids constructing a `TenantContext` outside host resolution and
|
||
//! tests; the type only removes the *accidental* path (deserializing a
|
||
//! client-chosen community), and review/lint closes the deliberate one. We say
|
||
//! this plainly rather than overclaim a guarantee the `pub` API doesn't give.
|
||
|
||
use std::fmt;
|
||
use uuid::Uuid;
|
||
|
||
/// A community: the first-class tenant key on every scoped row.
|
||
///
|
||
/// Opaque UUID newtype. Equality and ordering are the underlying UUID's.
|
||
/// There is deliberately no `community_id` parsed from client input anywhere;
|
||
/// a `CommunityId` only ever originates from host resolution or from a DB row
|
||
/// the server already scoped.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub struct CommunityId(Uuid);
|
||
|
||
impl CommunityId {
|
||
/// Wrap a UUID that the server has already established as a community id
|
||
/// (e.g. read back from the `communities` table during host resolution).
|
||
///
|
||
/// This is intentionally not a parse-from-client entry point: callers must
|
||
/// already hold a server-trusted UUID.
|
||
pub const fn from_uuid(id: Uuid) -> Self {
|
||
Self(id)
|
||
}
|
||
|
||
/// The underlying UUID, for DB binds and Redis key construction.
|
||
pub const fn as_uuid(&self) -> &Uuid {
|
||
&self.0
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for CommunityId {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
fmt::Display::fmt(&self.0, f)
|
||
}
|
||
}
|
||
|
||
/// The resolved tenant of an in-flight request, bound once at connection /
|
||
/// request establishment before any handler observes tenant data.
|
||
///
|
||
/// Carried by reference (`&TenantContext`) through every scoped call. This is
|
||
/// the *only* way to name a community downstream, and it cannot be constructed
|
||
/// from client input — see the module-level "fence" note.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct TenantContext {
|
||
community: CommunityId,
|
||
host: String,
|
||
}
|
||
|
||
impl TenantContext {
|
||
/// Construct a context from a completed host resolution.
|
||
///
|
||
/// Call this *only* from the host-resolution path (the function that maps a
|
||
/// connection's host to a `communities` row). Everywhere else takes
|
||
/// `&TenantContext` and reads it; nothing else mints one.
|
||
pub fn resolved(community: CommunityId, host: impl Into<String>) -> Self {
|
||
Self {
|
||
community,
|
||
host: host.into(),
|
||
}
|
||
}
|
||
|
||
/// The community every scoped operation under this request must use.
|
||
pub const fn community(&self) -> CommunityId {
|
||
self.community
|
||
}
|
||
|
||
/// The host that resolved to this community.
|
||
///
|
||
/// Authoritative for the NIP-05 domain and audit labelling; never re-derive
|
||
/// the community from it downstream — the community is already fixed.
|
||
pub fn host(&self) -> &str {
|
||
&self.host
|
||
}
|
||
}
|
||
|
||
/// Normalize a connection `Host` into the canonical form used as the community
|
||
/// lookup key.
|
||
///
|
||
/// This is the *one* normalization rule shared by both sides of the fence:
|
||
/// the `communities.host` column is stored already-normalized, and host
|
||
/// resolution normalizes the incoming `Host` header with this same function
|
||
/// before looking it up. Because both sides agree by construction,
|
||
/// `Relay.Example`, `relay.example.`, and `relay.example:443` all resolve to
|
||
/// the one community — they can never split into distinct tenants.
|
||
///
|
||
/// Rules (host only — the caller has already split off any path/scheme):
|
||
/// - ASCII-lowercase (hosts are case-insensitive per RFC 3986);
|
||
/// - strip a single trailing dot (the FQDN root label);
|
||
/// - strip a default port suffix (`:80`, `:443`) — non-default ports are kept,
|
||
/// since a deployment may legitimately serve different communities on
|
||
/// different ports of the same name.
|
||
///
|
||
/// The input is trimmed of surrounding whitespace. An empty result (e.g. the
|
||
/// caller passed `""`) is returned as-is; resolution treats an empty or
|
||
/// unmapped host as a fail-closed rejection, never a default tenant.
|
||
#[must_use]
|
||
pub fn normalize_host(host: &str) -> String {
|
||
let host = host.trim();
|
||
let mut host = host.to_ascii_lowercase();
|
||
// Strip default ports. We only touch a `:port` suffix that is exactly a
|
||
// default port, so IPv6 literals like `[::1]` (which contain colons but no
|
||
// trailing `:80`/`:443`) are left intact.
|
||
if let Some(stripped) = host
|
||
.strip_suffix(":443")
|
||
.or_else(|| host.strip_suffix(":80"))
|
||
{
|
||
host = stripped.to_string();
|
||
}
|
||
// Strip a single trailing FQDN-root dot.
|
||
if let Some(stripped) = host.strip_suffix('.') {
|
||
host = stripped.to_string();
|
||
}
|
||
host
|
||
}
|
||
|
||
/// Validate a client-typed community host (`name[:port]`) and return its
|
||
/// normalized form. Accepts only DNS labels of `[a-z0-9-]` (1–63 chars, no
|
||
/// leading or trailing hyphen, ≤ 253 total) and an optional port 1–65535, so
|
||
/// schemes, paths, queries, userinfo, IP-literal brackets and whitespace are
|
||
/// all rejected rather than normalized away.
|
||
pub fn validate_community_host(raw: &str) -> Result<String, &'static str> {
|
||
let lower = raw.to_ascii_lowercase();
|
||
let (name, port) = match lower.split_once(':') {
|
||
Some((name, port)) => (name, Some(port)),
|
||
None => (lower.as_str(), None),
|
||
};
|
||
if let Some(port) = port {
|
||
let valid = !port.is_empty()
|
||
&& port.bytes().all(|b| b.is_ascii_digit())
|
||
&& port.parse::<u16>().is_ok_and(|p| p > 0);
|
||
if !valid {
|
||
return Err("invalid port");
|
||
}
|
||
}
|
||
if name.is_empty() || name.len() > 253 {
|
||
return Err("invalid host length");
|
||
}
|
||
let label_ok = |l: &str| {
|
||
(1..=63).contains(&l.len())
|
||
&& !l.starts_with('-')
|
||
&& !l.ends_with('-')
|
||
&& l.bytes()
|
||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||
};
|
||
if !name.split('.').all(label_ok) {
|
||
return Err("invalid host");
|
||
}
|
||
Ok(normalize_host(&lower))
|
||
}
|
||
|
||
/// Extract the authority (host plus an explicit non-default port, if present)
|
||
/// from a relay URL in the same normalized shape as request `Host` headers and
|
||
/// `communities.host`.
|
||
///
|
||
/// Shared by the relay's host-resolution seam (startup community seeding and
|
||
/// the deployment-community bind), the relay's `bind_deployment_community`, and
|
||
/// the `buzz-admin` CLI's tenant resolution. All of these must derive the
|
||
/// *byte-identical* authority that live request resolution
|
||
/// ([`crate::tenant::normalize_host`]) produces from an inbound `Host`, or a
|
||
/// bootstrapped/looked-up community lands under a host no request resolves to.
|
||
///
|
||
/// In particular this preserves an explicit non-default port (`relay:8443` →
|
||
/// `relay:8443`) and IPv6 brackets (`[::1]:3000`) — both of which a naive
|
||
/// `Url::host_str()` drops. Returns the empty string when `relay_url` has no
|
||
/// parseable host (the caller fails closed on empty).
|
||
#[must_use]
|
||
pub fn relay_url_authority(relay_url: &str) -> String {
|
||
let Ok(url) = url::Url::parse(relay_url) else {
|
||
return String::new();
|
||
};
|
||
let Some(host) = url.host() else {
|
||
return String::new();
|
||
};
|
||
let host = match host {
|
||
url::Host::Domain(domain) => domain.to_string(),
|
||
url::Host::Ipv4(addr) => addr.to_string(),
|
||
url::Host::Ipv6(addr) => format!("[{addr}]"),
|
||
};
|
||
let authority = match url.port() {
|
||
Some(port) => format!("{host}:{port}"),
|
||
None => host,
|
||
};
|
||
normalize_host(&authority)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn validate_community_host_accepts_only_bare_host_and_port() {
|
||
for (input, expected) in [
|
||
("buzz.example", "buzz.example"),
|
||
("Buzz.Example", "buzz.example"),
|
||
("buzz.example:443", "buzz.example"),
|
||
("localhost:3000", "localhost:3000"),
|
||
("a-1.b2", "a-1.b2"),
|
||
] {
|
||
assert_eq!(
|
||
validate_community_host(input).as_deref(),
|
||
Ok(expected),
|
||
"{input}"
|
||
);
|
||
}
|
||
for input in [
|
||
"",
|
||
" buzz.example",
|
||
"https://buzz.example",
|
||
"buzz.example/path",
|
||
"buzz.example?x=1",
|
||
"buzz.example#f",
|
||
"user@buzz.example",
|
||
"[::1]",
|
||
"buzz.example:",
|
||
"buzz.example:0",
|
||
"buzz.example:65536",
|
||
"buzz.example:+1",
|
||
"-buzz.example",
|
||
"buzz-.example",
|
||
"buzz..example",
|
||
"buzz.example.",
|
||
"bü.example",
|
||
&format!("{}.example", "a".repeat(64)),
|
||
] {
|
||
assert!(
|
||
validate_community_host(input).is_err(),
|
||
"{input:?} must be rejected"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn community_id_roundtrips_uuid() {
|
||
let u = Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788);
|
||
let c = CommunityId::from_uuid(u);
|
||
assert_eq!(c.as_uuid(), &u);
|
||
assert_eq!(c.to_string(), u.to_string());
|
||
}
|
||
|
||
#[test]
|
||
fn tenant_context_exposes_resolution_inputs() {
|
||
let u = Uuid::from_u128(1);
|
||
let ctx = TenantContext::resolved(CommunityId::from_uuid(u), "relay.example");
|
||
assert_eq!(ctx.community().as_uuid(), &u);
|
||
assert_eq!(ctx.host(), "relay.example");
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_host_collapses_tenant_split_variants() {
|
||
// All of these are the SAME tenant and must normalize identically —
|
||
// this is the property that stops accidental split-tenant.
|
||
let canonical = "relay.example";
|
||
for variant in [
|
||
"relay.example",
|
||
"Relay.Example",
|
||
"RELAY.EXAMPLE",
|
||
"relay.example.", // trailing FQDN root dot
|
||
"relay.example:443", // default https port
|
||
"relay.example:80", // default http port
|
||
"Relay.Example.:443",
|
||
" relay.example ", // surrounding whitespace
|
||
] {
|
||
assert_eq!(normalize_host(variant), canonical, "variant {variant:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_host_keeps_nondefault_port() {
|
||
// A non-default port is a legitimate distinct selector — keep it.
|
||
assert_eq!(normalize_host("relay.example:8443"), "relay.example:8443");
|
||
assert_eq!(normalize_host("relay.example:3000"), "relay.example:3000");
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_host_leaves_ipv6_literal_intact() {
|
||
// IPv6 literals contain colons but no trailing default-port suffix.
|
||
assert_eq!(normalize_host("[::1]"), "[::1]");
|
||
assert_eq!(normalize_host("[::1]:443"), "[::1]");
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_host_empty_stays_empty() {
|
||
// Empty / whitespace-only resolves to empty; resolution fails closed.
|
||
assert_eq!(normalize_host(""), "");
|
||
assert_eq!(normalize_host(" "), "");
|
||
}
|
||
|
||
#[test]
|
||
fn relay_url_authority_keeps_explicit_nondefault_port() {
|
||
// The default dev seed: startup, bind_deployment_community, and
|
||
// buzz-admin must all derive `localhost:3000` (NOT bare `localhost`),
|
||
// or the admin lookup misses the community startup seeded.
|
||
assert_eq!(relay_url_authority("ws://localhost:3000"), "localhost:3000");
|
||
assert_eq!(
|
||
relay_url_authority("wss://relay.example:8443"),
|
||
"relay.example:8443"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn relay_url_authority_collapses_default_ports() {
|
||
// Default ports collapse to the bare host, matching how an inbound
|
||
// `Host` header for the same deployment normalizes.
|
||
assert_eq!(
|
||
relay_url_authority("wss://relay.example:443"),
|
||
"relay.example"
|
||
);
|
||
assert_eq!(
|
||
relay_url_authority("ws://relay.example:80"),
|
||
"relay.example"
|
||
);
|
||
assert_eq!(relay_url_authority("wss://relay.example"), "relay.example");
|
||
}
|
||
|
||
#[test]
|
||
fn relay_url_authority_preserves_ipv6_brackets() {
|
||
// `host_str()` strips IPv6 brackets and the port; `relay_url_authority`
|
||
// must keep both so the authority matches `communities.host`.
|
||
assert_eq!(relay_url_authority("ws://[::1]:3000"), "[::1]:3000");
|
||
}
|
||
|
||
#[test]
|
||
fn relay_url_authority_unparseable_is_empty() {
|
||
// No parseable host → empty authority; callers fail closed.
|
||
assert_eq!(relay_url_authority("not a url"), "");
|
||
assert_eq!(relay_url_authority(""), "");
|
||
}
|
||
}
|