1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use chrono::{DateTime, Utc};
5use sqlx::SqlitePool;
6use uuid::Uuid;
7
8use allowthem_core::applications::CreateApplicationParams;
9use allowthem_core::types::ClientType;
10use allowthem_core::{AllowThem, ClientSecret, EmailSender, EventSink};
11
12use crate::control_db::ControlDb;
13use crate::error::{SaasError, map_slug_conflict};
14use crate::mau::MauSink;
15use crate::router::is_reserved_slug;
16
17pub fn validate_slug(slug: &str) -> Result<(), SaasError> {
19 if is_reserved_slug(slug) {
20 return Err(SaasError::SlugReserved);
21 }
22 let bytes = slug.as_bytes();
23 if bytes.len() < 3 || bytes.len() > 40 {
24 return Err(SaasError::SlugInvalid("must be 3–40 characters"));
25 }
26 if !bytes[0].is_ascii_lowercase() {
27 return Err(SaasError::SlugInvalid("must start with a lowercase letter"));
28 }
29 let rest_valid = bytes[1..]
30 .iter()
31 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-');
32 if !rest_valid {
33 return Err(SaasError::SlugInvalid(
34 "only lowercase letters, digits, and hyphens allowed",
35 ));
36 }
37 Ok(())
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, serde::Serialize, serde::Deserialize)]
41#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
42#[serde(rename_all = "lowercase")]
43pub enum TenantStatus {
44 Active,
45 Suspended,
46 Deleted,
47}
48
49#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize)]
51pub struct Tenant {
52 pub id: Vec<u8>, pub name: String,
54 pub slug: String,
55 pub owner_email: String,
56 pub plan_id: Vec<u8>, pub status: TenantStatus,
58 pub db_path: String,
59 pub last_seen_at: Option<DateTime<Utc>>,
60 pub created_at: DateTime<Utc>,
61 pub updated_at: DateTime<Utc>,
62}
63
64impl Tenant {
65 pub fn id_as_uuid(&self) -> Option<Uuid> {
66 Uuid::from_slice(&self.id).ok()
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub struct TenantId(Uuid);
73
74impl Default for TenantId {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80impl TenantId {
81 pub fn new() -> Self {
82 Self(Uuid::now_v7())
83 }
84
85 pub fn as_uuid(&self) -> &Uuid {
86 &self.0
87 }
88
89 pub fn as_bytes(&self) -> &[u8] {
91 self.0.as_bytes()
92 }
93}
94
95impl From<Uuid> for TenantId {
96 fn from(u: Uuid) -> Self {
97 Self(u)
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub struct MemberId(Uuid);
104
105impl MemberId {
106 pub fn new() -> Self {
107 Self(Uuid::now_v7())
108 }
109
110 pub fn as_uuid(&self) -> &Uuid {
111 &self.0
112 }
113
114 pub fn as_bytes(&self) -> &[u8] {
116 self.0.as_bytes()
117 }
118}
119
120impl Default for MemberId {
121 fn default() -> Self {
122 Self::new()
123 }
124}
125
126impl From<Uuid> for MemberId {
127 fn from(u: Uuid) -> Self {
128 Self(u)
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub struct PlanId(Uuid);
137
138impl PlanId {
139 pub fn as_uuid(&self) -> &Uuid {
140 &self.0
141 }
142
143 pub fn as_bytes(&self) -> &[u8] {
144 self.0.as_bytes()
145 }
146}
147
148impl From<Uuid> for PlanId {
149 fn from(u: Uuid) -> Self {
150 Self(u)
151 }
152}
153
154#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize)]
158pub struct TenantMember {
159 pub id: Vec<u8>,
160 pub tenant_id: Vec<u8>,
161 pub email: String,
162 pub role: crate::control_db::TenantRole,
163 pub invited_at: DateTime<Utc>,
164 pub accepted_at: Option<DateTime<Utc>>,
165 pub invite_token_expires_at: Option<i64>,
168}
169
170impl TenantMember {
171 pub fn id_as_member_id(&self) -> Option<MemberId> {
172 Uuid::from_slice(&self.id).ok().map(MemberId::from)
173 }
174
175 pub fn tenant_id_as_tenant_id(&self) -> Option<TenantId> {
176 Uuid::from_slice(&self.tenant_id).ok().map(TenantId::from)
177 }
178}
179
180#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize)]
184pub struct TenantPlan {
185 pub id: Vec<u8>,
186 pub name: String,
187 pub mau_limit: i64,
188 pub price_cents: i64,
189 pub features: String,
190}
191
192impl TenantPlan {
193 pub fn id_as_plan_id(&self) -> Option<PlanId> {
194 Uuid::from_slice(&self.id).ok().map(PlanId::from)
195 }
196}
197
198pub struct TenantBuilderConfig {
200 pub mfa_key: [u8; 32],
201 pub signing_key: [u8; 32],
202 pub csrf_key: [u8; 32],
203 pub base_domain: String,
204 pub is_production: bool,
208 pub email_sender: Option<Arc<dyn EmailSender>>,
213 pub event_sink: Option<Arc<dyn EventSink>>,
220 pub event_sink_factory: Option<Arc<dyn crate::webhook_sink::EventSinkFactory>>,
226 pub mau_sink: Option<Arc<MauSink>>,
231 pub email_sender_factory: Option<Arc<dyn crate::managed_email::EmailSenderFactory>>,
238}
239
240pub fn tenant_cookie_name(is_production: bool) -> &'static str {
247 if is_production {
248 "__Host-allowthem_session"
249 } else {
250 "allowthem_session"
251 }
252}
253
254pub struct ProvisionResult {
256 pub tenant: Tenant,
257 pub ath: AllowThem,
259 pub client_id: String,
260 pub client_secret: ClientSecret,
261}
262
263struct TenantDbFileGuard {
268 path: PathBuf,
269 disarmed: bool,
270}
271
272impl TenantDbFileGuard {
273 fn new(path: PathBuf) -> Self {
274 Self {
275 path,
276 disarmed: false,
277 }
278 }
279
280 fn disarm(&mut self) {
281 self.disarmed = true;
282 }
283}
284
285impl Drop for TenantDbFileGuard {
286 fn drop(&mut self) {
287 if self.disarmed {
288 return;
289 }
290 for suffix in ["", "-wal", "-shm"] {
291 let p = PathBuf::from(format!("{}{suffix}", self.path.display()));
292 let _ = std::fs::remove_file(p);
293 }
294 }
295}
296
297impl ControlDb {
302 pub async fn tenant_by_slug(&self, slug: &str) -> Result<Option<Tenant>, SaasError> {
303 let row = sqlx::query_as::<_, Tenant>(
304 "SELECT id, name, slug, owner_email, plan_id, status, db_path, \
305 last_seen_at, created_at, updated_at \
306 FROM tenants WHERE slug = ?1",
307 )
308 .bind(slug)
309 .fetch_optional(self.pool())
310 .await?;
311 Ok(row)
312 }
313
314 pub async fn tenant_by_id(&self, id: &TenantId) -> Result<Option<Tenant>, SaasError> {
315 self.tenant_by_id_raw(id.as_bytes()).await
316 }
317
318 pub(crate) async fn tenant_by_id_raw(&self, id: &[u8]) -> Result<Option<Tenant>, SaasError> {
319 let row = sqlx::query_as::<_, Tenant>(
320 "SELECT id, name, slug, owner_email, plan_id, status, db_path, \
321 last_seen_at, created_at, updated_at \
322 FROM tenants WHERE id = ?1",
323 )
324 .bind(id)
325 .fetch_optional(self.pool())
326 .await?;
327 Ok(row)
328 }
329
330 pub async fn tenant_by_owner_email(&self, email: &str) -> Result<Vec<Tenant>, SaasError> {
331 let rows = sqlx::query_as::<_, Tenant>(
332 "SELECT id, name, slug, owner_email, plan_id, status, db_path, \
333 last_seen_at, created_at, updated_at \
334 FROM tenants WHERE owner_email = ?1",
335 )
336 .bind(email)
337 .fetch_all(self.pool())
338 .await?;
339 Ok(rows)
340 }
341
342 pub async fn list_tenants(&self) -> Result<Vec<Tenant>, SaasError> {
343 let rows = sqlx::query_as::<_, Tenant>(
344 "SELECT id, name, slug, owner_email, plan_id, status, db_path, \
345 last_seen_at, created_at, updated_at \
346 FROM tenants WHERE status != 'deleted' ORDER BY created_at ASC",
347 )
348 .fetch_all(self.pool())
349 .await?;
350 Ok(rows)
351 }
352}
353
354impl ControlDb {
359 pub async fn update_tenant_name(&self, id: &TenantId, name: String) -> Result<(), SaasError> {
360 let rows = sqlx::query(
361 "UPDATE tenants \
362 SET name = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
363 WHERE id = ?2 AND status != 'deleted'",
364 )
365 .bind(&name)
366 .bind(id.as_bytes())
367 .execute(self.pool())
368 .await?;
369 if rows.rows_affected() == 0 {
370 return Err(SaasError::TenantNotFound);
371 }
372 Ok(())
373 }
374
375 pub async fn suspend_tenant(&self, id: &TenantId) -> Result<(), SaasError> {
376 let rows = sqlx::query(
377 "UPDATE tenants \
378 SET status = 'suspended', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
379 WHERE id = ?1 AND status = 'active'",
380 )
381 .bind(id.as_bytes())
382 .execute(self.pool())
383 .await?;
384 if rows.rows_affected() == 0 {
385 return Err(SaasError::TenantNotFound);
386 }
387 Ok(())
388 }
389
390 pub async fn delete_tenant(&self, id: &TenantId) -> Result<(), SaasError> {
391 let rows = sqlx::query(
392 "UPDATE tenants \
393 SET status = 'deleted', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
394 WHERE id = ?1 AND status != 'deleted'",
395 )
396 .bind(id.as_bytes())
397 .execute(self.pool())
398 .await?;
399 if rows.rows_affected() == 0 {
400 return Err(SaasError::TenantNotFound);
401 }
402 Ok(())
403 }
404}
405
406impl ControlDb {
411 pub async fn provision_tenant(
412 &self,
413 name: String,
414 slug: String,
415 owner_email: String,
416 tenant_data_dir: &Path,
417 config: &TenantBuilderConfig,
418 ) -> Result<ProvisionResult, SaasError> {
419 use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
420
421 validate_slug(&slug)?;
423
424 let mut tx = self.pool().begin().await?;
426
427 let tenant_id = TenantId::new();
429 let db_file = format!("{}.db", tenant_id.as_uuid());
430 let inserted = sqlx::query(
431 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
432 SELECT ?1, ?2, ?3, ?4, id, 'active', ?5 FROM tenant_plans WHERE name = 'dev'",
433 )
434 .bind(tenant_id.as_bytes())
435 .bind(&name)
436 .bind(&slug)
437 .bind(&owner_email)
438 .bind(&db_file)
439 .execute(&mut *tx)
440 .await
441 .map_err(map_slug_conflict)?;
442
443 if inserted.rows_affected() == 0 {
446 return Err(SaasError::ProvisionFailed(
447 "'dev' plan not found in tenant_plans".into(),
448 ));
449 }
450
451 let member_id = Uuid::now_v7();
456 sqlx::query(
457 "INSERT INTO tenant_members (id, tenant_id, email, role, accepted_at) \
458 VALUES (?1, ?2, ?3, 'owner', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
459 )
460 .bind(member_id.as_bytes().as_slice())
461 .bind(tenant_id.as_bytes())
462 .bind(&owner_email)
463 .execute(&mut *tx)
464 .await?;
465
466 let full_path = tenant_data_dir.join(&db_file);
470 let mut file_guard = TenantDbFileGuard::new(full_path.clone());
471
472 let opts = SqliteConnectOptions::new()
473 .filename(&full_path)
474 .create_if_missing(true)
475 .pragma("foreign_keys", "ON")
476 .journal_mode(SqliteJournalMode::Wal)
477 .busy_timeout(std::time::Duration::from_millis(5000));
478 let pool = SqlitePool::connect_with(opts)
479 .await
480 .map_err(|e| SaasError::ProvisionFailed(e.to_string()))?;
481
482 let ath = allowthem_core::AllowThemBuilder::with_pool(pool)
487 .mfa_key(config.mfa_key)
488 .signing_key(config.signing_key)
489 .csrf_key(config.csrf_key)
490 .base_url(format!("https://{}.{}", slug, config.base_domain))
491 .cookie_name(tenant_cookie_name(config.is_production))
492 .cookie_secure(config.is_production)
493 .build()
494 .await
495 .map_err(|e| SaasError::ProvisionFailed(e.to_string()))?;
496
497 let (app, maybe_secret) = ath
500 .db()
501 .create_application(CreateApplicationParams {
502 name: "Default OIDC Application".to_string(),
503 client_type: ClientType::Confidential,
504 redirect_uris: vec!["http://localhost/callback".to_string()],
505 is_trusted: false,
506 created_by: None,
507 logo_url: None,
508 primary_color: None,
509 accent_hex: None,
510 accent_ink: None,
511 forced_mode: None,
512 font_css_url: None,
513 font_family: None,
514 splash_text: None,
515 splash_image_url: None,
516 splash_primitive: None,
517 splash_url: None,
518 shader_cell_scale: None,
519 })
520 .await?;
521 let client_secret = maybe_secret.expect("confidential app always has a secret");
523
524 tx.commit().await?;
526 file_guard.disarm();
527
528 let tenant = self
530 .tenant_by_id_raw(tenant_id.as_bytes())
531 .await?
532 .ok_or(SaasError::TenantNotFound)?;
533
534 Ok(ProvisionResult {
535 tenant,
536 ath,
537 client_id: app.client_id.to_string(),
538 client_secret,
539 })
540 }
541
542 pub async fn update_tenant_slug(
543 &self,
544 id: &TenantId,
545 new_slug: String,
546 tenant_pool: &SqlitePool,
547 ) -> Result<(), SaasError> {
548 validate_slug(&new_slug)?;
549
550 let has_sessions: bool =
551 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM allowthem_sessions LIMIT 1)")
552 .fetch_one(tenant_pool)
553 .await?;
554
555 if has_sessions {
556 return Err(SaasError::SlugChangeAfterFirstLogin);
557 }
558
559 let rows = sqlx::query(
560 "UPDATE tenants \
561 SET slug = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
562 WHERE id = ?2 AND status != 'deleted'",
563 )
564 .bind(&new_slug)
565 .bind(id.as_bytes())
566 .execute(self.pool())
567 .await
568 .map_err(map_slug_conflict)?;
569
570 if rows.rows_affected() == 0 {
571 return Err(SaasError::TenantNotFound);
572 }
573 Ok(())
574 }
575}
576
577#[cfg(test)]
582mod tests {
583 use super::*;
584 use crate::control_db::tests::test_pool;
585
586 #[test]
587 fn member_id_roundtrips_through_uuid() {
588 let id = MemberId::new();
589 assert_eq!(id.as_bytes().len(), 16);
590 let copy = MemberId::from(*id.as_uuid());
591 assert_eq!(copy, id);
592 }
593
594 #[test]
595 fn plan_id_roundtrips_through_uuid() {
596 let uuid = Uuid::now_v7();
597 let pid = PlanId::from(uuid);
598 assert_eq!(pid.as_uuid(), &uuid);
599 }
600
601 #[test]
602 fn tenant_role_str_round_trip() {
603 use std::str::FromStr;
604 for role in [
605 crate::control_db::TenantRole::Owner,
606 crate::control_db::TenantRole::Admin,
607 crate::control_db::TenantRole::Viewer,
608 ] {
609 assert_eq!(
610 crate::control_db::TenantRole::from_str(role.as_str()).unwrap(),
611 role
612 );
613 }
614 assert!(crate::control_db::TenantRole::from_str("guest").is_err());
615 }
616
617 async fn test_db() -> ControlDb {
618 let pool = test_pool().await;
619 ControlDb::new(pool).await.expect("ControlDb::new")
620 }
621
622 pub(super) async fn insert_tenant(db: &ControlDb, slug: &str, email: &str) -> Vec<u8> {
623 let id = TenantId::new();
624 sqlx::query(
625 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
626 SELECT ?1, ?2, ?3, ?4, id, 'active', ?5 FROM tenant_plans WHERE name = 'dev'",
627 )
628 .bind(id.as_bytes())
629 .bind(format!("{slug}-name"))
630 .bind(slug)
631 .bind(email)
632 .bind(format!("{slug}.db"))
633 .execute(db.pool())
634 .await
635 .expect("insert_tenant");
636 id.as_bytes().to_vec()
637 }
638
639 #[tokio::test]
640 async fn tenant_by_slug_found() {
641 let db = test_db().await;
642 insert_tenant(&db, "acme-corp", "owner@acme.com").await;
643 let tenant = db.tenant_by_slug("acme-corp").await.expect("query");
644 assert!(tenant.is_some());
645 assert_eq!(tenant.unwrap().slug, "acme-corp");
646 }
647
648 #[tokio::test]
649 async fn tenant_by_slug_not_found() {
650 let db = test_db().await;
651 let tenant = db.tenant_by_slug("no-such").await.expect("query");
652 assert!(tenant.is_none());
653 }
654
655 #[tokio::test]
656 async fn tenant_by_id_found() {
657 let db = test_db().await;
658 let id_bytes = insert_tenant(&db, "beta-corp", "owner@beta.com").await;
659 let uuid = Uuid::from_slice(&id_bytes).unwrap();
660 let tenant = db.tenant_by_id(&TenantId::from(uuid)).await.expect("query");
661 assert!(tenant.is_some());
662 assert_eq!(tenant.unwrap().id, id_bytes);
663 }
664
665 #[tokio::test]
666 async fn tenant_by_owner_email() {
667 let db = test_db().await;
668 insert_tenant(&db, "corp-one", "shared@example.com").await;
669 insert_tenant(&db, "corp-two", "shared@example.com").await;
670 let tenants = db
671 .tenant_by_owner_email("shared@example.com")
672 .await
673 .expect("query");
674 assert_eq!(tenants.len(), 2);
675 }
676
677 #[tokio::test]
680 async fn update_name_persists() {
681 let db = test_db().await;
682 let id_bytes = insert_tenant(&db, "rename-me", "owner@example.com").await;
683 let uuid = Uuid::from_slice(&id_bytes).unwrap();
684 let tid = TenantId::from(uuid);
685 db.update_tenant_name(&tid, "Renamed Corp".into())
686 .await
687 .expect("update_name");
688 let tenant = db.tenant_by_id(&tid).await.expect("query").unwrap();
689 assert_eq!(tenant.name, "Renamed Corp");
690 }
691
692 #[tokio::test]
693 async fn suspend_sets_status() {
694 let db = test_db().await;
695 let id_bytes = insert_tenant(&db, "suspend-me", "owner@example.com").await;
696 let uuid = Uuid::from_slice(&id_bytes).unwrap();
697 let tid = TenantId::from(uuid);
698 db.suspend_tenant(&tid).await.expect("suspend");
699 let tenant = db.tenant_by_id(&tid).await.expect("query").unwrap();
700 assert_eq!(tenant.status, TenantStatus::Suspended);
701 }
702
703 #[tokio::test]
704 async fn delete_sets_status() {
705 let db = test_db().await;
706 let id_bytes = insert_tenant(&db, "delete-me", "owner@example.com").await;
707 let uuid = Uuid::from_slice(&id_bytes).unwrap();
708 let tid = TenantId::from(uuid);
709 db.delete_tenant(&tid).await.expect("delete");
710 let tenant = db.tenant_by_id(&tid).await.expect("query").unwrap();
711 assert_eq!(tenant.status, TenantStatus::Deleted);
712 }
713
714 #[tokio::test]
715 async fn update_name_unknown_id() {
716 let db = test_db().await;
717 let tid = TenantId::new();
718 let err = db
719 .update_tenant_name(&tid, "Ghost".into())
720 .await
721 .err()
722 .expect("expected error");
723 assert!(matches!(err, SaasError::TenantNotFound));
724 }
725
726 fn test_builder_config() -> TenantBuilderConfig {
729 TenantBuilderConfig {
730 mfa_key: [1u8; 32],
731 signing_key: [2u8; 32],
732 csrf_key: [3u8; 32],
733 base_domain: "test.local".into(),
734 is_production: false,
735 email_sender: None,
736 event_sink: None,
737 event_sink_factory: None,
738 mau_sink: None,
739 email_sender_factory: None,
740 }
741 }
742
743 #[test]
744 fn tenant_cookie_name_prod_uses_host_prefix() {
745 assert_eq!(tenant_cookie_name(true), "__Host-allowthem_session");
746 }
747
748 #[test]
749 fn tenant_cookie_name_dev_drops_prefix() {
750 assert_eq!(tenant_cookie_name(false), "allowthem_session");
751 }
752
753 #[tokio::test]
754 async fn provision_prod_handle_emits_host_prefixed_cookie() {
755 let db = test_db().await;
756 let dir = tempfile::tempdir().expect("tempdir");
757 let config = TenantBuilderConfig {
758 is_production: true,
759 ..test_builder_config()
760 };
761
762 let result = db
763 .provision_tenant(
764 "Prod Co".into(),
765 "prodco".into(),
766 "owner@prodco.com".into(),
767 dir.path(),
768 &config,
769 )
770 .await
771 .expect("provision_tenant");
772
773 let token = allowthem_core::sessions::generate_token();
776 let cookie = result.ath.session_cookie(&token);
777 assert!(
778 cookie.starts_with("__Host-allowthem_session="),
779 "expected __Host-allowthem_session prefix, got: {cookie}"
780 );
781 assert!(
782 cookie.contains("; Secure"),
783 "Secure flag required for __Host-: {cookie}"
784 );
785 assert!(
786 !cookie.contains("Domain="),
787 "no Domain= attribute allowed for __Host-: {cookie}"
788 );
789 }
790
791 #[tokio::test]
792 async fn provision_happy_path() {
793 let db = test_db().await;
794 let dir = tempfile::tempdir().expect("tempdir");
795 let config = test_builder_config();
796
797 let result = db
798 .provision_tenant(
799 "Happy Corp".into(),
800 "happy-corp".into(),
801 "owner@happy.com".into(),
802 dir.path(),
803 &config,
804 )
805 .await
806 .expect("provision_tenant");
807
808 let tenant = db
809 .tenant_by_slug("happy-corp")
810 .await
811 .expect("query")
812 .expect("tenant must exist");
813 assert_eq!(tenant.name, "Happy Corp");
814
815 let db_file = dir.path().join(&tenant.db_path);
816 assert!(db_file.exists(), "tenant db file must exist");
817
818 assert!(!result.client_id.is_empty());
819 assert!(!result.client_secret.as_str().is_empty());
820
821 let _count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM allowthem_applications")
823 .fetch_one(result.ath.db().pool())
824 .await
825 .expect("db ping");
826
827 let (role, accepted_at): (String, Option<String>) = sqlx::query_as(
829 "SELECT role, accepted_at FROM tenant_members \
830 WHERE tenant_id = ?1 AND email = ?2",
831 )
832 .bind(tenant.id.as_slice())
833 .bind(&tenant.owner_email)
834 .fetch_one(db.pool())
835 .await
836 .expect("owner member row");
837 assert_eq!(role, "owner");
838 assert!(
839 accepted_at.is_some(),
840 "owner accepted_at must be set on signup-time provisioning"
841 );
842 }
843
844 #[tokio::test]
845 async fn provision_slug_conflict() {
846 let db = test_db().await;
847 let dir = tempfile::tempdir().expect("tempdir");
848 let config = test_builder_config();
849
850 db.provision_tenant(
851 "First".into(),
852 "clash-slug".into(),
853 "a@example.com".into(),
854 dir.path(),
855 &config,
856 )
857 .await
858 .expect("first provision");
859
860 let err = db
861 .provision_tenant(
862 "Second".into(),
863 "clash-slug".into(),
864 "b@example.com".into(),
865 dir.path(),
866 &config,
867 )
868 .await
869 .err()
870 .expect("expected error");
871
872 assert!(matches!(err, SaasError::SlugTaken));
873 }
874
875 #[tokio::test]
876 async fn provision_rolls_back_owner_member_on_file_failure() {
877 let db = test_db().await;
882 let config = test_builder_config();
883
884 let bad = tempfile::NamedTempFile::new().expect("tempfile");
888 let bad_dir = bad.path();
889
890 let result = db
891 .provision_tenant(
892 "Atomic".into(),
893 "atomic".into(),
894 "owner@atomic.com".into(),
895 bad_dir,
896 &config,
897 )
898 .await;
899 assert!(result.is_err(), "expected provision failure");
900
901 let tenants_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tenants")
902 .fetch_one(db.pool())
903 .await
904 .expect("count tenants");
905 assert_eq!(
906 tenants_count, 0,
907 "tenants row must be rolled back on file-open failure"
908 );
909
910 let members_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tenant_members")
911 .fetch_one(db.pool())
912 .await
913 .expect("count members");
914 assert_eq!(
915 members_count, 0,
916 "tenant_members owner row must be rolled back on file-open failure"
917 );
918 }
919
920 #[tokio::test]
921 async fn provision_slug_invalid() {
922 let db = test_db().await;
923 let dir = tempfile::tempdir().expect("tempdir");
924 let config = test_builder_config();
925
926 let err = db
927 .provision_tenant(
928 "Bad".into(),
929 "12abc".into(),
930 "x@example.com".into(),
931 dir.path(),
932 &config,
933 )
934 .await
935 .err()
936 .expect("expected error");
937
938 assert!(matches!(err, SaasError::SlugInvalid(_)));
939 }
940
941 #[tokio::test]
942 async fn provision_slug_reserved() {
943 let db = test_db().await;
944 let dir = tempfile::tempdir().expect("tempdir");
945 let config = test_builder_config();
946
947 let err = db
948 .provision_tenant(
949 "Admin".into(),
950 "admin".into(),
951 "x@example.com".into(),
952 dir.path(),
953 &config,
954 )
955 .await
956 .err()
957 .expect("expected error");
958
959 assert!(matches!(err, SaasError::SlugReserved));
960 }
961
962 #[tokio::test]
963 async fn provision_unwind_no_row_on_slug_conflict() {
964 let db = test_db().await;
965 let dir = tempfile::tempdir().expect("tempdir");
966 let config = test_builder_config();
967
968 db.provision_tenant(
969 "First".into(),
970 "conflict-test".into(),
971 "a@example.com".into(),
972 dir.path(),
973 &config,
974 )
975 .await
976 .expect("first provision");
977
978 let _ = db
979 .provision_tenant(
980 "Second".into(),
981 "conflict-test".into(),
982 "b@example.com".into(),
983 dir.path(),
984 &config,
985 )
986 .await;
987
988 let count: i64 =
989 sqlx::query_scalar("SELECT COUNT(*) FROM tenants WHERE slug = 'conflict-test'")
990 .fetch_one(db.pool())
991 .await
992 .expect("count query");
993 assert_eq!(count, 1, "slug conflict must not leave a partial row");
994 }
995
996 #[tokio::test]
997 async fn update_slug_no_sessions() {
998 let db = test_db().await;
999 let dir = tempfile::tempdir().expect("tempdir");
1000 let config = test_builder_config();
1001
1002 let result = db
1003 .provision_tenant(
1004 "Rename Corp".into(),
1005 "rename-corp".into(),
1006 "owner@rename.com".into(),
1007 dir.path(),
1008 &config,
1009 )
1010 .await
1011 .expect("provision");
1012
1013 let tid = TenantId::from(result.tenant.id_as_uuid().unwrap());
1014 db.update_tenant_slug(&tid, "renamed-corp".into(), result.ath.db().pool())
1015 .await
1016 .expect("update_slug should succeed with no sessions");
1017
1018 let tenant = db
1019 .tenant_by_id(&tid)
1020 .await
1021 .expect("query")
1022 .expect("tenant must exist");
1023 assert_eq!(tenant.slug, "renamed-corp");
1024
1025 drop(result.ath);
1027 }
1028
1029 #[tokio::test]
1030 async fn update_slug_with_sessions() {
1031 let db = test_db().await;
1032 let dir = tempfile::tempdir().expect("tempdir");
1033 let config = test_builder_config();
1034
1035 let result = db
1036 .provision_tenant(
1037 "Session Corp".into(),
1038 "session-corp".into(),
1039 "owner@session.com".into(),
1040 dir.path(),
1041 &config,
1042 )
1043 .await
1044 .expect("provision");
1045
1046 let user_id = uuid::Uuid::now_v7().to_string();
1048 sqlx::query("INSERT INTO allowthem_users (id, email) VALUES (?1, 'test@session.com')")
1049 .bind(&user_id)
1050 .execute(result.ath.db().pool())
1051 .await
1052 .expect("insert user");
1053 let session_id = uuid::Uuid::now_v7().to_string();
1054 sqlx::query(
1055 "INSERT INTO allowthem_sessions \
1056 (id, user_id, token_hash, expires_at) \
1057 VALUES (?1, ?2, 'fakehash', strftime('%Y-%m-%dT%H:%M:%fZ','now','+1 day'))",
1058 )
1059 .bind(&session_id)
1060 .bind(&user_id)
1061 .execute(result.ath.db().pool())
1062 .await
1063 .expect("insert session");
1064
1065 let tid = TenantId::from(result.tenant.id_as_uuid().unwrap());
1066 let err = db
1067 .update_tenant_slug(&tid, "new-slug".into(), result.ath.db().pool())
1068 .await
1069 .err()
1070 .expect("expected error");
1071
1072 assert!(matches!(err, SaasError::SlugChangeAfterFirstLogin));
1073
1074 drop(result.ath);
1076 }
1077}