lenso-module-auth 0.1.5

First-party auth anchor module for the Lenso backend framework.
Documentation
use auth::admin::AuthAdminData;
use auth::models::{AuthUser, AuthUserId};
use auth::repositories::{AuthUserRepository, PostgresAuthUserRepository};
use auth::resolver::{CachedSession, SessionCache, session_token_hash};
use chrono::{Duration, Utc};
use platform_core::AppResult;
use platform_core::{PLATFORM_MIGRATIONS, apply_migrations};
use platform_module::{AdminActionSource, AdminDataSource, AdminListQuery};
use platform_runtime::RUNTIME_MIGRATIONS;
use platform_testing::TestDatabase;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;

async fn seed(repo: &PostgresAuthUserRepository, id: &str) {
    repo.insert(&AuthUser {
        id: AuthUserId(id.to_owned()),
        created_at: Utc::now(),
        disabled_at: None,
        disabled_reason: None,
        disabled_until: None,
    })
    .await
    .expect("insert should succeed");
}

#[tokio::test]
async fn admin_data_lists_auth_users_with_cursor_pagination() {
    let Some(db) = TestDatabase::create().await else {
        return;
    };
    let migrations = PLATFORM_MIGRATIONS
        .iter()
        .chain(RUNTIME_MIGRATIONS)
        .chain(auth::migrations::AUTH_MIGRATIONS)
        .copied()
        .collect::<Vec<_>>();
    apply_migrations(&db.pool, &migrations)
        .await
        .expect("migrations apply");

    let repo = PostgresAuthUserRepository::new(db.pool.clone());
    seed(&repo, "usr_a").await;
    seed(&repo, "usr_b").await;
    seed(&repo, "usr_c").await;

    let admin = AuthAdminData::new(Arc::new(repo));
    let page1 = admin
        .list("users", &AdminListQuery::new(2, None))
        .await
        .expect("list page 1");
    assert_eq!(page1.records.len(), 2);
    assert_eq!(page1.records[0]["id"], "usr_a");
    assert_eq!(page1.records[1]["id"], "usr_b");

    let page2 = admin
        .list(
            "users",
            &AdminListQuery::new(2, Some(page1.next_cursor.expect("cursor"))),
        )
        .await
        .expect("list page 2");
    assert_eq!(page2.records.len(), 1);
    assert_eq!(page2.records[0]["id"], "usr_c");
    assert!(page2.next_cursor.is_none());

    let one = admin.get("users", "usr_a").await.expect("get");
    assert_eq!(one.expect("some")["id"], "usr_a");

    db.cleanup().await;
}

#[tokio::test]
async fn admin_data_lists_auth_sessions_without_token_hashes() {
    let Some(db) = TestDatabase::create().await else {
        return;
    };
    let migrations = PLATFORM_MIGRATIONS
        .iter()
        .chain(RUNTIME_MIGRATIONS)
        .chain(auth::migrations::AUTH_MIGRATIONS)
        .copied()
        .collect::<Vec<_>>();
    apply_migrations(&db.pool, &migrations)
        .await
        .expect("migrations apply");

    let repo = PostgresAuthUserRepository::new(db.pool.clone());
    let now = Utc::now();
    repo.create_dev_session(
        AuthUserId("usr_sessions".to_owned()),
        "sess_a".to_owned(),
        "token_a".to_owned(),
        now,
        now + Duration::hours(1),
    )
    .await
    .expect("session should be created");

    let admin = AuthAdminData::new(Arc::new(repo));
    let page = admin
        .list("sessions", &AdminListQuery::new(10, None))
        .await
        .expect("list sessions");
    assert_eq!(page.records.len(), 1);
    assert_eq!(page.records[0]["id"], "sess_a");
    assert_eq!(page.records[0]["user_id"], "usr_sessions");
    assert!(page.records[0].get("token_hash").is_none());

    let one = admin.get("sessions", "sess_a").await.expect("get session");
    assert_eq!(one.expect("some")["id"], "sess_a");

    db.cleanup().await;
}

#[tokio::test]
async fn admin_action_revokes_auth_session() {
    let Some(db) = TestDatabase::create().await else {
        return;
    };
    let migrations = PLATFORM_MIGRATIONS
        .iter()
        .chain(RUNTIME_MIGRATIONS)
        .chain(auth::migrations::AUTH_MIGRATIONS)
        .copied()
        .collect::<Vec<_>>();
    apply_migrations(&db.pool, &migrations)
        .await
        .expect("migrations apply");

    let repo = PostgresAuthUserRepository::new(db.pool.clone());
    let now = Utc::now();
    repo.create_dev_session(
        AuthUserId("usr_revoke".to_owned()),
        "sess_revoke".to_owned(),
        "token_revoke".to_owned(),
        now,
        now + Duration::hours(1),
    )
    .await
    .expect("session should be created");

    let admin = AuthAdminData::new(Arc::new(repo));
    let result = admin
        .invoke(
            "revoke_session",
            serde_json::json!({"session_id": "sess_revoke"}),
        )
        .await
        .expect("revoke session");
    assert_eq!(result["revoked"], true);

    let one = admin
        .get("sessions", "sess_revoke")
        .await
        .expect("get session")
        .expect("session");
    assert!(one["revoked_at"].as_str().is_some());
    assert!(one.get("token_hash").is_none());

    db.cleanup().await;
}

#[tokio::test]
async fn admin_action_revoking_session_deletes_cached_token() {
    let Some(db) = TestDatabase::create().await else {
        return;
    };
    let migrations = PLATFORM_MIGRATIONS
        .iter()
        .chain(RUNTIME_MIGRATIONS)
        .chain(auth::migrations::AUTH_MIGRATIONS)
        .copied()
        .collect::<Vec<_>>();
    apply_migrations(&db.pool, &migrations)
        .await
        .expect("migrations apply");

    let cache = Arc::new(FakeSessionCache::default());
    let repo =
        PostgresAuthUserRepository::new_with_session_cache(db.pool.clone(), Some(cache.clone()));
    let now = Utc::now();
    repo.create_dev_session(
        AuthUserId("usr_cached_revoke".to_owned()),
        "sess_cached_revoke".to_owned(),
        "token_cached_revoke".to_owned(),
        now,
        now + Duration::hours(1),
    )
    .await
    .expect("session should be created");
    let token_hash = session_token_hash("token_cached_revoke");
    cache
        .put(
            &token_hash,
            CachedSession {
                user_id: "usr_cached_revoke".to_owned(),
                expires_at: now + Duration::hours(1),
            },
        )
        .await
        .expect("cache seed");

    let admin = AuthAdminData::new(Arc::new(repo));
    admin
        .invoke(
            "revoke_session",
            serde_json::json!({"session_id": "sess_cached_revoke"}),
        )
        .await
        .expect("revoke session");

    assert!(cache.get(&token_hash).await.expect("cache get").is_none());

    db.cleanup().await;
}

#[tokio::test]
async fn admin_action_disables_and_enables_auth_user() {
    let Some(db) = TestDatabase::create().await else {
        return;
    };
    let migrations = PLATFORM_MIGRATIONS
        .iter()
        .chain(RUNTIME_MIGRATIONS)
        .chain(auth::migrations::AUTH_MIGRATIONS)
        .copied()
        .collect::<Vec<_>>();
    apply_migrations(&db.pool, &migrations)
        .await
        .expect("migrations apply");

    let repo = PostgresAuthUserRepository::new(db.pool.clone());
    seed(&repo, "usr_disable").await;

    let admin = AuthAdminData::new(Arc::new(repo));
    let disabled_until = (Utc::now() + Duration::hours(1)).to_rfc3339();
    let disabled = admin
        .invoke(
            "disable_user",
            serde_json::json!({
                "disabled_until": disabled_until,
                "reason": "abuse",
                "user_id": "usr_disable",
            }),
        )
        .await
        .expect("disable user");
    assert_eq!(disabled["disabled"], true);
    assert_eq!(disabled["reason"], "abuse");
    assert_eq!(disabled["user_id"], "usr_disable");

    let one = admin
        .get("users", "usr_disable")
        .await
        .expect("get disabled user")
        .expect("user");
    assert!(one["disabled_at"].as_str().is_some());
    assert_eq!(one["disabled_reason"], "abuse");
    assert!(one["disabled_until"].as_str().is_some());

    let enabled = admin
        .invoke("enable_user", serde_json::json!({"user_id": "usr_disable"}))
        .await
        .expect("enable user");
    assert_eq!(enabled["enabled"], true);
    assert_eq!(enabled["user_id"], "usr_disable");

    let one = admin
        .get("users", "usr_disable")
        .await
        .expect("get enabled user")
        .expect("user");
    assert!(one["disabled_at"].is_null());
    assert!(one["disabled_reason"].is_null());
    assert!(one["disabled_until"].is_null());

    db.cleanup().await;
}

#[derive(Debug, Default)]
struct FakeSessionCache {
    values: Mutex<HashMap<String, CachedSession>>,
}

#[async_trait::async_trait]
impl SessionCache for FakeSessionCache {
    async fn get(&self, token_hash: &str) -> AppResult<Option<CachedSession>> {
        Ok(self.values.lock().expect("values").get(token_hash).cloned())
    }

    async fn put(&self, token_hash: &str, session: CachedSession) -> AppResult<()> {
        self.values
            .lock()
            .expect("values")
            .insert(token_hash.to_owned(), session);
        Ok(())
    }

    async fn delete(&self, token_hash: &str) -> AppResult<()> {
        self.values.lock().expect("values").remove(token_hash);
        Ok(())
    }
}

#[tokio::test]
async fn expired_user_disable_allows_new_sessions() {
    let Some(db) = TestDatabase::create().await else {
        return;
    };
    let migrations = PLATFORM_MIGRATIONS
        .iter()
        .chain(RUNTIME_MIGRATIONS)
        .chain(auth::migrations::AUTH_MIGRATIONS)
        .copied()
        .collect::<Vec<_>>();
    apply_migrations(&db.pool, &migrations)
        .await
        .expect("migrations apply");

    let repo = PostgresAuthUserRepository::new(db.pool.clone());
    let now = Utc::now();
    repo.insert(&AuthUser {
        id: AuthUserId("usr_expired_disable".to_owned()),
        created_at: now,
        disabled_at: Some(now - Duration::hours(2)),
        disabled_reason: Some("temporary".to_owned()),
        disabled_until: Some(now - Duration::hours(1)),
    })
    .await
    .expect("insert should succeed");

    repo.create_dev_session(
        AuthUserId("usr_expired_disable".to_owned()),
        "sess_expired_disable".to_owned(),
        "token_expired_disable".to_owned(),
        now,
        now + Duration::hours(1),
    )
    .await
    .expect("expired disable should not block session creation");

    let admin = AuthAdminData::new(Arc::new(repo));
    let one = admin
        .get("users", "usr_expired_disable")
        .await
        .expect("get user")
        .expect("user");
    assert!(one["disabled_at"].is_null());
    assert!(one["disabled_reason"].is_null());
    assert!(one["disabled_until"].is_null());

    db.cleanup().await;
}