Skip to main content

allowthem_saas/
tenants.rs

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
17/// Validates slug format per spec §4 step 1: ^[a-z][a-z0-9-]{2,39}$
18pub 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/// A row from the `tenants` control-plane table.
50#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize)]
51pub struct Tenant {
52    pub id: Vec<u8>, // UUIDv7 bytes (BLOB)
53    pub name: String,
54    pub slug: String,
55    pub owner_email: String,
56    pub plan_id: Vec<u8>, // opaque randomblob(16) from seed migration
57    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/// UUIDv7-backed identifier for a tenant.
71#[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    /// Returns raw bytes suitable for binding to a BLOB column.
90    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/// UUIDv7-backed identifier for a `tenant_members` row.
102#[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    /// Raw 16-byte payload suitable for binding to a SQLite BLOB column.
115    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/// UUIDv7-backed identifier for a `tenant_plans` row. Plans are seeded
133/// (no `new()` — the IDs come from the seed migration), so unlike
134/// [`MemberId`] there's no constructor.
135#[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/// One row of `tenant_members`. Mirrors the BLOB-as-`Vec<u8>` convention
155/// used by [`Tenant`]; call [`Self::id_as_member_id`] to unpack into a
156/// typed handle.
157#[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    /// Unix seconds; `NULL` once the invite has been accepted (and the
166    /// hash is also cleared then).
167    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/// One row of `tenant_plans`. Read-only at v1 (plans are seeded by a
181/// migration). `features` is the raw JSON string from the column;
182/// callers parse on demand.
183#[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
198/// SaaS-wide keys and base domain needed when building a tenant AllowThem handle.
199pub 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    /// Drives the cookie name + Secure attribute for tenant sessions.
205    /// `__Host-` requires Secure → requires HTTPS, so dev (HTTP localhost)
206    /// uses the plain name and `Secure=false`.
207    pub is_production: bool,
208    /// Optional email sender shared across all tenant handles built from this
209    /// config. When `None` the handle falls back to `NoopEmailSender` with a
210    /// startup warning. Set to `Some` in production and in any integration test
211    /// that exercises email flows.
212    pub email_sender: Option<Arc<dyn EmailSender>>,
213    /// Optional event sink. When `None` the handle uses `NoopEventSink` (silent).
214    /// Set to `Some(Arc::new(LoggingEventSink))` in dev, or a real sink in production.
215    ///
216    /// Prefer `event_sink_factory` for any sink that needs the tenant id (e.g.
217    /// the SaaS webhook sink). When both are set, the handle-builder uses the
218    /// factory and ignores `event_sink`.
219    pub event_sink: Option<Arc<dyn EventSink>>,
220    /// Optional per-tenant event-sink factory. When `Some`, the handle-builder
221    /// calls `factory.for_tenant(tenant_id)` once per tenant `AllowThem`
222    /// build and binds the resulting sink to that tenant. Required for
223    /// webhook delivery (epic 7xw.2) where the sink scopes its
224    /// `tenant_webhooks` lookup by tenant id.
225    pub event_sink_factory: Option<Arc<dyn crate::webhook_sink::EventSinkFactory>>,
226    /// Optional MAU sink. When `Some`, every tenant `AllowThem` handle is built
227    /// with an `on_user_active` callback that forwards events to this sink for
228    /// control-plane MAU counting. `None` disables MAU tracking (e.g. embedded
229    /// mode tests, or unit tests that don't exercise login flows).
230    pub mau_sink: Option<Arc<MauSink>>,
231    /// Optional per-tenant email-sender factory. When `Some`, the handle-builder
232    /// calls `factory.for_tenant(tenant_id, &pool).await` once per tenant
233    /// `AllowThem` build and binds the resolved sender to that tenant. The
234    /// resolved sender takes precedence over `email_sender`. Use in SaaS-mode
235    /// binaries; embedded integrators leave this `None` and rely on the
236    /// simpler shared `email_sender` path.
237    pub email_sender_factory: Option<Arc<dyn crate::managed_email::EmailSenderFactory>>,
238}
239
240/// Cookie name for tenant sessions.
241///
242/// Production uses the `__Host-` prefix, which forbids the `Domain` attribute
243/// and requires `Secure` — both already true under SaaS deployment via Caddy
244/// (per parent spec §6.2). Dev uses the plain name to keep the local HTTP
245/// workflow friction-free.
246pub 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
254/// Result of a successful `provision_tenant` call.
255pub struct ProvisionResult {
256    pub tenant: Tenant,
257    /// The AllowThem handle for the new tenant — hold this alive to keep the pool open.
258    pub ath: AllowThem,
259    pub client_id: String,
260    pub client_secret: ClientSecret,
261}
262
263// ---------------------------------------------------------------------------
264// RAII guard — deletes tenant DB files on drop unless disarmed.
265// ---------------------------------------------------------------------------
266
267struct 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
297// ---------------------------------------------------------------------------
298// Read queries
299// ---------------------------------------------------------------------------
300
301impl 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
354// ---------------------------------------------------------------------------
355// Simple mutations
356// ---------------------------------------------------------------------------
357
358impl 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
406// ---------------------------------------------------------------------------
407// Provisioning + update_tenant_slug
408// ---------------------------------------------------------------------------
409
410impl 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        // Step 1: Validate slug.
422        validate_slug(&slug)?;
423
424        // Step 2: Begin control plane transaction.
425        let mut tx = self.pool().begin().await?;
426
427        // Step 3: Insert tenant row (status=active, plan=dev).
428        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        // Guard: if 'dev' plan row is missing, the SELECT returns 0 rows →
444        // INSERT is a no-op. Return an error before creating any files.
445        if inserted.rows_affected() == 0 {
446            return Err(SaasError::ProvisionFailed(
447                "'dev' plan not found in tenant_plans".into(),
448            ));
449        }
450
451        // Step 3b: Insert the owner row in the same transaction so a tenant
452        // is never persisted without an owner. This is the "join key" the
453        // dashboard uses to authorize a logged-in user against tenants they
454        // own. UUIDv7 for monotonic id without an extra index.
455        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        // Step 4: Create SQLite file. Guard auto-deletes on drop unless disarmed.
467        // Declaration order is load-bearing (§0.4): file_guard must be declared before ath
468        // so Rust's reverse drop order closes the pool before deleting the file on unwind.
469        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        // Steps 5+6: Build handle — AllowThemBuilder::build() runs core migrations via Db::new.
483        // No `cookie_domain` — host-only cookies satisfy the `__Host-` prefix
484        // (see crates/core/sessions.rs:313). Tenant sessions are bound to the
485        // exact `<slug>.<base_domain>` host the issuing request hit.
486        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        // Step 7: Create default OIDC application (ClientType::Confidential — §0.5).
498        // validate_redirect_uris rejects empty slices; use a localhost placeholder.
499        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        // ClientType::Confidential always yields Some(ClientSecret).
522        let client_secret = maybe_secret.expect("confidential app always has a secret");
523
524        // Step 8: Commit. Disarm file guard after commit — no cleanup on success.
525        tx.commit().await?;
526        file_guard.disarm();
527
528        // Step 9: Fetch tenant row (updated_at etc. populated by DB defaults).
529        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// ---------------------------------------------------------------------------
578// Tests
579// ---------------------------------------------------------------------------
580
581#[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    // --- Commit 2: simple mutations ---
678
679    #[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    // --- Commit 3: provisioning + update_slug ---
727
728    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        // Build a cookie value via the new tenant handle; assert __Host- prefix,
774        // Secure flag, and absence of Domain attribute.
775        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        // ath handle alive — ping the DB
822        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        // tenant_members owner row was inserted in the same control-plane tx.
828        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        // If the SQLite file open fails after the tenants/tenant_members
878        // INSERTs, the control-plane transaction must be rolled back so the
879        // tenants table doesn't carry an orphan row and tenant_members has
880        // no leftover owner pointing at a non-existent tenant.
881        let db = test_db().await;
882        let config = test_builder_config();
883
884        // Use a regular file as the "directory" — SqlitePool::connect_with
885        // will fail because joining a filename onto a regular file produces
886        // an invalid path.
887        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        // Hold ath alive through all assertions.
1026        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        // Insert a user then a session to simulate first-login having occurred.
1047        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        // Hold ath alive through all assertions.
1075        drop(result.ath);
1076    }
1077}