1use chrono::{DateTime, Utc};
2use serde::Serialize;
3use sqlx::{FromRow, Row, SqlitePool};
4use uuid::Uuid;
5
6use allowthem_core::error::AuthError;
7
8use crate::cache::TenantMeta;
9use crate::error::SaasError;
10use crate::tenants::{MemberId, Tenant, TenantId, TenantMember, TenantPlan, TenantStatus};
11
12#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
13pub struct TenantUsage {
14 pub period: String,
15 pub mau_count: i64,
16 pub limit_reached_at: Option<DateTime<Utc>>,
17 pub notified_at: Option<DateTime<Utc>>,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize, sqlx::Type)]
22#[sqlx(rename_all = "lowercase")]
23#[serde(rename_all = "lowercase")]
24pub enum TenantRole {
25 Owner,
26 Admin,
27 Viewer,
28}
29
30impl TenantRole {
31 pub fn is_owner(&self) -> bool {
32 matches!(self, TenantRole::Owner)
33 }
34
35 pub fn is_admin_or_owner(&self) -> bool {
36 matches!(self, TenantRole::Owner | TenantRole::Admin)
37 }
38
39 pub fn as_str(&self) -> &'static str {
41 match self {
42 TenantRole::Owner => "owner",
43 TenantRole::Admin => "admin",
44 TenantRole::Viewer => "viewer",
45 }
46 }
47}
48
49impl std::str::FromStr for TenantRole {
50 type Err = SaasError;
51
52 fn from_str(s: &str) -> Result<Self, Self::Err> {
53 match s {
54 "owner" => Ok(TenantRole::Owner),
55 "admin" => Ok(TenantRole::Admin),
56 "viewer" => Ok(TenantRole::Viewer),
57 other => Err(SaasError::InvalidRole(other.to_owned())),
58 }
59 }
60}
61
62pub struct ControlDb {
63 pool: SqlitePool,
64}
65
66impl ControlDb {
67 pub async fn new(pool: SqlitePool) -> Result<Self, AuthError> {
68 sqlx::migrate!("./migrations")
69 .run(&pool)
70 .await
71 .map_err(sqlx::Error::from)?;
72 Ok(Self { pool })
73 }
74
75 pub fn pool(&self) -> &SqlitePool {
76 &self.pool
77 }
78
79 pub async fn tenant_meta_by_slug(&self, slug: &str) -> Result<Option<TenantMeta>, SaasError> {
80 let row = sqlx::query("SELECT id, status, plan_id FROM tenants WHERE slug = ?1")
81 .bind(slug)
82 .fetch_optional(&self.pool)
83 .await?;
84
85 let Some(row) = row else { return Ok(None) };
86
87 let id_bytes: Vec<u8> = row.try_get("id")?;
88 let status: TenantStatus = row.try_get("status")?;
89 let plan_id: Vec<u8> = row.try_get("plan_id")?;
90 let id = Uuid::from_slice(&id_bytes).map_err(|_| SaasError::TenantNotFound)?;
91
92 Ok(Some(TenantMeta {
93 id: TenantId::from(id),
94 status,
95 plan_id,
96 }))
97 }
98
99 pub async fn tenants_for_member(
103 &self,
104 email: &str,
105 ) -> Result<Vec<(Tenant, TenantRole)>, SaasError> {
106 let rows = sqlx::query(
107 "SELECT t.id, t.name, t.slug, t.owner_email, t.plan_id, t.status, \
108 t.db_path, t.last_seen_at, t.created_at, t.updated_at, m.role \
109 FROM tenants t \
110 JOIN tenant_members m ON m.tenant_id = t.id \
111 WHERE m.email = ?1 AND m.accepted_at IS NOT NULL \
112 AND t.status != 'deleted' \
113 ORDER BY t.name ASC",
114 )
115 .bind(email)
116 .fetch_all(&self.pool)
117 .await?;
118
119 let mut out = Vec::with_capacity(rows.len());
120 for row in rows {
121 let tenant = Tenant::from_row(&row)?;
122 let role: TenantRole = row.try_get("role")?;
123 out.push((tenant, role));
124 }
125 Ok(out)
126 }
127
128 pub async fn member_role(
132 &self,
133 tenant_id: &TenantId,
134 email: &str,
135 ) -> Result<Option<TenantRole>, SaasError> {
136 let row: Option<(TenantRole,)> = sqlx::query_as(
137 "SELECT role FROM tenant_members \
138 WHERE tenant_id = ?1 AND email = ?2 AND accepted_at IS NOT NULL",
139 )
140 .bind(tenant_id.as_bytes())
141 .bind(email)
142 .fetch_optional(&self.pool)
143 .await?;
144 Ok(row.map(|(r,)| r))
145 }
146
147 pub async fn most_recently_seen_tenants(&self, count: i64) -> Result<Vec<TenantId>, SaasError> {
148 let rows = sqlx::query(
149 "SELECT id FROM tenants \
150 WHERE status = 'active' AND last_seen_at IS NOT NULL \
151 ORDER BY last_seen_at DESC LIMIT ?1",
152 )
153 .bind(count)
154 .fetch_all(&self.pool)
155 .await?;
156
157 let mut result = Vec::with_capacity(rows.len());
158 for row in rows {
159 let bytes: Vec<u8> = row.try_get("id")?;
160 match Uuid::from_slice(&bytes) {
161 Ok(uuid) => result.push(TenantId::from(uuid)),
162 Err(_) => {
163 tracing::warn!("skipping tenant with undecodable UUID in most_recently_seen");
164 }
165 }
166 }
167 Ok(result)
168 }
169
170 pub async fn touch_last_seen(&self, tenant_id: &TenantId) -> Result<(), SaasError> {
171 sqlx::query("UPDATE tenants SET last_seen_at = datetime('now') WHERE id = ?1")
172 .bind(tenant_id.as_bytes())
173 .execute(&self.pool)
174 .await?;
175 Ok(())
176 }
177
178 pub async fn usage_for_tenant(
179 &self,
180 tenant_id: &TenantId,
181 ) -> Result<Vec<TenantUsage>, SaasError> {
182 let rows = sqlx::query_as::<_, TenantUsage>(
183 "SELECT period, mau_count, limit_reached_at, notified_at \
184 FROM tenant_usage \
185 WHERE tenant_id = ?1 \
186 ORDER BY period DESC",
187 )
188 .bind(tenant_id.as_bytes())
189 .fetch_all(&self.pool)
190 .await?;
191 Ok(rows)
192 }
193
194 pub async fn log_control_audit(
201 &self,
202 actor: &str,
203 action: &str,
204 tenant_id: Option<&TenantId>,
205 context: &serde_json::Value,
206 ) -> Result<(), SaasError> {
207 let id = Uuid::now_v7();
208 let context_str = context.to_string();
209 sqlx::query(
210 "INSERT INTO control_audit_events (id, actor, action, tenant_id, context) \
211 VALUES (?1, ?2, ?3, ?4, ?5)",
212 )
213 .bind(id.as_bytes().as_slice())
214 .bind(actor)
215 .bind(action)
216 .bind(tenant_id.map(|t| t.as_bytes()))
217 .bind(&context_str)
218 .execute(&self.pool)
219 .await?;
220 Ok(())
221 }
222}
223
224impl ControlDb {
231 pub async fn list_tenant_members(
234 &self,
235 tenant_id: &TenantId,
236 ) -> Result<Vec<TenantMember>, SaasError> {
237 let rows = sqlx::query_as::<_, TenantMember>(
238 "SELECT id, tenant_id, email, role, invited_at, accepted_at, \
239 invite_token_expires_at \
240 FROM tenant_members \
241 WHERE tenant_id = ?1 \
242 ORDER BY (accepted_at IS NULL) DESC, invited_at ASC",
243 )
244 .bind(tenant_id.as_bytes())
245 .fetch_all(&self.pool)
246 .await?;
247 Ok(rows)
248 }
249
250 pub async fn get_tenant_member(
251 &self,
252 member_id: &MemberId,
253 ) -> Result<Option<TenantMember>, SaasError> {
254 let row = sqlx::query_as::<_, TenantMember>(
255 "SELECT id, tenant_id, email, role, invited_at, accepted_at, \
256 invite_token_expires_at \
257 FROM tenant_members WHERE id = ?1",
258 )
259 .bind(member_id.as_bytes())
260 .fetch_optional(&self.pool)
261 .await?;
262 Ok(row)
263 }
264
265 pub async fn invite_member(
269 &self,
270 tenant_id: &TenantId,
271 email: &str,
272 role: TenantRole,
273 token_hash: &[u8],
274 expires_at: i64,
275 ) -> Result<MemberId, SaasError> {
276 let id = MemberId::new();
277 let result = sqlx::query(
278 "INSERT INTO tenant_members \
279 (id, tenant_id, email, role, invite_token_hash, invite_token_expires_at) \
280 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
281 )
282 .bind(id.as_bytes())
283 .bind(tenant_id.as_bytes())
284 .bind(email)
285 .bind(role.as_str())
286 .bind(token_hash)
287 .bind(expires_at)
288 .execute(&self.pool)
289 .await;
290 match result {
291 Ok(_) => Ok(id),
292 Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
293 Err(SaasError::MemberAlreadyExists)
294 }
295 Err(e) => Err(SaasError::Db(e)),
296 }
297 }
298
299 pub async fn find_pending_invite_by_hash(
303 &self,
304 token_hash: &[u8],
305 ) -> Result<Option<TenantMember>, SaasError> {
306 let now_secs = chrono::Utc::now().timestamp();
307 let row = sqlx::query_as::<_, TenantMember>(
308 "SELECT id, tenant_id, email, role, invited_at, accepted_at, \
309 invite_token_expires_at \
310 FROM tenant_members \
311 WHERE invite_token_hash = ?1 \
312 AND accepted_at IS NULL \
313 AND (invite_token_expires_at IS NULL OR invite_token_expires_at > ?2)",
314 )
315 .bind(token_hash)
316 .bind(now_secs)
317 .fetch_optional(&self.pool)
318 .await?;
319 Ok(row)
320 }
321
322 pub async fn accept_invite(&self, token_hash: &[u8]) -> Result<TenantMember, SaasError> {
328 let now_secs = chrono::Utc::now().timestamp();
329 let mut tx = self.pool.begin().await?;
330 let row = sqlx::query_as::<_, TenantMember>(
331 "SELECT id, tenant_id, email, role, invited_at, accepted_at, \
332 invite_token_expires_at \
333 FROM tenant_members \
334 WHERE invite_token_hash = ?1 \
335 AND accepted_at IS NULL \
336 AND (invite_token_expires_at IS NULL OR invite_token_expires_at > ?2)",
337 )
338 .bind(token_hash)
339 .bind(now_secs)
340 .fetch_optional(&mut *tx)
341 .await?
342 .ok_or(SaasError::InviteNotFoundOrExpired)?;
343
344 sqlx::query(
345 "UPDATE tenant_members \
346 SET accepted_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), \
347 invite_token_hash = NULL, \
348 invite_token_expires_at = NULL \
349 WHERE id = ?1",
350 )
351 .bind(&row.id)
352 .execute(&mut *tx)
353 .await?;
354 tx.commit().await?;
355 Ok(row)
356 }
357}
358
359impl ControlDb {
369 pub async fn count_owners(&self, tenant_id: &TenantId) -> Result<i64, SaasError> {
371 let n: i64 = sqlx::query_scalar(
372 "SELECT COUNT(*) FROM tenant_members \
373 WHERE tenant_id = ?1 AND role = 'owner' AND accepted_at IS NOT NULL",
374 )
375 .bind(tenant_id.as_bytes())
376 .fetch_one(&self.pool)
377 .await?;
378 Ok(n)
379 }
380
381 pub async fn update_member_role(
384 &self,
385 member_id: &MemberId,
386 new_role: TenantRole,
387 ) -> Result<(), SaasError> {
388 let mut tx = self.pool.begin().await?;
389 let target: Option<(Vec<u8>, String, Option<DateTime<Utc>>)> =
390 sqlx::query_as("SELECT tenant_id, role, accepted_at FROM tenant_members WHERE id = ?1")
391 .bind(member_id.as_bytes())
392 .fetch_optional(&mut *tx)
393 .await?;
394 let (tenant_blob, current_role, accepted_at) = target.ok_or(SaasError::MemberNotFound)?;
395
396 let demoting_owner = current_role == "owner" && !matches!(new_role, TenantRole::Owner);
397 if demoting_owner && accepted_at.is_some() {
398 let n: i64 = sqlx::query_scalar(
399 "SELECT COUNT(*) FROM tenant_members \
400 WHERE tenant_id = ?1 AND role = 'owner' AND accepted_at IS NOT NULL",
401 )
402 .bind(&tenant_blob)
403 .fetch_one(&mut *tx)
404 .await?;
405 if n <= 1 {
406 return Err(SaasError::CannotDemoteLastOwner);
407 }
408 }
409
410 sqlx::query("UPDATE tenant_members SET role = ?1 WHERE id = ?2")
411 .bind(new_role.as_str())
412 .bind(member_id.as_bytes())
413 .execute(&mut *tx)
414 .await?;
415 tx.commit().await?;
416 Ok(())
417 }
418
419 pub async fn remove_member(&self, member_id: &MemberId) -> Result<(), SaasError> {
422 let mut tx = self.pool.begin().await?;
423 let target: Option<(Vec<u8>, String, Option<DateTime<Utc>>)> =
424 sqlx::query_as("SELECT tenant_id, role, accepted_at FROM tenant_members WHERE id = ?1")
425 .bind(member_id.as_bytes())
426 .fetch_optional(&mut *tx)
427 .await?;
428 let (tenant_blob, role, accepted_at) = target.ok_or(SaasError::MemberNotFound)?;
429
430 if role == "owner" && accepted_at.is_some() {
431 let n: i64 = sqlx::query_scalar(
432 "SELECT COUNT(*) FROM tenant_members \
433 WHERE tenant_id = ?1 AND role = 'owner' AND accepted_at IS NOT NULL",
434 )
435 .bind(&tenant_blob)
436 .fetch_one(&mut *tx)
437 .await?;
438 if n <= 1 {
439 return Err(SaasError::CannotRemoveLastOwner);
440 }
441 }
442
443 sqlx::query("DELETE FROM tenant_members WHERE id = ?1")
444 .bind(member_id.as_bytes())
445 .execute(&mut *tx)
446 .await?;
447 tx.commit().await?;
448 Ok(())
449 }
450}
451
452#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
458pub struct TenantOverviewRow {
459 pub id: Vec<u8>,
460 pub name: String,
461 pub slug: String,
462 pub owner_email: String,
463 pub status: TenantStatus,
464 pub plan_name: String,
465 pub mau_count: i64,
466 pub created_at: DateTime<Utc>,
467 pub last_seen_at: Option<DateTime<Utc>>,
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
472pub enum TenantSortCol {
473 #[default]
474 Name,
475 Slug,
476 Status,
477 Plan,
478 Mau,
479 CreatedAt,
480 LastSeenAt,
481}
482
483impl TenantSortCol {
484 fn as_sql(&self) -> &'static str {
485 match self {
486 Self::Name => "t.name",
487 Self::Slug => "t.slug",
488 Self::Status => "t.status",
489 Self::Plan => "p.name",
490 Self::Mau => "COALESCE(tu.mau_count, 0)",
491 Self::CreatedAt => "t.created_at",
492 Self::LastSeenAt => "COALESCE(t.last_seen_at, '')",
494 }
495 }
496}
497
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
500pub enum SortDir {
501 #[default]
502 Asc,
503 Desc,
504}
505
506impl SortDir {
507 fn as_sql(&self) -> &'static str {
508 match self {
509 Self::Asc => "ASC",
510 Self::Desc => "DESC",
511 }
512 }
513}
514
515pub struct SearchTenantsParams<'a> {
517 pub query: Option<&'a str>,
519 pub status: Option<TenantStatus>,
520 pub plan_id: Option<&'a [u8]>,
522 pub current_period: &'a str,
525 pub sort_col: TenantSortCol,
526 pub sort_dir: SortDir,
527 pub limit: u32,
528 pub offset: u32,
529}
530
531pub struct SearchTenantsResult {
533 pub rows: Vec<TenantOverviewRow>,
534 pub total: u32,
535}
536
537#[derive(Debug, Clone, Serialize)]
540pub struct PeriodAggregate {
541 pub period: String,
542 pub total_mau: i64,
543 pub active_tenants: i64,
544}
545
546impl ControlDb {
551 pub async fn count_tenants_by_status(&self, status: TenantStatus) -> Result<i64, SaasError> {
553 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tenants WHERE status = ?1")
554 .bind(status)
555 .fetch_one(&self.pool)
556 .await?;
557 Ok(n)
558 }
559
560 pub async fn count_tenants_created_since(
562 &self,
563 since: DateTime<Utc>,
564 ) -> Result<i64, SaasError> {
565 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tenants WHERE created_at >= ?1")
566 .bind(since)
567 .fetch_one(&self.pool)
568 .await?;
569 Ok(n)
570 }
571
572 pub async fn count_tenants_grouped_by_plan(&self) -> Result<Vec<(String, i64)>, SaasError> {
576 let rows: Vec<(String, i64)> = sqlx::query_as(
577 "SELECT p.name, COUNT(t.id) AS count \
578 FROM tenant_plans p \
579 LEFT JOIN tenants t ON t.plan_id = p.id AND t.status != 'deleted' \
580 GROUP BY p.id, p.name \
581 ORDER BY count DESC",
582 )
583 .fetch_all(&self.pool)
584 .await?;
585 Ok(rows)
586 }
587
588 pub async fn search_tenants(
594 &self,
595 p: SearchTenantsParams<'_>,
596 ) -> Result<SearchTenantsResult, SaasError> {
597 let mut where_clauses: Vec<String> = Vec::new();
598 let mut string_binds: Vec<String> = Vec::new();
599
600 if let Some(q) = p.query {
601 let trimmed = q.trim();
602 if !trimmed.is_empty() {
603 let esc = trimmed
604 .replace('\\', "\\\\")
605 .replace('%', "\\%")
606 .replace('_', "\\_");
607 let pat = format!("%{esc}%");
608 where_clauses.push(
609 "(t.name LIKE ? ESCAPE '\\' \
610 OR t.slug LIKE ? ESCAPE '\\' \
611 OR t.owner_email LIKE ? ESCAPE '\\')"
612 .into(),
613 );
614 string_binds.push(pat.clone());
615 string_binds.push(pat.clone());
616 string_binds.push(pat);
617 }
618 }
619
620 if let Some(s) = p.status {
621 where_clauses.push("t.status = ?".into());
622 let s_str = match s {
623 TenantStatus::Active => "active",
624 TenantStatus::Suspended => "suspended",
625 TenantStatus::Deleted => "deleted",
626 };
627 string_binds.push(s_str.to_owned());
628 }
629
630 let has_plan_filter = p.plan_id.is_some();
631 if has_plan_filter {
632 where_clauses.push("t.plan_id = ?".into());
633 }
634
635 let where_sql = if where_clauses.is_empty() {
636 String::new()
637 } else {
638 format!("WHERE {}", where_clauses.join(" AND "))
639 };
640
641 let count_sql: &'static str =
643 Box::leak(format!("SELECT COUNT(*) FROM tenants t {where_sql}").into_boxed_str());
644 let mut count_q = sqlx::query_scalar::<_, i64>(count_sql);
645 for v in &string_binds {
646 count_q = count_q.bind(v);
647 }
648 if let Some(pid) = p.plan_id {
649 count_q = count_q.bind(pid);
650 }
651 let total = count_q.fetch_one(&self.pool).await? as u32;
652
653 let sort_col_sql = p.sort_col.as_sql();
656 let sort_dir_sql = p.sort_dir.as_sql();
657 let data_sql: &'static str = Box::leak(
658 format!(
659 "SELECT t.id, t.name, t.slug, t.owner_email, t.status, \
660 t.created_at, t.last_seen_at, \
661 p.name AS plan_name, COALESCE(tu.mau_count, 0) AS mau_count \
662 FROM tenants t \
663 JOIN tenant_plans p ON p.id = t.plan_id \
664 LEFT JOIN tenant_usage tu \
665 ON tu.tenant_id = t.id AND tu.period = ? \
666 {where_sql} \
667 ORDER BY {sort_col_sql} {sort_dir_sql} \
668 LIMIT ? OFFSET ?"
669 )
670 .into_boxed_str(),
671 );
672 let mut data_q = sqlx::query_as::<_, TenantOverviewRow>(data_sql).bind(p.current_period);
673 for v in &string_binds {
674 data_q = data_q.bind(v);
675 }
676 if let Some(pid) = p.plan_id {
677 data_q = data_q.bind(pid);
678 }
679 data_q = data_q.bind(p.limit).bind(p.offset);
680
681 let rows = data_q.fetch_all(&self.pool).await?;
682
683 Ok(SearchTenantsResult { rows, total })
684 }
685
686 pub async fn aggregate_usage_for_period(
690 &self,
691 period: &str,
692 ) -> Result<PeriodAggregate, SaasError> {
693 let (total_mau, active_tenants): (i64, i64) = sqlx::query_as(
694 "SELECT \
695 COALESCE(SUM(mau_count), 0) AS total_mau, \
696 COUNT(CASE WHEN mau_count > 0 THEN 1 END) AS active_tenants \
697 FROM tenant_usage \
698 WHERE period = ?1",
699 )
700 .bind(period)
701 .fetch_one(&self.pool)
702 .await?;
703
704 Ok(PeriodAggregate {
705 period: period.to_owned(),
706 total_mau,
707 active_tenants,
708 })
709 }
710
711 pub async fn aggregate_usage_history(
713 &self,
714 n_months: u32,
715 ) -> Result<Vec<PeriodAggregate>, SaasError> {
716 let rows: Vec<(String, i64, i64)> = sqlx::query_as(
717 "SELECT period, \
718 SUM(mau_count) AS total_mau, \
719 COUNT(CASE WHEN mau_count > 0 THEN 1 END) AS active_tenants \
720 FROM tenant_usage \
721 GROUP BY period \
722 ORDER BY period DESC \
723 LIMIT ?1",
724 )
725 .bind(n_months)
726 .fetch_all(&self.pool)
727 .await?;
728
729 Ok(rows
730 .into_iter()
731 .map(|(period, total_mau, active_tenants)| PeriodAggregate {
732 period,
733 total_mau,
734 active_tenants,
735 })
736 .collect())
737 }
738
739 pub async fn count_dormant_tenants(&self, before: DateTime<Utc>) -> Result<i64, SaasError> {
742 let n: i64 = sqlx::query_scalar(
743 "SELECT COUNT(*) FROM tenants \
744 WHERE status != 'deleted' \
745 AND (last_seen_at IS NULL OR last_seen_at < ?1)",
746 )
747 .bind(before)
748 .fetch_one(&self.pool)
749 .await?;
750 Ok(n)
751 }
752
753 pub async fn set_tenant_status(
755 &self,
756 tenant_id: &TenantId,
757 status: TenantStatus,
758 ) -> Result<(), SaasError> {
759 sqlx::query(
760 "UPDATE tenants \
761 SET status = ?1, \
762 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
763 WHERE id = ?2",
764 )
765 .bind(status)
766 .bind(tenant_id.as_bytes())
767 .execute(&self.pool)
768 .await?;
769 Ok(())
770 }
771
772 pub async fn set_tenant_plan(
774 &self,
775 tenant_id: &TenantId,
776 plan_id: &[u8],
777 ) -> Result<(), SaasError> {
778 sqlx::query(
779 "UPDATE tenants \
780 SET plan_id = ?1, \
781 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
782 WHERE id = ?2",
783 )
784 .bind(plan_id)
785 .bind(tenant_id.as_bytes())
786 .execute(&self.pool)
787 .await?;
788 Ok(())
789 }
790
791 pub async fn list_plans(&self) -> Result<Vec<TenantPlan>, SaasError> {
793 let rows = sqlx::query_as::<_, TenantPlan>(
794 "SELECT id, name, mau_limit, price_cents, features \
795 FROM tenant_plans \
796 ORDER BY price_cents ASC",
797 )
798 .fetch_all(&self.pool)
799 .await?;
800 Ok(rows)
801 }
802
803 pub async fn get_plan_by_id(&self, plan_id: &[u8]) -> Result<Option<TenantPlan>, SaasError> {
805 let row = sqlx::query_as::<_, TenantPlan>(
806 "SELECT id, name, mau_limit, price_cents, features \
807 FROM tenant_plans WHERE id = ?1",
808 )
809 .bind(plan_id)
810 .fetch_optional(&self.pool)
811 .await?;
812 Ok(row)
813 }
814}
815
816impl ControlDb {
821 pub async fn create_tenant_domain(
825 &self,
826 tenant_id: &crate::tenants::TenantId,
827 domain: &str,
828 dns_target: &str,
829 ) -> Result<crate::domains::TenantDomain, SaasError> {
830 use crate::domains::{DomainId, DomainStatus, TenantDomain};
831
832 let id = DomainId::new();
833 let result = sqlx::query(
834 "INSERT INTO tenant_domains \
835 (id, tenant_id, domain, status, dns_target) \
836 VALUES (?1, ?2, ?3, ?4, ?5)",
837 )
838 .bind(id.as_bytes())
839 .bind(tenant_id.as_bytes())
840 .bind(domain)
841 .bind(DomainStatus::PendingVerification.as_str())
842 .bind(dns_target)
843 .execute(&self.pool)
844 .await;
845
846 match result {
847 Ok(_) => {}
848 Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
849 return Err(SaasError::DomainAlreadyExists);
850 }
851 Err(e) => return Err(SaasError::Db(e)),
852 }
853
854 let row = sqlx::query_as::<_, TenantDomain>(
855 "SELECT id, tenant_id, domain, status, dns_target, \
856 verified_at, cert_expires_at, last_error, created_at, updated_at \
857 FROM tenant_domains WHERE id = ?1",
858 )
859 .bind(id.as_bytes())
860 .fetch_one(&self.pool)
861 .await?;
862 Ok(row)
863 }
864
865 pub async fn list_tenant_domains(
867 &self,
868 tenant_id: &crate::tenants::TenantId,
869 ) -> Result<Vec<crate::domains::TenantDomain>, SaasError> {
870 use crate::domains::TenantDomain;
871
872 let rows = sqlx::query_as::<_, TenantDomain>(
873 "SELECT id, tenant_id, domain, status, dns_target, \
874 verified_at, cert_expires_at, last_error, created_at, updated_at \
875 FROM tenant_domains \
876 WHERE tenant_id = ?1 \
877 ORDER BY created_at DESC, id DESC",
878 )
879 .bind(tenant_id.as_bytes())
880 .fetch_all(&self.pool)
881 .await?;
882 Ok(rows)
883 }
884
885 pub async fn get_tenant_domain_scoped(
890 &self,
891 domain_id: &crate::domains::DomainId,
892 tenant_id: &crate::tenants::TenantId,
893 ) -> Result<Option<crate::domains::TenantDomain>, SaasError> {
894 use crate::domains::TenantDomain;
895
896 let row = sqlx::query_as::<_, TenantDomain>(
897 "SELECT id, tenant_id, domain, status, dns_target, \
898 verified_at, cert_expires_at, last_error, created_at, updated_at \
899 FROM tenant_domains \
900 WHERE id = ?1 AND tenant_id = ?2",
901 )
902 .bind(domain_id.as_bytes())
903 .bind(tenant_id.as_bytes())
904 .fetch_optional(&self.pool)
905 .await?;
906 Ok(row)
907 }
908
909 pub async fn set_tenant_domain_status(
914 &self,
915 domain_id: &crate::domains::DomainId,
916 status: crate::domains::DomainStatus,
917 verified_at: Option<chrono::DateTime<chrono::Utc>>,
918 last_error: Option<&str>,
919 ) -> Result<(), SaasError> {
920 sqlx::query(
921 "UPDATE tenant_domains \
922 SET status = ?1, \
923 verified_at = ?2, \
924 last_error = ?3, \
925 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
926 WHERE id = ?4",
927 )
928 .bind(status.as_str())
929 .bind(verified_at)
930 .bind(last_error)
931 .bind(domain_id.as_bytes())
932 .execute(&self.pool)
933 .await?;
934 Ok(())
935 }
936
937 pub async fn delete_tenant_domain_scoped(
942 &self,
943 domain_id: &crate::domains::DomainId,
944 tenant_id: &crate::tenants::TenantId,
945 ) -> Result<bool, SaasError> {
946 let result = sqlx::query("DELETE FROM tenant_domains WHERE id = ?1 AND tenant_id = ?2")
947 .bind(domain_id.as_bytes())
948 .bind(tenant_id.as_bytes())
949 .execute(&self.pool)
950 .await?;
951 Ok(result.rows_affected() > 0)
952 }
953
954 pub async fn tenant_by_custom_domain(
960 &self,
961 domain: &str,
962 ) -> Result<Option<crate::tenants::Tenant>, SaasError> {
963 use crate::tenants::Tenant;
964
965 let lower = domain.to_lowercase();
966 let row = sqlx::query_as::<_, Tenant>(
967 "SELECT t.id, t.name, t.slug, t.owner_email, t.plan_id, t.status, \
968 t.db_path, t.last_seen_at, t.created_at, t.updated_at \
969 FROM tenants t \
970 JOIN tenant_domains d ON d.tenant_id = t.id \
971 WHERE d.domain = ?1 \
972 AND d.status IN ('verified', 'active')",
973 )
974 .bind(&lower)
975 .fetch_optional(&self.pool)
976 .await?;
977 Ok(row)
978 }
979
980 pub async fn list_domains_for_sweep(
984 &self,
985 limit: i64,
986 ) -> Result<Vec<crate::domains::TenantDomain>, SaasError> {
987 use crate::domains::TenantDomain;
988
989 let rows = sqlx::query_as::<_, TenantDomain>(
990 "SELECT id, tenant_id, domain, status, dns_target, \
991 verified_at, cert_expires_at, last_error, created_at, updated_at \
992 FROM tenant_domains \
993 WHERE status IN ('pending_verification', 'failed') \
994 ORDER BY updated_at ASC \
995 LIMIT ?1",
996 )
997 .bind(limit)
998 .fetch_all(&self.pool)
999 .await?;
1000 Ok(rows)
1001 }
1002}
1003
1004#[cfg(test)]
1005pub(crate) mod tests {
1006 use super::*;
1007 use sqlx::Row;
1008 use std::str::FromStr;
1009
1010 pub async fn test_pool() -> SqlitePool {
1011 let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:")
1012 .unwrap()
1013 .pragma("foreign_keys", "ON");
1014 SqlitePool::connect_with(opts).await.unwrap()
1015 }
1016
1017 #[tokio::test]
1018 async fn control_db_runs_migrations() {
1019 let pool = test_pool().await;
1020 let db = ControlDb::new(pool).await;
1021 assert!(db.is_ok());
1022 }
1023
1024 #[tokio::test]
1025 async fn log_control_audit_inserts_row() {
1026 let pool = test_pool().await;
1027 let db = ControlDb::new(pool).await.unwrap();
1028 db.log_control_audit(
1029 "owner@acme.com",
1030 "tenant.provisioned",
1031 None,
1032 &serde_json::json!({"slug": "acme"}),
1033 )
1034 .await
1035 .expect("log_control_audit");
1036
1037 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM control_audit_events")
1038 .fetch_one(db.pool())
1039 .await
1040 .unwrap();
1041 assert_eq!(count, 1);
1042
1043 let (actor, action, ctx): (String, String, String) =
1044 sqlx::query_as("SELECT actor, action, context FROM control_audit_events LIMIT 1")
1045 .fetch_one(db.pool())
1046 .await
1047 .unwrap();
1048 assert_eq!(actor, "owner@acme.com");
1049 assert_eq!(action, "tenant.provisioned");
1050 assert!(ctx.contains("acme"));
1051 }
1052
1053 #[tokio::test]
1054 async fn tenant_slug_unique() {
1055 let pool = test_pool().await;
1056 let db = ControlDb::new(pool).await.unwrap();
1057 let row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
1058 .fetch_one(db.pool())
1059 .await
1060 .unwrap();
1061 let plan_id: Vec<u8> = row.get("id");
1062 let id_a = uuid::Uuid::new_v4();
1063 let id_b = uuid::Uuid::new_v4();
1064 sqlx::query(
1065 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1066 VALUES (?, 'Acme', 'acme', 'a@a.com', ?, 'active', 'acme.db')",
1067 )
1068 .bind(id_a.as_bytes().as_ref())
1069 .bind(&plan_id)
1070 .execute(db.pool())
1071 .await
1072 .unwrap();
1073 let res = sqlx::query(
1074 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1075 VALUES (?, 'Acme 2', 'acme', 'b@b.com', ?, 'active', 'acme2.db')",
1076 )
1077 .bind(id_b.as_bytes().as_ref())
1078 .bind(&plan_id)
1079 .execute(db.pool())
1080 .await;
1081 assert!(res.is_err(), "duplicate slug should be rejected");
1082 }
1083
1084 #[tokio::test]
1085 async fn tenant_status_check_rejects_invalid() {
1086 let pool = test_pool().await;
1087 let db = ControlDb::new(pool).await.unwrap();
1088 let row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
1089 .fetch_one(db.pool())
1090 .await
1091 .unwrap();
1092 let plan_id: Vec<u8> = row.get("id");
1093 let id = uuid::Uuid::new_v4();
1094 let res = sqlx::query(
1095 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1096 VALUES (?, 'Bad', 'bad-status', 'c@c.com', ?, 'banned', 'bad.db')",
1097 )
1098 .bind(id.as_bytes().as_ref())
1099 .bind(&plan_id)
1100 .execute(db.pool())
1101 .await;
1102 assert!(res.is_err(), "invalid status should be rejected by CHECK");
1103 }
1104
1105 #[tokio::test]
1106 async fn member_role_check_rejects_invalid() {
1107 let pool = test_pool().await;
1108 let db = ControlDb::new(pool).await.unwrap();
1109 let row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
1110 .fetch_one(db.pool())
1111 .await
1112 .unwrap();
1113 let plan_id: Vec<u8> = row.get("id");
1114 let tenant_id = uuid::Uuid::new_v4();
1115 sqlx::query(
1116 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1117 VALUES (?, 'Role Test', 'role-test', 'd@d.com', ?, 'active', 'rtest.db')",
1118 )
1119 .bind(tenant_id.as_bytes().as_ref())
1120 .bind(&plan_id)
1121 .execute(db.pool())
1122 .await
1123 .unwrap();
1124 let member_id = uuid::Uuid::new_v4();
1125 let res = sqlx::query(
1126 "INSERT INTO tenant_members (id, tenant_id, email, role) \
1127 VALUES (?, ?, 'e@e.com', 'superuser')",
1128 )
1129 .bind(member_id.as_bytes().as_ref())
1130 .bind(tenant_id.as_bytes().as_ref())
1131 .execute(db.pool())
1132 .await;
1133 assert!(res.is_err(), "invalid role should be rejected by CHECK");
1134 }
1135
1136 #[tokio::test]
1137 async fn usage_for_tenant_returns_records() {
1138 let pool = test_pool().await;
1139 let db = ControlDb::new(pool).await.unwrap();
1140 let row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
1141 .fetch_one(db.pool())
1142 .await
1143 .unwrap();
1144 let plan_id: Vec<u8> = row.get("id");
1145 let tid = uuid::Uuid::new_v4();
1146 sqlx::query(
1147 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1148 VALUES (?, 'U', 'usagetest', 'u@u.com', ?, 'active', 'u.db')",
1149 )
1150 .bind(tid.as_bytes().as_ref())
1151 .bind(&plan_id)
1152 .execute(db.pool())
1153 .await
1154 .unwrap();
1155 let uid = uuid::Uuid::new_v4();
1156 sqlx::query(
1157 "INSERT INTO tenant_usage (id, tenant_id, period, mau_count) \
1158 VALUES (?, ?, '2026-04', 42)",
1159 )
1160 .bind(uid.as_bytes().as_ref())
1161 .bind(tid.as_bytes().as_ref())
1162 .execute(db.pool())
1163 .await
1164 .unwrap();
1165 let tenant_id = TenantId::from(tid);
1166 let usage = db.usage_for_tenant(&tenant_id).await.unwrap();
1167 assert_eq!(usage.len(), 1);
1168 assert_eq!(usage[0].period, "2026-04");
1169 assert_eq!(usage[0].mau_count, 42);
1170 assert!(usage[0].limit_reached_at.is_none());
1171 }
1172
1173 #[tokio::test]
1174 async fn api_key_hash_unique() {
1175 let pool = test_pool().await;
1176 let db = ControlDb::new(pool).await.unwrap();
1177 let row = sqlx::query("SELECT id FROM tenant_plans LIMIT 1")
1178 .fetch_one(db.pool())
1179 .await
1180 .unwrap();
1181 let plan_id: Vec<u8> = row.get("id");
1182 let tenant_id = uuid::Uuid::new_v4();
1183 sqlx::query(
1184 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1185 VALUES (?, 'Key Test', 'key-test', 'f@f.com', ?, 'active', 'ktest.db')",
1186 )
1187 .bind(tenant_id.as_bytes().as_ref())
1188 .bind(&plan_id)
1189 .execute(db.pool())
1190 .await
1191 .unwrap();
1192 let hash = vec![0u8; 32];
1193 let key_id_a = uuid::Uuid::new_v4();
1194 let key_id_b = uuid::Uuid::new_v4();
1195 sqlx::query(
1196 "INSERT INTO tenant_api_keys (id, tenant_id, name, key_hash, scope) \
1197 VALUES (?, ?, 'key-a', ?, '[]')",
1198 )
1199 .bind(key_id_a.as_bytes().as_ref())
1200 .bind(tenant_id.as_bytes().as_ref())
1201 .bind(&hash)
1202 .execute(db.pool())
1203 .await
1204 .unwrap();
1205 let res = sqlx::query(
1206 "INSERT INTO tenant_api_keys (id, tenant_id, name, key_hash, scope) \
1207 VALUES (?, ?, 'key-b', ?, '[]')",
1208 )
1209 .bind(key_id_b.as_bytes().as_ref())
1210 .bind(tenant_id.as_bytes().as_ref())
1211 .bind(&hash)
1212 .execute(db.pool())
1213 .await;
1214 assert!(res.is_err(), "duplicate key_hash should be rejected");
1215 }
1216
1217 async fn seed_tenant_with_status(db: &ControlDb, slug: &str, status: &str) -> TenantId {
1219 let plan_id: Vec<u8> = sqlx::query_scalar("SELECT id FROM tenant_plans LIMIT 1")
1220 .fetch_one(db.pool())
1221 .await
1222 .unwrap();
1223 let tid = uuid::Uuid::new_v4();
1224 let db_path = format!("{slug}.db");
1225 sqlx::query(
1226 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1227 VALUES (?, ?, ?, ?, ?, ?, ?)",
1228 )
1229 .bind(tid.as_bytes().as_ref())
1230 .bind(slug)
1231 .bind(slug)
1232 .bind(format!("owner-{slug}@example.com"))
1233 .bind(&plan_id)
1234 .bind(status)
1235 .bind(&db_path)
1236 .execute(db.pool())
1237 .await
1238 .unwrap();
1239 TenantId::from(tid)
1240 }
1241
1242 async fn seed_tenant(db: &ControlDb, slug: &str) -> TenantId {
1243 seed_tenant_with_status(db, slug, "active").await
1244 }
1245
1246 async fn insert_member(
1247 db: &ControlDb,
1248 tenant_id: &TenantId,
1249 email: &str,
1250 role: &str,
1251 accepted: bool,
1252 ) {
1253 let id = uuid::Uuid::new_v4();
1254 if accepted {
1255 sqlx::query(
1256 "INSERT INTO tenant_members (id, tenant_id, email, role, accepted_at) \
1257 VALUES (?, ?, ?, ?, datetime('now'))",
1258 )
1259 .bind(id.as_bytes().as_ref())
1260 .bind(tenant_id.as_bytes())
1261 .bind(email)
1262 .bind(role)
1263 .execute(db.pool())
1264 .await
1265 .unwrap();
1266 } else {
1267 sqlx::query(
1268 "INSERT INTO tenant_members (id, tenant_id, email, role) \
1269 VALUES (?, ?, ?, ?)",
1270 )
1271 .bind(id.as_bytes().as_ref())
1272 .bind(tenant_id.as_bytes())
1273 .bind(email)
1274 .bind(role)
1275 .execute(db.pool())
1276 .await
1277 .unwrap();
1278 }
1279 }
1280
1281 #[tokio::test]
1282 async fn member_role_returns_role_for_accepted_member() {
1283 let db = ControlDb::new(test_pool().await).await.unwrap();
1284 let tid = seed_tenant(&db, "acme").await;
1285 insert_member(&db, &tid, "alice@x.com", "admin", true).await;
1286
1287 let role = db.member_role(&tid, "alice@x.com").await.unwrap();
1288 assert_eq!(role, Some(TenantRole::Admin));
1289 }
1290
1291 #[tokio::test]
1292 async fn member_role_ignores_unaccepted_invite() {
1293 let db = ControlDb::new(test_pool().await).await.unwrap();
1294 let tid = seed_tenant(&db, "acme").await;
1295 insert_member(&db, &tid, "bob@x.com", "viewer", false).await;
1296
1297 let role = db.member_role(&tid, "bob@x.com").await.unwrap();
1298 assert_eq!(role, None);
1299 }
1300
1301 #[tokio::test]
1302 async fn member_role_returns_none_for_non_member() {
1303 let db = ControlDb::new(test_pool().await).await.unwrap();
1304 let tid = seed_tenant(&db, "acme").await;
1305
1306 let role = db.member_role(&tid, "stranger@x.com").await.unwrap();
1307 assert_eq!(role, None);
1308 }
1309
1310 #[tokio::test]
1311 async fn tenants_for_member_returns_pairs_sorted_by_name() {
1312 let db = ControlDb::new(test_pool().await).await.unwrap();
1313 let zid = seed_tenant(&db, "zulu").await;
1314 let aid = seed_tenant(&db, "alpha").await;
1315 insert_member(&db, &aid, "x@y.com", "owner", true).await;
1316 insert_member(&db, &zid, "x@y.com", "admin", true).await;
1317
1318 let pairs = db.tenants_for_member("x@y.com").await.unwrap();
1319 assert_eq!(pairs.len(), 2);
1320 assert_eq!(pairs[0].0.slug, "alpha");
1321 assert_eq!(pairs[0].1, TenantRole::Owner);
1322 assert_eq!(pairs[1].0.slug, "zulu");
1323 assert_eq!(pairs[1].1, TenantRole::Admin);
1324 }
1325
1326 #[tokio::test]
1327 async fn tenants_for_member_excludes_deleted_tenants() {
1328 let db = ControlDb::new(test_pool().await).await.unwrap();
1329 let tid = seed_tenant_with_status(&db, "deadco", "deleted").await;
1330 insert_member(&db, &tid, "x@y.com", "owner", true).await;
1331
1332 let pairs = db.tenants_for_member("x@y.com").await.unwrap();
1333 assert!(pairs.is_empty());
1334 }
1335
1336 #[tokio::test]
1337 async fn tenants_for_member_excludes_unaccepted_invites() {
1338 let db = ControlDb::new(test_pool().await).await.unwrap();
1339 let tid = seed_tenant(&db, "acme").await;
1340 insert_member(&db, &tid, "pending@x.com", "viewer", false).await;
1341
1342 let pairs = db.tenants_for_member("pending@x.com").await.unwrap();
1343 assert!(pairs.is_empty());
1344 }
1345
1346 fn token_hash(seed: u8) -> Vec<u8> {
1349 vec![seed; 32]
1350 }
1351
1352 fn future_ts() -> i64 {
1353 chrono::Utc::now().timestamp() + 60 * 60 * 24
1354 }
1355
1356 #[tokio::test]
1357 async fn list_tenant_members_returns_pending_first() {
1358 let db = ControlDb::new(test_pool().await).await.unwrap();
1359 let tid = seed_tenant(&db, "listtest").await;
1360 insert_member(&db, &tid, "owner@x.com", "owner", true).await;
1361 db.invite_member(
1362 &tid,
1363 "pending@x.com",
1364 TenantRole::Admin,
1365 &token_hash(1),
1366 future_ts(),
1367 )
1368 .await
1369 .unwrap();
1370
1371 let members = db.list_tenant_members(&tid).await.unwrap();
1372 assert_eq!(members.len(), 2);
1373 assert_eq!(members[0].email, "pending@x.com");
1374 assert!(members[0].accepted_at.is_none());
1375 assert_eq!(members[1].email, "owner@x.com");
1376 assert!(members[1].accepted_at.is_some());
1377 }
1378
1379 #[tokio::test]
1380 async fn invite_member_unique_collision_returns_error() {
1381 let db = ControlDb::new(test_pool().await).await.unwrap();
1382 let tid = seed_tenant(&db, "collide").await;
1383 db.invite_member(
1384 &tid,
1385 "dup@x.com",
1386 TenantRole::Viewer,
1387 &token_hash(2),
1388 future_ts(),
1389 )
1390 .await
1391 .unwrap();
1392 let err = db
1393 .invite_member(
1394 &tid,
1395 "dup@x.com",
1396 TenantRole::Viewer,
1397 &token_hash(3),
1398 future_ts(),
1399 )
1400 .await
1401 .expect_err("second invite must collide");
1402 assert!(matches!(err, SaasError::MemberAlreadyExists));
1403 }
1404
1405 #[tokio::test]
1406 async fn accept_invite_happy_path_clears_token() {
1407 let db = ControlDb::new(test_pool().await).await.unwrap();
1408 let tid = seed_tenant(&db, "happy").await;
1409 let hash = token_hash(4);
1410 db.invite_member(&tid, "joiner@x.com", TenantRole::Admin, &hash, future_ts())
1411 .await
1412 .unwrap();
1413
1414 let row = db.accept_invite(&hash).await.unwrap();
1415 assert_eq!(row.email, "joiner@x.com");
1416 assert!(
1417 row.accepted_at.is_none(),
1418 "pre-update row carries the prior NULL accepted_at"
1419 );
1420
1421 let mid = row.id_as_member_id().unwrap();
1422 let after = db.get_tenant_member(&mid).await.unwrap().unwrap();
1423 assert!(after.accepted_at.is_some());
1424 assert!(after.invite_token_expires_at.is_none());
1425 }
1426
1427 #[tokio::test]
1428 async fn accept_invite_expired_returns_error() {
1429 let db = ControlDb::new(test_pool().await).await.unwrap();
1430 let tid = seed_tenant(&db, "expired").await;
1431 let hash = token_hash(5);
1432 let past = chrono::Utc::now().timestamp() - 60;
1433 db.invite_member(&tid, "stale@x.com", TenantRole::Viewer, &hash, past)
1434 .await
1435 .unwrap();
1436 let err = db.accept_invite(&hash).await.expect_err("expired");
1437 assert!(matches!(err, SaasError::InviteNotFoundOrExpired));
1438 }
1439
1440 #[tokio::test]
1441 async fn accept_invite_already_accepted_returns_error() {
1442 let db = ControlDb::new(test_pool().await).await.unwrap();
1443 let tid = seed_tenant(&db, "twice").await;
1444 let hash = token_hash(6);
1445 db.invite_member(&tid, "once@x.com", TenantRole::Admin, &hash, future_ts())
1446 .await
1447 .unwrap();
1448 db.accept_invite(&hash).await.unwrap();
1449 let err = db.accept_invite(&hash).await.expect_err("re-accept");
1450 assert!(matches!(err, SaasError::InviteNotFoundOrExpired));
1451 }
1452
1453 #[tokio::test]
1454 async fn find_pending_invite_skips_expired_and_accepted() {
1455 let db = ControlDb::new(test_pool().await).await.unwrap();
1456 let tid = seed_tenant(&db, "find").await;
1457 let live = token_hash(7);
1458 db.invite_member(&tid, "live@x.com", TenantRole::Viewer, &live, future_ts())
1459 .await
1460 .unwrap();
1461 let found = db.find_pending_invite_by_hash(&live).await.unwrap();
1462 assert!(found.is_some());
1463
1464 db.accept_invite(&live).await.unwrap();
1465 let after = db.find_pending_invite_by_hash(&live).await.unwrap();
1466 assert!(after.is_none());
1467 }
1468
1469 async fn insert_member_typed(
1474 db: &ControlDb,
1475 tenant_id: &TenantId,
1476 email: &str,
1477 role: &str,
1478 ) -> MemberId {
1479 let mid = MemberId::new();
1480 sqlx::query(
1481 "INSERT INTO tenant_members (id, tenant_id, email, role, accepted_at) \
1482 VALUES (?, ?, ?, ?, datetime('now'))",
1483 )
1484 .bind(mid.as_bytes())
1485 .bind(tenant_id.as_bytes())
1486 .bind(email)
1487 .bind(role)
1488 .execute(db.pool())
1489 .await
1490 .unwrap();
1491 mid
1492 }
1493
1494 #[tokio::test]
1495 async fn count_owners_counts_only_accepted_owners() {
1496 let db = ControlDb::new(test_pool().await).await.unwrap();
1497 let tid = seed_tenant(&db, "count").await;
1498 insert_member_typed(&db, &tid, "owner@x.com", "owner").await;
1499 insert_member_typed(&db, &tid, "admin@x.com", "admin").await;
1500 db.invite_member(
1502 &tid,
1503 "pending@x.com",
1504 TenantRole::Owner,
1505 &token_hash(8),
1506 future_ts(),
1507 )
1508 .await
1509 .unwrap();
1510
1511 assert_eq!(db.count_owners(&tid).await.unwrap(), 1);
1512 }
1513
1514 #[tokio::test]
1515 async fn update_member_role_demote_last_owner_fails() {
1516 let db = ControlDb::new(test_pool().await).await.unwrap();
1517 let tid = seed_tenant(&db, "demote-last").await;
1518 let owner = insert_member_typed(&db, &tid, "lone@x.com", "owner").await;
1519
1520 let err = db
1521 .update_member_role(&owner, TenantRole::Admin)
1522 .await
1523 .expect_err("demote sole owner");
1524 assert!(matches!(err, SaasError::CannotDemoteLastOwner));
1525
1526 let after = db.get_tenant_member(&owner).await.unwrap().unwrap();
1528 assert_eq!(after.role, TenantRole::Owner);
1529 }
1530
1531 #[tokio::test]
1532 async fn update_member_role_demote_one_of_two_owners_succeeds() {
1533 let db = ControlDb::new(test_pool().await).await.unwrap();
1534 let tid = seed_tenant(&db, "demote-ok").await;
1535 let first = insert_member_typed(&db, &tid, "a@x.com", "owner").await;
1536 insert_member_typed(&db, &tid, "b@x.com", "owner").await;
1537
1538 db.update_member_role(&first, TenantRole::Admin)
1539 .await
1540 .unwrap();
1541 let after = db.get_tenant_member(&first).await.unwrap().unwrap();
1542 assert_eq!(after.role, TenantRole::Admin);
1543 }
1544
1545 #[tokio::test]
1546 async fn update_role_unknown_member_returns_not_found() {
1547 let db = ControlDb::new(test_pool().await).await.unwrap();
1548 let bogus = MemberId::new();
1549 let err = db
1550 .update_member_role(&bogus, TenantRole::Admin)
1551 .await
1552 .expect_err("unknown");
1553 assert!(matches!(err, SaasError::MemberNotFound));
1554 }
1555
1556 #[tokio::test]
1557 async fn remove_last_owner_fails() {
1558 let db = ControlDb::new(test_pool().await).await.unwrap();
1559 let tid = seed_tenant(&db, "rm-last").await;
1560 let owner = insert_member_typed(&db, &tid, "only@x.com", "owner").await;
1561
1562 let err = db.remove_member(&owner).await.expect_err("remove last");
1563 assert!(matches!(err, SaasError::CannotRemoveLastOwner));
1564 assert!(db.get_tenant_member(&owner).await.unwrap().is_some());
1565 }
1566
1567 #[tokio::test]
1568 async fn remove_one_of_two_owners_succeeds() {
1569 let db = ControlDb::new(test_pool().await).await.unwrap();
1570 let tid = seed_tenant(&db, "rm-ok").await;
1571 let first = insert_member_typed(&db, &tid, "a@x.com", "owner").await;
1572 insert_member_typed(&db, &tid, "b@x.com", "owner").await;
1573
1574 db.remove_member(&first).await.unwrap();
1575 assert!(db.get_tenant_member(&first).await.unwrap().is_none());
1576 }
1577
1578 #[tokio::test]
1579 async fn remove_admin_succeeds() {
1580 let db = ControlDb::new(test_pool().await).await.unwrap();
1581 let tid = seed_tenant(&db, "rm-admin").await;
1582 insert_member_typed(&db, &tid, "owner@x.com", "owner").await;
1583 let admin = insert_member_typed(&db, &tid, "admin@x.com", "admin").await;
1584
1585 db.remove_member(&admin).await.unwrap();
1586 assert!(db.get_tenant_member(&admin).await.unwrap().is_none());
1587 }
1588
1589 #[tokio::test]
1592 async fn count_tenants_by_status_returns_correct_counts() {
1593 let db = ControlDb::new(test_pool().await).await.unwrap();
1594 seed_tenant_with_status(&db, "active-a", "active").await;
1595 seed_tenant_with_status(&db, "active-b", "active").await;
1596 seed_tenant_with_status(&db, "active-c", "active").await;
1597 seed_tenant_with_status(&db, "suspended-a", "suspended").await;
1598 seed_tenant_with_status(&db, "suspended-b", "suspended").await;
1599 seed_tenant_with_status(&db, "deleted-a", "deleted").await;
1600
1601 assert_eq!(
1602 db.count_tenants_by_status(TenantStatus::Active)
1603 .await
1604 .unwrap(),
1605 3
1606 );
1607 assert_eq!(
1608 db.count_tenants_by_status(TenantStatus::Suspended)
1609 .await
1610 .unwrap(),
1611 2
1612 );
1613 assert_eq!(
1614 db.count_tenants_by_status(TenantStatus::Deleted)
1615 .await
1616 .unwrap(),
1617 1
1618 );
1619 }
1620
1621 #[tokio::test]
1622 async fn count_tenants_created_since_threshold() {
1623 let db = ControlDb::new(test_pool().await).await.unwrap();
1624 seed_tenant(&db, "old-a").await;
1625 seed_tenant(&db, "old-b").await;
1626
1627 let threshold = chrono::Utc::now();
1628 seed_tenant(&db, "new-a").await;
1629
1630 let after = db.count_tenants_created_since(threshold).await.unwrap();
1633 let all = db
1634 .count_tenants_created_since(chrono::DateTime::UNIX_EPOCH)
1635 .await
1636 .unwrap();
1637 assert_eq!(all, 3);
1638 assert!(after >= 0 && after <= 3, "threshold count in sane range");
1639 }
1640
1641 #[tokio::test]
1642 async fn count_tenants_grouped_by_plan_returns_all_plans_zero() {
1643 let db = ControlDb::new(test_pool().await).await.unwrap();
1644 let by_plan = db.count_tenants_grouped_by_plan().await.unwrap();
1646 assert_eq!(by_plan.len(), 4, "all 4 seed plans returned");
1647 assert!(by_plan.iter().all(|(_, c)| *c == 0));
1648 }
1649
1650 #[tokio::test]
1651 async fn count_tenants_grouped_by_plan_counts_correctly() {
1652 let db = ControlDb::new(test_pool().await).await.unwrap();
1653 let plans: Vec<(Vec<u8>, String)> =
1654 sqlx::query_as("SELECT id, name FROM tenant_plans ORDER BY price_cents ASC LIMIT 2")
1655 .fetch_all(db.pool())
1656 .await
1657 .unwrap();
1658 let (dev_id, _) = &plans[0];
1659 let (starter_id, _) = &plans[1];
1660
1661 for slug in ["dev-a", "dev-b"] {
1662 let id = uuid::Uuid::new_v4();
1663 sqlx::query(
1664 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1665 VALUES (?, ?, ?, ?, ?, 'active', ?)",
1666 )
1667 .bind(id.as_bytes().as_ref())
1668 .bind(slug)
1669 .bind(slug)
1670 .bind(format!("{slug}@x.com"))
1671 .bind(dev_id)
1672 .bind(format!("{slug}.db"))
1673 .execute(db.pool())
1674 .await
1675 .unwrap();
1676 }
1677 let sid = uuid::Uuid::new_v4();
1678 sqlx::query(
1679 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
1680 VALUES (?, 'Starter', 'strt', 'strt@x.com', ?, 'active', 'strt.db')",
1681 )
1682 .bind(sid.as_bytes().as_ref())
1683 .bind(starter_id)
1684 .execute(db.pool())
1685 .await
1686 .unwrap();
1687
1688 let by_plan = db.count_tenants_grouped_by_plan().await.unwrap();
1689 let dev_count = by_plan.iter().find(|(n, _)| n == "dev").map(|(_, c)| *c);
1690 let starter_count = by_plan
1691 .iter()
1692 .find(|(n, _)| n == "starter")
1693 .map(|(_, c)| *c);
1694 assert_eq!(dev_count, Some(2));
1695 assert_eq!(starter_count, Some(1));
1696 }
1697
1698 #[tokio::test]
1699 async fn search_tenants_no_filter_returns_all() {
1700 let db = ControlDb::new(test_pool().await).await.unwrap();
1701 seed_tenant(&db, "alpha").await;
1702 seed_tenant(&db, "beta").await;
1703 seed_tenant(&db, "gamma").await;
1704
1705 let result = db
1706 .search_tenants(SearchTenantsParams {
1707 query: None,
1708 status: None,
1709 plan_id: None,
1710 current_period: "2026-05",
1711 sort_col: TenantSortCol::Name,
1712 sort_dir: SortDir::Asc,
1713 limit: 25,
1714 offset: 0,
1715 })
1716 .await
1717 .unwrap();
1718
1719 assert_eq!(result.total, 3);
1720 assert_eq!(result.rows.len(), 3);
1721 assert_eq!(result.rows[0].slug, "alpha");
1722 assert_eq!(result.rows[2].slug, "gamma");
1723 }
1724
1725 #[tokio::test]
1726 async fn search_tenants_query_matches_slug_and_name() {
1727 let db = ControlDb::new(test_pool().await).await.unwrap();
1728 seed_tenant(&db, "acme").await;
1729 seed_tenant(&db, "acme-staging").await;
1730 seed_tenant(&db, "beta").await;
1731
1732 let result = db
1733 .search_tenants(SearchTenantsParams {
1734 query: Some("acme"),
1735 status: None,
1736 plan_id: None,
1737 current_period: "2026-05",
1738 sort_col: TenantSortCol::Name,
1739 sort_dir: SortDir::Asc,
1740 limit: 25,
1741 offset: 0,
1742 })
1743 .await
1744 .unwrap();
1745
1746 assert_eq!(result.total, 2);
1747 assert!(result.rows.iter().all(|r| r.slug.contains("acme")));
1748 }
1749
1750 #[tokio::test]
1751 async fn search_tenants_status_filter() {
1752 let db = ControlDb::new(test_pool().await).await.unwrap();
1753 seed_tenant_with_status(&db, "susp-a", "suspended").await;
1754 seed_tenant_with_status(&db, "susp-b", "suspended").await;
1755 seed_tenant(&db, "active-z").await;
1756
1757 let result = db
1758 .search_tenants(SearchTenantsParams {
1759 query: None,
1760 status: Some(TenantStatus::Suspended),
1761 plan_id: None,
1762 current_period: "2026-05",
1763 sort_col: TenantSortCol::Name,
1764 sort_dir: SortDir::Asc,
1765 limit: 25,
1766 offset: 0,
1767 })
1768 .await
1769 .unwrap();
1770
1771 assert_eq!(result.total, 2);
1772 assert!(
1773 result
1774 .rows
1775 .iter()
1776 .all(|r| r.status == TenantStatus::Suspended)
1777 );
1778 }
1779
1780 #[tokio::test]
1781 async fn search_tenants_pagination() {
1782 let db = ControlDb::new(test_pool().await).await.unwrap();
1783 for i in 0..30u32 {
1784 seed_tenant(&db, &format!("page-t-{i:02}")).await;
1785 }
1786
1787 let page1 = db
1788 .search_tenants(SearchTenantsParams {
1789 query: None,
1790 status: None,
1791 plan_id: None,
1792 current_period: "2026-05",
1793 sort_col: TenantSortCol::Name,
1794 sort_dir: SortDir::Asc,
1795 limit: 25,
1796 offset: 0,
1797 })
1798 .await
1799 .unwrap();
1800 let page2 = db
1801 .search_tenants(SearchTenantsParams {
1802 query: None,
1803 status: None,
1804 plan_id: None,
1805 current_period: "2026-05",
1806 sort_col: TenantSortCol::Name,
1807 sort_dir: SortDir::Asc,
1808 limit: 25,
1809 offset: 25,
1810 })
1811 .await
1812 .unwrap();
1813
1814 assert_eq!(page1.total, 30);
1815 assert_eq!(page1.rows.len(), 25);
1816 assert_eq!(page2.rows.len(), 5);
1817 }
1818
1819 #[tokio::test]
1820 async fn search_tenants_sort_name_desc() {
1821 let db = ControlDb::new(test_pool().await).await.unwrap();
1822 seed_tenant(&db, "aaa").await;
1823 seed_tenant(&db, "bbb").await;
1824 seed_tenant(&db, "ccc").await;
1825
1826 let result = db
1827 .search_tenants(SearchTenantsParams {
1828 query: None,
1829 status: None,
1830 plan_id: None,
1831 current_period: "2026-05",
1832 sort_col: TenantSortCol::Name,
1833 sort_dir: SortDir::Desc,
1834 limit: 25,
1835 offset: 0,
1836 })
1837 .await
1838 .unwrap();
1839
1840 assert_eq!(result.rows[0].slug, "ccc");
1841 assert_eq!(result.rows[2].slug, "aaa");
1842 }
1843
1844 #[tokio::test]
1845 async fn aggregate_usage_for_period_returns_zeros_when_empty() {
1846 let db = ControlDb::new(test_pool().await).await.unwrap();
1847 let agg = db.aggregate_usage_for_period("2026-05").await.unwrap();
1848 assert_eq!(agg.period, "2026-05");
1849 assert_eq!(agg.total_mau, 0);
1850 assert_eq!(agg.active_tenants, 0);
1851 }
1852
1853 #[tokio::test]
1854 async fn aggregate_usage_for_period_sums_correctly() {
1855 let db = ControlDb::new(test_pool().await).await.unwrap();
1856 let ta = seed_tenant(&db, "agg-a").await;
1857 let tb = seed_tenant(&db, "agg-b").await;
1858 let tc = seed_tenant(&db, "agg-c").await;
1859
1860 for (tid, mau) in [(&ta, 100i64), (&tb, 200), (&tc, 0)] {
1861 let uid = uuid::Uuid::new_v4();
1862 sqlx::query(
1863 "INSERT INTO tenant_usage (id, tenant_id, period, mau_count) \
1864 VALUES (?, ?, '2026-05', ?)",
1865 )
1866 .bind(uid.as_bytes().as_ref())
1867 .bind(tid.as_bytes())
1868 .bind(mau)
1869 .execute(db.pool())
1870 .await
1871 .unwrap();
1872 }
1873
1874 let agg = db.aggregate_usage_for_period("2026-05").await.unwrap();
1875 assert_eq!(agg.total_mau, 300);
1876 assert_eq!(agg.active_tenants, 2);
1878 }
1879
1880 #[tokio::test]
1881 async fn aggregate_usage_history_returns_n_most_recent() {
1882 let db = ControlDb::new(test_pool().await).await.unwrap();
1883 let tid = seed_tenant(&db, "hist").await;
1884
1885 for i in 0..14u32 {
1887 let uid = uuid::Uuid::new_v4();
1888 let period = format!("2025-{:02}", i + 1);
1889 sqlx::query(
1890 "INSERT INTO tenant_usage (id, tenant_id, period, mau_count) \
1891 VALUES (?, ?, ?, 1)",
1892 )
1893 .bind(uid.as_bytes().as_ref())
1894 .bind(tid.as_bytes())
1895 .bind(&period)
1896 .execute(db.pool())
1897 .await
1898 .unwrap();
1899 }
1900
1901 let history = db.aggregate_usage_history(12).await.unwrap();
1902 assert_eq!(
1903 history.len(),
1904 12,
1905 "only the 12 most recent periods returned"
1906 );
1907 assert!(history[0].period > history[11].period);
1909 }
1910
1911 #[tokio::test]
1912 async fn count_dormant_tenants_excludes_deleted() {
1913 let db = ControlDb::new(test_pool().await).await.unwrap();
1914 seed_tenant(&db, "never-seen").await;
1916 seed_tenant_with_status(&db, "del", "deleted").await;
1918
1919 let n = db
1920 .count_dormant_tenants(chrono::Utc::now() + chrono::Duration::days(365))
1921 .await
1922 .unwrap();
1923 assert_eq!(n, 1, "deleted tenant must not be counted as dormant");
1924 }
1925
1926 #[tokio::test]
1927 async fn count_dormant_tenants_excludes_recently_seen() {
1928 let db = ControlDb::new(test_pool().await).await.unwrap();
1929 let tid = seed_tenant(&db, "recent-tenant").await;
1930 db.touch_last_seen(&tid).await.unwrap();
1931
1932 let n = db
1934 .count_dormant_tenants(chrono::Utc::now() - chrono::Duration::days(30))
1935 .await
1936 .unwrap();
1937 assert_eq!(n, 0, "recently-seen tenant should not be dormant");
1938 }
1939
1940 #[tokio::test]
1941 async fn set_tenant_status_updates_row() {
1942 let db = ControlDb::new(test_pool().await).await.unwrap();
1943 let tid = seed_tenant(&db, "suspend-me").await;
1944
1945 db.set_tenant_status(&tid, TenantStatus::Suspended)
1946 .await
1947 .unwrap();
1948
1949 let (status,): (String,) = sqlx::query_as("SELECT status FROM tenants WHERE id = ?1")
1950 .bind(tid.as_bytes())
1951 .fetch_one(db.pool())
1952 .await
1953 .unwrap();
1954 assert_eq!(status, "suspended");
1955 }
1956
1957 #[tokio::test]
1958 async fn set_tenant_plan_updates_plan_id() {
1959 let db = ControlDb::new(test_pool().await).await.unwrap();
1960 let tid = seed_tenant(&db, "change-plan").await;
1961 let plans = db.list_plans().await.unwrap();
1962 let new_plan = &plans[1];
1964
1965 db.set_tenant_plan(&tid, &new_plan.id).await.unwrap();
1966
1967 let (plan_id,): (Vec<u8>,) = sqlx::query_as("SELECT plan_id FROM tenants WHERE id = ?1")
1968 .bind(tid.as_bytes())
1969 .fetch_one(db.pool())
1970 .await
1971 .unwrap();
1972 assert_eq!(plan_id, new_plan.id);
1973 }
1974
1975 #[tokio::test]
1976 async fn list_plans_returns_four_seed_plans_ordered_by_price() {
1977 let db = ControlDb::new(test_pool().await).await.unwrap();
1978 let plans = db.list_plans().await.unwrap();
1979 assert_eq!(plans.len(), 4);
1980 let names: Vec<&str> = plans.iter().map(|p| p.name.as_str()).collect();
1981 assert!(names.contains(&"dev"));
1982 assert!(names.contains(&"starter"));
1983 assert!(names.contains(&"growth"));
1984 assert!(names.contains(&"scale"));
1985 assert_eq!(plans[0].name, "dev");
1987 assert_eq!(plans[0].price_cents, 0);
1988 }
1989
1990 #[tokio::test]
1991 async fn get_plan_by_id_some_and_none() {
1992 let db = ControlDb::new(test_pool().await).await.unwrap();
1993 let plans = db.list_plans().await.unwrap();
1994 let first = &plans[0];
1995
1996 let found = db.get_plan_by_id(&first.id).await.unwrap();
1997 assert!(found.is_some());
1998 assert_eq!(found.unwrap().name, first.name);
1999
2000 let bogus = vec![0u8; 16];
2001 let not_found = db.get_plan_by_id(&bogus).await.unwrap();
2002 assert!(not_found.is_none());
2003 }
2004
2005 #[tokio::test]
2008 async fn create_tenant_domain_inserts_pending() {
2009 use crate::domains::DomainStatus;
2010
2011 let db = ControlDb::new(test_pool().await).await.unwrap();
2012 let tid = seed_tenant(&db, "dom-insert").await;
2013
2014 let row = db
2015 .create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2016 .await
2017 .unwrap();
2018
2019 assert_eq!(row.domain, "auth.example.com");
2020 assert_eq!(row.dns_target, "custom.allowthem.io");
2021 assert_eq!(row.status, DomainStatus::PendingVerification);
2022 assert!(row.verified_at.is_none());
2023 assert!(row.last_error.is_none());
2024 }
2025
2026 #[tokio::test]
2027 async fn create_tenant_domain_unique_violation_returns_conflict() {
2028 let db = ControlDb::new(test_pool().await).await.unwrap();
2029 let tid = seed_tenant(&db, "dom-conflict").await;
2030
2031 db.create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2032 .await
2033 .unwrap();
2034
2035 let err = db
2036 .create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2037 .await
2038 .unwrap_err();
2039 assert!(
2040 matches!(err, SaasError::DomainAlreadyExists),
2041 "expected DomainAlreadyExists, got {err:?}"
2042 );
2043 }
2044
2045 #[tokio::test]
2046 async fn list_tenant_domains_orders_by_created_at_desc() {
2047 let db = ControlDb::new(test_pool().await).await.unwrap();
2048 let tid = seed_tenant(&db, "dom-list-order").await;
2049
2050 db.create_tenant_domain(&tid, "a.example.com", "custom.allowthem.io")
2051 .await
2052 .unwrap();
2053 db.create_tenant_domain(&tid, "b.example.com", "custom.allowthem.io")
2054 .await
2055 .unwrap();
2056 db.create_tenant_domain(&tid, "c.example.com", "custom.allowthem.io")
2057 .await
2058 .unwrap();
2059
2060 sqlx::query("UPDATE tenant_domains SET created_at = ?1 WHERE tenant_id = ?2")
2061 .bind("2026-01-01T00:00:00.000Z")
2062 .bind(tid.as_bytes())
2063 .execute(db.pool())
2064 .await
2065 .unwrap();
2066
2067 let rows = db.list_tenant_domains(&tid).await.unwrap();
2068 assert_eq!(rows.len(), 3);
2069 assert_eq!(rows[0].domain, "c.example.com");
2071 assert_eq!(rows[1].domain, "b.example.com");
2072 assert_eq!(rows[2].domain, "a.example.com");
2073 }
2074
2075 #[tokio::test]
2076 async fn list_tenant_domains_filters_by_tenant_id() {
2077 let db = ControlDb::new(test_pool().await).await.unwrap();
2078 let tid_a = seed_tenant(&db, "dom-filter-a").await;
2079 let tid_b = seed_tenant(&db, "dom-filter-b").await;
2080
2081 db.create_tenant_domain(&tid_a, "auth.acme.com", "custom.allowthem.io")
2082 .await
2083 .unwrap();
2084 db.create_tenant_domain(&tid_b, "auth.beta.com", "custom.allowthem.io")
2085 .await
2086 .unwrap();
2087
2088 let rows_a = db.list_tenant_domains(&tid_a).await.unwrap();
2089 assert_eq!(rows_a.len(), 1);
2090 assert_eq!(rows_a[0].domain, "auth.acme.com");
2091
2092 let rows_b = db.list_tenant_domains(&tid_b).await.unwrap();
2093 assert_eq!(rows_b.len(), 1);
2094 assert_eq!(rows_b[0].domain, "auth.beta.com");
2095 }
2096
2097 #[tokio::test]
2098 async fn get_tenant_domain_scoped_other_tenant_is_none() {
2099 use crate::domains::DomainId;
2100
2101 let db = ControlDb::new(test_pool().await).await.unwrap();
2102 let tid_a = seed_tenant(&db, "dom-scope-a").await;
2103 let tid_b = seed_tenant(&db, "dom-scope-b").await;
2104
2105 let row = db
2106 .create_tenant_domain(&tid_a, "auth.acme.com", "custom.allowthem.io")
2107 .await
2108 .unwrap();
2109 let did = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2110
2111 let found = db.get_tenant_domain_scoped(&did, &tid_a).await.unwrap();
2113 assert!(found.is_some());
2114
2115 let not_found = db.get_tenant_domain_scoped(&did, &tid_b).await.unwrap();
2117 assert!(not_found.is_none());
2118 }
2119
2120 #[tokio::test]
2121 async fn set_tenant_domain_status_to_verified_clears_last_error_and_sets_verified_at() {
2122 use crate::domains::{DomainId, DomainStatus};
2123
2124 let db = ControlDb::new(test_pool().await).await.unwrap();
2125 let tid = seed_tenant(&db, "dom-verify").await;
2126
2127 let row = db
2128 .create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2129 .await
2130 .unwrap();
2131 let did = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2132
2133 let now = chrono::Utc::now();
2134 db.set_tenant_domain_status(&did, DomainStatus::Verified, Some(now), None)
2135 .await
2136 .unwrap();
2137
2138 let updated = db
2139 .get_tenant_domain_scoped(&did, &tid)
2140 .await
2141 .unwrap()
2142 .unwrap();
2143 assert_eq!(updated.status, DomainStatus::Verified);
2144 assert!(updated.verified_at.is_some());
2145 assert!(updated.last_error.is_none());
2146 }
2147
2148 #[tokio::test]
2149 async fn set_tenant_domain_status_to_failed_records_last_error() {
2150 use crate::domains::{DomainId, DomainStatus};
2151
2152 let db = ControlDb::new(test_pool().await).await.unwrap();
2153 let tid = seed_tenant(&db, "dom-fail").await;
2154
2155 let row = db
2156 .create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2157 .await
2158 .unwrap();
2159 let did = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2160
2161 db.set_tenant_domain_status(
2162 &did,
2163 DomainStatus::Failed,
2164 None,
2165 Some("no CNAME record found"),
2166 )
2167 .await
2168 .unwrap();
2169
2170 let updated = db
2171 .get_tenant_domain_scoped(&did, &tid)
2172 .await
2173 .unwrap()
2174 .unwrap();
2175 assert_eq!(updated.status, DomainStatus::Failed);
2176 assert!(updated.verified_at.is_none());
2177 assert_eq!(updated.last_error.as_deref(), Some("no CNAME record found"));
2178 }
2179
2180 #[tokio::test]
2181 async fn delete_tenant_domain_scoped_other_tenant_is_no_op() {
2182 use crate::domains::DomainId;
2183
2184 let db = ControlDb::new(test_pool().await).await.unwrap();
2185 let tid_a = seed_tenant(&db, "dom-del-a").await;
2186 let tid_b = seed_tenant(&db, "dom-del-b").await;
2187
2188 let row = db
2189 .create_tenant_domain(&tid_a, "auth.example.com", "custom.allowthem.io")
2190 .await
2191 .unwrap();
2192 let did = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2193
2194 let affected = db.delete_tenant_domain_scoped(&did, &tid_b).await.unwrap();
2196 assert!(!affected);
2197
2198 let still_there = db.get_tenant_domain_scoped(&did, &tid_a).await.unwrap();
2200 assert!(still_there.is_some());
2201
2202 let deleted = db.delete_tenant_domain_scoped(&did, &tid_a).await.unwrap();
2204 assert!(deleted);
2205
2206 let gone = db.get_tenant_domain_scoped(&did, &tid_a).await.unwrap();
2207 assert!(gone.is_none());
2208 }
2209
2210 #[tokio::test]
2211 async fn tenant_by_custom_domain_returns_only_verified_or_active() {
2212 use crate::domains::{DomainId, DomainStatus};
2213
2214 let db = ControlDb::new(test_pool().await).await.unwrap();
2215 let tid = seed_tenant(&db, "dom-router").await;
2216
2217 let row = db
2219 .create_tenant_domain(&tid, "pending.example.com", "custom.allowthem.io")
2220 .await
2221 .unwrap();
2222 let did_pending = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2223 assert!(
2224 db.tenant_by_custom_domain("pending.example.com")
2225 .await
2226 .unwrap()
2227 .is_none()
2228 );
2229
2230 db.set_tenant_domain_status(
2232 &did_pending,
2233 DomainStatus::Verified,
2234 Some(chrono::Utc::now()),
2235 None,
2236 )
2237 .await
2238 .unwrap();
2239 let found = db
2240 .tenant_by_custom_domain("pending.example.com")
2241 .await
2242 .unwrap();
2243 assert!(found.is_some());
2244 assert_eq!(found.unwrap().slug, "dom-router");
2245
2246 let row2 = db
2248 .create_tenant_domain(&tid, "failed.example.com", "custom.allowthem.io")
2249 .await
2250 .unwrap();
2251 let did_failed = DomainId::from(Uuid::from_slice(&row2.id).unwrap());
2252 db.set_tenant_domain_status(&did_failed, DomainStatus::Failed, None, Some("err"))
2253 .await
2254 .unwrap();
2255 assert!(
2256 db.tenant_by_custom_domain("failed.example.com")
2257 .await
2258 .unwrap()
2259 .is_none()
2260 );
2261 }
2262
2263 #[tokio::test]
2264 async fn tenant_by_custom_domain_lowercases_input() {
2265 use crate::domains::{DomainId, DomainStatus};
2266
2267 let db = ControlDb::new(test_pool().await).await.unwrap();
2268 let tid = seed_tenant(&db, "dom-case").await;
2269
2270 let row = db
2271 .create_tenant_domain(&tid, "auth.example.com", "custom.allowthem.io")
2272 .await
2273 .unwrap();
2274 let did = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2275 db.set_tenant_domain_status(&did, DomainStatus::Verified, Some(chrono::Utc::now()), None)
2276 .await
2277 .unwrap();
2278
2279 let found = db
2281 .tenant_by_custom_domain("Auth.EXAMPLE.COM")
2282 .await
2283 .unwrap();
2284 assert!(found.is_some());
2285 }
2286
2287 #[tokio::test]
2288 async fn list_domains_for_sweep_oldest_first_capped() {
2289 use crate::domains::{DomainId, DomainStatus};
2290
2291 let db = ControlDb::new(test_pool().await).await.unwrap();
2292 let tid = seed_tenant(&db, "dom-sweep").await;
2293
2294 for domain in ["a.sweep.com", "b.sweep.com", "c.sweep.com"] {
2296 db.create_tenant_domain(&tid, domain, "custom.allowthem.io")
2297 .await
2298 .unwrap();
2299 }
2300
2301 let row = db
2303 .create_tenant_domain(&tid, "v.sweep.com", "custom.allowthem.io")
2304 .await
2305 .unwrap();
2306 let did_v = DomainId::from(Uuid::from_slice(&row.id).unwrap());
2307 db.set_tenant_domain_status(
2308 &did_v,
2309 DomainStatus::Verified,
2310 Some(chrono::Utc::now()),
2311 None,
2312 )
2313 .await
2314 .unwrap();
2315
2316 let sweep = db.list_domains_for_sweep(2).await.unwrap();
2318 assert_eq!(sweep.len(), 2);
2319 for r in &sweep {
2321 assert!(
2322 matches!(
2323 r.status,
2324 DomainStatus::PendingVerification | DomainStatus::Failed
2325 ),
2326 "unexpected status {:?}",
2327 r.status
2328 );
2329 }
2330 }
2331}