fraiseql-server 2.3.0

HTTP server for FraiseQL v2 GraphQL engine
#![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
#![allow(clippy::missing_panics_doc)] // Reason: test helper functions, panics are expected
#![allow(clippy::doc_markdown)] // Reason: test comments reference identifiers
#![allow(missing_docs)] // Reason: test code does not require documentation
//! Integration tests for `GET /auth/me` endpoint (issue #193).
//!
//! Tests the full route handler with `AuthMeState` configuration, verifying:
//! - Always-present fields: `sub`, `user_id`, `expires_at`
//! - Extra claims filtered by `expose_claims` allowlist
//! - 401 when no auth context (middleware rejects)
//! - Cookie-based token extraction via middleware
//!
//! The OIDC token validation is tested separately in `auth_regression_test.rs`
//! and `fraiseql-core` unit tests. Here we inject `AuthUser` via a test
//! middleware to exercise the handler logic end-to-end.
//!
//! **Execution engine:** none
//! **Infrastructure:** none
//! **Parallelism:** safe (oneshot, no shared state)

use std::{collections::HashMap, sync::Arc};

use axum::{
    Router,
    body::Body,
    http::{Request, StatusCode, header},
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::get,
};
use chrono::{Duration, Utc};
use fraiseql_core::security::AuthenticatedUser;
use fraiseql_server::{
    middleware::AuthUser,
    routes::auth::{AuthMeState, auth_me},
};
use tower::ServiceExt;

// ── Helpers ─────────────────────────────────────────────────────────────────

fn make_me_state(expose_claims: &[&str]) -> Arc<AuthMeState> {
    Arc::new(AuthMeState {
        expose_claims: expose_claims.iter().map(|s| (*s).to_owned()).collect(),
    })
}

fn make_auth_user(user_id: &str, extra_claims: HashMap<String, serde_json::Value>) -> AuthUser {
    AuthUser(AuthenticatedUser {
        user_id: fraiseql_core::types::UserId::new(user_id),
        scopes: vec!["read".to_owned()],
        expires_at: Utc::now() + Duration::hours(1),
        email: None,
        display_name: None,
        extra_claims,
    })
}

/// Middleware that injects a pre-built AuthUser into request extensions,
/// simulating a successful OIDC validation.
async fn inject_auth_user(request: Request<Body>, next: Next) -> Response {
    // AuthUser is already set by the test — pass through
    next.run(request).await
}

/// Middleware that requires AuthUser extension and returns 401 if missing.
async fn require_auth_user(request: Request<Body>, next: Next) -> Response {
    if request.extensions().get::<AuthUser>().is_none() {
        return (StatusCode::UNAUTHORIZED, "Authentication required").into_response();
    }
    next.run(request).await
}

/// Build a router where AuthUser is pre-injected via Extension.
fn auth_me_router_with_user(state: Arc<AuthMeState>, user: AuthUser) -> Router {
    Router::new()
        .route("/auth/me", get(auth_me))
        .route_layer(middleware::from_fn(inject_auth_user))
        .layer(axum::Extension(user))
        .with_state(state)
}

/// Build a router that checks for AuthUser (returns 401 if absent).
fn auth_me_router_no_user(state: Arc<AuthMeState>) -> Router {
    Router::new()
        .route("/auth/me", get(auth_me))
        .route_layer(middleware::from_fn(require_auth_user))
        .with_state(state)
}

async fn get_response(router: &Router, uri: &str) -> (StatusCode, serde_json::Value) {
    let response = router
        .clone()
        .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
        .await
        .unwrap();

    let status = response.status();
    let body = axum::body::to_bytes(response.into_body(), 1024 * 64).await.unwrap();

    if body.is_empty() {
        return (status, serde_json::Value::Null);
    }
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
    (status, json)
}

// ── Tests ───────────────────────────────────────────────────────────────────

/// Valid session → 200 with sub, user_id, expires_at always present.
#[tokio::test]
async fn test_auth_me_returns_core_fields() {
    let state = make_me_state(&[]);
    let user = make_auth_user("user_123", HashMap::new());
    let router = auth_me_router_with_user(state, user);

    let (status, json) = get_response(&router, "/auth/me").await;

    assert_eq!(status, StatusCode::OK);
    assert_eq!(json["sub"], "user_123");
    assert_eq!(json["user_id"], "user_123");
    assert!(json["expires_at"].is_string());
    // Should parse as ISO-8601
    let expires_str = json["expires_at"].as_str().unwrap();
    assert!(chrono::DateTime::parse_from_rfc3339(expires_str).is_ok());
}

/// Extra claims present in token and in expose_claims → included in response.
#[tokio::test]
async fn test_auth_me_exposes_allowed_extra_claims() {
    let state = make_me_state(&["email", "tenant_id"]);
    let mut extra = HashMap::new();
    extra.insert("email".to_owned(), serde_json::json!("alice@example.com"));
    extra.insert("tenant_id".to_owned(), serde_json::json!("tenant_42"));
    extra.insert("secret_internal".to_owned(), serde_json::json!("hidden"));
    let user = make_auth_user("alice", extra);
    let router = auth_me_router_with_user(state, user);

    let (status, json) = get_response(&router, "/auth/me").await;

    assert_eq!(status, StatusCode::OK);
    assert_eq!(json["email"], "alice@example.com");
    assert_eq!(json["tenant_id"], "tenant_42");
    // Claims NOT in expose_claims must be absent
    assert!(json.get("secret_internal").is_none());
}

/// Extra claims in expose_claims but NOT in token → silently omitted (no null).
#[tokio::test]
async fn test_auth_me_omits_absent_claims() {
    let state = make_me_state(&["email", "missing_claim"]);
    let mut extra = HashMap::new();
    extra.insert("email".to_owned(), serde_json::json!("bob@example.com"));
    let user = make_auth_user("bob", extra);
    let router = auth_me_router_with_user(state, user);

    let (status, json) = get_response(&router, "/auth/me").await;

    assert_eq!(status, StatusCode::OK);
    assert_eq!(json["email"], "bob@example.com");
    // missing_claim should not appear at all (not null, not empty)
    assert!(json.get("missing_claim").is_none());
}

/// No auth context → 401.
#[tokio::test]
async fn test_auth_me_returns_401_without_auth() {
    let state = make_me_state(&[]);
    let router = auth_me_router_no_user(state);

    let (status, _) = get_response(&router, "/auth/me").await;
    assert_eq!(status, StatusCode::UNAUTHORIZED);
}

/// Empty expose_claims → only core fields returned.
#[tokio::test]
async fn test_auth_me_empty_expose_claims() {
    let state = make_me_state(&[]);
    let mut extra = HashMap::new();
    extra.insert("email".to_owned(), serde_json::json!("eve@example.com"));
    extra.insert("role".to_owned(), serde_json::json!("admin"));
    let user = make_auth_user("eve", extra);
    let router = auth_me_router_with_user(state, user);

    let (status, json) = get_response(&router, "/auth/me").await;

    assert_eq!(status, StatusCode::OK);
    // Only core fields
    let obj = json.as_object().unwrap();
    assert_eq!(obj.len(), 3); // sub, user_id, expires_at
    assert!(obj.contains_key("sub"));
    assert!(obj.contains_key("user_id"));
    assert!(obj.contains_key("expires_at"));
}

/// Cookie-based flow: test that __Host-access_token cookie extraction works
/// via the real OIDC middleware (401 path — since we can't mint a valid JWT
/// without a real JWKS, this confirms the middleware reads the cookie and
/// attempts validation rather than ignoring it).
#[tokio::test]
async fn test_auth_me_reads_host_cookie() {
    use fraiseql_core::security::{OidcConfig, OidcValidator};
    use fraiseql_server::middleware::{OidcAuthState, oidc_auth_middleware};

    // Required auth — will reject invalid tokens with 401
    let config = OidcConfig {
        issuer:               "https://test.fraiseql.dev".to_string(),
        audience:             Some("https://api.test.fraiseql.dev".to_string()),
        required:             true,
        additional_audiences: vec![],
        jwks_cache_ttl_secs:  3600,
        allowed_algorithms:   vec!["RS256".to_string()],
        clock_skew_secs:      60,
        jwks_uri:             None,
        scope_claim:          "scope".to_string(),
        require_jti:          false,
        me:                   None,
    };
    let validator = OidcValidator::with_jwks_uri(config, "https://192.0.2.1/jwks".to_string());
    let auth_state = OidcAuthState::new(Arc::new(validator));

    let state = make_me_state(&[]);
    let router = Router::new()
        .route("/auth/me", get(auth_me))
        .route_layer(middleware::from_fn_with_state(auth_state, oidc_auth_middleware))
        .with_state(state);

    // Request with __Host-access_token cookie containing a dummy JWT.
    // The middleware should attempt to validate it (and fail since JWKS is unreachable),
    // returning 401 — proving the cookie path is exercised.
    let response = router
        .clone()
        .oneshot(
            Request::builder()
                .uri("/auth/me")
                .header(header::COOKIE, "__Host-access_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InRlc3Qta2V5In0.eyJzdWIiOiJ0ZXN0In0.invalid")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    // 401 proves the cookie was read and token validation was attempted
    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);

    // Without any auth header or cookie → also 401 (required mode)
    let response_no_auth = router
        .oneshot(Request::builder().uri("/auth/me").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(response_no_auth.status(), StatusCode::UNAUTHORIZED);
}