1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use axum::body::Body;
6use axum::extract::State;
7use axum::http::{Request, StatusCode, header, request::Parts};
8use axum::middleware::Next;
9use axum::response::{IntoResponse, Response};
10use dashmap::DashMap;
11use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool};
12
13use allowthem_core::AllowThem;
14
15use crate::cache::{HandleCache, SlugCache, TenantMeta};
16use crate::control_db::ControlDb;
17use crate::error::SaasError;
18use crate::tenants::{TenantBuilderConfig, TenantId, TenantStatus};
19
20pub(crate) const RESERVED_SLUGS: &[&str] = &[
21 "www",
22 "api",
23 "admin",
24 "auth",
25 "manage",
26 "oauth",
27 "app",
28 "dashboard",
29 "status",
30 "mail",
31 "docs",
32 "help",
33 "support",
34 "static",
35 "cdn",
36 "assets",
37 "id",
38 "sso",
39 "allowthem",
40];
41
42pub(crate) fn is_reserved_slug(slug: &str) -> bool {
43 RESERVED_SLUGS.contains(&slug)
44}
45
46#[derive(Debug, PartialEq)]
47pub enum SlugOrRoot {
48 Slug(String),
49 Root,
50}
51
52pub fn parse_slug(host_raw: &str, base_domain: &str) -> Option<SlugOrRoot> {
59 let host = host_raw
60 .split(':')
61 .next()
62 .unwrap_or(host_raw)
63 .to_lowercase();
64
65 if host == base_domain {
66 return Some(SlugOrRoot::Root);
67 }
68
69 let suffix = format!(".{base_domain}");
70 let label = host.strip_suffix(suffix.as_str())?;
71
72 if label.is_empty() || label.contains('.') {
73 return None;
74 }
75
76 Some(SlugOrRoot::Slug(label.to_owned()))
77}
78
79#[derive(Clone)]
80pub struct TenantRouterState {
81 pub control_db: Arc<ControlDb>,
82 pub slug_cache: SlugCache,
83 pub handle_cache: HandleCache,
84 pub tenant_data_dir: PathBuf,
85 pub config: Arc<TenantBuilderConfig>,
86 pub seen_times: Arc<DashMap<TenantId, Instant>>,
87 pub dashboard_handle: Option<AllowThem>,
93}
94
95pub async fn tenant_router_middleware(
96 State(state): State<TenantRouterState>,
97 mut request: Request<Body>,
98 next: Next,
99) -> Response {
100 let host = match request
101 .headers()
102 .get(header::HOST)
103 .and_then(|v| v.to_str().ok())
104 {
105 Some(h) => h.to_owned(),
106 None => return StatusCode::BAD_REQUEST.into_response(),
107 };
108
109 let base_domain = &state.config.base_domain;
110 let slug = match parse_slug(&host, base_domain) {
111 Some(SlugOrRoot::Slug(s)) => s,
112 Some(SlugOrRoot::Root) => {
113 if let Some(ref dh) = state.dashboard_handle {
114 request.extensions_mut().insert(dh.clone());
115 }
116 return next.run(request).await;
117 }
118 None => return StatusCode::NOT_FOUND.into_response(),
119 };
120
121 let meta = match state
122 .slug_cache
123 .get_or_fetch(&slug, || {
124 let ctrl = state.control_db.clone();
125 let s = slug.clone();
126 async move { ctrl.tenant_meta_by_slug(&s).await }
127 })
128 .await
129 {
130 Ok(Some(m)) => m,
131 Ok(None) => return StatusCode::NOT_FOUND.into_response(),
132 Err(e) => {
133 tracing::error!(error = %e, slug, "slug cache fetch failed");
134 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
135 }
136 };
137
138 if matches!(meta.status, TenantStatus::Deleted) {
139 return StatusCode::NOT_FOUND.into_response();
140 }
141
142 if matches!(meta.status, TenantStatus::Suspended) {
143 request.extensions_mut().insert(meta);
144 return next.run(request).await;
145 }
146
147 let tenant_id = meta.id;
148 let handle = {
149 let ctrl = state.control_db.clone();
150 let dir = state.tenant_data_dir.clone();
151 let cfg = state.config.clone();
152 let slug_owned = slug.clone();
153 match state
154 .handle_cache
155 .get_or_init(tenant_id, async move {
156 build_handle(ctrl, dir, cfg, tenant_id, &slug_owned).await
157 })
158 .await
159 {
160 Ok(h) => h,
161 Err(e) => {
162 tracing::error!(error = %e, slug = slug.as_str(), "handle cache init failed");
163 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
164 }
165 }
166 };
167
168 tracing::debug!(tenant_id = %tenant_id.as_uuid(), slug, "tenant request");
169
170 request.extensions_mut().insert(meta);
171 request.extensions_mut().insert(handle);
172
173 debounce_last_seen(&state, tenant_id);
174
175 next.run(request).await
176}
177
178pub(crate) async fn build_handle(
179 control_db: Arc<ControlDb>,
180 tenant_data_dir: PathBuf,
181 config: Arc<TenantBuilderConfig>,
182 tenant_id: TenantId,
183 slug: &str,
184) -> Result<AllowThem, SaasError> {
185 let tenant = control_db
186 .tenant_by_id_raw(tenant_id.as_bytes())
187 .await?
188 .ok_or(SaasError::TenantNotFound)?;
189 build_handle_with_path(&tenant.db_path, tenant_id, &tenant_data_dir, &config, slug).await
190}
191
192pub async fn build_handle_with_path(
193 db_file: &str,
194 tenant_id: TenantId,
195 tenant_data_dir: &std::path::Path,
196 config: &TenantBuilderConfig,
197 slug: &str,
198) -> Result<AllowThem, SaasError> {
199 let full_path = tenant_data_dir.join(db_file);
200
201 let opts = SqliteConnectOptions::new()
202 .filename(&full_path)
203 .create_if_missing(false)
204 .pragma("foreign_keys", "ON")
205 .journal_mode(SqliteJournalMode::Wal)
206 .busy_timeout(Duration::from_millis(5000));
207 let pool = SqlitePool::connect_with(opts)
208 .await
209 .map_err(|e| SaasError::ProvisionFailed(e.to_string()))?;
210
211 let mut builder = allowthem_core::AllowThemBuilder::with_pool(pool.clone())
215 .mfa_key(config.mfa_key)
216 .signing_key(config.signing_key)
217 .csrf_key(config.csrf_key)
218 .base_url(format!("https://{slug}.{}", config.base_domain))
219 .cookie_name(crate::tenants::tenant_cookie_name(config.is_production))
220 .cookie_secure(config.is_production);
221
222 if let Some(factory) = &config.email_sender_factory {
227 let sender = factory
228 .for_tenant(tenant_id, &pool)
229 .await
230 .map_err(|e| SaasError::ProvisionFailed(e.to_string()))?;
231 builder = builder.email_sender(Box::new(sender));
232 } else if let Some(sender) = &config.email_sender {
233 builder = builder.email_sender(Box::new(sender.clone()));
234 }
235
236 if let Some(factory) = &config.event_sink_factory {
239 let sink = factory.for_tenant(tenant_id);
240 builder = builder.event_sink(Box::new(sink));
241 } else if let Some(sink) = &config.event_sink {
242 builder = builder.event_sink(Box::new(sink.clone()));
243 }
244
245 if let Some(mau_sink) = &config.mau_sink {
246 let mau_sink = mau_sink.clone();
247 let cb: allowthem_core::OnUserActive = std::sync::Arc::new(move |user_id, ts| {
248 let mau_sink = mau_sink.clone();
249 tokio::spawn(async move {
250 if let Err(e) = mau_sink.record_active(tenant_id, user_id, ts).await {
251 tracing::warn!(
252 tenant_id = %tenant_id.as_uuid(),
253 user_id = %user_id,
254 error = %e,
255 "MauSink::record_active failed"
256 );
257 }
258 });
259 });
260 builder = builder.on_user_active(cb);
261 }
262
263 builder
264 .build()
265 .await
266 .map_err(|e| SaasError::ProvisionFailed(e.to_string()))
267}
268
269pub async fn pre_warm(
275 control_db: Arc<ControlDb>,
276 cache: &HandleCache,
277 tenant_data_dir: PathBuf,
278 config: Arc<TenantBuilderConfig>,
279 count: i64,
280) {
281 let ids = match control_db.most_recently_seen_tenants(count).await {
282 Ok(v) => v,
283 Err(e) => {
284 tracing::warn!(error = %e, "pre_warm: failed to fetch tenant list");
285 return;
286 }
287 };
288
289 let futs: Vec<_> = ids
290 .into_iter()
291 .map(|tenant_id| {
292 let cache = cache.clone();
293 let ctrl = control_db.clone();
294 let dir = tenant_data_dir.clone();
295 let cfg = config.clone();
296 async move {
297 let tenant = match ctrl.tenant_by_id_raw(tenant_id.as_bytes()).await {
298 Ok(Some(t)) => t,
299 Ok(None) => {
300 tracing::warn!(
301 tenant_id = %tenant_id.as_uuid(),
302 "pre_warm: tenant not found"
303 );
304 return;
305 }
306 Err(e) => {
307 tracing::warn!(
308 tenant_id = %tenant_id.as_uuid(),
309 error = %e,
310 "pre_warm: fetch failed"
311 );
312 return;
313 }
314 };
315 let db_file = tenant.db_path.clone();
316 let slug = tenant.slug.clone();
317 let result = cache
318 .get_or_init(tenant_id, async move {
319 build_handle_with_path(&db_file, tenant_id, &dir, &cfg, &slug).await
320 })
321 .await;
322 if let Err(e) = result {
323 tracing::warn!(
324 tenant_id = %tenant_id.as_uuid(),
325 error = %e,
326 "pre_warm: handle init failed"
327 );
328 }
329 }
330 })
331 .collect();
332
333 futures::future::join_all(futs).await;
334}
335
336pub(crate) fn debounce_last_seen(state: &TenantRouterState, tenant_id: TenantId) {
337 const DEBOUNCE: Duration = Duration::from_secs(60);
338
339 let now = Instant::now();
340 let last = state.seen_times.get(&tenant_id).map(|v| *v);
341
342 if last.is_some_and(|t| now.duration_since(t) < DEBOUNCE) {
343 return;
344 }
345
346 state.seen_times.insert(tenant_id, now);
347
348 let ctrl = state.control_db.clone();
349 tokio::spawn(async move {
350 if let Err(e) = ctrl.touch_last_seen(&tenant_id).await {
351 tracing::warn!(error = %e, tenant_id = %tenant_id.as_uuid(), "touch_last_seen failed");
352 }
353 });
354}
355
356#[derive(Debug, Clone)]
361pub struct RequireActiveTenant;
362
363impl<S: Send + Sync> axum::extract::FromRequestParts<S> for RequireActiveTenant {
364 type Rejection = Response;
365
366 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
367 let Some(meta) = parts.extensions.get::<TenantMeta>() else {
368 return Ok(RequireActiveTenant);
369 };
370 match meta.status {
371 TenantStatus::Active => Ok(RequireActiveTenant),
372 TenantStatus::Suspended => Err(StatusCode::SERVICE_UNAVAILABLE.into_response()),
373 TenantStatus::Deleted => Err(StatusCode::NOT_FOUND.into_response()),
374 }
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use axum::extract::FromRequestParts;
382 use axum::http::Request;
383
384 fn base() -> &'static str {
385 "example.com"
386 }
387
388 #[test]
389 fn parse_slug_root_domain_returns_root() {
390 assert_eq!(parse_slug("example.com", base()), Some(SlugOrRoot::Root));
391 }
392
393 #[test]
394 fn parse_slug_subdomain_returns_slug() {
395 assert_eq!(
396 parse_slug("acme.example.com", base()),
397 Some(SlugOrRoot::Slug("acme".into()))
398 );
399 }
400
401 #[test]
402 fn parse_slug_strips_port() {
403 assert_eq!(
404 parse_slug("acme.example.com:8080", base()),
405 Some(SlugOrRoot::Slug("acme".into()))
406 );
407 }
408
409 #[test]
410 fn parse_slug_root_with_port() {
411 assert_eq!(
412 parse_slug("example.com:443", base()),
413 Some(SlugOrRoot::Root)
414 );
415 }
416
417 #[test]
418 fn parse_slug_multi_level_returns_none() {
419 assert_eq!(parse_slug("a.b.example.com", base()), None);
420 }
421
422 #[test]
423 fn parse_slug_unknown_domain_returns_none() {
424 assert_eq!(parse_slug("other.io", base()), None);
425 }
426
427 #[test]
428 fn parse_slug_lowercases_slug() {
429 assert_eq!(
430 parse_slug("ACME.example.com", base()),
431 Some(SlugOrRoot::Slug("acme".into()))
432 );
433 }
434
435 #[test]
436 fn parse_slug_empty_host_returns_none() {
437 assert_eq!(parse_slug("", base()), None);
438 }
439
440 #[test]
441 fn parse_slug_unrelated_subdomain_returns_none() {
442 assert_eq!(parse_slug("acme.other.com", base()), None);
443 }
444
445 #[test]
446 fn parse_slug_does_not_reject_reserved() {
447 assert_eq!(
449 parse_slug("admin.example.com", base()),
450 Some(SlugOrRoot::Slug("admin".into()))
451 );
452 }
453
454 fn make_meta(status: TenantStatus) -> TenantMeta {
455 use uuid::Uuid;
456 TenantMeta {
457 id: TenantId::from(Uuid::nil()),
458 status,
459 plan_id: vec![],
460 }
461 }
462
463 async fn make_state() -> TenantRouterState {
464 use std::str::FromStr;
465 let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:")
466 .unwrap()
467 .pragma("foreign_keys", "ON");
468 let pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
469 let ctrl = Arc::new(crate::control_db::ControlDb::new(pool).await.unwrap());
470 TenantRouterState {
471 control_db: ctrl,
472 slug_cache: SlugCache::new(10, 60),
473 handle_cache: HandleCache::new(10),
474 tenant_data_dir: PathBuf::from("/tmp"),
475 config: Arc::new(TenantBuilderConfig {
476 mfa_key: [0u8; 32],
477 signing_key: [0u8; 32],
478 csrf_key: [0u8; 32],
479 base_domain: "example.com".into(),
480 is_production: false,
481 email_sender: None,
482 event_sink: None,
483 event_sink_factory: None,
484 mau_sink: None,
485 email_sender_factory: None,
486 }),
487 seen_times: Arc::new(DashMap::new()),
488 dashboard_handle: None,
489 }
490 }
491
492 #[tokio::test]
493 async fn extractor_passes_when_no_meta() {
494 let (mut parts, _) = Request::new(()).into_parts();
495 let result: Result<RequireActiveTenant, _> =
496 RequireActiveTenant::from_request_parts(&mut parts, &()).await;
497 assert!(result.is_ok());
498 }
499
500 #[tokio::test]
501 async fn extractor_passes_when_active() {
502 let (mut parts, _) = Request::new(()).into_parts();
503 parts.extensions.insert(make_meta(TenantStatus::Active));
504 let result: Result<RequireActiveTenant, _> =
505 RequireActiveTenant::from_request_parts(&mut parts, &()).await;
506 assert!(result.is_ok());
507 }
508
509 #[tokio::test]
510 async fn extractor_returns_503_when_suspended() {
511 let (mut parts, _) = Request::new(()).into_parts();
512 parts.extensions.insert(make_meta(TenantStatus::Suspended));
513 let result: Result<RequireActiveTenant, _> =
514 RequireActiveTenant::from_request_parts(&mut parts, &()).await;
515 let err = result.unwrap_err();
516 assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
517 }
518
519 #[tokio::test]
520 async fn debounce_inserts_into_seen_times() {
521 let state = make_state().await;
522 let id = TenantId::from(uuid::Uuid::nil());
523 debounce_last_seen(&state, id);
524 assert!(state.seen_times.contains_key(&id));
525 }
526
527 #[tokio::test]
528 async fn debounce_suppresses_rapid_repeat() {
529 let state = make_state().await;
530 let id = TenantId::from(uuid::Uuid::from_bytes([0x11; 16]));
531 debounce_last_seen(&state, id);
532 let first_time = *state.seen_times.get(&id).unwrap();
533 debounce_last_seen(&state, id);
535 let second_time = *state.seen_times.get(&id).unwrap();
536 assert_eq!(first_time, second_time);
537 }
538
539 #[tokio::test]
540 async fn pre_warm_noop_when_no_active_tenants() {
541 let state = make_state().await;
542 pre_warm(
544 state.control_db.clone(),
545 &state.handle_cache,
546 state.tenant_data_dir.clone(),
547 state.config.clone(),
548 10,
549 )
550 .await;
551 assert_eq!(state.handle_cache.entry_count(), 0);
553 }
554
555 #[tokio::test]
556 async fn pre_warm_swallows_error_for_missing_tenant_db() {
557 use uuid::Uuid;
558 let state = make_state().await;
559
560 let plan_row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
562 .fetch_one(state.control_db.pool())
563 .await
564 .unwrap();
565 let plan_id: Vec<u8> = sqlx::Row::try_get(&plan_row, "id").unwrap();
566 let tid = Uuid::now_v7();
567 sqlx::query(
568 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path, last_seen_at) \
569 VALUES (?1, 'Test', 'pretest', 't@t.com', ?2, 'active', 'pretest.db', datetime('now'))",
570 )
571 .bind(tid.as_bytes().as_ref())
572 .bind(&plan_id)
573 .execute(state.control_db.pool())
574 .await
575 .unwrap();
576
577 pre_warm(
580 state.control_db.clone(),
581 &state.handle_cache,
582 state.tenant_data_dir.clone(),
583 state.config.clone(),
584 10,
585 )
586 .await;
587 }
588
589 async fn root_request_sees_extension(state: TenantRouterState, host: &str) -> bool {
595 use axum::Router;
596 use axum::body::Body;
597 use axum::http::header;
598 use axum::middleware::from_fn_with_state;
599 use axum::routing::get;
600 use std::sync::Arc;
601 use std::sync::atomic::{AtomicBool, Ordering};
602 use tower::ServiceExt;
603
604 let observed = Arc::new(AtomicBool::new(false));
605 let observed_for_handler = observed.clone();
606
607 let app = Router::new()
608 .route(
609 "/",
610 get(move |req: Request<Body>| {
611 let observed = observed_for_handler.clone();
612 async move {
613 if req.extensions().get::<AllowThem>().is_some() {
614 observed.store(true, Ordering::SeqCst);
615 }
616 StatusCode::OK
617 }
618 }),
619 )
620 .layer(from_fn_with_state(state, tenant_router_middleware));
621
622 let req = Request::builder()
623 .uri("/")
624 .header(header::HOST, host)
625 .body(Body::empty())
626 .unwrap();
627 let _resp = app.oneshot(req).await.unwrap();
628 observed.load(Ordering::SeqCst)
629 }
630
631 async fn make_dashboard_handle() -> AllowThem {
632 allowthem_core::AllowThemBuilder::new("sqlite::memory:")
633 .mfa_key([0u8; 32])
634 .signing_key([0u8; 32])
635 .csrf_key([0u8; 32])
636 .base_url("https://example.com")
637 .cookie_secure(false)
638 .build()
639 .await
640 .unwrap()
641 }
642
643 #[tokio::test]
644 async fn root_branch_injects_dashboard_handle_when_set() {
645 let dh = make_dashboard_handle().await;
646 let mut state = make_state().await;
647 state.dashboard_handle = Some(dh);
648 assert!(root_request_sees_extension(state, "example.com").await);
649 }
650
651 #[tokio::test]
652 async fn root_branch_no_injection_when_none() {
653 let state = make_state().await;
654 assert!(!root_request_sees_extension(state, "example.com").await);
655 }
656
657 fn mau_test_builder_config(mau_sink: Option<Arc<crate::mau::MauSink>>) -> TenantBuilderConfig {
660 TenantBuilderConfig {
661 mfa_key: [1u8; 32],
662 signing_key: [2u8; 32],
663 csrf_key: [3u8; 32],
664 base_domain: "test.local".into(),
665 is_production: false,
666 email_sender: None,
667 event_sink: None,
668 event_sink_factory: None,
669 mau_sink,
670 email_sender_factory: None,
671 }
672 }
673
674 #[tokio::test]
675 async fn build_handle_with_path_registers_mau_callback_when_sink_present() {
676 use allowthem_core::types::UserId;
677
678 let dir = tempfile::tempdir().expect("tempdir");
679 let state = make_state().await;
680 let mau_sink = Arc::new(crate::mau::MauSink::new(state.control_db.clone()));
681
682 let provision_config = mau_test_builder_config(None);
687 let provisioned = state
688 .control_db
689 .provision_tenant(
690 "Mau Co".into(),
691 "mauco".into(),
692 "owner@mauco.test".into(),
693 dir.path(),
694 &provision_config,
695 )
696 .await
697 .expect("provision_tenant");
698 let tenant_id = TenantId::from(provisioned.tenant.id_as_uuid().unwrap());
699
700 let wired_config = mau_test_builder_config(Some(mau_sink.clone()));
702 let ath = build_handle_with_path(
703 &provisioned.tenant.db_path,
704 tenant_id,
705 dir.path(),
706 &wired_config,
707 "mauco",
708 )
709 .await
710 .expect("build_handle_with_path");
711
712 let cb = ath
715 .on_user_active()
716 .expect("on_user_active must be registered when mau_sink is set");
717 let user_id = UserId::new();
718 cb(user_id, chrono::Utc::now());
719
720 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
721 loop {
722 let count: i64 =
723 sqlx::query_scalar("SELECT COUNT(*) FROM tenant_active_users WHERE tenant_id = ?1")
724 .bind(tenant_id.as_bytes())
725 .fetch_one(state.control_db.pool())
726 .await
727 .unwrap();
728 if count == 1 {
729 break;
730 }
731 if std::time::Instant::now() > deadline {
732 panic!("MAU row never appeared in tenant_active_users");
733 }
734 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
735 }
736 }
737
738 #[tokio::test]
739 async fn build_handle_with_path_no_callback_when_sink_absent() {
740 let dir = tempfile::tempdir().expect("tempdir");
741 let state = make_state().await;
742
743 let config = mau_test_builder_config(None);
744 let provisioned = state
745 .control_db
746 .provision_tenant(
747 "Plain Co".into(),
748 "plainco".into(),
749 "owner@plainco.test".into(),
750 dir.path(),
751 &config,
752 )
753 .await
754 .expect("provision_tenant");
755 let tenant_id = TenantId::from(provisioned.tenant.id_as_uuid().unwrap());
756
757 let ath = build_handle_with_path(
758 &provisioned.tenant.db_path,
759 tenant_id,
760 dir.path(),
761 &config,
762 "plainco",
763 )
764 .await
765 .expect("build_handle_with_path");
766
767 assert!(
768 ath.on_user_active().is_none(),
769 "no callback should be registered when mau_sink is None"
770 );
771 }
772
773 struct StubEmailFactory {
780 captured: Arc<std::sync::Mutex<Option<TenantId>>>,
781 }
782
783 struct SentinelEmailSender;
786
787 impl allowthem_core::EmailSender for SentinelEmailSender {
788 fn send<'a>(
789 &'a self,
790 _message: &'a allowthem_core::email::EmailMessage,
791 ) -> std::pin::Pin<
792 Box<
793 dyn std::future::Future<Output = Result<(), allowthem_core::error::AuthError>>
794 + Send
795 + 'a,
796 >,
797 > {
798 Box::pin(std::future::ready(Ok(())))
799 }
800 }
801
802 impl crate::managed_email::EmailSenderFactory for StubEmailFactory {
803 fn for_tenant<'a>(
804 &'a self,
805 tenant_id: TenantId,
806 _tenant_pool: &'a sqlx::SqlitePool,
807 ) -> allowthem_core::auth_client::AuthFuture<'a, Arc<dyn allowthem_core::EmailSender>>
808 {
809 let captured = self.captured.clone();
810 Box::pin(async move {
811 *captured.lock().unwrap() = Some(tenant_id);
812 Ok(Arc::new(SentinelEmailSender) as Arc<dyn allowthem_core::EmailSender>)
813 })
814 }
815 }
816
817 #[tokio::test]
818 async fn build_handle_with_path_uses_email_sender_factory_when_set() {
819 use allowthem_core::email::{EmailMessage, EmailTemplate};
825
826 let dir = tempfile::tempdir().expect("tempdir");
827 let state = make_state().await;
828 let captured: Arc<std::sync::Mutex<Option<TenantId>>> =
829 Arc::new(std::sync::Mutex::new(None));
830 let factory = Arc::new(StubEmailFactory {
831 captured: captured.clone(),
832 });
833
834 let provision_config = TenantBuilderConfig {
839 mfa_key: [1u8; 32],
840 signing_key: [2u8; 32],
841 csrf_key: [3u8; 32],
842 base_domain: "test.local".into(),
843 is_production: false,
844 email_sender: Some(Arc::new(allowthem_core::LogEmailSender)),
845 event_sink: None,
846 event_sink_factory: None,
847 mau_sink: None,
848 email_sender_factory: None,
849 };
850
851 let provisioned = state
852 .control_db
853 .provision_tenant(
854 "Email Co".into(),
855 "emailco".into(),
856 "owner@emailco.test".into(),
857 dir.path(),
858 &provision_config,
859 )
860 .await
861 .expect("provision_tenant");
862 let tenant_id = TenantId::from(provisioned.tenant.id_as_uuid().unwrap());
863
864 let wired_config = TenantBuilderConfig {
865 email_sender_factory: Some(factory.clone()),
866 ..provision_config
867 };
868 let ath = build_handle_with_path(
869 &provisioned.tenant.db_path,
870 tenant_id,
871 dir.path(),
872 &wired_config,
873 "emailco",
874 )
875 .await
876 .expect("build_handle_with_path");
877
878 let observed = captured.lock().unwrap().clone();
881 assert_eq!(
882 observed,
883 Some(tenant_id),
884 "EmailSenderFactory::for_tenant must run with the supplied tenant_id"
885 );
886
887 ath.email_sender()
892 .send(&EmailMessage {
893 to: "user@example.com".into(),
894 subject: "x".into(),
895 template: EmailTemplate::PasswordReset {
896 url: "https://x".into(),
897 username: "user".into(),
898 },
899 })
900 .await
901 .expect("send via factory-resolved sender");
902 }
903}