entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
#![allow(missing_docs)]

//! Complete OAuth flow exercised through the public API.

use entropy_auth::{
    AuthorizationRequest, OAuthConfig, PkceChallenge, RefreshRequest, TokenResponse,
};

/// Builds a valid `OAuthConfig` for testing.
fn test_config() -> OAuthConfig {
    OAuthConfig::builder("my-client-id", "my-client-secret")
        .authorization_endpoint("https://auth.example.com/authorize")
        .token_endpoint("https://auth.example.com/token")
        .redirect_uri("https://myapp.example.com/callback")
        .scope("openid")
        .scope("profile")
        .build()
        .unwrap()
}

// --- OAuthConfig ---

#[test]
fn oauth_config_build_with_all_required_fields() {
    let config = test_config();
    assert_eq!(config.client_id(), "my-client-id");
    assert_eq!(config.client_secret(), "my-client-secret");
    assert_eq!(
        config.authorization_endpoint(),
        "https://auth.example.com/authorize",
    );
    assert_eq!(config.token_endpoint(), "https://auth.example.com/token");
    assert_eq!(config.redirect_uri(), "https://myapp.example.com/callback");
    assert_eq!(config.scopes(), &["openid", "profile"]);
}

#[test]
fn oauth_config_rejects_empty_client_id() {
    let result = OAuthConfig::builder("", "secret")
        .authorization_endpoint("https://auth.example.com/authorize")
        .token_endpoint("https://auth.example.com/token")
        .redirect_uri("https://myapp.example.com/callback")
        .build();
    assert!(result.is_err());
}

// --- AuthorizationRequest ---

#[test]
fn authorization_url_contains_client_id() {
    let config = test_config();
    let req = AuthorizationRequest::build(&config).unwrap();
    assert!(
        req.url().contains("client_id=my-client-id"),
        "URL should contain client_id: {}",
        req.url(),
    );
}

#[test]
fn authorization_url_contains_redirect_uri() {
    let config = test_config();
    let req = AuthorizationRequest::build(&config).unwrap();
    assert!(
        req.url().contains("redirect_uri="),
        "URL should contain redirect_uri: {}",
        req.url(),
    );
    assert!(
        req.url().contains("myapp.example.com"),
        "URL should reference the redirect host: {}",
        req.url(),
    );
}

#[test]
fn authorization_url_contains_state() {
    let config = test_config();
    let req = AuthorizationRequest::build(&config).unwrap();
    assert!(
        req.url().contains("state="),
        "URL should contain state: {}",
        req.url(),
    );
    // The state value should appear in the URL.
    assert!(
        req.url().contains(req.state().value()),
        "URL should contain the state value: {}",
        req.url(),
    );
}

#[test]
fn authorization_url_contains_code_challenge() {
    let config = test_config();
    let req = AuthorizationRequest::build(&config).unwrap();
    assert!(
        req.url().contains("code_challenge="),
        "URL should contain code_challenge: {}",
        req.url(),
    );
    assert!(
        req.url().contains(req.pkce().challenge()),
        "URL should contain the PKCE challenge value: {}",
        req.url(),
    );
}

#[test]
fn authorization_url_does_not_contain_client_secret() {
    let config = test_config();
    let req = AuthorizationRequest::build(&config).unwrap();
    assert!(
        !req.url().contains("my-client-secret"),
        "URL must not contain the client secret: {}",
        req.url(),
    );
}

// --- PKCE ---

#[test]
fn pkce_challenge_method_is_s256() {
    let pkce = PkceChallenge::generate().unwrap();
    assert_eq!(pkce.method(), "S256");
}

#[test]
fn pkce_verifier_and_challenge_are_non_empty() {
    let pkce = PkceChallenge::generate().unwrap();
    assert!(!pkce.verifier().is_empty());
    assert!(!pkce.challenge().is_empty());
}

#[test]
fn authorization_request_pkce_method_is_s256() {
    let config = test_config();
    let req = AuthorizationRequest::build(&config).unwrap();
    assert!(
        req.url().contains("code_challenge_method=S256"),
        "URL should specify S256 method: {}",
        req.url(),
    );
    assert_eq!(req.pkce().method(), "S256");
}

// --- TokenResponse Parsing ---

#[test]
fn parse_token_response_with_access_token() {
    let json = r#"{
        "access_token": "ya29.a0AX",
        "token_type": "Bearer",
        "expires_in": 3600
    }"#;
    let resp = TokenResponse::parse(json).unwrap();
    assert_eq!(resp.access_token(), "ya29.a0AX");
    assert_eq!(resp.token_type(), "Bearer");
    assert_eq!(resp.expires_in(), Some(3600));
    assert!(resp.refresh_token().is_none());
}

#[test]
fn parse_token_response_with_refresh_token() {
    let json = r#"{
        "access_token": "access-tok",
        "token_type": "Bearer",
        "expires_in": 3600,
        "refresh_token": "refresh-tok"
    }"#;
    let resp = TokenResponse::parse(json).unwrap();
    assert_eq!(resp.refresh_token(), Some("refresh-tok"));
}

#[test]
fn parse_invalid_json_returns_error() {
    let result = TokenResponse::parse("this is not json");
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(
        err.to_string().contains("invalid JSON"),
        "error should mention invalid JSON: {err}",
    );
}

#[test]
fn parse_oauth_error_response() {
    let json = r#"{"error":"invalid_grant","error_description":"Code expired"}"#;
    let result = TokenResponse::parse(json);
    assert!(result.is_err());
    let err = result.unwrap_err();
    let msg = err.to_string();
    assert!(
        msg.contains("invalid_grant"),
        "error should contain error code: {msg}",
    );
}

#[test]
fn parse_empty_json_returns_error() {
    let result = TokenResponse::parse("");
    assert!(result.is_err());
}

#[test]
fn parse_missing_access_token_returns_error() {
    let json = r#"{"token_type":"Bearer"}"#;
    let result = TokenResponse::parse(json);
    assert!(result.is_err());
}

#[test]
fn parse_missing_token_type_returns_error() {
    let json = r#"{"access_token":"tok"}"#;
    let result = TokenResponse::parse(json);
    assert!(result.is_err());
}

// --- RefreshRequest ---

#[test]
fn refresh_request_from_token_response() {
    // Parse a token response that contains a refresh_token.
    let json = r#"{
        "access_token": "access-tok",
        "token_type": "Bearer",
        "expires_in": 3600,
        "refresh_token": "refresh-tok-123"
    }"#;
    let resp = TokenResponse::parse(json).unwrap();
    let refresh_token = resp.refresh_token().unwrap();

    let config = test_config();
    let req = RefreshRequest::new(&config, refresh_token);

    assert!(
        req.body().contains("grant_type=refresh_token"),
        "body should contain grant_type: {}",
        req.body(),
    );
    assert!(
        req.body().contains("refresh_token=refresh-tok-123"),
        "body should contain refresh_token: {}",
        req.body(),
    );
    assert!(
        req.body().contains("client_id=my-client-id"),
        "body should contain client_id: {}",
        req.body(),
    );
    assert_eq!(req.content_type(), "application/x-www-form-urlencoded");
}