mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Why Phase 1 of progressive delivery needs an explicit eligibility boundary before deployment automation can safely consume relay artifacts. Today the canonical Docker workflow can publish independently of the same-SHA CI result, the relay does not expose its source/build identity, and the Helm chart only renders mutable tags. The existing `ghcr.io/block/buzz-staging-dev` workflow in #6709 remains a manual pre-merge preview lane. It is deliberately not a canonical promotion input. ## What changed - Compile the full source SHA and GitHub Actions build identity into canonical relay binaries and expose it at `/_status`. - Gate creation of tagged multi-architecture manifests on the latest exact-SHA `CI` push run succeeding on `main` or `release`. - Preserve the signed SLSA provenance and add a signed, release-only deployment-eligibility predicate with source, build run, qualifying CI run, and exact compatible Buzz chart version. - Keep failed or pending builds untagged and without an eligibility predicate; preview-package artifacts are excluded by package and signer contract. - Add optional `image.digest` support to Buzz chart `0.1.8`, retaining the existing tag fallback. - Document verification and the intentional boundary between image/chart compatibility and backwards-compatible database migrations. ## Risk and rollout Canonical tag publication now waits for same-SHA CI and fails closed when the newest matching run fails. Per-architecture blobs may remain untagged after a failed qualification; they are not promotion inputs. Debug manifests are also CI-gated but do not receive the release eligibility predicate. There are no migration or schema-orchestration changes. The companion BuilderBot infrastructure PR will pin a separately verified canonical main digest and expose a read-only running-deployment inspection command. Signed eligibility is available only for artifacts published after this workflow lands. ## Verification - Blox: workflow contract fixtures, including stale-green rejection and predicate structure - Blox: `actionlint` for `ci.yml` and `docker.yml` - Blox: 46 Helm unit tests, chart lint, and production/quickstart render fixtures - Blox: relay status unit test, workspace Rust formatting, and workspace all-target clippy - Live evidence: exact-SHA CI selector chose successful run `32857345153` for source `9d1e4b257657f382d3111ce748f3da8d063b7671` - Existing canonical digest provenance verified with `gh attestation verify` ## References - #6709 — developer-preview precursor; intentionally separate from canonical eligibility - `docs/deployment-identity.md` *Generated with Codex* --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com>
645 lines
24 KiB
Rust
645 lines
24 KiB
Rust
//! axum routers — app (WebSocket + REST), health (K8s probes), metrics (Prometheus).
|
|
|
|
use std::sync::atomic::Ordering;
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
body::Body,
|
|
extract::{ConnectInfo, FromRequest, State, WebSocketUpgrade},
|
|
http::{HeaderMap, Request, StatusCode},
|
|
middleware,
|
|
response::{IntoResponse, Json},
|
|
routing::{get, post, put},
|
|
Router,
|
|
};
|
|
use serde_json::json;
|
|
use tower::ServiceExt;
|
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
|
use tower_http::limit::RequestBodyLimitLayer;
|
|
use tower_http::services::ServeDir;
|
|
use tower_http::trace::{HttpMakeClassifier, TraceLayer};
|
|
|
|
use crate::api;
|
|
use crate::audio;
|
|
use crate::connection::handle_connection;
|
|
use crate::metrics::track_metrics;
|
|
use crate::nip11::{nip11_document, relay_info_handler};
|
|
use crate::state::AppState;
|
|
|
|
/// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration.
|
|
///
|
|
/// Pure Nostr protocol: WebSocket (NIP-01), HTTP bridge (NIP-98), media (Blossom),
|
|
/// git (smart HTTP), NIP-05, and health probes.
|
|
pub fn build_router(state: Arc<AppState>) -> Router {
|
|
let media_body_limit = state
|
|
.config
|
|
.media
|
|
.max_image_bytes
|
|
.max(state.config.media.max_video_bytes) as usize;
|
|
let media_router = Router::new()
|
|
.route("/upload", put(api::media::upload_blob))
|
|
.route("/media/upload", put(api::media::upload_blob))
|
|
.route(
|
|
"/media/{sha256_ext}",
|
|
get(api::media::get_blob).head(api::media::head_blob),
|
|
)
|
|
.layer(RequestBodyLimitLayer::new(media_body_limit))
|
|
.with_state(state.clone());
|
|
|
|
let git_router = api::git::git_router(state.clone());
|
|
|
|
let git_policy_router = api::git::git_policy_router(state.clone());
|
|
|
|
let admin_enabled = state.config.admin.is_some();
|
|
let admin_web_dir = state
|
|
.config
|
|
.admin
|
|
.as_ref()
|
|
.and_then(|config| config.web_dir.clone());
|
|
let admin_router = admin_enabled
|
|
.then(|| Router::new().nest("/api/admin/v1", api::admin::router(state.clone())));
|
|
|
|
let api_router = Router::new()
|
|
// WebSocket + NIP-11
|
|
.route("/", get(nip11_or_ws_handler))
|
|
.route("/info", get(relay_info_handler))
|
|
.route("/.well-known/nostr.json", get(api::nip05::nostr_nip05))
|
|
// Health endpoints
|
|
.route("/health", get(health_handler))
|
|
.route("/_liveness", get(liveness_handler))
|
|
.route("/_readiness", get(readiness_handler))
|
|
// Nostr HTTP bridge (NIP-98 auth)
|
|
.route("/events", post(api::bridge::submit_event))
|
|
.route("/query", post(api::bridge::query_events))
|
|
.route("/count", post(api::bridge::count_events))
|
|
.route(
|
|
"/workflows/{workflow_id}/runs",
|
|
get(api::workflows::workflow_runs),
|
|
)
|
|
.route(
|
|
"/workflows/{workflow_id}/runs/{run_id}/approvals",
|
|
get(api::workflows::run_approvals),
|
|
)
|
|
.route(
|
|
"/operator/communities",
|
|
get(api::operator::list_owned_communities).post(api::operator::provision_community),
|
|
)
|
|
.route(
|
|
"/operator/communities/archive",
|
|
post(api::operator::archive_community),
|
|
)
|
|
.route(
|
|
"/operator/communities/unarchive",
|
|
post(api::operator::unarchive_community),
|
|
)
|
|
.route(
|
|
"/operator/communities/availability",
|
|
get(api::operator::community_availability),
|
|
)
|
|
.route(
|
|
"/operator/communities/transfer",
|
|
post(api::operator::transfer_community),
|
|
)
|
|
// Relay invites: mint (owner/admin) + claim (membership-gate exempt)
|
|
.route("/api/invites", post(api::invites::mint_invite))
|
|
.route("/api/join-policy", get(api::invites::join_policy))
|
|
// Policy documents as standalone pages — desktop opens these in the
|
|
// system browser instead of rendering the Markdown in-app.
|
|
.route(
|
|
"/api/join-policy/terms",
|
|
get(api::invites::join_policy_terms),
|
|
)
|
|
.route(
|
|
"/api/join-policy/privacy",
|
|
get(api::invites::join_policy_privacy),
|
|
)
|
|
.route(
|
|
"/api/invites/accept-policy",
|
|
post(api::invites::accept_policy),
|
|
)
|
|
.route("/api/invites/claim", post(api::invites::claim_invite))
|
|
// Moderation queue reads (NIP-98 auth + mod-authz gate, L6)
|
|
.route("/moderation/reports", get(api::bridge::moderation_reports))
|
|
.route("/moderation/audit", get(api::bridge::moderation_audit))
|
|
.route(
|
|
"/moderation/restricted",
|
|
get(api::bridge::moderation_restricted),
|
|
)
|
|
// Webhook trigger (secret-authenticated, no NIP-98)
|
|
.route("/hooks/{id}", post(api::bridge::workflow_webhook))
|
|
// Mesh demo echo probe — testbed-only; 404 unless BUZZ_MESH=on and
|
|
// BUZZ_MESH_DEMO_ECHO=on (see api::mesh_demo).
|
|
.route("/_mesh/demo/echo", post(api::mesh_demo::demo_echo))
|
|
// Huddle audio WebSocket route
|
|
.route(
|
|
"/huddle/{channel_id}/audio",
|
|
get(audio::handler::ws_audio_handler),
|
|
)
|
|
// Reject request bodies larger than 1 MB to prevent resource exhaustion.
|
|
.layer(RequestBodyLimitLayer::new(1024 * 1024))
|
|
.with_state(state.clone());
|
|
|
|
// Merge — each sub-router carries its own body limit.
|
|
// Metrics → Trace → CORS applied once over the combined router.
|
|
let mut merged = api_router
|
|
.merge(media_router)
|
|
.merge(git_router)
|
|
.merge(git_policy_router);
|
|
if let Some(admin_router) = admin_router {
|
|
merged = merged.merge(admin_router);
|
|
}
|
|
|
|
// Serve both bundles from one fallback. The admin host is checked first so
|
|
// it can never fall through to the public web bundle.
|
|
let web_dir = state.config.web_dir.clone();
|
|
if admin_web_dir.is_some() || web_dir.is_some() {
|
|
let admin_index = admin_web_dir.as_ref().map(|dir| dir.join("index.html"));
|
|
let admin_files = admin_web_dir.map(ServeDir::new);
|
|
let web_index = web_dir.as_ref().map(|dir| dir.join("index.html"));
|
|
let web_files = web_dir.map(ServeDir::new);
|
|
let serve_git_web_gui = state.config.serve_git_web_gui;
|
|
let fallback_state = state.clone();
|
|
let spa_fallback = tower::service_fn(move |req: axum::extract::Request| {
|
|
let admin_index = admin_index.clone();
|
|
let admin_files = admin_files.clone();
|
|
let web_index = web_index.clone();
|
|
let web_files = web_files.clone();
|
|
let state = fallback_state.clone();
|
|
async move {
|
|
let path = req.uri().path();
|
|
let admin_host = api::admin::is_admin_host(&state, req.headers());
|
|
if admin_host {
|
|
if let (Some(index), Some(files)) = (admin_index, admin_files) {
|
|
if path.starts_with("/assets/") {
|
|
return files.oneshot(req).await.map(IntoResponse::into_response);
|
|
}
|
|
if is_admin_spa_path(path) {
|
|
return Ok(read_spa_index(&index).await);
|
|
}
|
|
}
|
|
return Ok(StatusCode::NOT_FOUND.into_response());
|
|
}
|
|
|
|
if let (Some(index), Some(files)) = (web_index, web_files) {
|
|
if path.starts_with("/assets/") {
|
|
return files.oneshot(req).await.map(IntoResponse::into_response);
|
|
}
|
|
if should_serve_spa(path, serve_git_web_gui) {
|
|
return Ok(read_spa_index(&index).await);
|
|
}
|
|
}
|
|
Ok(StatusCode::NOT_FOUND.into_response())
|
|
}
|
|
});
|
|
merged = merged.fallback_service(spa_fallback);
|
|
}
|
|
|
|
merged
|
|
.layer(middleware::from_fn(track_metrics))
|
|
.layer(http_trace_layer())
|
|
.layer(build_cors_layer(&state.config.cors_origins))
|
|
}
|
|
|
|
fn http_trace_layer() -> TraceLayer<HttpMakeClassifier, fn(&Request<Body>) -> tracing::Span> {
|
|
TraceLayer::new_for_http().make_span_with(make_http_span as fn(&Request<Body>) -> tracing::Span)
|
|
}
|
|
|
|
fn make_http_span(request: &Request<Body>) -> tracing::Span {
|
|
tracing::info_span!(
|
|
target: "buzz_relay",
|
|
"http.request",
|
|
otel.kind = "server",
|
|
http.request.method = %request.method(),
|
|
)
|
|
}
|
|
|
|
fn is_admin_spa_path(path: &str) -> bool {
|
|
path == "/"
|
|
|| path == "/reports"
|
|
|| path.starts_with("/reports/")
|
|
|| path == "/feedback"
|
|
|| path.starts_with("/feedback/")
|
|
}
|
|
|
|
fn is_invite_landing_path(path: &str) -> bool {
|
|
path.strip_prefix("/invite/")
|
|
.is_some_and(|code| !code.is_empty() && !code.contains('/'))
|
|
}
|
|
|
|
fn should_serve_spa(path: &str, serve_git_web_gui: bool) -> bool {
|
|
is_invite_landing_path(path) || (serve_git_web_gui && is_git_web_gui_path(path))
|
|
}
|
|
|
|
fn is_git_web_gui_path(path: &str) -> bool {
|
|
path == "/" || path == "/repos" || path.starts_with("/repos/")
|
|
}
|
|
|
|
async fn read_spa_index(index: &std::path::Path) -> axum::response::Response {
|
|
match tokio::fs::read(index).await {
|
|
Ok(body) => axum::response::Html(body).into_response(),
|
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Build the health-only router for K8s probes (port 8080 in CAKE).
|
|
///
|
|
/// No metrics middleware, no auth, no CORS, no body limit.
|
|
pub fn build_health_router(state: Arc<AppState>) -> Router {
|
|
Router::new()
|
|
.route("/_liveness", get(liveness_handler))
|
|
.route("/_readiness", get(readiness_handler))
|
|
.route("/_status", get(status_handler))
|
|
.route("/_mesh", get(mesh_status_handler))
|
|
.with_state(state)
|
|
}
|
|
|
|
/// Content-negotiated: NIP-11 JSON for plain HTTP, WebSocket upgrade otherwise.
|
|
async fn nip11_or_ws_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
req: axum::extract::Request,
|
|
) -> impl IntoResponse {
|
|
let addr = req
|
|
.extensions()
|
|
.get::<ConnectInfo<std::net::SocketAddr>>()
|
|
.map(|ci| ci.0)
|
|
.unwrap_or_else(|| std::net::SocketAddr::from(([0, 0, 0, 0], 0)));
|
|
|
|
let accept = headers
|
|
.get("accept")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
|
|
let raw_host = headers
|
|
.get(axum::http::header::HOST)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
|
|
// `/` is an explicit relay route, so it never reaches the SPA fallback.
|
|
// Short-circuit the exact admin authority here and never let it serve the
|
|
// public web bundle, NIP-11 document, or WebSocket endpoint.
|
|
if api::admin::is_admin_host(&state, &headers) {
|
|
if !accept.contains("text/html") {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
}
|
|
let Some(index) = state
|
|
.config
|
|
.admin
|
|
.as_ref()
|
|
.and_then(|config| config.web_dir.as_ref())
|
|
.map(|dir| dir.join("index.html"))
|
|
else {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
};
|
|
return read_spa_index(&index).await;
|
|
}
|
|
|
|
if accept.contains("application/nostr+json") {
|
|
return Json(nip11_document(&state, raw_host).await).into_response();
|
|
}
|
|
|
|
// Row zero: bind the connection to its community from the request host
|
|
// BEFORE the WebSocket upgrade, so no frame is ever read on an unbound
|
|
// connection. The host is the authoritative selector; an unmapped host or a
|
|
// lookup failure fails closed with a generic rejection — never a default
|
|
// tenant. NIP-11 above is served before binding and stays fail-open: an
|
|
// unmapped host still gets the document (with host-scoped fields like
|
|
// `icon` simply absent), so the doc cannot leak which hosts are mapped.
|
|
let tenant = match crate::tenant::bind_community(&state.db, raw_host).await {
|
|
Ok(ctx) => ctx,
|
|
Err(_) => {
|
|
// Generic rejection: do not distinguish "unmapped" from "lookup
|
|
// error", and never echo the host, so an unauthenticated caller
|
|
// cannot probe which communities exist on this deployment.
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
"relay: no community is configured for this host",
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let max_frame_bytes = state.config.max_frame_bytes;
|
|
match WebSocketUpgrade::from_request(req, &state).await {
|
|
Ok(ws) => {
|
|
// Shutting down: refuse new sockets instead of accepting a
|
|
// connection onto a dying pod. Readiness already returns 503, but
|
|
// that only stops K8s routing — direct and in-flight upgrades
|
|
// still reach here during the pre-drain grace window. Clients
|
|
// treat the refusal as a normal dial failure and retry, landing
|
|
// on a healthy pod.
|
|
if state.shutting_down.load(Ordering::Relaxed) {
|
|
return (StatusCode::SERVICE_UNAVAILABLE, "relay restarting").into_response();
|
|
}
|
|
limit_relay_websocket(ws, max_frame_bytes)
|
|
.on_upgrade(move |socket| handle_connection(socket, state, addr, tenant))
|
|
.into_response()
|
|
}
|
|
Err(_) => {
|
|
// Browser requesting HTML and Git web GUI is enabled → serve SPA.
|
|
if state.config.serve_git_web_gui {
|
|
if let Some(ref dir) = state.config.web_dir {
|
|
if accept.contains("text/html") {
|
|
let index = dir.join("index.html");
|
|
if let Ok(body) = tokio::fs::read(&index).await {
|
|
return axum::response::Html(body).into_response();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
|
|
Json(nip11_document(&state, raw_host).await).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn limit_relay_websocket<F>(
|
|
ws: WebSocketUpgrade<F>,
|
|
max_frame_bytes: usize,
|
|
) -> WebSocketUpgrade<F> {
|
|
// recv_loop keeps the application-level check as defense in depth, but
|
|
// parser limits must be set before tungstenite assembles the message.
|
|
ws.max_message_size(max_frame_bytes)
|
|
.max_frame_size(max_frame_bytes)
|
|
}
|
|
|
|
async fn health_handler() -> impl IntoResponse {
|
|
(StatusCode::OK, "ok")
|
|
}
|
|
|
|
async fn liveness_handler() -> impl IntoResponse {
|
|
(StatusCode::OK, "ok")
|
|
}
|
|
|
|
/// Readiness probe — checks shutdown flag, Postgres, and Redis connectivity.
|
|
async fn readiness_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
use std::time::Duration;
|
|
|
|
if state.shutting_down.load(Ordering::Relaxed) {
|
|
return (
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
Json(json!({"status": "shutting_down"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let check = async {
|
|
let (pg_ok, redis_ok, deletion_catalog_ok) = tokio::join!(
|
|
state.db.ping(),
|
|
async { state.redis_pool.get().await.is_ok() },
|
|
async { state.db.validate_deletion_serving_catalog().await.is_ok() },
|
|
);
|
|
(pg_ok, redis_ok, deletion_catalog_ok)
|
|
};
|
|
|
|
let (pg_ok, redis_ok, deletion_catalog_ok) =
|
|
tokio::time::timeout(Duration::from_secs(2), check)
|
|
.await
|
|
.unwrap_or((false, false, false));
|
|
|
|
if pg_ok && redis_ok && deletion_catalog_ok {
|
|
(StatusCode::OK, Json(json!({"status": "ready"}))).into_response()
|
|
} else {
|
|
(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
Json(json!({
|
|
"status": "not_ready",
|
|
"postgres": pg_ok,
|
|
"redis": redis_ok,
|
|
"deletion_catalog": deletion_catalog_ok
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
fn status_payload(uptime_secs: u64) -> serde_json::Value {
|
|
json!({
|
|
"service": "buzz-relay",
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"uptime_seconds": uptime_secs,
|
|
"build": {
|
|
"source_sha": crate::build_info::source_sha(),
|
|
"id": crate::build_info::build_id(),
|
|
"url": crate::build_info::build_url(),
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Status endpoint — service name, version, uptime, and intrinsic build identity.
|
|
async fn status_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
Json(status_payload(state.started_at.elapsed().as_secs()))
|
|
}
|
|
|
|
/// `/_mesh` — live mesh status: peer table, connection/phi state, per-peer
|
|
/// counters, fence-rejection totals. Mesh-off reports `{"enabled": false}` so
|
|
/// operators can distinguish "off" from "on with zero peers".
|
|
async fn mesh_status_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
match state.mesh() {
|
|
Some(handle) => Json(serde_json::to_value(handle.status()).unwrap_or_else(
|
|
|e| json!({"enabled": true, "error": format!("status serialize: {e}")}),
|
|
)),
|
|
None => Json(json!({"enabled": false})),
|
|
}
|
|
}
|
|
|
|
/// Build a CORS layer from the configured origins list.
|
|
fn build_cors_layer(cors_origins: &[String]) -> CorsLayer {
|
|
if cors_origins.is_empty() {
|
|
return CorsLayer::permissive();
|
|
}
|
|
|
|
let origins: Vec<axum::http::HeaderValue> = cors_origins
|
|
.iter()
|
|
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
|
|
.collect();
|
|
|
|
if origins.is_empty() {
|
|
tracing::error!(
|
|
"BUZZ_CORS_ORIGINS set but no valid origins could be parsed — \
|
|
refusing to fall back to permissive CORS. Fix the origins or unset \
|
|
the variable for development mode."
|
|
);
|
|
return CorsLayer::new();
|
|
}
|
|
|
|
CorsLayer::new()
|
|
.allow_origin(AllowOrigin::list(origins))
|
|
.allow_methods(tower_http::cors::Any)
|
|
.allow_headers(tower_http::cors::Any)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use axum::{routing::get, Router};
|
|
use futures_util::SinkExt;
|
|
use opentelemetry::trace::TracerProvider as _;
|
|
use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider};
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::mpsc;
|
|
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
|
use tower::ServiceBuilder;
|
|
use tracing::Instrument as _;
|
|
use tracing_subscriber::prelude::*;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn invite_landing_path_requires_exactly_one_nonempty_code_segment() {
|
|
assert!(is_invite_landing_path("/invite/payload.mac"));
|
|
assert!(!is_invite_landing_path("/invite/"));
|
|
assert!(!is_invite_landing_path("/invite/code/extra"));
|
|
assert!(!is_invite_landing_path("/repos"));
|
|
assert!(!is_invite_landing_path("/"));
|
|
}
|
|
|
|
#[test]
|
|
fn git_web_gui_paths_are_explicit() {
|
|
assert!(is_git_web_gui_path("/"));
|
|
assert!(is_git_web_gui_path("/repos"));
|
|
assert!(is_git_web_gui_path("/repos/example"));
|
|
assert!(!is_git_web_gui_path("/repository"));
|
|
assert!(!is_git_web_gui_path("/arbitrary"));
|
|
assert!(!is_git_web_gui_path("/api/invites"));
|
|
}
|
|
|
|
#[test]
|
|
fn invite_is_always_served_but_git_gui_requires_opt_in() {
|
|
assert!(should_serve_spa("/invite/payload.mac", false));
|
|
assert!(should_serve_spa("/invite/payload.mac", true));
|
|
assert!(!should_serve_spa("/", false));
|
|
assert!(!should_serve_spa("/repos/example", false));
|
|
assert!(should_serve_spa("/", true));
|
|
assert!(should_serve_spa("/repos/example", true));
|
|
assert!(!should_serve_spa("/arbitrary", true));
|
|
}
|
|
|
|
#[test]
|
|
fn status_payload_exposes_source_and_build_identity() {
|
|
let payload = status_payload(42);
|
|
|
|
assert_eq!(payload["service"], "buzz-relay");
|
|
assert_eq!(payload["version"], env!("CARGO_PKG_VERSION"));
|
|
assert_eq!(payload["uptime_seconds"], 42);
|
|
for field in ["source_sha", "id", "url"] {
|
|
assert!(
|
|
payload["build"][field]
|
|
.as_str()
|
|
.is_some_and(|value| !value.is_empty()),
|
|
"build.{field} must be a non-empty string"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn http_and_datastore_spans_are_exported_in_the_same_trace() {
|
|
let exporter = InMemorySpanExporter::default();
|
|
let provider = SdkTracerProvider::builder()
|
|
.with_simple_exporter(exporter.clone())
|
|
.build();
|
|
let subscriber = tracing_subscriber::registry().with(
|
|
tracing_opentelemetry::layer()
|
|
.with_tracer(provider.tracer("test"))
|
|
.with_filter(crate::telemetry::otel_env_filter(None)),
|
|
);
|
|
let _subscriber_guard = tracing::subscriber::set_default(subscriber);
|
|
let service = ServiceBuilder::new()
|
|
.layer(http_trace_layer())
|
|
.service(tower::service_fn(
|
|
|_: axum::http::Request<axum::body::Body>| async {
|
|
async {}
|
|
.instrument(tracing::info_span!(
|
|
target: "buzz_datastore",
|
|
"SELECT",
|
|
otel.kind = "client",
|
|
db.system.name = "postgresql",
|
|
))
|
|
.await;
|
|
Ok::<_, std::convert::Infallible>(axum::response::Response::new(
|
|
axum::body::Body::empty(),
|
|
))
|
|
},
|
|
));
|
|
|
|
service
|
|
.oneshot(
|
|
axum::http::Request::get("/")
|
|
.body(axum::body::Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
provider.force_flush().unwrap();
|
|
let spans = exporter.get_finished_spans().unwrap();
|
|
let http = spans
|
|
.iter()
|
|
.find(|span| span.name == "http.request")
|
|
.unwrap();
|
|
let datastore = spans.iter().find(|span| span.name == "SELECT").unwrap();
|
|
|
|
assert_eq!(
|
|
datastore.span_context.trace_id(),
|
|
http.span_context.trace_id()
|
|
);
|
|
assert_eq!(datastore.parent_span_id, http.span_context.span_id());
|
|
}
|
|
|
|
async fn handler_receives_message_with_limit(limit: usize, size: usize) -> bool {
|
|
let (received_tx, mut received_rx) = mpsc::unbounded_channel();
|
|
let app = Router::new().route(
|
|
"/",
|
|
get(move |ws: WebSocketUpgrade| {
|
|
let received_tx = received_tx.clone();
|
|
async move {
|
|
limit_relay_websocket(ws, limit).on_upgrade(move |mut socket| async move {
|
|
let _ = received_tx.send(matches!(socket.recv().await, Some(Ok(_))));
|
|
})
|
|
}
|
|
}),
|
|
);
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind test WebSocket listener");
|
|
let addr = listener.local_addr().expect("test listener address");
|
|
let server = tokio::spawn(async move {
|
|
axum::serve(listener, app)
|
|
.await
|
|
.expect("test WebSocket server");
|
|
});
|
|
|
|
let (mut client, _) = connect_async(format!("ws://{addr}/"))
|
|
.await
|
|
.expect("connect test WebSocket client");
|
|
client
|
|
.send(Message::Text("x".repeat(size).into()))
|
|
.await
|
|
.expect("send test WebSocket message");
|
|
|
|
let received = tokio::time::timeout(std::time::Duration::from_secs(2), received_rx.recv())
|
|
.await
|
|
.expect("server should process the test message")
|
|
.expect("server should report whether it received the message");
|
|
|
|
server.abort();
|
|
let _ = server.await;
|
|
|
|
received
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relay_websocket_parser_rejects_oversized_messages_before_handler_reads_them() {
|
|
let limit = 64;
|
|
|
|
assert!(
|
|
handler_receives_message_with_limit(limit, limit).await,
|
|
"messages at the relay limit should still reach the handler"
|
|
);
|
|
assert!(
|
|
!handler_receives_message_with_limit(limit, limit + 1).await,
|
|
"oversized messages must be rejected by the WebSocket parser before the handler sees them"
|
|
);
|
|
}
|
|
}
|