Skip to main content

allowthem_core/
token_cleanup.rs

1use chrono::Utc;
2
3use crate::db::Db;
4use crate::error::AuthError;
5
6impl Db {
7    pub async fn cleanup_expired_tokens(&self) -> Result<u64, AuthError> {
8        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
9
10        let r1 = sqlx::query(
11            "DELETE FROM allowthem_password_reset_tokens \
12             WHERE expires_at <= ? OR used_at IS NOT NULL",
13        )
14        .bind(&now)
15        .execute(self.pool())
16        .await
17        .map_err(AuthError::Database)?;
18
19        let r2 = sqlx::query(
20            "DELETE FROM allowthem_email_verification_tokens \
21             WHERE expires_at <= ? OR used_at IS NOT NULL",
22        )
23        .bind(&now)
24        .execute(self.pool())
25        .await
26        .map_err(AuthError::Database)?;
27
28        Ok(r1.rows_affected() + r2.rows_affected())
29    }
30}
31
32#[cfg(test)]
33mod tests {
34    use chrono::{Duration, Utc};
35
36    use crate::db::Db;
37    use crate::types::{Email, ResetTokenId, UserId, VerificationTokenId};
38
39    async fn test_db() -> Db {
40        Db::connect("sqlite::memory:").await.expect("in-memory db")
41    }
42
43    async fn make_user(db: &Db) -> UserId {
44        let email = Email::new("cleanup@example.com".to_string()).unwrap();
45        let user = db
46            .create_user(email, "test-password", None, None)
47            .await
48            .expect("create user");
49        user.id
50    }
51
52    async fn insert_reset_token(db: &Db, user_id: UserId, expires_at: &str, used: bool) {
53        let id = ResetTokenId::new();
54        let used_at = if used {
55            Some(Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
56        } else {
57            None
58        };
59        sqlx::query(
60            "INSERT INTO allowthem_password_reset_tokens \
61             (id, user_id, token_hash, expires_at, used_at) VALUES (?, ?, ?, ?, ?)",
62        )
63        .bind(id)
64        .bind(user_id)
65        .bind(format!("reset-hash-{}", uuid::Uuid::now_v7()))
66        .bind(expires_at)
67        .bind(used_at.as_deref())
68        .execute(db.pool())
69        .await
70        .expect("insert reset token");
71    }
72
73    async fn insert_verification_token(db: &Db, user_id: UserId, expires_at: &str, used: bool) {
74        let id = VerificationTokenId::new();
75        let used_at = if used {
76            Some(Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
77        } else {
78            None
79        };
80        sqlx::query(
81            "INSERT INTO allowthem_email_verification_tokens \
82             (id, user_id, token_hash, expires_at, used_at) VALUES (?, ?, ?, ?, ?)",
83        )
84        .bind(id)
85        .bind(user_id)
86        .bind(format!("verify-hash-{}", uuid::Uuid::now_v7()))
87        .bind(expires_at)
88        .bind(used_at.as_deref())
89        .execute(db.pool())
90        .await
91        .expect("insert verification token");
92    }
93
94    async fn count_reset_tokens(db: &Db) -> i64 {
95        let (count,): (i64,) =
96            sqlx::query_as("SELECT COUNT(*) FROM allowthem_password_reset_tokens")
97                .fetch_one(db.pool())
98                .await
99                .expect("count");
100        count
101    }
102
103    async fn count_verification_tokens(db: &Db) -> i64 {
104        let (count,): (i64,) =
105            sqlx::query_as("SELECT COUNT(*) FROM allowthem_email_verification_tokens")
106                .fetch_one(db.pool())
107                .await
108                .expect("count");
109        count
110    }
111
112    #[tokio::test]
113    async fn cleanup_removes_expired_tokens() {
114        let db = test_db().await;
115        let user_id = make_user(&db).await;
116        let past = (Utc::now() - Duration::hours(1))
117            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
118            .to_string();
119        let future = (Utc::now() + Duration::hours(1))
120            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
121            .to_string();
122
123        insert_reset_token(&db, user_id, &past, false).await;
124        insert_reset_token(&db, user_id, &future, false).await;
125        insert_verification_token(&db, user_id, &past, false).await;
126        insert_verification_token(&db, user_id, &future, false).await;
127
128        let removed = db.cleanup_expired_tokens().await.expect("cleanup");
129        assert_eq!(
130            removed, 2,
131            "should remove 1 expired reset + 1 expired verification"
132        );
133        assert_eq!(count_reset_tokens(&db).await, 1);
134        assert_eq!(count_verification_tokens(&db).await, 1);
135    }
136
137    #[tokio::test]
138    async fn cleanup_removes_used_tokens() {
139        let db = test_db().await;
140        let user_id = make_user(&db).await;
141        let future = (Utc::now() + Duration::hours(1))
142            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
143            .to_string();
144
145        insert_reset_token(&db, user_id, &future, true).await;
146        insert_verification_token(&db, user_id, &future, true).await;
147
148        let removed = db.cleanup_expired_tokens().await.expect("cleanup");
149        assert_eq!(removed, 2, "should remove used tokens even if not expired");
150    }
151
152    #[tokio::test]
153    async fn cleanup_preserves_active_tokens() {
154        let db = test_db().await;
155        let user_id = make_user(&db).await;
156        let future = (Utc::now() + Duration::hours(1))
157            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
158            .to_string();
159
160        insert_reset_token(&db, user_id, &future, false).await;
161        insert_verification_token(&db, user_id, &future, false).await;
162
163        let removed = db.cleanup_expired_tokens().await.expect("cleanup");
164        assert_eq!(removed, 0, "should not remove active tokens");
165        assert_eq!(count_reset_tokens(&db).await, 1);
166        assert_eq!(count_verification_tokens(&db).await, 1);
167    }
168}