1use axum::{
2 extract::State,
3 http::Request,
4 middleware::Next,
5 response::{IntoResponse, Response},
6};
7use http_body::Body as HttpBody;
8use pin_project_lite::pin_project;
9use secrecy::ExposeSecret;
10use std::future::Future;
11use std::pin::Pin;
12use std::task::{Context, Poll};
13
14tokio::task_local! {
16 pub static CURRENT_TENANT: Option<String>;
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Tenant(pub String);
22
23impl axum::extract::FromRequestParts<crate::AppState> for Tenant {
24 type Rejection = crate::AutumnError;
25
26 async fn from_request_parts(
27 parts: &mut axum::http::request::Parts,
28 state: &crate::AppState,
29 ) -> Result<Self, Self::Rejection> {
30 if let Ok(Some(tenant_id)) = CURRENT_TENANT.try_with(Clone::clone) {
34 return Ok(Self(tenant_id));
35 }
36
37 let config = state
38 .extension::<crate::config::AutumnConfig>()
39 .ok_or_else(|| {
40 crate::AutumnError::service_unavailable_msg("Config is not available")
41 })?;
42 let tenant_id = extract_tenant_from_parts(parts, &config).await?;
43 Ok(Self(tenant_id))
44 }
45}
46
47pub async fn with_tenant<F, R>(tenant_id: String, future: F) -> R
49where
50 F: Future<Output = R>,
51{
52 CURRENT_TENANT.scope(Some(tenant_id), future).await
53}
54
55#[allow(clippy::missing_errors_doc, clippy::too_many_lines)]
57pub async fn extract_tenant_from_parts(
58 parts: &mut axum::http::request::Parts,
59 config: &crate::config::AutumnConfig,
60) -> Result<String, crate::AutumnError> {
61 if !config.tenancy.enabled {
62 return Err(crate::AutumnError::service_unavailable_msg(
63 "Tenancy is not enabled; set [tenancy] enabled = true in autumn.toml",
64 ));
65 }
66
67 match config.tenancy.source.as_str() {
68 "header" => {
69 let header_value = parts
70 .headers
71 .get(&config.tenancy.header_name)
72 .ok_or_else(|| {
73 crate::AutumnError::bad_request_msg(format!(
74 "Missing required tenant header: {}",
75 config.tenancy.header_name
76 ))
77 })?;
78 let val = header_value
79 .to_str()
80 .map_err(|_| {
81 crate::AutumnError::bad_request_msg(format!(
82 "Invalid UTF-8 in tenant header: {}",
83 config.tenancy.header_name
84 ))
85 })?
86 .to_string();
87 if val.trim().is_empty() {
88 return Err(crate::AutumnError::bad_request_msg(format!(
89 "Tenant header {} is empty",
90 config.tenancy.header_name
91 )));
92 }
93 Ok(val)
94 }
95 "subdomain" => {
96 let host_owned: String = parts
99 .extensions
100 .get::<crate::security::ResolvedClientIdentity>()
101 .and_then(|id| id.host.clone())
102 .map_or_else(
103 || {
104 parts
105 .headers
106 .get(axum::http::header::HOST)
107 .ok_or_else(|| {
108 crate::AutumnError::bad_request_msg(
109 "Missing Host header for subdomain tenancy",
110 )
111 })
112 .and_then(|h| {
113 h.to_str().map(ToOwned::to_owned).map_err(|_| {
114 crate::AutumnError::bad_request_msg(
115 "Invalid UTF-8 in Host header",
116 )
117 })
118 })
119 },
120 Ok,
121 )?;
122
123 let host = host_owned.as_str();
124 let host_only = host.split(':').next().unwrap_or(host).trim();
125
126 if host_only.parse::<std::net::IpAddr>().is_ok() {
127 return Err(crate::AutumnError::bad_request_msg(
128 "IP address host not allowed in subdomain mode",
129 ));
130 }
131
132 let host_lower = host_only.to_lowercase();
135
136 if let Some(ref base_domain) = config.tenancy.base_domain {
137 let base_domain_clean = base_domain.trim().to_lowercase();
138 if !host_lower.ends_with(base_domain_clean.as_str()) {
139 return Err(crate::AutumnError::bad_request_msg(format!(
140 "Host does not match base domain: {base_domain_clean}"
141 )));
142 }
143 if host_lower.len() <= base_domain_clean.len() {
144 return Err(crate::AutumnError::bad_request_msg(
145 "Apex domain not allowed in subdomain mode",
146 ));
147 }
148 let prefix_len = host_lower.len() - base_domain_clean.len();
149 if !host_lower[..prefix_len].ends_with('.') {
150 return Err(crate::AutumnError::bad_request_msg(
151 "Invalid subdomain format",
152 ));
153 }
154 let subdomain_part = &host_lower[..prefix_len - 1];
155 let tenant = subdomain_part.split('.').next().ok_or_else(|| {
156 crate::AutumnError::bad_request_msg("Unable to extract subdomain tenant")
157 })?;
158 if tenant.trim().is_empty() {
159 return Err(crate::AutumnError::bad_request_msg(
160 "Extracted subdomain tenant is empty",
161 ));
162 }
163 Ok(tenant.to_string())
164 } else {
165 let labels: Vec<&str> = host_lower.split('.').filter(|s| !s.is_empty()).collect();
166 if labels.is_empty() {
167 return Err(crate::AutumnError::bad_request_msg("Empty host header"));
168 }
169
170 if labels.len() < 2 {
171 return Err(crate::AutumnError::bad_request_msg(
172 "Apex or local host without subdomain not allowed",
173 ));
174 }
175
176 if labels.len() == 2 && labels[1] != "localhost" {
177 return Err(crate::AutumnError::bad_request_msg(
178 "Apex domain not allowed in subdomain mode",
179 ));
180 }
181
182 let tenant = labels[0].to_string();
183 if tenant.trim().is_empty() {
184 return Err(crate::AutumnError::bad_request_msg(
185 "Extracted subdomain tenant is empty",
186 ));
187 }
188 Ok(tenant)
189 }
190 }
191 "session" => {
192 let session = parts
193 .extensions
194 .get::<crate::session::Session>()
195 .ok_or_else(|| {
196 crate::AutumnError::internal_server_error_msg(
197 "SessionLayer not installed but session tenancy source is configured",
198 )
199 })?;
200 let tenant = session
201 .get(&config.tenancy.session_key)
202 .await
203 .ok_or_else(|| {
204 crate::AutumnError::unauthorized_msg(format!(
205 "Tenant ID missing from session key: {}",
206 config.tenancy.session_key
207 ))
208 })?;
209 if tenant.trim().is_empty() {
210 return Err(crate::AutumnError::unauthorized_msg(format!(
211 "Tenant ID in session key {} is empty",
212 config.tenancy.session_key
213 )));
214 }
215 Ok(tenant)
216 }
217 "jwt" => {
218 let auth_header = parts
219 .headers
220 .get(axum::http::header::AUTHORIZATION)
221 .ok_or_else(|| {
222 crate::AutumnError::unauthorized_msg(
223 "Missing Authorization header for JWT tenancy",
224 )
225 })?;
226 let auth_str = auth_header.to_str().map_err(|_| {
227 crate::AutumnError::unauthorized_msg("Invalid UTF-8 in Authorization header")
228 })?;
229
230 if auth_str.len() < 7
231 || !auth_str.is_char_boundary(7)
232 || !auth_str[..7].eq_ignore_ascii_case("bearer ")
233 {
234 return Err(crate::AutumnError::unauthorized_msg(
235 "Invalid Authorization header format. Expected Bearer <token>",
236 ));
237 }
238 let token = &auth_str[7..];
239
240 let secret = config.tenancy.jwt_secret.as_ref().ok_or_else(|| {
241 crate::AutumnError::unauthorized_msg("JWT secret is not configured")
242 })?;
243
244 let mut validation = ::jsonwebtoken::Validation::default();
245 if let Some(ref iss) = config.tenancy.jwt_issuer {
246 validation.set_issuer(::std::slice::from_ref(iss));
247 }
248 if let Some(ref aud) = config.tenancy.jwt_audience {
249 validation.set_audience(&[aud.as_str()]);
250 } else {
251 validation.validate_aud = false;
252 }
253
254 let token_data = ::jsonwebtoken::decode::<serde_json::Value>(
255 token,
256 &::jsonwebtoken::DecodingKey::from_secret(secret.expose_secret().as_bytes()),
257 &validation,
258 )
259 .map_err(|e| {
260 crate::AutumnError::unauthorized_msg(format!("JWT validation failed: {e}"))
261 })?;
262
263 if let Some(ref expected_aud) = config.tenancy.jwt_audience {
269 let aud_ok = token_data.claims.get("aud").is_some_and(|v| match v {
270 serde_json::Value::String(s) => s == expected_aud,
271 serde_json::Value::Array(arr) => arr
272 .iter()
273 .any(|e| e.as_str() == Some(expected_aud.as_str())),
274 _ => false,
275 });
276 if !aud_ok {
277 return Err(crate::AutumnError::unauthorized_msg(
278 "JWT audience validation failed: missing or invalid aud claim",
279 ));
280 }
281 }
282
283 let tenant = token_data
284 .claims
285 .get(&config.tenancy.jwt_claim)
286 .and_then(|v| v.as_str())
287 .ok_or_else(|| {
288 crate::AutumnError::unauthorized_msg(format!(
289 "Tenant claim '{}' missing from JWT payload",
290 config.tenancy.jwt_claim
291 ))
292 })?
293 .to_string();
294
295 if tenant.trim().is_empty() {
296 return Err(crate::AutumnError::unauthorized_msg(format!(
297 "Tenant claim '{}' in JWT payload is empty",
298 config.tenancy.jwt_claim
299 )));
300 }
301 Ok(tenant)
302 }
303 other => Err(crate::AutumnError::internal_server_error_msg(format!(
304 "Unsupported tenancy source: {other}"
305 ))),
306 }
307}
308
309fn is_public_path(path: &str, config: &crate::config::AutumnConfig) -> bool {
332 let matches =
337 |prefix: &str| !prefix.is_empty() && crate::router::path_matches_route_prefix(path, prefix);
338
339 let user_paths_match = config.tenancy.public_paths.iter().any(|p| {
343 let p = if p.len() > 1 {
344 p.trim_end_matches('/')
345 } else {
346 p.as_str()
347 };
348 matches(p)
349 });
350
351 let redirect_match = config
362 .tenancy
363 .login_redirect
364 .as_deref()
365 .and_then(|target| target.parse::<axum::http::Uri>().ok())
366 .filter(|uri| uri.authority().is_none())
367 .is_some_and(|uri| uri.path() == path);
368
369 let actuator_prefix = crate::actuator::normalize_actuator_prefix(&config.actuator.prefix);
375 let actuator_match = if actuator_prefix.is_empty() {
376 crate::actuator::actuator_endpoint_paths(
380 &actuator_prefix,
381 config.actuator.sensitive,
382 config.actuator.prometheus,
383 )
384 .iter()
385 .any(|p| matches(p))
386 } else {
387 matches(&actuator_prefix)
388 };
389
390 let probe_match = path == config.health.path
395 || path == config.health.live_path
396 || path == config.health.ready_path
397 || path == config.health.startup_path;
398
399 user_paths_match || redirect_match || probe_match || actuator_match
400}
401
402pub async fn tenancy_middleware(
404 State(state): State<crate::AppState>,
405 request: Request<axum::body::Body>,
406 next: Next,
407) -> Response {
408 let Some(config) = state.extension::<crate::config::AutumnConfig>() else {
409 return crate::AutumnError::internal_server_error_msg("AutumnConfig not found in AppState")
410 .into_response();
411 };
412
413 if !config.tenancy.enabled {
414 return next.run(request).await;
415 }
416
417 let (mut parts, body) = request.into_parts();
418
419 if is_public_path(parts.uri.path(), &config) {
423 return next.run(Request::from_parts(parts, body)).await;
424 }
425
426 let tenant_id = match extract_tenant_from_parts(&mut parts, &config).await {
427 Ok(t) => t,
428 Err(e) => {
429 if e.status() == axum::http::StatusCode::UNAUTHORIZED
437 && let Some(target) = &config.tenancy.login_redirect
438 && parts
439 .headers
440 .get(axum::http::header::ACCEPT)
441 .and_then(|v| v.to_str().ok())
442 .is_some_and(|accept| accept.contains("text/html"))
443 {
444 return axum::response::Redirect::to(target).into_response();
445 }
446 return e.into_response();
447 }
448 };
449
450 crate::log::context::set_tenant_id(&tenant_id);
453
454 let request = Request::from_parts(parts, body);
455 let tenant_id_clone = tenant_id.clone();
456
457 let registry = state.extension_or_insert_with(|| {
465 crate::tenant_cell::TenantCellRegistry::with_limits(
466 config.tenancy.max_cells,
467 (config.tenancy.idle_ttl_secs > 0)
468 .then(|| std::time::Duration::from_secs(config.tenancy.idle_ttl_secs)),
469 )
470 });
471 let handle = crate::tenant_cell::TenantCellHandle::new(
472 (*registry).clone(),
473 tenant_id.clone(),
474 config.tenancy.quota_bytes,
475 );
476 let handle_for_body = Some(handle.clone());
477
478 let response = CURRENT_TENANT
479 .scope(
480 Some(tenant_id),
481 crate::tenant_cell::CURRENT_TENANT_CELL.scope(Some(handle), next.run(request)),
482 )
483 .await;
484
485 let (parts, body) = response.into_parts();
486 let wrapped = TenantPropagatingBody {
487 inner: body,
488 tenant_id: tenant_id_clone,
489 handle: handle_for_body,
490 };
491 Response::from_parts(parts, axum::body::Body::new(wrapped))
492}
493
494pin_project! {
495 pub struct TenantPropagatingBody<B> {
499 #[pin]
500 pub inner: B,
501 pub tenant_id: String,
502 pub handle: Option<crate::tenant_cell::TenantCellHandle>,
503 }
504}
505
506impl<B> HttpBody for TenantPropagatingBody<B>
507where
508 B: HttpBody,
509{
510 type Data = B::Data;
511 type Error = B::Error;
512
513 fn poll_frame(
514 self: Pin<&mut Self>,
515 cx: &mut Context<'_>,
516 ) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
517 let this = self.project();
518 let tenant_id = this.tenant_id.clone();
519 let handle = this.handle.clone();
520 CURRENT_TENANT.sync_scope(Some(tenant_id), || {
521 crate::tenant_cell::CURRENT_TENANT_CELL.sync_scope(handle, || this.inner.poll_frame(cx))
522 })
523 }
524
525 fn is_end_stream(&self) -> bool {
526 self.inner.is_end_stream()
527 }
528
529 fn size_hint(&self) -> http_body::SizeHint {
530 self.inner.size_hint()
531 }
532}
533
534#[cfg(feature = "db")]
539pub trait TenantInsertable<'a, Table> {
540 type Values;
541 fn tenant_values(self, tenant_id: &'a str) -> Self::Values;
542}
543
544#[cfg(feature = "db")]
546pub trait ModelTenantIdMeta {
547 const HAS_MANUAL_TENANT_ID: bool;
549 fn try_set_tenant_id(&mut self, tenant_id: &str);
551}
552
553#[cfg(feature = "db")]
555pub trait HasTenantIdColumn {
556 type Column: ::diesel::Expression;
557 fn column() -> Self::Column;
558}
559
560#[cfg(feature = "db")]
562pub struct TenantInsertableValuesSelector<'a, T, Table, const HAS_MANUAL: bool> {
563 pub inner: T,
564 pub tenant_id: &'a str,
565 pub _marker: std::marker::PhantomData<Table>,
566}
567
568#[cfg(feature = "db")]
570pub trait GetInsertableValues {
571 type Values;
572 fn get_values(self) -> Self::Values;
573}
574
575#[cfg(feature = "db")]
576impl<T, Table> GetInsertableValues for TenantInsertableValuesSelector<'_, T, Table, true>
577where
578 T: ModelTenantIdMeta,
579{
580 type Values = T;
581 fn get_values(mut self) -> Self::Values {
582 self.inner.try_set_tenant_id(self.tenant_id);
583 self.inner
584 }
585}
586
587#[cfg(feature = "db")]
588impl<'a, T, Table> GetInsertableValues for TenantInsertableValuesSelector<'a, T, Table, false>
589where
590 Table: HasTenantIdColumn,
591 Table::Column: ::diesel::ExpressionMethods,
592 <Table::Column as ::diesel::Expression>::SqlType: ::diesel::sql_types::SqlType,
593 &'a str: ::diesel::expression::AsExpression<<Table::Column as ::diesel::Expression>::SqlType>,
594{
595 type Values = (T, ::diesel::dsl::Eq<Table::Column, &'a str>);
596 fn get_values(self) -> Self::Values {
597 use ::diesel::ExpressionMethods;
598 (self.inner, Table::column().eq(self.tenant_id))
599 }
600}
601
602pub trait DisplayTenantId {
604 fn tenant_id_str(&self) -> &str;
606}
607
608impl DisplayTenantId for String {
609 fn tenant_id_str(&self) -> &str {
610 self
611 }
612}
613
614impl DisplayTenantId for Option<String> {
615 fn tenant_id_str(&self) -> &str {
616 self.as_deref().unwrap_or("default")
617 }
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use crate::security::ResolvedClientIdentity;
624
625 fn subdomain_config() -> crate::config::AutumnConfig {
626 let mut c = crate::config::AutumnConfig::default();
627 c.tenancy.enabled = true;
628 c.tenancy.source = "subdomain".to_string();
629 c
630 }
631
632 fn subdomain_config_with_base(base: &str) -> crate::config::AutumnConfig {
633 let mut c = subdomain_config();
634 c.tenancy.base_domain = Some(base.to_string());
635 c
636 }
637
638 fn make_parts(host: &str) -> axum::http::request::Parts {
639 let (parts, ()) = axum::http::Request::builder()
640 .uri("http://ignored/")
641 .header(axum::http::header::HOST, host)
642 .body(())
643 .unwrap()
644 .into_parts();
645 parts
646 }
647
648 fn make_parts_with_identity(
649 host_header: &str,
650 resolved_host: &str,
651 ) -> axum::http::request::Parts {
652 let (mut parts, ()) = axum::http::Request::builder()
653 .uri("http://ignored/")
654 .header(axum::http::header::HOST, host_header)
655 .body(())
656 .unwrap()
657 .into_parts();
658 parts.extensions.insert(ResolvedClientIdentity {
659 addr: None,
660 host: Some(resolved_host.to_string()),
661 scheme: None,
662 });
663 parts
664 }
665
666 #[tokio::test]
669 async fn subdomain_falls_back_to_host_header_without_extension() {
670 let config = subdomain_config();
671 let mut parts = make_parts("tenant1.example.com");
672 let result = extract_tenant_from_parts(&mut parts, &config).await;
673 assert_eq!(result.unwrap(), "tenant1");
674 }
675
676 #[tokio::test]
679 async fn subdomain_uses_resolved_host_from_extension() {
680 let config = subdomain_config();
681 let mut parts = make_parts_with_identity("internal.cluster.local", "tenant1.example.com");
683 let result = extract_tenant_from_parts(&mut parts, &config).await;
684 assert_eq!(result.unwrap(), "tenant1");
685 }
686
687 #[tokio::test]
689 async fn subdomain_uses_resolved_host_with_base_domain() {
690 let config = subdomain_config_with_base("example.com");
691 let mut parts = make_parts_with_identity("internal.cluster.local", "acme.example.com");
692 let result = extract_tenant_from_parts(&mut parts, &config).await;
693 assert_eq!(result.unwrap(), "acme");
694 }
695
696 #[tokio::test]
698 async fn subdomain_strips_port_from_resolved_host() {
699 let config = subdomain_config_with_base("example.com");
700 let mut parts =
701 make_parts_with_identity("internal.cluster.local", "tenant2.example.com:8080");
702 let result = extract_tenant_from_parts(&mut parts, &config).await;
703 assert_eq!(result.unwrap(), "tenant2");
704 }
705
706 #[tokio::test]
709 async fn subdomain_falls_back_when_resolved_host_is_none() {
710 let config = subdomain_config();
711 let (mut parts, ()) = axum::http::Request::builder()
712 .uri("http://ignored/")
713 .header(axum::http::header::HOST, "tenant3.example.com")
714 .body(())
715 .unwrap()
716 .into_parts();
717 parts.extensions.insert(ResolvedClientIdentity {
718 addr: None,
719 host: None,
720 scheme: None,
721 });
722 let result = extract_tenant_from_parts(&mut parts, &config).await;
723 assert_eq!(result.unwrap(), "tenant3");
724 }
725
726 fn public_paths_config(paths: &[&str]) -> crate::config::AutumnConfig {
727 let mut c = crate::config::AutumnConfig::default();
728 c.tenancy.public_paths = paths.iter().map(|s| (*s).to_string()).collect();
729 c
730 }
731
732 #[test]
734 fn public_path_exact_and_subtree_match() {
735 let c = public_paths_config(&["/login", "/static"]);
736 assert!(is_public_path("/login", &c));
737 assert!(is_public_path("/login/sso", &c));
738 assert!(is_public_path("/static/css/app.css", &c));
739 }
740
741 #[test]
743 fn public_path_does_not_bleed_to_adjacent_prefix() {
744 let c = public_paths_config(&["/login"]);
745 assert!(!is_public_path("/login-admin", &c));
746 assert!(!is_public_path("/dashboard", &c));
747 }
748
749 #[test]
751 fn health_paths_are_always_public() {
752 let c = crate::config::AutumnConfig::default();
753 assert!(c.tenancy.public_paths.is_empty());
754 assert!(is_public_path(&c.health.path, &c));
755 assert!(is_public_path(&c.health.live_path, &c));
756 assert!(is_public_path(&c.health.ready_path, &c));
757 assert!(is_public_path(&c.health.startup_path, &c));
758 }
759
760 #[test]
764 fn probe_paths_match_exactly_not_as_prefix() {
765 let c = crate::config::AutumnConfig::default();
766 assert!(is_public_path("/health", &c));
767 assert!(!is_public_path("/health/history", &c));
768 assert!(!is_public_path("/live/details", &c));
769 }
770
771 #[test]
773 fn empty_public_path_entry_does_not_exempt_all() {
774 let c = public_paths_config(&[""]);
775 assert!(!is_public_path("/dashboard", &c));
776 assert!(!is_public_path("/secret", &c));
777 }
778
779 #[test]
781 fn trailing_slash_entry_matches_subtree() {
782 let c = public_paths_config(&["/static/"]);
783 assert!(is_public_path("/static", &c));
784 assert!(is_public_path("/static/css/app.css", &c));
785 assert!(!is_public_path("/dashboard", &c));
786 }
787
788 #[test]
790 fn actuator_prefix_is_always_public() {
791 let c = crate::config::AutumnConfig::default();
792 assert!(is_public_path(&c.actuator.prefix, &c));
793 assert!(is_public_path(
794 &format!("{}/prometheus", c.actuator.prefix),
795 &c
796 ));
797 assert!(is_public_path(&format!("{}/health", c.actuator.prefix), &c));
798 }
799
800 #[test]
803 fn actuator_prefix_is_normalized_before_exemption() {
804 for raw in ["ops/", "/ops/", "/ops", "ops"] {
805 let mut c = crate::config::AutumnConfig::default();
806 c.actuator.prefix = raw.to_string();
807 assert!(
808 is_public_path("/ops/prometheus", &c),
809 "prefix {raw:?} should exempt the mounted /ops/prometheus path"
810 );
811 assert!(
812 is_public_path("/ops", &c),
813 "prefix {raw:?} should exempt the mounted /ops base path"
814 );
815 assert!(!is_public_path("/dashboard", &c));
816 }
817 }
818
819 #[test]
822 fn root_mounted_actuator_endpoints_are_public() {
823 let mut c = crate::config::AutumnConfig::default();
824 c.actuator.prefix = "/".to_string();
825 c.actuator.prometheus = true;
826 assert!(is_public_path("/prometheus", &c));
828 assert!(is_public_path("/metrics", &c));
829 assert!(!is_public_path("/dashboard", &c));
831 }
832
833 #[test]
837 fn openapi_path_is_not_auto_exempt() {
838 let c = crate::config::AutumnConfig::default();
839 assert!(c.openapi_runtime.enabled);
840 assert!(!is_public_path(&c.openapi_runtime.path, &c));
841 let c = public_paths_config(&["/openapi.json"]);
843 assert!(is_public_path("/openapi.json", &c));
844 }
845
846 #[test]
849 fn login_redirect_target_is_always_public() {
850 let mut c = crate::config::AutumnConfig::default();
851 c.tenancy.login_redirect = Some("/auth/login".to_string());
852 assert!(is_public_path("/auth/login", &c));
854 assert!(!is_public_path("/auth/login/sso", &c));
856 }
857
858 #[test]
861 fn login_redirect_target_with_query_is_public_by_path() {
862 let mut c = crate::config::AutumnConfig::default();
863 c.tenancy.login_redirect = Some("/login?next=/dashboard".to_string());
864 assert!(is_public_path("/login", &c));
866 assert!(!is_public_path("/dashboard", &c));
867 }
868
869 #[test]
874 fn login_redirect_absolute_url_is_not_auto_exempt() {
875 let mut c = crate::config::AutumnConfig::default();
876 c.tenancy.login_redirect = Some("https://idp.example.com/login".to_string());
877 assert!(!is_public_path("/login", &c));
878
879 c.tenancy.login_redirect = Some("https://app.example.com/login".to_string());
881 assert!(!is_public_path("/login", &c));
882
883 c.tenancy.public_paths = vec!["/login".to_string()];
885 assert!(is_public_path("/login", &c));
886 }
887
888 #[test]
891 fn root_public_path_is_preserved() {
892 let c = public_paths_config(&["/"]);
893 assert!(is_public_path("/", &c));
894 assert!(!is_public_path("/dashboard", &c));
897 }
898
899 #[test]
902 fn empty_builtin_path_does_not_exempt_all() {
903 let mut c = crate::config::AutumnConfig::default();
904 c.health.path = String::new();
905 c.health.live_path = String::new();
906 c.health.ready_path = String::new();
907 c.health.startup_path = String::new();
908 c.actuator.prefix = String::new();
909 assert!(!is_public_path("/dashboard", &c));
910 assert!(!is_public_path("/", &c));
911 }
912}