road-runner-common 0.11.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
use road_runner_common::auth::{PrincipalKind, UserContext};
use road_runner_common::error::AppError;
use uuid::Uuid;

fn context() -> UserContext {
    UserContext {
        subject: "11111111-1111-1111-1111-111111111111".to_string(),
        principal: PrincipalKind::User,
        email: Some("user@example.com".to_string()),
        username: Some("user".to_string()),
        roles: vec!["trader".to_string(), "risk_admin".to_string()],
        scopes: vec!["read".to_string(), "spot".to_string()],
        capabilities: vec!["account.read".to_string()],
        api_key_id: None,
        acr: Some("aal2".to_string()),
    }
}

#[test]
fn principal_kind_parse_accepts_aliases_and_defaults_to_user() {
    // Arrange / Act / Assert
    assert_eq!(PrincipalKind::default(), PrincipalKind::User);
    assert_eq!(PrincipalKind::parse("apikey"), PrincipalKind::ApiKey);
    assert_eq!(PrincipalKind::parse("api_key"), PrincipalKind::ApiKey);
    assert_eq!(PrincipalKind::parse("api-key"), PrincipalKind::ApiKey);
    assert_eq!(PrincipalKind::parse(" service "), PrincipalKind::Service);
    assert_eq!(PrincipalKind::parse("unknown"), PrincipalKind::User);
}

#[test]
fn principal_kind_as_str_returns_wire_values() {
    // Arrange / Act / Assert
    assert_eq!(PrincipalKind::User.as_str(), "user");
    assert_eq!(PrincipalKind::ApiKey.as_str(), "apikey");
    assert_eq!(PrincipalKind::Service.as_str(), "service");
}

#[test]
fn user_id_parses_uuid_and_user_id_opt_matches() {
    // Arrange
    let ctx = context();

    // Act
    let parsed = ctx.user_id().unwrap();

    // Assert
    assert_eq!(parsed, Uuid::parse_str(&ctx.subject).unwrap());
    assert_eq!(ctx.user_id_opt(), Some(parsed));
}

#[test]
fn user_id_returns_unauthorized_for_non_uuid_subject() {
    // Arrange
    let mut ctx = context();
    ctx.subject = "service-account".to_string();

    // Act
    let result = ctx.user_id();

    // Assert
    assert!(matches!(result, Err(AppError::Unauthorized { message }) if message == "subject is not a valid user id"));
    assert_eq!(ctx.user_id_opt(), None);
}

#[test]
fn principal_type_helpers_identify_context_kind() {
    // Arrange
    let mut ctx = context();

    // Act / Assert
    assert!(ctx.is_user());
    ctx.principal = PrincipalKind::ApiKey;
    assert!(ctx.is_api_key());
    ctx.principal = PrincipalKind::Service;
    assert!(ctx.is_service());
}

#[test]
fn role_checks_cover_success_failure_and_admin_suffix() {
    // Arrange
    let ctx = context();

    // Act / Assert
    assert!(ctx.has_role("trader"));
    assert!(ctx.has_any_role(&["viewer", "trader"]));
    assert!(ctx.require_role("trader").is_ok());
    assert!(ctx.require_any_role(&["viewer", "risk_admin"]).is_ok());
    assert!(ctx.is_admin());
    assert!(ctx.require_admin().is_ok());
    assert!(matches!(ctx.require_role("missing"), Err(AppError::Forbidden { message }) if message == "Missing role: missing"));
    assert!(matches!(
        ctx.require_any_role(&["viewer", "ops"]),
        Err(AppError::Forbidden { message }) if message == "Missing any of roles: viewer, ops"
    ));
}

#[test]
fn admin_requires_admin_or_suffix_case_insensitively() {
    // Arrange
    let mut ctx = context();
    ctx.roles = vec!["support".to_string()];

    // Act / Assert
    assert!(!ctx.is_admin());
    assert!(matches!(ctx.require_admin(), Err(AppError::Forbidden { message }) if message == "Admin role required"));

    ctx.roles = vec!["ADMIN".to_string()];
    assert!(ctx.is_admin());
}

#[test]
fn scope_checks_return_forbidden_for_missing_scope() {
    // Arrange
    let ctx = context();

    // Act / Assert
    assert!(ctx.has_scope("read"));
    assert!(ctx.require_scope("spot").is_ok());
    assert!(matches!(ctx.require_scope("withdraw"), Err(AppError::Forbidden { message }) if message == "Missing scope: withdraw"));
}

#[test]
fn capability_checks_return_forbidden_for_missing_capability() {
    // Arrange
    let ctx = context();

    // Act / Assert
    assert!(ctx.has_capability("account.read"));
    assert!(ctx.require_capability("account.read").is_ok());
    assert!(matches!(
        ctx.require_capability("withdraw.crypto"),
        Err(AppError::Forbidden { message }) if message == "Capability not allowed: withdraw.crypto"
    ));
}

#[cfg(feature = "web")]
mod web {
    use super::{context, UserContext};
    use actix_web::dev::Payload;
    use actix_web::{test::TestRequest, FromRequest, HttpMessage};
    use road_runner_common::error::AppError;

    #[actix_web::test]
    async fn user_context_extractor_reads_context_from_extensions() {
        // Arrange
        let req = TestRequest::default().to_http_request();
        req.extensions_mut().insert(context());
        let mut payload = Payload::None;

        // Act
        let extracted = UserContext::from_request(&req, &mut payload).await.unwrap();

        // Assert
        assert_eq!(extracted.subject, "11111111-1111-1111-1111-111111111111");
    }

    #[actix_web::test]
    async fn user_context_extractor_returns_unauthorized_when_missing() {
        // Arrange
        let req = TestRequest::default().to_http_request();
        let mut payload = Payload::None;

        // Act
        let result = UserContext::from_request(&req, &mut payload).await;

        // Assert
        assert!(matches!(result, Err(AppError::Unauthorized { message }) if message == "Missing user context"));
    }
}