use std::collections::HashMap;
use axum::extract::FromRequestParts;
use chrono::Utc;
use fraiseql_core::{security::AuthenticatedUser, types::UserId};
use serde_json::json;
use super::OptionalSecurityContext;
use crate::middleware::AuthUser;
async fn context_for(user: AuthenticatedUser) -> fraiseql_core::security::SecurityContext {
let (mut parts, _body) = axum::http::Request::builder()
.body(axum::body::Body::empty())
.expect("empty request body builds")
.into_parts();
parts.extensions.insert(AuthUser(user));
let OptionalSecurityContext(ctx) = OptionalSecurityContext::from_request_parts(&mut parts, &())
.await
.expect("OptionalSecurityContext extraction is infallible here");
ctx.expect("an AuthUser in extensions yields a SecurityContext")
}
fn user_with_claims(extra_claims: HashMap<String, serde_json::Value>) -> AuthenticatedUser {
AuthenticatedUser {
user_id: UserId::new("user-1"),
scopes: vec![],
expires_at: Utc::now() + chrono::Duration::hours(1),
email: None,
display_name: None,
extra_claims,
}
}
#[tokio::test]
async fn extractor_populates_roles_from_jwt_roles_claim() {
let mut extra = HashMap::new();
extra.insert("roles".to_string(), json!(["report_reader"]));
let ctx = context_for(user_with_claims(extra)).await;
assert!(
ctx.has_role("report_reader"),
"roles must be reachable for the requires_role gate"
);
}
#[tokio::test]
async fn extractor_populates_roles_from_scalar_role_claim() {
let mut extra = HashMap::new();
extra.insert("role".to_string(), json!("admin"));
let ctx = context_for(user_with_claims(extra)).await;
assert_eq!(ctx.roles, vec!["admin".to_string()]);
}
#[tokio::test]
async fn extractor_keeps_role_claim_in_attributes_too() {
let mut extra = HashMap::new();
extra.insert("roles".to_string(), json!(["report_reader"]));
let ctx = context_for(user_with_claims(extra)).await;
assert_eq!(ctx.attributes.get("roles"), Some(&json!(["report_reader"])));
}
#[tokio::test]
async fn extractor_leaves_roles_empty_without_claim() {
let ctx = context_for(user_with_claims(HashMap::new())).await;
assert!(ctx.roles.is_empty());
}
mod shared_security_context {
use super::{HashMap, json, user_with_claims};
use crate::extractors::build_security_context;
fn tenant_of(
claims: &[(&str, serde_json::Value)],
tenant_claim: Option<&str>,
) -> Option<String> {
let extra: HashMap<_, _> =
claims.iter().map(|(k, v)| ((*k).to_string(), v.clone())).collect();
build_security_context(&user_with_claims(extra), "req-1".to_string(), tenant_claim)
.tenant_id
.map(|t| t.0)
}
#[test]
fn the_configured_tenant_claim_becomes_the_tenant_id() {
let claims = [("tenant_id", json!("a")), ("org_id", json!("b"))];
assert_eq!(tenant_of(&claims, Some("tenant_id")).as_deref(), Some("a"));
assert_eq!(tenant_of(&claims, Some("org_id")).as_deref(), Some("b"));
}
#[test]
fn org_id_is_not_the_tenant_unless_it_is_the_configured_claim() {
assert_eq!(tenant_of(&[("org_id", json!("b"))], Some("tenant_id")), None);
}
#[test]
fn without_a_configured_claim_there_is_no_tenant() {
assert_eq!(tenant_of(&[("tenant_id", json!("a")), ("org_id", json!("b"))], None), None);
}
#[test]
fn a_numeric_tenant_claim_is_a_tenant_and_a_structured_one_is_not() {
assert_eq!(
tenant_of(&[("tenant_id", json!(42))], Some("tenant_id")).as_deref(),
Some("42")
);
for value in [
json!({"id": "a"}),
json!(["a"]),
json!(true),
json!(null),
json!(""),
] {
assert_eq!(
tenant_of(&[("tenant_id", value.clone())], Some("tenant_id")),
None,
"{value}"
);
}
}
#[test]
fn extra_claims_are_forwarded_to_attributes() {
let mut extra = HashMap::new();
extra.insert("department".to_string(), json!("finance"));
let ctx = build_security_context(&user_with_claims(extra), "req-1".to_string(), None);
assert_eq!(ctx.attributes.get("department"), Some(&json!("finance")));
}
#[test]
fn framework_namespaced_claims_are_not_forwarded() {
let mut extra = HashMap::new();
extra.insert("fraiseql.actor_type".to_string(), json!("system"));
let ctx = build_security_context(&user_with_claims(extra), "req-1".to_string(), None);
assert_ne!(ctx.attributes.get("fraiseql.actor_type"), Some(&json!("system")));
}
}
#[tokio::test]
async fn a_tenant_header_does_not_set_the_tenant() {
for (claims, want) in [
(HashMap::from([("tenant_id".to_string(), json!("tenant-a"))]), Some("tenant-a")),
(HashMap::new(), None),
] {
let (mut parts, _body) = axum::http::Request::builder()
.header("x-org-id", "tenant-evil")
.header("x-tenant-id", "tenant-evil")
.body(axum::body::Body::empty())
.expect("request builds")
.into_parts();
parts.extensions.insert(AuthUser(user_with_claims(claims)));
parts
.extensions
.insert(crate::middleware::oidc_auth::TenantClaim("tenant_id".into()));
let OptionalSecurityContext(ctx) =
OptionalSecurityContext::from_request_parts(&mut parts, &())
.await
.expect("OptionalSecurityContext extraction is infallible here");
let tenant = ctx.expect("an AuthUser yields a SecurityContext").tenant_id;
assert_eq!(tenant.as_ref().map(|t| t.as_str()), want);
}
}