blob: cbbb515ad6917853bcc5626d7e2dc979d8e1f4dd [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Shard-0 HTTP/REST listener. This root binds the listener and assembles the
//! router; the rest is split across submodules: the `state` bridge and axum
//! `State`, the bearer `extractor`, the `jwt` issuer and its `jwks` resolver,
//! the route `handlers`, the `reads` gates, the `submit` write paths, `wire`
//! request mapping, partition-write `admission`, committed-reply `reply`
//! decoding, the rejection `error` types, and per-credential `session` state.
mod admission;
mod error;
mod extractor;
mod forward;
mod handlers;
mod jwks;
mod jwt;
mod metrics;
mod reads;
mod reply;
mod session;
mod state;
mod submit;
mod tls;
#[cfg(feature = "iggy-web")]
mod web;
mod wire;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::fmt::Display;
use std::net::SocketAddr;
use std::rc::Rc;
use std::str::FromStr;
use std::sync::Arc;
use axum::Router;
use axum::extract::connect_info::Connected;
use axum::extract::{DefaultBodyLimit, Request};
use axum::http::{HeaderName, HeaderValue, Method, StatusCode, Version, header::CONNECTION};
use axum::middleware::{Next, from_fn, from_fn_with_state};
use axum::response::Response;
use axum::routing::{delete, get, post, put};
use compio::net::TcpListener;
use configs::cluster::{ClusterConfig, http_forwarding_key_material};
use configs::http::{HttpConfig, HttpCorsConfig};
use configs::server::ServerSystemConfig;
use iggy_common::IggyError;
use message_bus::client_listener;
use send_wrapper::SendWrapper;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tracing::{error, info, warn};
use crate::cluster_meta::ClusterRoster;
use crate::http::handlers::{
change_password, create_cg, create_partitions, create_pat, create_stream, create_topic,
create_user, delete_cg, delete_consumer_offset, delete_partitions, delete_pat, delete_segments,
delete_stream, delete_topic, delete_user, describe_options, get_cg, get_cgs, get_client,
get_clients, get_cluster_metadata, get_consumer_offset, get_pats, get_snapshot, get_stats,
get_stream, get_streams, get_topic, get_topics, get_user, get_users, login_user,
login_with_personal_access_token, logout_user, ping, poll_messages, purge_stream, purge_topic,
refresh_token, send_messages, store_consumer_offset, update_permissions, update_stream,
update_topic, update_user,
};
use crate::http::jwt::JwtManager;
use crate::http::session::RegistrationBarrier;
use crate::http::state::{ForwardState, HttpInner, HttpState, insert_view_header};
use crate::server_error::ServerError;
use crate::shell::ServerShard;
/// The shard-0 HTTP listener before its socket exists: the address to bind
/// and the config products the serve loop needs. Built first at boot, so a
/// bad `[http.*]` section fails before any listener accepts; [`Self::bind`]
/// opens the socket.
pub struct PreparedHttp {
addr: SocketAddr,
jwt: JwtManager,
forward: ForwardState,
cors: Option<CorsLayer>,
metrics_endpoint: Option<String>,
max_request_size: usize,
}
/// The shard-0 HTTP listener between bind and serve, held apart so the bound
/// port can be published (cluster roster, `current_config.toml`) before any
/// request is answered.
pub struct BoundHttp {
listener: TcpListener,
pub bound_addr: SocketAddr,
prepared: PreparedHttp,
}
/// Validate the HTTP config and build what the serve loop needs, without
/// opening the socket; [`PreparedHttp::bind`] binds it and [`start`] serves
/// it. The caller gates this to shard 0 and to `http.enabled`.
///
/// # Errors
///
/// Returns [`ServerError`] if the JWT manager cannot be built from
/// `http_config.jwt`, the `[http.cors]` config is invalid, the `[http.tls]`
/// forwarding credentials cannot be loaded, or the `[http.metrics]` endpoint
/// is not a route path.
pub fn prepare(
addr: SocketAddr,
http_config: &HttpConfig,
cluster: &ClusterConfig,
) -> Result<PreparedHttp, ServerError> {
// In cluster mode with no configured JWT secret the signing key derives
// from the cluster PSK, so a bearer minted on any node verifies on every
// node - the invariant follower-to-primary forwarding depends on.
let cluster_psk =
(cluster.enabled && cluster.auth.enabled && !cluster.auth.shared_secret.is_empty())
.then_some(cluster.auth.shared_secret.as_str());
let jwt = JwtManager::build(&http_config.jwt, cluster_psk)?;
// Forwarding needs a bearer every node can verify; without key material it
// degrades to off (followers answer the transient 503) instead of failing
// the boot, so keyless clusters still serve HTTP node-locally.
let forwarding_active = http_forwarding_key_material(&http_config.jwt, cluster);
if cluster.enabled && !forwarding_active {
warn!(
"cluster mode with http enabled but no http.jwt secrets and no cluster.auth: bearers are node-local and follower-to-primary forwarding is disabled - control-plane writes on followers answer a transient 503; configure http.jwt encoding/decoding secrets or enable cluster.auth (identical on every node) to activate forwarding"
);
}
// Saturating: a configured limit past the pointer width (32-bit target,
// >4 GiB value) clamps to the largest enforceable cap instead of wrapping.
let max_request_size =
usize::try_from(http_config.max_request_size.as_bytes_u64()).unwrap_or(usize::MAX);
let forward =
forward::build_forward_state(&http_config.tls, max_request_size, forwarding_active)?;
// Validated here, before the socket opens, so a bad [http.cors] fails boot
// before the "started" log prints.
let cors = http_config
.cors
.enabled
.then(|| configure_cors(&http_config.cors))
.transpose()?;
// Same early-fail rule for the scrape path: axum panics on a route
// without a leading '/', so reject it as a config error instead.
let metrics_endpoint = metrics::validated_endpoint(&http_config.metrics)?;
Ok(PreparedHttp {
addr,
jwt,
forward,
cors,
metrics_endpoint,
max_request_size,
})
}
impl PreparedHttp {
/// Open the socket. Kept apart from [`prepare`] so the caller can order it
/// after any boot step that may block, and the port never sits listening
/// with nothing serving it.
///
/// # Errors
///
/// Returns [`ServerError`] if the listener cannot bind to the prepared
/// address.
pub fn bind(self) -> Result<BoundHttp, ServerError> {
let (listener, bound_addr) = client_listener::tcp::bind(self.addr).map_err(|source| {
error!(addr = %self.addr, error = %source, "failed to bind HTTP listener");
source
})?;
Ok(BoundHttp {
listener,
bound_addr,
prepared: self,
})
}
}
/// Assemble the router over a bound listener and spawn the `cyper-axum`
/// serve loop as a background task on shard 0's compio runtime. Serves HTTPS
/// when `http.tls.enabled` (a TLS accept pump feeds handshaken streams to the
/// serve loop, see [`mod@tls`]), plain HTTP otherwise; the listener stops
/// when the bus shutdown token fires.
///
/// `roster` is shard 0's own [`ClusterRoster`], so the HTTP and binary
/// cluster-metadata reads serve one truth.
///
/// # Errors
///
/// Returns [`ServerError`] if the `[http.tls]` server credentials cannot be
/// loaded.
#[allow(clippy::too_many_arguments)]
pub fn start(
bound: BoundHttp,
shard: &Rc<ServerShard>,
http_config: &HttpConfig,
clients_table_max: usize,
max_tokens_per_user: u32,
system_config: Arc<ServerSystemConfig>,
roster: Rc<ClusterRoster>,
shard_metrics_all: &[shard::metrics::ShardMetrics],
) -> Result<(), ServerError> {
let BoundHttp {
listener,
bound_addr,
prepared:
PreparedHttp {
jwt,
forward,
cors,
metrics_endpoint,
max_request_size,
..
},
} = bound;
let state: HttpState = SendWrapper::new(Rc::new(HttpInner {
shard: Rc::clone(shard),
jwt,
system_config,
sessions: RefCell::new(HashMap::new()),
registrations: RegistrationBarrier::default(),
roster,
max_http_sessions: crate::http::session::max_http_sessions(clients_table_max),
max_tokens_per_user,
in_flight_writes: Cell::new(0),
forward,
metrics: metrics::HttpMetrics::init(shard_metrics_all),
}));
let app = router(
state,
max_request_size,
cors,
metrics_endpoint.as_deref(),
http_config.web_ui,
);
if http_config.tls.enabled {
let server_config = tls::load_http_tls_server_config(&http_config.tls)?;
let (connections, pump) = tls::spawn_accept_pump(
listener,
server_config,
shard.bus.config().handshake_grace,
shard.bus.token(),
);
shard.bus.track_background(pump);
info!(address = %bound_addr, "server HTTPS listener started");
let handle = compio::runtime::spawn(tls::serve(connections, app, shard.bus.token()));
shard.bus.track_background(handle);
} else {
info!(address = %bound_addr, "server HTTP listener started");
let shutdown = shard.bus.token();
let handle = compio::runtime::spawn(async move {
if let Err(error) = cyper_axum::serve(
listener,
app.into_make_service_with_connect_info::<ClientAddr>(),
)
.with_graceful_shutdown(async move { shutdown.wait().await })
.await
{
error!(%error, "server HTTP listener terminated with error");
}
});
shard.bus.track_background(handle);
}
Ok(())
}
/// Connect-info payload carrying the peer socket address of an HTTP client.
///
/// The plain listener records it through axum's connect-info make-service
/// (the [`Connected`] impl below); the TLS path cannot (its hand-rolled serve
/// loop bypasses the make-service), so `tls::serve` injects the identical
/// `ConnectInfo<ClientAddr>` extension per connection instead. Handlers read
/// it through the `Identity` extractor's `client_ip`, which picks the
/// advertised address a client is told about - never authorization.
#[derive(Debug, Clone, Copy)]
pub struct ClientAddr(pub SocketAddr);
impl Connected<cyper_axum::IncomingStream<'_, TcpListener>> for ClientAddr {
fn connect_info(stream: cyper_axum::IncomingStream<'_, TcpListener>) -> Self {
Self(*stream.remote_addr())
}
}
/// Health-probe path. Public and pre-auth, and the one success route reached
/// without proving a credential, so the `Iggy-View` layer withholds the
/// cluster-internal view number here (see the response layer below).
const PING_PATH: &str = "/ping";
/// Assemble the shard-0 router: unauthenticated health + login routes plus the
/// authenticated REST surface.
///
/// The surface is split by consensus dependency. The control-plane routes -
/// whose writes all commit through the metadata consensus group - carry the
/// `forward_to_primary` route layer, so on a follower they are relayed to the
/// primary instead of failing with a transient 503 (reads under that layer
/// still serve locally unless `?consistency=linearizable`). The local routes
/// never need the metadata primary: health, the login/refresh flows (STM read
/// + JWT mint), node-local reads, and the partition-plane routes.
///
/// `max_request_size` becomes the router-wide `DefaultBodyLimit` (413 past
/// it), exactly like the legacy server: it bounds the per-request term of the
/// admission math - what one body may cost in bytes and decode CPU - while
/// the in-flight caps bound the multiplier. Those 413s reject the body unread,
/// so they are stamped `Connection: close` (see below).
///
/// `cors`, present only when `[http.cors]` is enabled, is applied as the
/// outermost layer. This node authenticates per route (the `Authenticated` /
/// `Identity` extractors), so a preflight `OPTIONS` - which carries no
/// `Authorization` header and matches none of the method routes - would 401 or
/// 405 if it reached the router; the outermost `CorsLayer` answers it first
/// instead, and stamps the CORS response headers over every reply, including
/// the inner layer's `iggy-view`.
///
/// `metrics_endpoint`, present only when `[http.metrics]` is enabled, mounts
/// the auth-only scrape route among the local routes (a scrape must describe
/// the serving node, never a forwarded primary) and switches on the
/// request-counting layer.
fn router(
state: HttpState,
max_request_size: usize,
cors: Option<CorsLayer>,
metrics_endpoint: Option<&str>,
web_ui: bool,
) -> Router {
// Cloned for the response layer so `Iggy-View` reads the live view per
// response; the original `state` is moved into `with_state` below.
let view_source = state.clone();
// Counter handle taken before `state` moves below; the counting layer
// shares it with the registry the scrape handler encodes.
let http_requests = metrics_endpoint
.is_some()
.then(|| state.metrics.request_counter());
let forwardable = forwardable_routes(state.clone());
let partition_writes = partition_write_routes(state.clone());
let local = Router::new()
.route(PING_PATH, get(ping))
.route("/users/login", post(login_user))
.route("/users/refresh-token", post(refresh_token))
.route(
"/personal-access-tokens/login",
post(login_with_personal_access_token),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/messages",
get(poll_messages),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-offsets",
get(get_consumer_offset),
)
.route("/stats", get(get_stats))
.route("/options/{scope}", get(describe_options))
.route("/snapshot", post(get_snapshot))
.route("/cluster/metadata", get(get_cluster_metadata))
.route("/clients", get(get_clients))
.route("/clients/{client_id}", get(get_client));
let local = match metrics_endpoint {
Some(endpoint) => local.route(endpoint, get(handlers::get_metrics)),
None => local,
};
let router = Router::new()
.merge(forwardable)
.merge(partition_writes)
.merge(local)
.with_state(state)
.layer(DefaultBodyLimit::max(max_request_size))
.layer(from_fn(move |request: Request, next: Next| {
let view_source = view_source.clone();
// `/ping` is the one success route reached without proving a
// credential, so it must not leak the cluster-internal view number
// (the anon-leak gate). Every other route - the metrics scrape
// included - authenticates before its handler, so a success or
// redirect there is an authed flow that may carry the header; the
// login routes prove credentials on success.
let suppress_view = request.uri().path() == PING_PATH;
async move {
let response = next.run(request).await;
if suppress_view {
response
} else {
insert_view_header(&view_source, response)
}
}
}))
.layer(from_fn(close_on_payload_too_large));
let router = match cors {
Some(cors) => router.layer(cors),
None => router,
};
// Outermost, mirroring the legacy server's wiring: every request is
// counted, including CORS preflights answered by the layer beneath.
let router = match http_requests {
Some(http_requests) => router.layer(from_fn(move |request: Request, next: Next| {
http_requests.inc();
async move { next.run(request).await }
})),
None => router,
};
// `with_state(())` finalizes every route eagerly, once for the whole
// listener - including the web-ui routes merged after the stateful
// `with_state` above, which would otherwise stay boxed handlers that
// axum rebuilds per request. Identity on already-finalized routes, so
// both the plain and the TLS serve paths share the finalized form.
merge_web_ui(router, web_ui).with_state(())
}
/// Acknowledged partition writes use a bounded HTTP roster fallback. This is
/// a correctness path for the existing stateless HTTP transport. Direct
/// partition-primary routing remains the scalable long-term design.
fn partition_write_routes(state: HttpState) -> Router<HttpState> {
Router::new()
.route(
"/streams/{stream_id}/topics/{topic_id}/messages",
post(send_messages),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-offsets",
put(store_consumer_offset),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-offsets/{consumer_id}",
delete(delete_consumer_offset),
)
.route_layer(from_fn_with_state(state, forward::forward_partition_write))
}
/// The control-plane route table: every write here commits through the
/// metadata consensus group, so the shared `forward_to_primary` route layer
/// (holding `state`) relays them on a follower.
fn forwardable_routes(state: HttpState) -> Router<HttpState> {
Router::new()
.route("/users", get(get_users).post(create_user))
.route(
"/users/{user_id}",
get(get_user).put(update_user).delete(delete_user),
)
.route("/users/{user_id}/password", put(change_password))
.route("/users/{user_id}/permissions", put(update_permissions))
// Static `logout` outranks the `{user_id}` capture in axum's matcher, so
// `DELETE /users/logout` never misroutes to `delete_user`.
.route("/users/logout", delete(logout_user))
.route("/streams", get(get_streams).post(create_stream))
.route(
"/streams/{stream_id}",
get(get_stream).put(update_stream).delete(delete_stream),
)
.route("/streams/{stream_id}/purge", delete(purge_stream))
.route(
"/streams/{stream_id}/topics",
get(get_topics).post(create_topic),
)
.route(
"/streams/{stream_id}/topics/{topic_id}",
get(get_topic).put(update_topic).delete(delete_topic),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/purge",
delete(purge_topic),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/partitions",
post(create_partitions).delete(delete_partitions),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/partitions/{partition_id}",
delete(delete_segments),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-groups",
get(get_cgs).post(create_cg),
)
.route(
"/streams/{stream_id}/topics/{topic_id}/consumer-groups/{group_id}",
get(get_cg).delete(delete_cg),
)
.route("/personal-access-tokens", get(get_pats).post(create_pat))
.route("/personal-access-tokens/{name}", delete(delete_pat))
.route_layer(from_fn_with_state(state, forward::forward_to_primary))
}
/// Stamp `Connection: close` on a 413, which rejects its request body unread.
///
/// hyper decides to close only once it notices the dropped body, and that is
/// after the response head is already encoded: the reply advertises keep-alive,
/// the peer pools a connection the server has stopped reading, and the next
/// request sent on it dies on a clean EOF. A caller-set `Connection` is written
/// through untouched, so setting it here has the peer retire the connection.
///
/// HTTP/2 carries no connection-level headers, so h2 replies are left alone
/// rather than handed a header hyper would strip and warn about.
async fn close_on_payload_too_large(request: Request, next: Next) -> Response {
let http2 = request.version() == Version::HTTP_2;
let mut response = next.run(request).await;
if !http2 && response.status() == StatusCode::PAYLOAD_TOO_LARGE {
response
.headers_mut()
.insert(CONNECTION, HeaderValue::from_static("close"));
}
response
}
/// Merge the unauthenticated `/ui` static-asset surface when `web_ui` is set.
///
/// The caller merges this outermost - after `with_state`, the body limit, the
/// view-header layer, and CORS - mirroring the legacy server. Staying past the
/// view-header layer also keeps the cluster-internal view number off these
/// unauthenticated responses, the same anon-leak gate `/ping` gets above.
///
/// Without the `iggy-web` feature the assets are not compiled in, so an enabled
/// flag only warns instead of serving.
fn merge_web_ui(router: Router, web_ui: bool) -> Router {
#[cfg(feature = "iggy-web")]
let router = if web_ui {
info!("Web UI enabled at /ui");
router.merge(web::router())
} else {
router
};
#[cfg(not(feature = "iggy-web"))]
if web_ui {
tracing::warn!(
"Web UI is enabled in configuration (http.web_ui = true) but the server \
was not compiled with 'iggy-web' feature. The Web UI will not be available. \
To enable it, rebuild the server with: cargo build --features iggy-web"
);
}
router
}
/// Build the [`CorsLayer`] from `[http.cors]`, porting the legacy server's
/// mapping: an empty origin list yields the tower-http default, a leading `*`
/// allows any origin (later entries are ignored), and anything else is an
/// explicit allow-list. Entries are trimmed and blank ones dropped, so a
/// placeholder like `[""]` maps to "none" rather than a parse error. Methods,
/// allowed headers, and exposed headers map the same way; any unparsable value
/// fails the build.
///
/// Combinations tower-http would reject by panicking (a `*` origin past the
/// first position, or `allow_credentials` with a wildcard origin, header, or
/// exposed-header list) are rejected here as configuration errors instead.
///
/// # Errors
///
/// Returns [`IggyError::InvalidConfiguration`] if an origin or header value is
/// not a valid header token, a method is not one of the standard HTTP verbs,
/// `*` appears past the first origin, or `allow_credentials` is combined with
/// a wildcard origin, header, or exposed-header list.
fn configure_cors(config: &HttpCorsConfig) -> Result<CorsLayer, IggyError> {
let wildcard_origin = config
.allowed_origins
.first()
.is_some_and(|origin| origin.trim() == "*");
let allowed_origins = match config.allowed_origins.as_slice() {
[] => AllowOrigin::default(),
_ if wildcard_origin => AllowOrigin::any(),
// `AllowOrigin::list` panics on a wildcard entry, so past the first
// position `*` is a config mistake, not "any origin".
origins if origins.iter().any(|origin| origin.trim() == "*") => {
error!("invalid CORS allowed_origins: \"*\" is honored only as the first entry");
return Err(IggyError::InvalidConfiguration);
}
origins => AllowOrigin::list(parse_cors_values::<HeaderValue>(origins, "origin")?),
};
let allowed_headers = parse_cors_values::<HeaderName>(&config.allowed_headers, "header")?;
let exposed_headers =
parse_cors_values::<HeaderName>(&config.exposed_headers, "exposed header")?;
let allowed_methods = parse_cors_methods(&config.allowed_methods)?;
// tower-http rejects credentials combined with any wildcard by panicking
// once the layer is applied to the router; catch those combinations here
// so they fail as configuration errors instead.
if config.allow_credentials {
let wildcard_field = if wildcard_origin {
Some("allowed_origins")
} else if is_wildcard_header_list(&allowed_headers) {
Some("allowed_headers")
} else if is_wildcard_header_list(&exposed_headers) {
Some("exposed_headers")
} else {
None
};
if let Some(field) = wildcard_field {
error!(
"invalid CORS config: allow_credentials cannot be combined with wildcard {field}"
);
return Err(IggyError::InvalidConfiguration);
}
}
Ok(CorsLayer::new()
.allow_methods(allowed_methods)
.allow_origin(allowed_origins)
.allow_headers(allowed_headers)
.expose_headers(exposed_headers)
.allow_credentials(config.allow_credentials)
.allow_private_network(config.allow_private_network))
}
/// Parse a CORS string list into header values or names, trimming entries and
/// skipping blank ones so a placeholder like `[""]` yields an empty set.
/// `label` names the field in the diagnostic log for an unparsable entry.
fn parse_cors_values<T>(values: &[String], label: &str) -> Result<Vec<T>, IggyError>
where
T: FromStr,
T::Err: Display,
{
values
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(|value| {
value.parse::<T>().map_err(|error| {
error!(%value, %error, "invalid CORS {label}");
IggyError::InvalidConfiguration
})
})
.collect()
}
/// Map the configured method names onto the standard HTTP verbs, trimming
/// entries and skipping blank ones. A name outside the standard set is rejected
/// (rather than accepted as a custom method token) so a typo fails the config
/// loudly.
fn parse_cors_methods(methods: &[String]) -> Result<Vec<Method>, IggyError> {
methods
.iter()
.map(|method| method.trim())
.filter(|method| !method.is_empty())
.map(|method| match method.to_uppercase().as_str() {
"GET" => Ok(Method::GET),
"POST" => Ok(Method::POST),
"PUT" => Ok(Method::PUT),
"DELETE" => Ok(Method::DELETE),
"HEAD" => Ok(Method::HEAD),
"OPTIONS" => Ok(Method::OPTIONS),
"CONNECT" => Ok(Method::CONNECT),
"PATCH" => Ok(Method::PATCH),
"TRACE" => Ok(Method::TRACE),
other => {
error!(method = %other, "invalid CORS method");
Err(IggyError::InvalidConfiguration)
}
})
.collect()
}
/// The exact shape tower-http treats as a wildcard header set: a lone `*`.
/// (`["*", "x"]` joins to the literal value `*,x`, which is not a wildcard.)
fn is_wildcard_header_list(headers: &[HeaderName]) -> bool {
matches!(headers, [only] if only.as_str() == "*")
}
#[cfg(test)]
mod tests {
use super::*;
fn cors_config() -> HttpCorsConfig {
HttpCorsConfig {
enabled: true,
allowed_methods: vec!["GET".to_owned(), "POST".to_owned()],
allowed_origins: vec!["*".to_owned()],
allowed_headers: vec!["content-type".to_owned(), "authorization".to_owned()],
exposed_headers: vec!["iggy-view".to_owned()],
allow_credentials: false,
allow_private_network: false,
}
}
#[test]
fn configure_cors_accepts_wildcard_origin() {
assert!(configure_cors(&cors_config()).is_ok());
}
#[test]
fn configure_cors_accepts_explicit_origins() {
let config = HttpCorsConfig {
allowed_origins: vec![
"http://localhost:3000".to_owned(),
"https://app.example.com".to_owned(),
],
..cors_config()
};
assert!(configure_cors(&config).is_ok());
}
#[test]
fn configure_cors_accepts_credentials_with_explicit_origin() {
let config = HttpCorsConfig {
allowed_origins: vec!["https://app.example.com".to_owned()],
allow_credentials: true,
..cors_config()
};
assert!(configure_cors(&config).is_ok());
}
#[test]
fn configure_cors_skips_blank_entries() {
// The shipped placeholder shape: a single empty string must map to an
// empty set, not a parse error.
let config = HttpCorsConfig {
allowed_origins: vec!["https://app.example.com".to_owned()],
exposed_headers: vec![String::new()],
..cors_config()
};
assert!(configure_cors(&config).is_ok());
}
#[test]
fn configure_cors_accepts_wildcard_headers_without_credentials() {
let config = HttpCorsConfig {
allowed_headers: vec!["*".to_owned()],
exposed_headers: vec!["*".to_owned()],
..cors_config()
};
assert!(configure_cors(&config).is_ok());
}
#[test]
fn configure_cors_trims_entries() {
let config = HttpCorsConfig {
allowed_origins: vec![" https://app.example.com ".to_owned()],
allowed_headers: vec![" content-type ".to_owned()],
allowed_methods: vec![" get ".to_owned()],
..cors_config()
};
assert!(configure_cors(&config).is_ok());
}
#[test]
fn configure_cors_rejects_wildcard_origin_after_first() {
// tower-http's `AllowOrigin::list` panics on a wildcard entry; the
// guard must turn it into a clean config error.
let config = HttpCorsConfig {
allowed_origins: vec!["https://app.example.com".to_owned(), "*".to_owned()],
..cors_config()
};
assert!(matches!(
configure_cors(&config),
Err(IggyError::InvalidConfiguration)
));
}
#[test]
fn configure_cors_rejects_credentials_with_wildcard_origin() {
// tower-http's `ensure_usable_cors_rules` panics on this combination
// when the layer is applied; the guard must turn it into a clean
// config error.
let config = HttpCorsConfig {
allow_credentials: true,
..cors_config()
};
assert!(matches!(
configure_cors(&config),
Err(IggyError::InvalidConfiguration)
));
}
#[test]
fn configure_cors_rejects_credentials_with_wildcard_headers() {
let config = HttpCorsConfig {
allowed_origins: vec!["https://app.example.com".to_owned()],
allowed_headers: vec!["*".to_owned()],
allow_credentials: true,
..cors_config()
};
assert!(matches!(
configure_cors(&config),
Err(IggyError::InvalidConfiguration)
));
}
#[test]
fn configure_cors_rejects_credentials_with_wildcard_exposed_headers() {
let config = HttpCorsConfig {
allowed_origins: vec!["https://app.example.com".to_owned()],
exposed_headers: vec!["*".to_owned()],
allow_credentials: true,
..cors_config()
};
assert!(matches!(
configure_cors(&config),
Err(IggyError::InvalidConfiguration)
));
}
#[test]
fn configure_cors_rejects_invalid_origin() {
let config = HttpCorsConfig {
allowed_origins: vec!["http://bad\norigin".to_owned()],
..cors_config()
};
assert!(matches!(
configure_cors(&config),
Err(IggyError::InvalidConfiguration)
));
}
#[test]
fn configure_cors_rejects_invalid_header() {
let config = HttpCorsConfig {
allowed_headers: vec!["invalid header".to_owned()],
..cors_config()
};
assert!(matches!(
configure_cors(&config),
Err(IggyError::InvalidConfiguration)
));
}
#[test]
fn configure_cors_rejects_unknown_method() {
let config = HttpCorsConfig {
allowed_methods: vec!["FOOBAR".to_owned()],
..cors_config()
};
assert!(matches!(
configure_cors(&config),
Err(IggyError::InvalidConfiguration)
));
}
}