Skip to main content

allowthem_core/
handle.rs

1use std::sync::Arc;
2
3use chrono::{DateTime, Duration, Utc};
4use sqlx::SqlitePool;
5
6use crate::db::Db;
7use crate::email::{EmailMessage, EmailSender, EmailTemplate, NoopEmailSender, fallback_username};
8use crate::error::AuthError;
9use crate::event_sink::{AuthEvent, EventSink, NoopEventSink};
10use crate::sessions::{self, SessionConfig};
11use crate::types::{Email, SessionToken, User, UserId};
12
13/// Callback type for active authentication events.
14///
15/// Invoked after every active auth event (successful login, OAuth callback
16/// completion, MFA/TOTP completion, OIDC access token issuance). Passive
17/// events (session validation, token refresh) do **not** fire this.
18///
19/// The callback must not block. Use a channel-send if heavy work is needed.
20/// Panics are caught and logged; they never propagate to the caller.
21pub type OnUserActive = Arc<dyn Fn(UserId, DateTime<Utc>) + Send + Sync>;
22
23/// Outcome of a successful login or session creation.
24pub struct LoginOutcome {
25    pub user: User,
26    pub token: SessionToken,
27    /// Value for the `Set-Cookie` response header.
28    pub set_cookie: String,
29}
30
31/// Error type for builder construction and validation failures.
32#[derive(Debug, thiserror::Error)]
33pub enum BuildError {
34    /// Database connection or migration failure.
35    #[error("database error: {0}")]
36    Database(#[from] AuthError),
37
38    /// Invalid builder configuration.
39    /// Reserved for future validation; not currently produced.
40    #[error("invalid configuration: {0}")]
41    InvalidConfig(&'static str),
42}
43
44enum PoolSource {
45    Url(String),
46    Pool(SqlitePool),
47}
48
49/// Builder for constructing a configured [`AllowThem`] handle.
50pub struct AllowThemBuilder {
51    pool_source: PoolSource,
52    session_ttl: Option<Duration>,
53    cookie_name: Option<&'static str>,
54    cookie_secure: Option<bool>,
55    cookie_domain: String,
56    mfa_key: Option<[u8; 32]>,
57    signing_key: Option<[u8; 32]>,
58    csrf_key: Option<[u8; 32]>,
59    base_url: Option<String>,
60    on_user_active: Option<OnUserActive>,
61    email_sender: Option<Box<dyn EmailSender>>,
62    event_sink: Option<Box<dyn EventSink>>,
63}
64
65impl AllowThemBuilder {
66    /// Start building from a database URL.
67    ///
68    /// At build time, calls `Db::connect(url)` which creates the pool,
69    /// sets pragmas (foreign_keys, WAL, busy_timeout), and runs migrations.
70    pub fn new(url: impl Into<String>) -> Self {
71        Self {
72            pool_source: PoolSource::Url(url.into()),
73            session_ttl: None,
74            cookie_name: None,
75            cookie_secure: None,
76            cookie_domain: String::new(),
77            mfa_key: None,
78            signing_key: None,
79            csrf_key: None,
80            base_url: None,
81            on_user_active: None,
82            email_sender: None,
83            event_sink: None,
84        }
85    }
86
87    /// Start building from an existing pool.
88    ///
89    /// At build time, calls `Db::new(pool)` which runs migrations.
90    /// The caller is responsible for pragma configuration on their pool.
91    pub fn with_pool(pool: SqlitePool) -> Self {
92        Self {
93            pool_source: PoolSource::Pool(pool),
94            session_ttl: None,
95            cookie_name: None,
96            cookie_secure: None,
97            cookie_domain: String::new(),
98            mfa_key: None,
99            signing_key: None,
100            csrf_key: None,
101            base_url: None,
102            on_user_active: None,
103            email_sender: None,
104            event_sink: None,
105        }
106    }
107
108    /// Override session TTL. Default: 24 hours.
109    pub fn session_ttl(mut self, ttl: Duration) -> Self {
110        self.session_ttl = Some(ttl);
111        self
112    }
113
114    /// Override session cookie name. Default: `"allowthem_session"`.
115    pub fn cookie_name(mut self, name: &'static str) -> Self {
116        self.cookie_name = Some(name);
117        self
118    }
119
120    /// Set the Secure attribute on session cookies.
121    ///
122    /// Default: `true`. Set to `false` for local development over HTTP.
123    pub fn cookie_secure(mut self, secure: bool) -> Self {
124        self.cookie_secure = Some(secure);
125        self
126    }
127
128    /// Set the Domain attribute on session cookies.
129    ///
130    /// Default: empty (omitted). When set, the cookie is sent to the domain
131    /// and all its subdomains.
132    pub fn cookie_domain(mut self, domain: impl Into<String>) -> Self {
133        self.cookie_domain = domain.into();
134        self
135    }
136
137    /// Set the AES-256-GCM encryption key for MFA secrets.
138    ///
139    /// When not set, all MFA operations return `AuthError::MfaNotConfigured`.
140    /// This keeps MFA opt-in for embedded integrators who don't need it.
141    pub fn mfa_key(mut self, key: [u8; 32]) -> Self {
142        self.mfa_key = Some(key);
143        self
144    }
145
146    /// Set the AES-256-GCM encryption key for RS256 signing key storage.
147    ///
148    /// Required for OIDC/standalone mode. When not set, all signing key
149    /// operations return `AuthError::SigningKeyNotConfigured`.
150    pub fn signing_key(mut self, key: [u8; 32]) -> Self {
151        self.signing_key = Some(key);
152        self
153    }
154
155    /// Set the base URL (issuer) for the OIDC provider.
156    ///
157    /// Required for standalone mode. Used as the `iss` claim in tokens
158    /// and for issuer validation on incoming access tokens.
159    /// When not set, OIDC operations return `AuthError::BaseUrlNotConfigured`.
160    pub fn base_url(mut self, url: impl Into<String>) -> Self {
161        self.base_url = Some(url.into());
162        self
163    }
164
165    /// Set the HMAC key for session-bound CSRF token derivation.
166    ///
167    /// Required for `csrf_middleware` in `crates/server`. If not set,
168    /// `csrf_middleware` returns 500. Use 32 random bytes distinct from
169    /// `mfa_key` and `signing_key`.
170    pub fn csrf_key(mut self, key: [u8; 32]) -> Self {
171        self.csrf_key = Some(key);
172        self
173    }
174
175    /// Register a callback invoked after every active authentication event.
176    ///
177    /// "Active" means: successful password login, OAuth callback completion,
178    /// MFA/TOTP completion, and OIDC access token issuance (authorization code
179    /// exchange). Session validation, token refresh, and API token checks do
180    /// **not** fire the callback.
181    ///
182    /// The callback must not block. Use a channel-send if heavy work is needed.
183    /// Panics inside the callback are caught, logged via `tracing::error!`, and
184    /// never propagated to the caller.
185    ///
186    /// Primarily used by the SaaS binary to record MAU into the control plane.
187    pub fn on_user_active(mut self, callback: OnUserActive) -> Self {
188        self.on_user_active = Some(callback);
189        self
190    }
191
192    /// Register the email sender used by every email-bearing flow
193    /// (password reset, email verification, invitations, MFA recovery).
194    ///
195    /// Default is [`NoopEmailSender`], which silently drops messages — call
196    /// this method for any production deployment. A `tracing::warn!` is
197    /// emitted at build time if the default is left in place.
198    ///
199    /// Email flows that compose URLs (`send_password_reset_email`,
200    /// `send_verification_email`) also require [`base_url`] to be set.
201    ///
202    /// [`base_url`]: AllowThemBuilder::base_url
203    pub fn email_sender(mut self, sender: Box<dyn EmailSender>) -> Self {
204        self.email_sender = Some(sender);
205        self
206    }
207
208    /// Register the event sink that fires for every state-changing auth
209    /// operation.
210    ///
211    /// Default is [`NoopEventSink`] (silent). The SaaS binary will register a
212    /// sink that writes rows to `webhook_deliveries` for outbound HTTP delivery
213    /// (epic 7xw.2). Embedded integrators that do not need webhook delivery can
214    /// leave this unset.
215    pub fn event_sink(mut self, sink: Box<dyn EventSink>) -> Self {
216        self.event_sink = Some(sink);
217        self
218    }
219
220    /// Construct the [`AllowThem`] handle.
221    ///
222    /// Connects to (or wraps) the database, runs migrations, and assembles
223    /// the session configuration from overrides plus defaults.
224    pub async fn build(self) -> Result<AllowThem, BuildError> {
225        let db = match self.pool_source {
226            PoolSource::Url(url) => Db::connect(&url).await?,
227            PoolSource::Pool(pool) => Db::new(pool).await?,
228        };
229
230        let defaults = SessionConfig::default();
231        let session_config = SessionConfig {
232            ttl: self.session_ttl.unwrap_or(defaults.ttl),
233            cookie_name: self.cookie_name.unwrap_or(defaults.cookie_name),
234            secure: self.cookie_secure.unwrap_or(defaults.secure),
235        };
236
237        let email_sender = self.email_sender.unwrap_or_else(|| {
238            tracing::warn!(
239                "no email_sender configured; defaulting to NoopEmailSender — \
240                 outgoing emails (password reset, verification, invitation, \
241                 MFA recovery) will be silently dropped",
242            );
243            Box::new(NoopEmailSender)
244        });
245
246        let event_sink = self.event_sink.unwrap_or_else(|| Box::new(NoopEventSink));
247
248        Ok(AllowThem {
249            inner: Arc::new(Inner {
250                db,
251                session_config,
252                cookie_domain: self.cookie_domain,
253                mfa_key: self.mfa_key,
254                signing_key: self.signing_key,
255                csrf_key: self.csrf_key,
256                base_url: self.base_url,
257                on_user_active: self.on_user_active,
258                email_sender,
259                event_sink,
260            }),
261        })
262    }
263}
264
265struct Inner {
266    db: Db,
267    session_config: SessionConfig,
268    cookie_domain: String,
269    mfa_key: Option<[u8; 32]>,
270    signing_key: Option<[u8; 32]>,
271    csrf_key: Option<[u8; 32]>,
272    base_url: Option<String>,
273    on_user_active: Option<OnUserActive>,
274    email_sender: Box<dyn EmailSender>,
275    event_sink: Box<dyn EventSink>,
276}
277
278/// Configured allowthem handle.
279///
280/// Bundles a `Db`, `SessionConfig`, and cookie domain into a single value
281/// that is cheaply cloneable and safe to share across Axum handlers via
282/// `State<AllowThem>` or `Extension<AllowThem>`.
283#[derive(Clone)]
284pub struct AllowThem {
285    inner: Arc<Inner>,
286}
287
288impl AllowThem {
289    /// Access the underlying database handle.
290    ///
291    /// Escape hatch for callers who need direct `Db` access for operations
292    /// not yet wrapped by `AllowThem` methods (e.g., user CRUD, role management).
293    pub fn db(&self) -> &Db {
294        &self.inner.db
295    }
296
297    /// Access the session configuration.
298    pub fn session_config(&self) -> &SessionConfig {
299        &self.inner.session_config
300    }
301
302    /// Build a `Set-Cookie` header value for the given session token.
303    ///
304    /// Uses the stored `SessionConfig` and cookie domain. Delegates to
305    /// `sessions::session_cookie()`.
306    pub fn session_cookie(&self, token: &SessionToken) -> String {
307        sessions::session_cookie(token, &self.inner.session_config, &self.inner.cookie_domain)
308    }
309
310    /// Returns the MFA encryption key, or `Err(MfaNotConfigured)` if not set.
311    pub(crate) fn mfa_key(&self) -> Result<&[u8; 32], AuthError> {
312        self.inner
313            .mfa_key
314            .as_ref()
315            .ok_or(AuthError::MfaNotConfigured)
316    }
317
318    /// Returns the signing key encryption key, or `Err(SigningKeyNotConfigured)` if not set.
319    pub(crate) fn signing_key(&self) -> Result<&[u8; 32], AuthError> {
320        self.inner
321            .signing_key
322            .as_ref()
323            .ok_or(AuthError::SigningKeyNotConfigured)
324    }
325
326    /// Returns the base URL (issuer), or `Err(BaseUrlNotConfigured)` if not set.
327    pub fn base_url(&self) -> Result<&str, AuthError> {
328        self.inner
329            .base_url
330            .as_deref()
331            .ok_or(AuthError::BaseUrlNotConfigured)
332    }
333
334    pub fn csrf_key(&self) -> Result<&[u8; 32], AuthError> {
335        self.inner
336            .csrf_key
337            .as_ref()
338            .ok_or(AuthError::CsrfKeyNotConfigured)
339    }
340
341    /// Return a reference to the `on_user_active` callback, if configured.
342    ///
343    /// Used to pass the callback into free functions (e.g.
344    /// `exchange_authorization_code`) that do not receive the full `AllowThem`
345    /// handle.
346    pub fn on_user_active(&self) -> Option<&OnUserActive> {
347        self.inner.on_user_active.as_ref()
348    }
349
350    /// Borrow the configured email sender.
351    ///
352    /// Defaults to [`NoopEmailSender`] unless overridden via
353    /// [`AllowThemBuilder::email_sender`].
354    pub fn email_sender(&self) -> &dyn EmailSender {
355        &*self.inner.email_sender
356    }
357
358    /// Borrow the configured event sink.
359    ///
360    /// Defaults to [`NoopEventSink`] unless overridden via
361    /// [`AllowThemBuilder::event_sink`].
362    pub fn event_sink(&self) -> &dyn EventSink {
363        &*self.inner.event_sink
364    }
365
366    /// Emit an event to the configured sink.
367    ///
368    /// Awaits the sink's `emit` future. Returns when the sink finishes
369    /// (typically a single local DB write or a noop). The sink contract
370    /// forbids panics and errors; this method is unconditionally infallible.
371    pub async fn emit_event(&self, event: AuthEvent) {
372        self.event_sink().emit(&event).await;
373    }
374
375    // -------------------------------------------------------------------------
376    // Email-sending helpers
377    // -------------------------------------------------------------------------
378
379    /// Send a password reset email to the given address.
380    ///
381    /// Creates a reset token, composes the reset URL from [`base_url`], and
382    /// sends a `PasswordReset` template. Returns `Ok(())` silently if no user
383    /// exists for that email (enumeration prevention). Requires [`base_url`] to
384    /// be configured; returns `Err(BaseUrlNotConfigured)` otherwise.
385    ///
386    /// [`base_url`]: AllowThemBuilder::base_url
387    pub async fn send_password_reset_email(&self, email: &Email) -> Result<(), AuthError> {
388        let raw_token = match self.db().create_password_reset(email).await? {
389            None => return Ok(()),
390            Some(t) => t,
391        };
392
393        let username = match self.db().get_user_by_email(email).await {
394            Ok(user) => fallback_username(&user),
395            Err(_) => email
396                .as_str()
397                .split('@')
398                .next()
399                .unwrap_or("there")
400                .to_owned(),
401        };
402
403        let reset_url = format!(
404            "{}/auth/reset-password?token={}",
405            self.base_url()?,
406            raw_token
407        );
408        let message = EmailMessage {
409            to: email.as_str().to_owned(),
410            subject: "Reset your password".to_owned(),
411            template: EmailTemplate::PasswordReset {
412                url: reset_url,
413                username,
414            },
415        };
416
417        self.email_sender()
418            .send(&message)
419            .await
420            .map_err(|e| AuthError::Email(e.to_string()))
421    }
422
423    /// Send an email verification link to the given user.
424    ///
425    /// Creates a verification token, composes the verification URL from
426    /// [`base_url`], and sends an `EmailVerification` template. Requires
427    /// [`base_url`] to be configured.
428    ///
429    /// [`base_url`]: AllowThemBuilder::base_url
430    pub async fn send_verification_email(
431        &self,
432        user_id: UserId,
433        email: &Email,
434    ) -> Result<(), AuthError> {
435        let raw_token = self.db().create_email_verification(user_id).await?;
436
437        let username = match self.db().get_user(user_id).await {
438            Ok(user) => fallback_username(&user),
439            Err(_) => email
440                .as_str()
441                .split('@')
442                .next()
443                .unwrap_or("there")
444                .to_owned(),
445        };
446
447        let verify_url = format!("{}/auth/verify-email?token={}", self.base_url()?, raw_token);
448        let message = EmailMessage {
449            to: email.as_str().to_owned(),
450            subject: "Verify your email address".to_owned(),
451            template: EmailTemplate::EmailVerification {
452                url: verify_url,
453                username,
454            },
455        };
456
457        self.email_sender()
458            .send(&message)
459            .await
460            .map_err(|e| AuthError::Email(e.to_string()))
461    }
462
463    /// Send an invitation email to the given address.
464    ///
465    /// Creates an invitation token in the database and sends an `Invitation`
466    /// template. `invitation_url` must be pre-composed by the caller — the
467    /// URL format varies by deployment (the saas binary uses
468    /// `https://{base_domain}/invite/{token}`; standalone server may differ).
469    /// `invited_by` is the `UserId` of the inviter; their display name is
470    /// resolved from the database and used in the template. On lookup failure,
471    /// falls back to `"your team"`.
472    pub async fn send_invitation_email(
473        &self,
474        email: &Email,
475        invitation_url: &str,
476        invited_by: UserId,
477        expires_at: chrono::DateTime<chrono::Utc>,
478    ) -> Result<(), AuthError> {
479        self.db()
480            .create_invitation(Some(email), None, Some(invited_by), expires_at)
481            .await?;
482
483        let inviter_name = match self.db().get_user(invited_by).await {
484            Ok(user) => fallback_username(&user),
485            Err(_) => "your team".to_owned(),
486        };
487
488        let message = EmailMessage {
489            to: email.as_str().to_owned(),
490            subject: format!("You've been invited by {inviter_name}"),
491            template: EmailTemplate::Invitation {
492                url: invitation_url.to_owned(),
493                invited_by: inviter_name,
494            },
495        };
496
497        self.email_sender()
498            .send(&message)
499            .await
500            .map_err(|e| AuthError::Email(e.to_string()))
501    }
502
503    /// Send MFA recovery codes to the given user via email.
504    ///
505    /// Looks up the user by `user_id` to determine recipient address and
506    /// username. Sends an `MfaRecovery` template with the supplied codes. Does
507    /// **not** write to the database — code generation and persistence are the
508    /// caller's responsibility (see `Db::enable_mfa`).
509    pub async fn send_mfa_recovery_email(
510        &self,
511        user_id: UserId,
512        codes: Vec<String>,
513    ) -> Result<(), AuthError> {
514        let user = self.db().get_user(user_id).await?;
515        let username = fallback_username(&user);
516
517        let message = EmailMessage {
518            to: user.email.as_str().to_owned(),
519            subject: "Your MFA recovery codes".to_owned(),
520            template: EmailTemplate::MfaRecovery { codes, username },
521        };
522
523        self.email_sender()
524            .send(&message)
525            .await
526            .map_err(|e| AuthError::Email(e.to_string()))
527    }
528
529    /// Fire the `on_user_active` callback, if configured.
530    ///
531    /// Call this immediately after a session row or access token is durably
532    /// written, for active auth events only. Panics from the callback are
533    /// caught and logged; they never propagate to the caller.
534    pub fn notify_user_active(&self, user_id: UserId) {
535        let Some(cb) = self.inner.on_user_active.as_ref() else {
536            return;
537        };
538        let now = Utc::now();
539        let cb = cb.clone();
540        if let Err(_payload) =
541            std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || cb(user_id, now)))
542        {
543            tracing::error!(user_id = %user_id, "on_user_active callback panicked");
544        }
545    }
546
547    /// Fetch the active signing key and decrypt its private key PEM.
548    ///
549    /// Combines the encryption key, active key lookup, and decryption into
550    /// a single call. Keeps the raw encryption key private to the core crate.
551    pub async fn get_decrypted_signing_key(
552        &self,
553    ) -> Result<(crate::signing_keys::SigningKey, String), AuthError> {
554        let enc_key = self.signing_key()?;
555        let key = self.db().get_active_signing_key().await?;
556        let pem = crate::signing_keys::decrypt_private_key(&key, enc_key)?;
557        Ok((key, pem))
558    }
559
560    /// Build a `Set-Cookie` header value that expires the session cookie.
561    ///
562    /// Returns `Max-Age=0` with the same cookie name, path, domain, and flags
563    /// used by `session_cookie()`. Pass this as the `Set-Cookie` header on a
564    /// logout response to clear the browser's stored session cookie.
565    pub fn clear_session_cookie(&self) -> String {
566        sessions::clear_session_cookie(&self.inner.session_config, &self.inner.cookie_domain)
567    }
568
569    /// Extract the session token from a `Cookie` header value.
570    ///
571    /// Uses the stored cookie name. Delegates to `sessions::parse_session_cookie()`.
572    pub fn parse_session_cookie(&self, cookie_header: &str) -> Option<SessionToken> {
573        sessions::parse_session_cookie(cookie_header, self.inner.session_config.cookie_name)
574    }
575
576    /// Authenticate with credentials and create a session.
577    ///
578    /// Returns `Err(AuthError::InvalidCredentials)` for any credential failure —
579    /// unknown identifier, wrong password, no local password hash (SSO-only
580    /// account), or inactive user — to prevent account enumeration.
581    ///
582    /// Records an `AuditEvent::Login` on success. IP and user-agent are not
583    /// available at this layer; callers who need them in the audit log should
584    /// use the low-level `Db` methods directly.
585    pub async fn login(&self, identifier: &str, password: &str) -> Result<LoginOutcome, AuthError> {
586        use crate::audit::AuditEvent;
587        use crate::password::verify_password;
588
589        let user = self
590            .db()
591            .find_for_login(identifier)
592            .await
593            .map_err(|e| match e {
594                AuthError::NotFound => AuthError::InvalidCredentials,
595                other => other,
596            })?;
597
598        if !user.is_active {
599            return Err(AuthError::InvalidCredentials);
600        }
601
602        let hash = user
603            .password_hash
604            .as_ref()
605            .ok_or(AuthError::InvalidCredentials)?;
606
607        if !verify_password(password, hash)? {
608            return Err(AuthError::InvalidCredentials);
609        }
610
611        let token = sessions::generate_token();
612        let token_hash = sessions::hash_token(&token);
613        let expires_at = Utc::now() + self.inner.session_config.ttl;
614        self.db()
615            .create_session(user.id, token_hash, None, None, expires_at)
616            .await?;
617
618        let _ = self
619            .db()
620            .log_audit(AuditEvent::Login, Some(&user.id), None, None, None, None)
621            .await;
622
623        self.notify_user_active(user.id);
624        self.emit_event(AuthEvent::new(
625            "session.created",
626            Some(user.id),
627            serde_json::json!({ "user_id": user.id }),
628        ))
629        .await;
630
631        let set_cookie = self.session_cookie(&token);
632        Ok(LoginOutcome {
633            user,
634            token,
635            set_cookie,
636        })
637    }
638
639    /// Create a session for an already-authenticated user.
640    ///
641    /// Does not verify credentials. Intended for use after OAuth, TOTP, or
642    /// other non-password authentication flows. The calling flow is responsible
643    /// for audit logging.
644    pub async fn create_session_cookie(&self, user_id: UserId) -> Result<LoginOutcome, AuthError> {
645        let user = self.db().get_user(user_id).await?;
646        let token = sessions::generate_token();
647        let token_hash = sessions::hash_token(&token);
648        let expires_at = Utc::now() + self.inner.session_config.ttl;
649        self.db()
650            .create_session(user_id, token_hash, None, None, expires_at)
651            .await?;
652
653        self.notify_user_active(user_id);
654        self.emit_event(AuthEvent::new(
655            "session.created",
656            Some(user_id),
657            serde_json::json!({ "user_id": user_id }),
658        ))
659        .await;
660
661        let set_cookie = self.session_cookie(&token);
662        Ok(LoginOutcome {
663            user,
664            token,
665            set_cookie,
666        })
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use sqlx::sqlite::SqliteConnectOptions;
673    use std::str::FromStr;
674
675    use super::*;
676    use crate::sessions::generate_token;
677    use crate::types::Email;
678
679    #[tokio::test]
680    async fn build_with_url_defaults() {
681        let ath = AllowThemBuilder::new("sqlite::memory:")
682            .build()
683            .await
684            .unwrap();
685
686        let config = ath.session_config();
687        assert_eq!(config.ttl, Duration::hours(24));
688        assert_eq!(config.cookie_name, "allowthem_session");
689        assert!(config.secure);
690
691        let token = generate_token();
692        let cookie = ath.session_cookie(&token);
693        assert!(!cookie.contains("; Domain="));
694    }
695
696    #[tokio::test]
697    async fn build_with_pool() {
698        let opts = SqliteConnectOptions::from_str("sqlite::memory:")
699            .unwrap()
700            .pragma("foreign_keys", "ON");
701        let pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
702
703        let ath = AllowThemBuilder::with_pool(pool).build().await.unwrap();
704
705        let email = Email::new("test@example.com".into()).unwrap();
706        let user = ath.db().create_user(email, "password123", None, None).await;
707        assert!(user.is_ok());
708    }
709
710    #[tokio::test]
711    async fn build_with_overrides() {
712        let ath = AllowThemBuilder::new("sqlite::memory:")
713            .session_ttl(Duration::hours(48))
714            .cookie_name("my_session")
715            .cookie_secure(false)
716            .cookie_domain("example.com")
717            .build()
718            .await
719            .unwrap();
720
721        let config = ath.session_config();
722        assert_eq!(config.ttl, Duration::hours(48));
723        assert_eq!(config.cookie_name, "my_session");
724        assert!(!config.secure);
725    }
726
727    #[tokio::test]
728    async fn session_cookie_uses_config() {
729        let ath = AllowThemBuilder::new("sqlite::memory:")
730            .cookie_name("custom")
731            .cookie_secure(false)
732            .cookie_domain("example.com")
733            .build()
734            .await
735            .unwrap();
736
737        let token = generate_token();
738        let cookie = ath.session_cookie(&token);
739
740        assert!(cookie.contains("custom="));
741        assert!(cookie.contains("; Domain=example.com"));
742        assert!(!cookie.contains("; Secure"));
743    }
744
745    #[tokio::test]
746    async fn clear_session_cookie_defaults() {
747        let ath = AllowThemBuilder::new("sqlite::memory:")
748            .build()
749            .await
750            .unwrap();
751
752        let cookie = ath.clear_session_cookie();
753        assert!(cookie.starts_with("allowthem_session=;"));
754        assert!(cookie.contains("; Max-Age=0"));
755        assert!(!cookie.contains("; Domain="));
756        assert!(cookie.contains("; Secure"));
757    }
758
759    #[tokio::test]
760    async fn clear_session_cookie_name_matches_session_cookie() {
761        let ath = AllowThemBuilder::new("sqlite::memory:")
762            .cookie_name("app_session")
763            .build()
764            .await
765            .unwrap();
766
767        let token = generate_token();
768        let set = ath.session_cookie(&token);
769        let clear = ath.clear_session_cookie();
770
771        // Both must share the same cookie name prefix so the browser matches them.
772        assert!(set.starts_with("app_session="));
773        assert!(clear.starts_with("app_session=;"));
774        assert!(clear.contains("; Path=/"));
775        assert!(clear.contains("; Max-Age=0"));
776    }
777
778    #[tokio::test]
779    async fn clear_session_cookie_with_domain_and_no_secure() {
780        let ath = AllowThemBuilder::new("sqlite::memory:")
781            .cookie_name("my_session")
782            .cookie_secure(false)
783            .cookie_domain("example.com")
784            .build()
785            .await
786            .unwrap();
787
788        let cookie = ath.clear_session_cookie();
789        assert!(cookie.starts_with("my_session=;"));
790        assert!(cookie.contains("; Max-Age=0"));
791        assert!(cookie.contains("; Domain=example.com"));
792        assert!(!cookie.contains("; Secure"));
793    }
794
795    #[tokio::test]
796    async fn parse_session_cookie_uses_config() {
797        let ath = AllowThemBuilder::new("sqlite::memory:")
798            .cookie_name("custom")
799            .build()
800            .await
801            .unwrap();
802
803        let header = "custom=abc123; other=xyz";
804        let result = ath.parse_session_cookie(header);
805
806        assert!(result.is_some());
807        assert_eq!(result.unwrap().as_str(), "abc123");
808    }
809
810    #[tokio::test]
811    async fn build_with_bad_url_fails() {
812        let result = AllowThemBuilder::new("not-a-url").build().await;
813
814        assert!(result.is_err());
815        assert!(matches!(result.err().unwrap(), BuildError::Database(_)));
816    }
817
818    #[tokio::test]
819    async fn clone_shares_state() {
820        let ath = AllowThemBuilder::new("sqlite::memory:")
821            .build()
822            .await
823            .unwrap();
824        let ath2 = ath.clone();
825
826        let email = Email::new("shared@example.com".into()).unwrap();
827        let user = ath
828            .db()
829            .create_user(email, "password123", None, None)
830            .await
831            .unwrap();
832
833        let found = ath2.db().get_user(user.id).await;
834        assert!(found.is_ok());
835        assert_eq!(found.unwrap().id, user.id);
836    }
837
838    #[tokio::test]
839    async fn signing_key_not_configured_returns_error() {
840        let ath = AllowThemBuilder::new("sqlite::memory:")
841            .build()
842            .await
843            .unwrap();
844        let result = ath.signing_key();
845        assert!(matches!(
846            result,
847            Err(crate::error::AuthError::SigningKeyNotConfigured)
848        ));
849    }
850
851    #[tokio::test]
852    async fn base_url_not_configured_returns_error() {
853        let ath = AllowThemBuilder::new("sqlite::memory:")
854            .build()
855            .await
856            .unwrap();
857        let result = ath.base_url();
858        assert!(matches!(
859            result,
860            Err(crate::error::AuthError::BaseUrlNotConfigured)
861        ));
862    }
863
864    #[tokio::test]
865    async fn base_url_configured_returns_value() {
866        let ath = AllowThemBuilder::new("sqlite::memory:")
867            .base_url("https://auth.example.com")
868            .build()
869            .await
870            .unwrap();
871        let result = ath.base_url();
872        assert!(matches!(result, Ok("https://auth.example.com")));
873    }
874
875    #[tokio::test]
876    async fn login_success() {
877        let ath = AllowThemBuilder::new("sqlite::memory:")
878            .cookie_secure(false)
879            .build()
880            .await
881            .unwrap();
882
883        let email = Email::new("login@example.com".into()).unwrap();
884        ath.db()
885            .create_user(email, "secret", None, None)
886            .await
887            .unwrap();
888
889        let outcome = ath.login("login@example.com", "secret").await.unwrap();
890        assert_eq!(outcome.user.email.as_str(), "login@example.com");
891        assert!(!outcome.token.as_str().is_empty());
892        assert!(outcome.set_cookie.contains("allowthem_session="));
893    }
894
895    #[tokio::test]
896    async fn login_wrong_password() {
897        let ath = AllowThemBuilder::new("sqlite::memory:")
898            .build()
899            .await
900            .unwrap();
901
902        let email = Email::new("wp@example.com".into()).unwrap();
903        ath.db()
904            .create_user(email, "correct", None, None)
905            .await
906            .unwrap();
907
908        let result = ath.login("wp@example.com", "wrong").await;
909        assert!(matches!(result, Err(AuthError::InvalidCredentials)));
910    }
911
912    #[tokio::test]
913    async fn login_unknown_identifier() {
914        let ath = AllowThemBuilder::new("sqlite::memory:")
915            .build()
916            .await
917            .unwrap();
918
919        let result = ath.login("nobody@example.com", "any").await;
920        assert!(matches!(result, Err(AuthError::InvalidCredentials)));
921    }
922
923    #[tokio::test]
924    async fn login_inactive_user() {
925        let ath = AllowThemBuilder::new("sqlite::memory:")
926            .build()
927            .await
928            .unwrap();
929
930        let email = Email::new("inactive@example.com".into()).unwrap();
931        let user = ath
932            .db()
933            .create_user(email, "secret", None, None)
934            .await
935            .unwrap();
936        ath.db().update_user_active(user.id, false).await.unwrap();
937
938        let result = ath.login("inactive@example.com", "secret").await;
939        assert!(matches!(result, Err(AuthError::InvalidCredentials)));
940    }
941
942    #[tokio::test]
943    async fn login_no_password_hash() {
944        use crate::types::UserId;
945
946        let ath = AllowThemBuilder::new("sqlite::memory:")
947            .build()
948            .await
949            .unwrap();
950
951        // Insert a user directly with password_hash = NULL (SSO-only account).
952        // UserId uses UUID v7; bind it properly so SQLx can round-trip it.
953        let id = UserId::new();
954        let now = chrono::Utc::now()
955            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
956            .to_string();
957        sqlx::query(
958            "INSERT INTO allowthem_users \
959             (id, email, username, password_hash, email_verified, is_active, created_at, updated_at) \
960             VALUES (?, 'sso@example.com', NULL, NULL, 1, 1, ?, ?)",
961        )
962        .bind(id)
963        .bind(&now)
964        .bind(&now)
965        .execute(ath.db().pool())
966        .await
967        .unwrap();
968
969        let result = ath.login("sso@example.com", "any").await;
970        assert!(matches!(result, Err(AuthError::InvalidCredentials)));
971    }
972
973    #[tokio::test]
974    async fn create_session_cookie_success() {
975        let ath = AllowThemBuilder::new("sqlite::memory:")
976            .cookie_secure(false)
977            .build()
978            .await
979            .unwrap();
980
981        let email = Email::new("sess@example.com".into()).unwrap();
982        let user = ath
983            .db()
984            .create_user(email, "secret", None, None)
985            .await
986            .unwrap();
987
988        let outcome = ath.create_session_cookie(user.id).await.unwrap();
989        assert_eq!(outcome.user.id, user.id);
990        assert!(!outcome.token.as_str().is_empty());
991        assert!(outcome.set_cookie.contains("allowthem_session="));
992
993        // Session must exist in DB
994        let session = ath.db().lookup_session(&outcome.token).await.unwrap();
995        assert!(session.is_some());
996    }
997
998    #[tokio::test]
999    async fn create_session_cookie_unknown_user() {
1000        use crate::types::UserId;
1001
1002        let ath = AllowThemBuilder::new("sqlite::memory:")
1003            .build()
1004            .await
1005            .unwrap();
1006
1007        let result = ath.create_session_cookie(UserId::new()).await;
1008        assert!(matches!(result, Err(AuthError::NotFound)));
1009    }
1010
1011    // -- on_user_active callback tests ------------------------------------------
1012
1013    #[tokio::test]
1014    async fn on_user_active_default_is_none() {
1015        let ath = AllowThemBuilder::new("sqlite::memory:")
1016            .build()
1017            .await
1018            .unwrap();
1019        assert!(ath.on_user_active().is_none());
1020    }
1021
1022    #[tokio::test]
1023    async fn on_user_active_builder_stores_callback() {
1024        use std::sync::Arc;
1025        use std::sync::atomic::{AtomicU64, Ordering};
1026
1027        let counter = Arc::new(AtomicU64::new(0));
1028        let c = counter.clone();
1029        let cb: OnUserActive = Arc::new(move |_uid, _ts| {
1030            c.fetch_add(1, Ordering::Relaxed);
1031        });
1032
1033        let ath = AllowThemBuilder::new("sqlite::memory:")
1034            .on_user_active(cb)
1035            .build()
1036            .await
1037            .unwrap();
1038
1039        assert!(ath.on_user_active().is_some());
1040    }
1041
1042    #[tokio::test]
1043    async fn on_user_active_fires_on_login_success() {
1044        use std::sync::{Arc, Mutex};
1045
1046        let captured: Arc<Mutex<Vec<(UserId, DateTime<Utc>)>>> = Arc::new(Mutex::new(Vec::new()));
1047        let cap = captured.clone();
1048        let cb: OnUserActive = Arc::new(move |uid, ts| {
1049            cap.lock().unwrap().push((uid, ts));
1050        });
1051
1052        let ath = AllowThemBuilder::new("sqlite::memory:")
1053            .cookie_secure(false)
1054            .on_user_active(cb)
1055            .build()
1056            .await
1057            .unwrap();
1058
1059        let email = Email::new("active@example.com".into()).unwrap();
1060        let user = ath
1061            .db()
1062            .create_user(email, "hunter2", None, None)
1063            .await
1064            .unwrap();
1065
1066        let before = Utc::now();
1067        ath.login("active@example.com", "hunter2").await.unwrap();
1068        let after = Utc::now();
1069
1070        let events = captured.lock().unwrap();
1071        assert_eq!(events.len(), 1, "callback must fire exactly once");
1072        assert_eq!(events[0].0, user.id, "callback receives correct UserId");
1073        assert!(
1074            events[0].1 >= before && events[0].1 <= after,
1075            "callback timestamp must be within the test window"
1076        );
1077    }
1078
1079    #[tokio::test]
1080    async fn on_user_active_no_fire_on_login_wrong_password() {
1081        use std::sync::Arc;
1082        use std::sync::atomic::{AtomicU64, Ordering};
1083
1084        let counter = Arc::new(AtomicU64::new(0));
1085        let c = counter.clone();
1086        let cb: OnUserActive = Arc::new(move |_uid, _ts| {
1087            c.fetch_add(1, Ordering::Relaxed);
1088        });
1089
1090        let ath = AllowThemBuilder::new("sqlite::memory:")
1091            .on_user_active(cb)
1092            .build()
1093            .await
1094            .unwrap();
1095
1096        let email = Email::new("wrongpw@example.com".into()).unwrap();
1097        ath.db()
1098            .create_user(email, "correct", None, None)
1099            .await
1100            .unwrap();
1101
1102        let _ = ath.login("wrongpw@example.com", "wrong").await;
1103        assert_eq!(counter.load(Ordering::Relaxed), 0);
1104    }
1105
1106    #[tokio::test]
1107    async fn on_user_active_no_fire_on_login_unknown_identifier() {
1108        use std::sync::Arc;
1109        use std::sync::atomic::{AtomicU64, Ordering};
1110
1111        let counter = Arc::new(AtomicU64::new(0));
1112        let c = counter.clone();
1113        let cb: OnUserActive = Arc::new(move |_uid, _ts| {
1114            c.fetch_add(1, Ordering::Relaxed);
1115        });
1116
1117        let ath = AllowThemBuilder::new("sqlite::memory:")
1118            .on_user_active(cb)
1119            .build()
1120            .await
1121            .unwrap();
1122
1123        let _ = ath.login("nobody@example.com", "any").await;
1124        assert_eq!(counter.load(Ordering::Relaxed), 0);
1125    }
1126
1127    #[tokio::test]
1128    async fn on_user_active_no_fire_on_login_inactive_user() {
1129        use std::sync::Arc;
1130        use std::sync::atomic::{AtomicU64, Ordering};
1131
1132        let counter = Arc::new(AtomicU64::new(0));
1133        let c = counter.clone();
1134        let cb: OnUserActive = Arc::new(move |_uid, _ts| {
1135            c.fetch_add(1, Ordering::Relaxed);
1136        });
1137
1138        let ath = AllowThemBuilder::new("sqlite::memory:")
1139            .on_user_active(cb)
1140            .build()
1141            .await
1142            .unwrap();
1143
1144        let email = Email::new("inact@example.com".into()).unwrap();
1145        let user = ath
1146            .db()
1147            .create_user(email, "secret", None, None)
1148            .await
1149            .unwrap();
1150        ath.db().update_user_active(user.id, false).await.unwrap();
1151
1152        let _ = ath.login("inact@example.com", "secret").await;
1153        assert_eq!(counter.load(Ordering::Relaxed), 0);
1154    }
1155
1156    #[tokio::test]
1157    async fn on_user_active_no_fire_on_login_no_password_hash() {
1158        use std::sync::Arc;
1159        use std::sync::atomic::{AtomicU64, Ordering};
1160
1161        let counter = Arc::new(AtomicU64::new(0));
1162        let c = counter.clone();
1163        let cb: OnUserActive = Arc::new(move |_uid, _ts| {
1164            c.fetch_add(1, Ordering::Relaxed);
1165        });
1166
1167        let ath = AllowThemBuilder::new("sqlite::memory:")
1168            .on_user_active(cb)
1169            .build()
1170            .await
1171            .unwrap();
1172
1173        let id = UserId::new();
1174        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
1175        sqlx::query(
1176            "INSERT INTO allowthem_users \
1177             (id, email, username, password_hash, email_verified, is_active, created_at, updated_at) \
1178             VALUES (?, 'sso2@example.com', NULL, NULL, 1, 1, ?, ?)",
1179        )
1180        .bind(id)
1181        .bind(&now)
1182        .bind(&now)
1183        .execute(ath.db().pool())
1184        .await
1185        .unwrap();
1186
1187        let _ = ath.login("sso2@example.com", "any").await;
1188        assert_eq!(counter.load(Ordering::Relaxed), 0);
1189    }
1190
1191    #[tokio::test]
1192    async fn on_user_active_fires_on_create_session_cookie() {
1193        use std::sync::Arc;
1194        use std::sync::atomic::{AtomicU64, Ordering};
1195
1196        let counter = Arc::new(AtomicU64::new(0));
1197        let c = counter.clone();
1198        let cb: OnUserActive = Arc::new(move |_uid, _ts| {
1199            c.fetch_add(1, Ordering::Relaxed);
1200        });
1201
1202        let ath = AllowThemBuilder::new("sqlite::memory:")
1203            .cookie_secure(false)
1204            .on_user_active(cb)
1205            .build()
1206            .await
1207            .unwrap();
1208
1209        let email = Email::new("csc@example.com".into()).unwrap();
1210        let user = ath
1211            .db()
1212            .create_user(email, "pass", None, None)
1213            .await
1214            .unwrap();
1215
1216        ath.create_session_cookie(user.id).await.unwrap();
1217        assert_eq!(counter.load(Ordering::Relaxed), 1);
1218    }
1219
1220    #[tokio::test]
1221    async fn on_user_active_no_fire_on_create_session_cookie_unknown_user() {
1222        use std::sync::Arc;
1223        use std::sync::atomic::{AtomicU64, Ordering};
1224
1225        let counter = Arc::new(AtomicU64::new(0));
1226        let c = counter.clone();
1227        let cb: OnUserActive = Arc::new(move |_uid, _ts| {
1228            c.fetch_add(1, Ordering::Relaxed);
1229        });
1230
1231        let ath = AllowThemBuilder::new("sqlite::memory:")
1232            .on_user_active(cb)
1233            .build()
1234            .await
1235            .unwrap();
1236
1237        let _ = ath.create_session_cookie(UserId::new()).await;
1238        assert_eq!(counter.load(Ordering::Relaxed), 0);
1239    }
1240
1241    #[tokio::test]
1242    async fn on_user_active_no_fire_on_session_validation() {
1243        use std::sync::Arc;
1244        use std::sync::atomic::{AtomicU64, Ordering};
1245
1246        let counter = Arc::new(AtomicU64::new(0));
1247        let c = counter.clone();
1248        let cb: OnUserActive = Arc::new(move |_uid, _ts| {
1249            c.fetch_add(1, Ordering::Relaxed);
1250        });
1251
1252        let ath = AllowThemBuilder::new("sqlite::memory:")
1253            .cookie_secure(false)
1254            .on_user_active(cb)
1255            .build()
1256            .await
1257            .unwrap();
1258
1259        let email = Email::new("passive@example.com".into()).unwrap();
1260        ath.db()
1261            .create_user(email, "pass", None, None)
1262            .await
1263            .unwrap();
1264
1265        let outcome = ath.login("passive@example.com", "pass").await.unwrap();
1266        // Counter is 1 after the login fire.
1267        assert_eq!(counter.load(Ordering::Relaxed), 1);
1268
1269        // Session validation is a passive event — counter must not change.
1270        let _ = ath
1271            .db()
1272            .validate_session(&outcome.token, Duration::hours(24))
1273            .await
1274            .unwrap();
1275        assert_eq!(
1276            counter.load(Ordering::Relaxed),
1277            1,
1278            "session validation must not fire callback"
1279        );
1280    }
1281
1282    #[tokio::test]
1283    async fn on_user_active_panic_does_not_propagate() {
1284        let cb: OnUserActive = Arc::new(|_uid, _ts| {
1285            panic!("intentional test panic in on_user_active callback");
1286        });
1287
1288        let ath = AllowThemBuilder::new("sqlite::memory:")
1289            .cookie_secure(false)
1290            .on_user_active(cb)
1291            .build()
1292            .await
1293            .unwrap();
1294
1295        let email = Email::new("panic@example.com".into()).unwrap();
1296        ath.db()
1297            .create_user(email, "pass", None, None)
1298            .await
1299            .unwrap();
1300
1301        // Suppress the default panic hook output so test output stays clean.
1302        let prev_hook = std::panic::take_hook();
1303        std::panic::set_hook(Box::new(|_| {}));
1304        let result = ath.login("panic@example.com", "pass").await;
1305        std::panic::set_hook(prev_hook);
1306
1307        assert!(
1308            result.is_ok(),
1309            "panic in callback must not propagate to caller"
1310        );
1311    }
1312
1313    // ---- EmailSender integration tests ----
1314
1315    /// A test-only sender that captures every message delivered to it.
1316    struct CapturingSender(std::sync::Arc<std::sync::Mutex<Vec<crate::email::EmailMessage>>>);
1317
1318    impl crate::email::EmailSender for CapturingSender {
1319        fn send<'a>(
1320            &'a self,
1321            message: &'a crate::email::EmailMessage,
1322        ) -> std::pin::Pin<
1323            Box<dyn std::future::Future<Output = Result<(), crate::error::AuthError>> + Send + 'a>,
1324        > {
1325            self.0.lock().unwrap().push(message.clone());
1326            Box::pin(std::future::ready(Ok(())))
1327        }
1328    }
1329
1330    fn capturing_sender() -> (
1331        Box<dyn crate::email::EmailSender>,
1332        std::sync::Arc<std::sync::Mutex<Vec<crate::email::EmailMessage>>>,
1333    ) {
1334        let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1335        let sender = CapturingSender(captured.clone());
1336        (Box::new(sender), captured)
1337    }
1338
1339    /// A test-only sender that always returns an error.
1340    struct FailingSender;
1341
1342    impl crate::email::EmailSender for FailingSender {
1343        fn send<'a>(
1344            &'a self,
1345            _message: &'a crate::email::EmailMessage,
1346        ) -> std::pin::Pin<
1347            Box<dyn std::future::Future<Output = Result<(), crate::error::AuthError>> + Send + 'a>,
1348        > {
1349            Box::pin(std::future::ready(Err(crate::error::AuthError::Email(
1350                "injected failure".into(),
1351            ))))
1352        }
1353    }
1354
1355    async fn make_user_with_username(ath: &AllowThem, email_str: &str, username: Option<&str>) {
1356        let email = Email::new(email_str.into()).unwrap();
1357        ath.db()
1358            .create_user(
1359                email,
1360                "password",
1361                username.map(|s| crate::types::Username::new(s)),
1362                None,
1363            )
1364            .await
1365            .unwrap();
1366    }
1367
1368    #[tokio::test]
1369    async fn email_sender_default_is_noop_and_succeeds() {
1370        // Build without email_sender — defaults to NoopEmailSender.
1371        let ath = AllowThemBuilder::new("sqlite::memory:")
1372            .base_url("https://example.com")
1373            .build()
1374            .await
1375            .unwrap();
1376
1377        // NoopEmailSender::send always returns Ok(()).
1378        let msg = crate::email::EmailMessage {
1379            to: "nobody@example.com".into(),
1380            subject: "test".into(),
1381            template: crate::email::EmailTemplate::PasswordReset {
1382                url: "https://example.com/reset".into(),
1383                username: "nobody".into(),
1384            },
1385        };
1386        let result = ath.email_sender().send(&msg).await;
1387        assert!(result.is_ok(), "NoopEmailSender must return Ok");
1388    }
1389
1390    #[tokio::test]
1391    async fn email_sender_custom_sender_installed() {
1392        let (sender_box, captured) = capturing_sender();
1393        let ath = AllowThemBuilder::new("sqlite::memory:")
1394            .base_url("https://example.com")
1395            .email_sender(sender_box)
1396            .build()
1397            .await
1398            .unwrap();
1399
1400        let msg = crate::email::EmailMessage {
1401            to: "test@example.com".into(),
1402            subject: "subject".into(),
1403            template: crate::email::EmailTemplate::PasswordReset {
1404                url: "https://example.com/reset".into(),
1405                username: "test".into(),
1406            },
1407        };
1408        ath.email_sender().send(&msg).await.unwrap();
1409        assert_eq!(captured.lock().unwrap().len(), 1);
1410    }
1411
1412    #[tokio::test]
1413    async fn send_password_reset_email_builds_correct_template() {
1414        let (sender_box, captured) = capturing_sender();
1415        let ath = AllowThemBuilder::new("sqlite::memory:")
1416            .base_url("https://example.com")
1417            .email_sender(sender_box)
1418            .build()
1419            .await
1420            .unwrap();
1421        make_user_with_username(&ath, "reset@example.com", Some("alice")).await;
1422
1423        let email = Email::new("reset@example.com".into()).unwrap();
1424        ath.send_password_reset_email(&email).await.unwrap();
1425
1426        let msgs = captured.lock().unwrap();
1427        assert_eq!(msgs.len(), 1);
1428        let msg = &msgs[0];
1429        assert_eq!(msg.to, "reset@example.com");
1430        assert_eq!(msg.subject, "Reset your password");
1431        match &msg.template {
1432            crate::email::EmailTemplate::PasswordReset { url, username } => {
1433                assert!(
1434                    url.contains("https://example.com"),
1435                    "URL must contain base_url"
1436                );
1437                assert!(
1438                    url.contains("/auth/reset-password?token="),
1439                    "URL must have path"
1440                );
1441                assert_eq!(username, "alice");
1442            }
1443            other => panic!("expected PasswordReset template, got {other:?}"),
1444        }
1445    }
1446
1447    #[tokio::test]
1448    async fn send_verification_email_builds_correct_template() {
1449        let (sender_box, captured) = capturing_sender();
1450        let ath = AllowThemBuilder::new("sqlite::memory:")
1451            .base_url("https://example.com")
1452            .email_sender(sender_box)
1453            .build()
1454            .await
1455            .unwrap();
1456        let email = Email::new("verify@example.com".into()).unwrap();
1457        let user = ath
1458            .db()
1459            .create_user(
1460                email.clone(),
1461                "pass",
1462                Some(crate::types::Username::new("bob")),
1463                None,
1464            )
1465            .await
1466            .unwrap();
1467
1468        ath.send_verification_email(user.id, &email).await.unwrap();
1469
1470        let msgs = captured.lock().unwrap();
1471        assert_eq!(msgs.len(), 1);
1472        let msg = &msgs[0];
1473        assert_eq!(msg.to, "verify@example.com");
1474        assert_eq!(msg.subject, "Verify your email address");
1475        match &msg.template {
1476            crate::email::EmailTemplate::EmailVerification { url, username } => {
1477                assert!(url.contains("https://example.com"));
1478                assert!(url.contains("/auth/verify-email?token="));
1479                assert_eq!(username, "bob");
1480            }
1481            other => panic!("expected EmailVerification template, got {other:?}"),
1482        }
1483    }
1484
1485    #[tokio::test]
1486    async fn send_password_reset_email_username_fallback_uses_email_local_part() {
1487        let (sender_box, captured) = capturing_sender();
1488        let ath = AllowThemBuilder::new("sqlite::memory:")
1489            .base_url("https://example.com")
1490            .email_sender(sender_box)
1491            .build()
1492            .await
1493            .unwrap();
1494        // Create user with no username — fallback must use email local part.
1495        make_user_with_username(&ath, "noname@example.com", None).await;
1496
1497        let email = Email::new("noname@example.com".into()).unwrap();
1498        ath.send_password_reset_email(&email).await.unwrap();
1499
1500        let msgs = captured.lock().unwrap();
1501        let msg = &msgs[0];
1502        match &msg.template {
1503            crate::email::EmailTemplate::PasswordReset { username, .. } => {
1504                assert_eq!(username, "noname", "must fall back to email local part");
1505            }
1506            other => panic!("expected PasswordReset, got {other:?}"),
1507        }
1508    }
1509
1510    #[tokio::test]
1511    async fn sender_error_propagates_as_auth_error_email() {
1512        let ath = AllowThemBuilder::new("sqlite::memory:")
1513            .base_url("https://example.com")
1514            .email_sender(Box::new(FailingSender))
1515            .build()
1516            .await
1517            .unwrap();
1518        make_user_with_username(&ath, "fail@example.com", Some("fail")).await;
1519
1520        let email = Email::new("fail@example.com".into()).unwrap();
1521        let result = ath.send_password_reset_email(&email).await;
1522        assert!(
1523            matches!(result, Err(crate::error::AuthError::Email(_))),
1524            "sender error must surface as AuthError::Email"
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn send_password_reset_email_silent_on_unknown_email() {
1530        let (sender_box, captured) = capturing_sender();
1531        let ath = AllowThemBuilder::new("sqlite::memory:")
1532            .base_url("https://example.com")
1533            .email_sender(sender_box)
1534            .build()
1535            .await
1536            .unwrap();
1537
1538        let email = Email::new("ghost@example.com".into()).unwrap();
1539        let result = ath.send_password_reset_email(&email).await;
1540        assert!(result.is_ok(), "must return Ok for unknown email");
1541        assert!(
1542            captured.lock().unwrap().is_empty(),
1543            "no email must be sent for unknown address"
1544        );
1545    }
1546
1547    #[tokio::test]
1548    async fn send_invitation_email_creates_invitation_and_sends() {
1549        use crate::types::UserId;
1550
1551        let (sender_box, captured) = capturing_sender();
1552        let ath = AllowThemBuilder::new("sqlite::memory:")
1553            .base_url("https://example.com")
1554            .email_sender(sender_box)
1555            .build()
1556            .await
1557            .unwrap();
1558
1559        // Create the inviter.
1560        let inviter_email = Email::new("inviter@example.com".into()).unwrap();
1561        let inviter = ath
1562            .db()
1563            .create_user(
1564                inviter_email,
1565                "pass",
1566                Some(crate::types::Username::new("Alice")),
1567                None,
1568            )
1569            .await
1570            .unwrap();
1571        let inviter_id: UserId = inviter.id;
1572
1573        let invitee = Email::new("invitee@example.com".into()).unwrap();
1574        let invite_url = "https://example.com/invite/tok123";
1575        let expires_at = chrono::Utc::now() + chrono::Duration::hours(48);
1576
1577        ath.send_invitation_email(&invitee, invite_url, inviter_id, expires_at)
1578            .await
1579            .unwrap();
1580
1581        // One email sent.
1582        let msgs = captured.lock().unwrap();
1583        assert_eq!(msgs.len(), 1);
1584        let msg = &msgs[0];
1585        assert_eq!(msg.to, "invitee@example.com");
1586        match &msg.template {
1587            crate::email::EmailTemplate::Invitation { url, invited_by } => {
1588                assert_eq!(url, invite_url);
1589                assert_eq!(invited_by, "Alice");
1590            }
1591            other => panic!("expected Invitation template, got {other:?}"),
1592        }
1593    }
1594
1595    #[tokio::test]
1596    async fn send_mfa_recovery_email_sends_codes_without_db_write() {
1597        let (sender_box, captured) = capturing_sender();
1598        let ath = AllowThemBuilder::new("sqlite::memory:")
1599            .base_url("https://example.com")
1600            .email_sender(sender_box)
1601            .build()
1602            .await
1603            .unwrap();
1604
1605        let email = Email::new("mfa@example.com".into()).unwrap();
1606        let user = ath
1607            .db()
1608            .create_user(
1609                email,
1610                "pass",
1611                Some(crate::types::Username::new("carol")),
1612                None,
1613            )
1614            .await
1615            .unwrap();
1616
1617        let codes = vec!["code-1".into(), "code-2".into(), "code-3".into()];
1618        ath.send_mfa_recovery_email(user.id, codes.clone())
1619            .await
1620            .unwrap();
1621
1622        let msgs = captured.lock().unwrap();
1623        assert_eq!(msgs.len(), 1);
1624        let msg = &msgs[0];
1625        assert_eq!(msg.to, "mfa@example.com");
1626        assert_eq!(msg.subject, "Your MFA recovery codes");
1627        match &msg.template {
1628            crate::email::EmailTemplate::MfaRecovery {
1629                codes: sent_codes,
1630                username,
1631            } => {
1632                assert_eq!(sent_codes, &codes, "codes must be forwarded as-is");
1633                assert_eq!(username, "carol");
1634            }
1635            other => panic!("expected MfaRecovery template, got {other:?}"),
1636        }
1637    }
1638
1639    // ---- EventSink integration tests ----
1640
1641    /// A test-only sink that captures every event emitted to it.
1642    struct CapturingSink(std::sync::Arc<std::sync::Mutex<Vec<crate::event_sink::AuthEvent>>>);
1643
1644    impl crate::event_sink::EventSink for CapturingSink {
1645        fn emit<'a>(
1646            &'a self,
1647            event: &'a crate::event_sink::AuthEvent,
1648        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
1649            self.0.lock().unwrap().push(event.clone());
1650            Box::pin(std::future::ready(()))
1651        }
1652    }
1653
1654    fn capturing_sink() -> (
1655        Box<dyn crate::event_sink::EventSink>,
1656        std::sync::Arc<std::sync::Mutex<Vec<crate::event_sink::AuthEvent>>>,
1657    ) {
1658        let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1659        let sink = CapturingSink(captured.clone());
1660        (Box::new(sink), captured)
1661    }
1662
1663    async fn ath_with_sink() -> (
1664        AllowThem,
1665        std::sync::Arc<std::sync::Mutex<Vec<crate::event_sink::AuthEvent>>>,
1666    ) {
1667        let (sink_box, captured) = capturing_sink();
1668        let ath = AllowThemBuilder::new("sqlite::memory:")
1669            .base_url("https://example.com")
1670            .event_sink(sink_box)
1671            .build()
1672            .await
1673            .unwrap();
1674        (ath, captured)
1675    }
1676
1677    #[tokio::test]
1678    async fn create_user_emits_user_created() {
1679        let (ath, captured) = ath_with_sink().await;
1680        let email = crate::types::Email::new("ev@example.com".into()).unwrap();
1681        ath.create_user(email, "pass1234", None, None)
1682            .await
1683            .unwrap();
1684
1685        let events = captured.lock().unwrap();
1686        assert_eq!(events.len(), 1);
1687        assert_eq!(events[0].event_type, "user.created");
1688        assert!(events[0].user_id.is_some());
1689    }
1690
1691    #[tokio::test]
1692    async fn login_emits_session_created() {
1693        let (ath, captured) = ath_with_sink().await;
1694        let email = crate::types::Email::new("ev@example.com".into()).unwrap();
1695        ath.create_user(email.clone(), "pass1234", None, None)
1696            .await
1697            .unwrap();
1698        captured.lock().unwrap().clear(); // discard user.created
1699
1700        ath.login("ev@example.com", "pass1234").await.unwrap();
1701
1702        let events = captured.lock().unwrap();
1703        assert_eq!(events.len(), 1);
1704        assert_eq!(events[0].event_type, "session.created");
1705    }
1706
1707    #[tokio::test]
1708    async fn delete_session_emits_session_destroyed() {
1709        let (ath, captured) = ath_with_sink().await;
1710        let email = crate::types::Email::new("ev@example.com".into()).unwrap();
1711        ath.create_user(email.clone(), "pass1234", None, None)
1712            .await
1713            .unwrap();
1714        let token = ath.login("ev@example.com", "pass1234").await.unwrap().token;
1715        captured.lock().unwrap().clear();
1716
1717        ath.delete_session(&token).await.unwrap();
1718
1719        let events = captured.lock().unwrap();
1720        assert_eq!(events.len(), 1);
1721        assert_eq!(events[0].event_type, "session.destroyed");
1722    }
1723
1724    #[tokio::test]
1725    async fn update_user_email_emits_user_updated() {
1726        let (ath, captured) = ath_with_sink().await;
1727        let email = crate::types::Email::new("ev@example.com".into()).unwrap();
1728        let user = ath
1729            .create_user(email, "pass1234", None, None)
1730            .await
1731            .unwrap();
1732        captured.lock().unwrap().clear();
1733
1734        let new_email = crate::types::Email::new("new@example.com".into()).unwrap();
1735        ath.update_user_email(user.id, new_email).await.unwrap();
1736
1737        let events = captured.lock().unwrap();
1738        assert_eq!(events.len(), 1);
1739        assert_eq!(events[0].event_type, "user.updated");
1740        assert_eq!(events[0].data["field"], "email");
1741    }
1742
1743    #[tokio::test]
1744    async fn update_user_active_false_emits_user_blocked() {
1745        let (ath, captured) = ath_with_sink().await;
1746        let email = crate::types::Email::new("ev@example.com".into()).unwrap();
1747        let user = ath
1748            .create_user(email, "pass1234", None, None)
1749            .await
1750            .unwrap();
1751        captured.lock().unwrap().clear();
1752
1753        ath.update_user_active(user.id, false).await.unwrap();
1754
1755        let events = captured.lock().unwrap();
1756        assert_eq!(events.len(), 1);
1757        assert_eq!(events[0].event_type, "user.blocked");
1758    }
1759
1760    #[tokio::test]
1761    async fn delete_user_emits_user_deleted() {
1762        let (ath, captured) = ath_with_sink().await;
1763        let email = crate::types::Email::new("ev@example.com".into()).unwrap();
1764        let user = ath
1765            .create_user(email, "pass1234", None, None)
1766            .await
1767            .unwrap();
1768        captured.lock().unwrap().clear();
1769
1770        ath.delete_user(user.id).await.unwrap();
1771
1772        let events = captured.lock().unwrap();
1773        assert_eq!(events.len(), 1);
1774        assert_eq!(events[0].event_type, "user.deleted");
1775    }
1776
1777    #[tokio::test]
1778    async fn noop_sink_is_default_and_no_events_captured() {
1779        // No event_sink set → defaults to NoopEventSink; no events stored.
1780        let ath = AllowThemBuilder::new("sqlite::memory:")
1781            .base_url("https://example.com")
1782            .build()
1783            .await
1784            .unwrap();
1785        let email = crate::types::Email::new("ev@example.com".into()).unwrap();
1786        // Just confirm this doesn't panic.
1787        ath.create_user(email, "pass1234", None, None)
1788            .await
1789            .unwrap();
1790    }
1791}