lenso-module-auth 0.1.2

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 chrono::{Duration, Utc};
use platform_core::{PLATFORM_MIGRATIONS, apply_migrations};
use platform_module::{AdminActionSource, AdminDataSource, AdminListQuery};
use platform_runtime::RUNTIME_MIGRATIONS;
use platform_testing::TestDatabase;
use std::sync::Arc;

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_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;
}

#[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;
}