better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
#![allow(
    clippy::expect_used,
    reason = "Fixture setup and response assertions require successful fallible construction."
)]
use super::*;
use crate::plugins::test_helpers::assert_coded_api_error;
use better_auth_core::AuthUser;
use better_auth_core::{AuthConfig, CreateSession, CreateUser, HttpMethod};
use chrono::{Duration, Utc};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;

type TestSchema = better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema;

async fn context() -> (AuthContext<TestSchema>, String, String) {
    let database = crate::plugins::test_helpers::create_test_database().await;
    let user = database
        .create_user(
            CreateUser::new()
                .with_email("crud@example.com")
                .with_name("CRUD user"),
        )
        .await
        .unwrap();
    let user_id = user.id().into_owned();
    let session = database
        .create_session(CreateSession {
            inherited_fields: Default::default(),
            additional_fields: Default::default(),
            user_id: user_id.clone(),
            expires_at: (Utc::now() + Duration::hours(1)).into(),
            ip_address: None,
            user_agent: None,
            impersonated_by: None,
            active_organization_id: None,
        })
        .await
        .unwrap();
    let token = better_auth_core::entity::AuthSession::token(&session)
        .typed()
        .unwrap()
        .to_string();
    let mut config = AuthConfig::new("a-secret-that-is-at-least-32-characters");
    config.session.bearer = Some(Default::default());
    (
        AuthContext::new(Arc::new(config), database).expect("fixture context initializes"),
        user_id.typed().unwrap().clone(),
        token,
    )
}

fn request(token: &str, path: &str, body: serde_json::Value) -> AuthRequest {
    let request = AuthRequest::from_parts(
        HttpMethod::Post,
        path.to_string(),
        HashMap::from([("authorization".to_string(), format!("Bearer {token}"))]),
        Some(serde_json::to_vec(&body).unwrap()),
        None,
    );
    request.clone().with_original_request(request)
}

async fn server_key(
    plugin: &ApiKeyPlugin,
    ctx: &AuthContext<TestSchema>,
    user_id: &str,
    config_id: &str,
) -> CreateKeyResponse {
    plugin
        .create_key(
            ctx,
            &CreateKeyRequest {
                user_id: Some(user_id.to_string()),
                config_id: Some(config_id.to_string()),
                ..Default::default()
            },
        )
        .await
        .unwrap()
}

#[tokio::test]
async fn list_without_configuration_includes_all_user_configurations() {
    let (ctx, user_id, _) = context().await;
    let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
        config_id: "secondary".to_string(),
        ..Default::default()
    });
    server_key(&plugin, &ctx, &user_id, "default").await;
    server_key(&plugin, &ctx, &user_id, "secondary").await;
    let list = list_keys_core(user_id.as_str(), &ListKeysQuery::default(), &plugin, &ctx)
        .await
        .unwrap();
    assert_eq!(list.total, 2);
    let list = list_keys_core(
        user_id.as_str(),
        &ListKeysQuery {
            config_id: Some("secondary".to_string()),
            ..Default::default()
        },
        &plugin,
        &ctx,
    )
    .await
    .unwrap();
    assert_eq!(list.total, 1);
    assert_eq!(list.api_keys[0].config_id, "secondary");
    let list = list_keys_core(
        user_id.as_str(),
        &ListKeysQuery {
            config_id: Some("missing".to_string()),
            ..Default::default()
        },
        &plugin,
        &ctx,
    )
    .await
    .unwrap();
    assert_eq!(list.total, 0);

    let no_default = ApiKeyPlugin::builder()
        .config_id("secondary".to_string())
        .build();
    assert_eq!(
        list_keys_core(
            user_id.as_str(),
            &ListKeysQuery::default(),
            &no_default,
            &ctx
        )
        .await
        .unwrap()
        .total,
        2
    );
}

#[tokio::test]
async fn http_requests_cannot_impersonate_users_or_change_server_permissions() {
    let (ctx, user_id, token) = context().await;
    let plugin = ApiKeyPlugin::builder().build();
    let create = request(&token, "/api-key/create", json!({"userId":user_id}));
    assert_eq!(
        plugin
            .handle_create(&create, &ctx)
            .await
            .unwrap_err()
            .status_code()
            .unwrap(),
        401
    );
    let key = server_key(&plugin, &ctx, &user_id, "default").await;
    let update = request(
        &token,
        "/api-key/update",
        json!({"keyId":key.api_key.id,"userId":"someone-else","enabled":false}),
    );
    assert_eq!(
        plugin
            .handle_update(&update, &ctx)
            .await
            .unwrap_err()
            .status_code()
            .unwrap(),
        401
    );
    let update = request(
        &token,
        "/api-key/update",
        json!({"keyId":key.api_key.id,"permissions":null}),
    );
    assert_coded_api_error(
        &plugin.handle_update(&update, &ctx).await.unwrap_err(),
        "BAD_REQUEST",
        400,
        "SERVER_ONLY_PROPERTY",
        "The property you're trying to set can only be set from the server auth instance only.",
    );
    let update = request(&token, "/api-key/update", json!({"keyId":"missing"}));
    assert_eq!(
        plugin
            .handle_update(&update, &ctx)
            .await
            .unwrap_err()
            .status_code()
            .unwrap(),
        404
    );
}

#[tokio::test]
async fn metadata_can_be_cleared_and_disabled_metadata_is_ignored_on_update() {
    let (ctx, user_id, token) = context().await;
    let enabled = ApiKeyPlugin::builder().enable_metadata(true).build();
    let key = enabled
        .create_key(
            &ctx,
            &CreateKeyRequest {
                user_id: Some(user_id.clone()),
                metadata: Some(json!(["initial"])),
                ..Default::default()
            },
        )
        .await
        .unwrap();
    assert_eq!(
        key.api_key.metadata.json().unwrap(),
        Some(json!(["initial"]))
    );
    let update = request(
        &token,
        "/api-key/update",
        json!({"keyId":key.api_key.id,"metadata":null}),
    );
    let response = enabled.handle_update(&update, &ctx).await.unwrap();
    assert!(
        serde_json::from_slice::<serde_json::Value>(&response.body.bytes().unwrap()).unwrap()["metadata"].is_null()
    );
    let disabled = ApiKeyPlugin::builder().build();
    let update = request(
        &token,
        "/api-key/update",
        json!({"keyId":key.api_key.id,"metadata":{"ignored":true},"enabled":false}),
    );
    let response = disabled.handle_update(&update, &ctx).await.unwrap();
    let result: serde_json::Value =
        serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
    assert!(result["metadata"].is_null());
    assert_eq!(result["enabled"], false);
    let update = request(
        &token,
        "/api-key/update",
        json!({"keyId":key.api_key.id,"metadata":null}),
    );
    assert_coded_api_error(
        &disabled.handle_update(&update, &ctx).await.unwrap_err(),
        "BAD_REQUEST",
        400,
        "NO_VALUES_TO_UPDATE",
        "No values to update.",
    );
}

#[tokio::test]
async fn trusted_creation_and_update_preserve_permissions_and_fractional_expiration() {
    let (ctx, user_id, _) = context().await;
    let plugin = ApiKeyPlugin::builder()
        .key_expiration(KeyExpirationConfig {
            min_expires_in: 0.0,
            ..Default::default()
        })
        .build();
    let before = Utc::now();
    let key = plugin
        .create_key(
            &ctx,
            &CreateKeyRequest {
                user_id: Some(user_id.clone()),
                expires_in: Some(86400.25),
                remaining: Some(3.0),
                permissions: Some(HashMap::from([(
                    "device".to_string(),
                    vec!["read".to_string()],
                )])),
                ..Default::default()
            },
        )
        .await
        .unwrap();
    assert_eq!(key.api_key.remaining, Some(3.0));
    assert_eq!(
        key.api_key.permissions.json().unwrap(),
        Some(json!({"device":["read"]}))
    );
    let expires_at = key
        .api_key
        .expires_at
        .typed()
        .unwrap()
        .as_ref()
        .unwrap()
        .to_datetime()
        .unwrap()
        .unwrap();
    assert!((expires_at - before).num_milliseconds() >= 86_400_249);
    assert!((expires_at - Utc::now()).num_milliseconds() <= 86_400_250);
    let updated = plugin
        .update_key(
            &ctx,
            &UpdateKeyRequest {
                key_id: key.api_key.id.typed().unwrap().clone(),
                user_id: Some(user_id),
                remaining: Some(5.0),
                permissions: Some(None),
                expires_in: Some(None),
                ..Default::default()
            },
        )
        .await
        .unwrap();
    assert_eq!(updated.remaining, Some(5.0));
    assert_eq!(
        updated.permissions.json().unwrap(),
        Some(serde_json::Value::Null)
    );
    assert_eq!(updated.expires_at, None);
    let stored = ctx
        .database
        .get_api_key_by_id(key.api_key.id.typed().unwrap())
        .await
        .unwrap()
        .unwrap();
    assert_ne!(stored.key_hash, key.key);
}

#[tokio::test]
async fn list_rejects_invalid_pagination_instead_of_ignoring_it() {
    let (ctx, _, token) = context().await;
    let plugin = ApiKeyPlugin::builder().build();
    let route = <ApiKeyPlugin as better_auth_core::AuthPlugin<TestSchema>>::routes(&plugin)
        .into_iter()
        .find(|route| route.path == "/api-key/list")
        .unwrap();
    let validate = route.query_validator.unwrap();
    for (field, value) in [
        ("limit", "-1"),
        ("offset", "1.5"),
        ("limit", "bad"),
        ("sortDirection", "sideways"),
    ] {
        let mut req = request(&token, "/api-key/list", json!({}));
        req.query
            .get_or_insert_with(|| serde_json::json!({}))
            .as_object_mut()
            .unwrap()
            .insert(
                field.to_string(),
                serde_json::Value::from(value.to_string()),
            );
        let response = match validate.validate(&req).await {
            Ok(query) => {
                req.set_endpoint_query(query);
                plugin.handle_list(&req, &ctx).await.unwrap()
            }
            Err(error) => error.to_auth_response().expect("fixture response succeeds"),
        };
        assert_eq!(
            response.api_error_status().unwrap(),
            better_auth_core::SchemaValue::Typed(400)
        );
        assert_eq!(
            response.api_error_body().unwrap().json().unwrap().unwrap()["code"],
            "VALIDATION_ERROR"
        );
    }
}