rustauth-core 0.2.0

Core types and primitives for RustAuth.
Documentation
use std::collections::BTreeMap;
use time::Duration;

use rustauth_core::db::{DbField, DbFieldType};
use rustauth_core::options::{SessionAdditionalField, SessionOptions};
use rustauth_core::plugin::PluginSchemaContribution;

use super::*;

#[tokio::test]
async fn update_session_route_updates_allowed_custom_fields(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(RouteAdapter::default());
    let now = OffsetDateTime::now_utc();
    adapter.insert_user(user(now)).await;
    let mut record = session_record(session(now, now + Duration::hours(1)));
    record.insert("theme".to_owned(), DbValue::String("light".to_owned()));
    adapter.create(create_query("session", record)).await?;
    let router = router_with_options(adapter.clone(), session_field_options())?;
    let cookie = signed_session_cookie("token_1")?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/update-session",
            r#"{"theme":"dark"}"#,
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["session"]["theme"], "dark");
    let updated = record_by_string(&adapter, "session", "token", "token_1")
        .await?
        .ok_or("missing session")?;
    assert_eq!(
        updated.get("theme"),
        Some(&DbValue::String("dark".to_owned()))
    );
    Ok(())
}

#[tokio::test]
async fn update_session_route_filters_hidden_plugin_session_fields(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(RouteAdapter::default());
    let now = OffsetDateTime::now_utc();
    adapter.insert_user(user(now)).await;
    let mut record = session_record(session(now, now + Duration::hours(1)));
    record.insert(
        "tenant_id".to_owned(),
        DbValue::String("tenant_1".to_owned()),
    );
    adapter.create(create_query("session", record)).await?;
    let router = router_with_options(adapter.clone(), hidden_plugin_session_field_options())?;
    let cookie = signed_session_cookie("token_1")?;

    let response = router
        .handle_async(json_request(
            Method::GET,
            "/api/auth/get-session",
            "",
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    let body: Value = serde_json::from_slice(response.body())?;
    assert!(body["session"].get("tenantId").is_none());
    let stored = record_by_string(&adapter, "session", "token", "token_1")
        .await?
        .ok_or("missing session")?;
    assert_eq!(
        stored.get("tenant_id"),
        Some(&DbValue::String("tenant_1".to_owned()))
    );
    Ok(())
}

#[tokio::test]
async fn get_session_route_returns_plugin_session_output_fields(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(RouteAdapter::default());
    let now = OffsetDateTime::now_utc();
    adapter.insert_user(user(now)).await;
    let mut record = session_record(session(now, now + Duration::hours(1)));
    record.insert(
        "tenant_id".to_owned(),
        DbValue::String("tenant_1".to_owned()),
    );
    adapter.create(create_query("session", record)).await?;
    let router = router_with_options(adapter, plugin_session_field_options())?;
    let cookie = signed_session_cookie("token_1")?;

    let response = router
        .handle_async(json_request(
            Method::GET,
            "/api/auth/get-session",
            "",
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["session"]["tenantId"], "tenant_1");
    Ok(())
}

#[tokio::test]
async fn update_session_route_exposes_updated_fields_on_get_session(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(RouteAdapter::default());
    let now = OffsetDateTime::now_utc();
    adapter.insert_user(user(now)).await;
    let mut record = session_record(session(now, now + Duration::hours(1)));
    record.insert("theme".to_owned(), DbValue::String("light".to_owned()));
    adapter.create(create_query("session", record)).await?;
    let router = router_with_options(adapter, session_field_options())?;
    let cookie = signed_session_cookie("token_1")?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/update-session",
            r#"{"theme":"dark"}"#,
            Some(&cookie),
        )?)
        .await?;
    assert_eq!(response.status(), StatusCode::OK);

    let response = router
        .handle_async(json_request(
            Method::GET,
            "/api/auth/get-session",
            "",
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["session"]["theme"], "dark");
    Ok(())
}

#[tokio::test]
async fn update_session_route_rejects_core_only_updates() -> Result<(), Box<dyn std::error::Error>>
{
    let adapter = Arc::new(RouteAdapter::default());
    let now = OffsetDateTime::now_utc();
    adapter.insert_user(user(now)).await;
    adapter
        .insert_session(session(now, now + Duration::hours(1)))
        .await;
    let router = router_with_options(adapter.clone(), session_field_options())?;
    let cookie = signed_session_cookie("token_1")?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/update-session",
            r#"{"token":"malicious-token"}"#,
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["code"], "NO_FIELDS_TO_UPDATE");
    assert!(contains_record_string(&adapter, "session", "token", "token_1").await?);
    Ok(())
}

#[tokio::test]
async fn update_session_route_rejects_non_input_fields() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(RouteAdapter::default());
    let now = OffsetDateTime::now_utc();
    adapter.insert_user(user(now)).await;
    adapter
        .insert_session(session(now, now + Duration::hours(1)))
        .await;
    let mut options = session_field_options();
    options.session.additional_fields.insert(
        "internal_note".to_owned(),
        SessionAdditionalField::new(DbFieldType::String).generated(),
    );
    let router = router_with_options(adapter, options)?;
    let cookie = signed_session_cookie("token_1")?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/update-session",
            r#"{"internal_note":"blocked"}"#,
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["code"], "FIELD_NOT_ALLOWED");
    Ok(())
}

#[tokio::test]
async fn update_session_route_rejects_invalid_field_type() -> Result<(), Box<dyn std::error::Error>>
{
    let adapter = Arc::new(RouteAdapter::default());
    let now = OffsetDateTime::now_utc();
    adapter.insert_user(user(now)).await;
    adapter
        .insert_session(session(now, now + Duration::hours(1)))
        .await;
    let router = router_with_options(adapter, session_field_options())?;
    let cookie = signed_session_cookie("token_1")?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/update-session",
            r#"{"theme":123}"#,
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["code"], "INVALID_REQUEST_BODY");
    Ok(())
}

#[tokio::test]
async fn sign_up_email_route_applies_additional_session_field_defaults(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(RouteAdapter::default());
    let mut options = session_field_options();
    options.session.additional_fields.insert(
        "mode".to_owned(),
        SessionAdditionalField::new(DbFieldType::String)
            .default_value(DbValue::String("standard".to_owned())),
    );
    let router = router_with_options(adapter, options)?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/sign-up/email",
            r#"{"name":"Ada","email":"ada@example.com","password":"secret123"}"#,
            None,
        )?)
        .await?;
    assert_eq!(response.status(), StatusCode::OK);
    let cookie = cookie_header_from_response(&response)?;

    let response = router
        .handle_async(json_request(
            Method::GET,
            "/api/auth/get-session",
            "",
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["session"]["mode"], "standard");
    Ok(())
}

fn session_field_options() -> RustAuthOptions {
    RustAuthOptions {
        session: SessionOptions {
            additional_fields: BTreeMap::from([(
                "theme".to_owned(),
                SessionAdditionalField::new(DbFieldType::String),
            )]),
            ..SessionOptions::default()
        },
        ..RustAuthOptions::default()
    }
}

fn hidden_plugin_session_field_options() -> RustAuthOptions {
    RustAuthOptions {
        plugins: vec![
            AuthPlugin::new("tenant").with_schema(PluginSchemaContribution::field(
                "session",
                "tenant_id",
                DbField::new("tenant_id", DbFieldType::String)
                    .optional()
                    .hidden(),
            )),
        ],
        ..RustAuthOptions::default()
    }
}

fn plugin_session_field_options() -> RustAuthOptions {
    RustAuthOptions {
        plugins: vec![
            AuthPlugin::new("tenant").with_schema(PluginSchemaContribution::field(
                "session",
                "tenant_id",
                DbField::new("tenant_id", DbFieldType::String).optional(),
            )),
        ],
        ..RustAuthOptions::default()
    }
}

fn cookie_header_from_response(
    response: &http::Response<Vec<u8>>,
) -> Result<String, RustAuthError> {
    let cookies = set_cookie_values(response);
    Ok(cookies
        .iter()
        .filter_map(|cookie| cookie.split_once(';').map(|(value, _)| value.to_owned()))
        .collect::<Vec<_>>()
        .join("; "))
}