#![allow(clippy::panic)]
mod service_account_tests {
use crate::{
security::{ActorType, SecurityContext},
types::TenantId,
};
#[test]
fn service_account_carries_name_identity_and_actor_type() {
let ctx = SecurityContext::service_account(
"reconciler",
"req-1",
vec!["ledger:read".to_string()],
vec![],
Some(TenantId::new("acme")),
);
assert_eq!(ctx.user_id.as_str(), "service_account:reconciler");
assert_eq!(ctx.actor_type(), ActorType::ServiceAccount);
assert!(ctx.has_role("ledger:read"));
assert_eq!(ctx.tenant_id.as_ref().map(|t| t.as_str()), Some("acme"));
}
#[test]
fn a_service_account_without_a_ceiling_has_no_authority() {
let ctx = SecurityContext::service_account("bare", "req-2", vec![], vec![], None);
assert_eq!(ctx.user_id.as_str(), "service_account:bare");
assert_eq!(ctx.actor_type(), ActorType::ServiceAccount);
assert!(ctx.roles.is_empty(), "no roles granted");
assert!(ctx.scopes.is_empty(), "no scopes granted");
assert!(ctx.tenant_id.is_none(), "no tenant pin");
}
#[test]
fn service_account_actor_type_serializes_for_audit() {
let ctx = SecurityContext::service_account("auditee", "req-3", vec![], vec![], None);
assert_eq!(ctx.actor_type().as_str(), "service_account");
}
}
mod audit_tests {
use chrono::Utc;
use crate::security::audit::{AuditEntry, AuditError, AuditExportConfig, AuditLevel};
#[test]
fn test_audit_entry_integrity_hash() {
let entry = AuditEntry {
id: Some(1),
timestamp: Utc::now(),
level: AuditLevel::INFO,
user_id: 123,
tenant_id: 456,
operation: "query".to_string(),
query: "{ users { id name } }".to_string(),
variables: serde_json::json!({}),
ip_address: "192.168.1.1".to_string(),
user_agent: "Mozilla/5.0".to_string(),
error: None,
duration_ms: Some(100),
previous_hash: None,
integrity_hash: None,
};
let hash = entry.calculate_hash();
assert!(!hash.is_empty());
assert_eq!(hash.len(), 64); }
#[test]
fn test_audit_integrity_verification() {
let mut entry = AuditEntry {
id: Some(1),
timestamp: Utc::now(),
level: AuditLevel::INFO,
user_id: 123,
tenant_id: 456,
operation: "query".to_string(),
query: "{ users { id name } }".to_string(),
variables: serde_json::json!({}),
ip_address: "192.168.1.1".to_string(),
user_agent: "Mozilla/5.0".to_string(),
error: None,
duration_ms: Some(100),
previous_hash: None,
integrity_hash: None,
};
let calculated_hash = entry.calculate_hash();
entry.integrity_hash = Some(calculated_hash);
assert!(entry.verify_integrity());
entry.user_id = 999;
assert!(!entry.verify_integrity());
}
#[test]
fn test_audit_hash_chain() {
let timestamp = Utc::now();
let mut entry1 = AuditEntry {
id: Some(1),
timestamp,
level: AuditLevel::INFO,
user_id: 123,
tenant_id: 456,
operation: "query".to_string(),
query: "{ users { id } }".to_string(),
variables: serde_json::json!({}),
ip_address: "192.168.1.1".to_string(),
user_agent: "Mozilla/5.0".to_string(),
error: None,
duration_ms: Some(100),
previous_hash: None,
integrity_hash: None,
};
let hash1 = entry1.calculate_hash();
entry1.integrity_hash = Some(hash1.clone());
let mut entry2 = AuditEntry {
id: Some(2),
timestamp,
level: AuditLevel::INFO,
user_id: 123,
tenant_id: 456,
operation: "query".to_string(),
query: "{ posts { id } }".to_string(),
variables: serde_json::json!({}),
ip_address: "192.168.1.1".to_string(),
user_agent: "Mozilla/5.0".to_string(),
error: None,
duration_ms: Some(50),
previous_hash: Some(hash1),
integrity_hash: None,
};
let hash2 = entry2.calculate_hash();
entry2.integrity_hash = Some(hash2);
assert!(entry1.verify_integrity());
assert!(entry2.verify_integrity());
entry1.user_id = 999;
assert!(!entry1.verify_integrity());
}
#[test]
fn test_audit_level_parsing() {
assert_eq!(AuditLevel::parse("WARN"), AuditLevel::WARN);
assert_eq!(AuditLevel::parse("ERROR"), AuditLevel::ERROR);
assert_eq!(AuditLevel::parse("INFO"), AuditLevel::INFO);
assert_eq!(AuditLevel::parse("UNKNOWN"), AuditLevel::INFO);
}
#[test]
fn test_audit_export_config_deserialization() {
let json = r#"{
"syslog": { "address": "syslog.internal", "port": 514, "protocol": "tcp" },
"webhook": { "url": "https://logs.example.com/ingest" }
}"#;
let config: AuditExportConfig =
serde_json::from_str(json).expect("should deserialize AuditExportConfig");
assert!(config.syslog.is_some());
assert!(config.webhook.is_some());
let syslog = config.syslog.expect("syslog should be Some");
assert_eq!(syslog.address, "syslog.internal");
assert_eq!(syslog.port, 514);
assert_eq!(syslog.protocol, "tcp");
let webhook = config.webhook.expect("webhook should be Some");
assert_eq!(webhook.url, "https://logs.example.com/ingest");
assert_eq!(webhook.batch_size, 100);
assert_eq!(webhook.flush_interval_secs, 30);
}
#[test]
fn test_audit_export_config_empty() {
let config: AuditExportConfig =
serde_json::from_str("{}").expect("should deserialize empty config");
assert!(config.syslog.is_none());
assert!(config.webhook.is_none());
}
#[test]
fn test_audit_error_export_variant() {
let err = AuditError::Export("connection refused".to_string());
assert!(err.to_string().contains("connection refused"));
}
}
#[cfg(feature = "audit-syslog")]
mod audit_export_syslog_tests {
#![allow(clippy::unwrap_used)]
use std::{io::Read, net::UdpSocket};
use chrono::Utc;
use parking_lot::Mutex;
use crate::security::{
audit::{AuditEntry, AuditExporter, AuditLevel, SyslogExportConfig},
audit_export_syslog::{SyslogAuditExporter, Transport, escape_sd_value},
};
fn test_entry() -> AuditEntry {
AuditEntry {
id: Some(1),
timestamp: Utc::now(),
level: AuditLevel::INFO,
user_id: 123,
tenant_id: 456,
operation: "query".to_string(),
query: "{ users { id name } }".to_string(),
variables: serde_json::json!({}),
ip_address: "192.168.1.1".to_string(),
user_agent: "Mozilla/5.0".to_string(),
error: None,
duration_ms: Some(42),
previous_hash: None,
integrity_hash: None,
}
}
#[test]
fn test_format_message_rfc5424() {
let exporter = SyslogAuditExporter {
transport: Mutex::new(Transport::Udp(UdpSocket::bind("0.0.0.0:0").unwrap())),
hostname: "test-host".to_string(),
};
let msg = exporter.format_message(&test_entry());
assert!(msg.starts_with("<86>1 "), "should start with priority 86, got: {msg}");
assert!(msg.contains("test-host"));
assert!(msg.contains("fraiseql"));
assert!(msg.contains("AUDIT"));
assert!(msg.contains(r#"user="123""#));
assert!(msg.contains(r#"tenant="456""#));
assert!(msg.contains(r#"operation="query""#));
assert!(msg.contains(r#"level="INFO""#));
assert!(msg.contains("{ users { id name } }"));
}
#[test]
fn test_format_message_with_error() {
let exporter = SyslogAuditExporter {
transport: Mutex::new(Transport::Udp(UdpSocket::bind("0.0.0.0:0").unwrap())),
hostname: "test-host".to_string(),
};
let mut entry = test_entry();
entry.level = AuditLevel::ERROR;
entry.error = Some("timeout".to_string());
let msg = exporter.format_message(&entry);
assert!(msg.starts_with("<83>1 "), "error priority should be 83");
assert!(msg.contains(r#"error="timeout""#));
assert!(msg.contains(r#"level="ERROR""#));
}
#[test]
fn test_severity_mapping() {
assert_eq!(SyslogAuditExporter::severity(AuditLevel::INFO), 6);
assert_eq!(SyslogAuditExporter::severity(AuditLevel::WARN), 4);
assert_eq!(SyslogAuditExporter::severity(AuditLevel::ERROR), 3);
}
#[test]
fn test_syslog_udp_export() {
let receiver = UdpSocket::bind("127.0.0.1:0").unwrap();
let recv_addr = receiver.local_addr().unwrap();
receiver.set_read_timeout(Some(std::time::Duration::from_secs(1))).unwrap();
let config = SyslogExportConfig {
address: "127.0.0.1".to_string(),
port: recv_addr.port(),
protocol: "udp".to_string(),
};
let exporter = SyslogAuditExporter::new(&config).unwrap();
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(exporter.export(&test_entry())).unwrap();
let mut buf = [0u8; 4096];
let n = receiver.recv(&mut buf).unwrap();
let received = std::str::from_utf8(&buf[..n]).unwrap();
assert!(received.contains("AUDIT"));
assert!(received.contains(r#"user="123""#));
}
#[test]
fn test_syslog_tcp_export() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let listen_addr = listener.local_addr().unwrap();
let config = SyslogExportConfig {
address: "127.0.0.1".to_string(),
port: listen_addr.port(),
protocol: "tcp".to_string(),
};
let exporter = SyslogAuditExporter::new(&config).unwrap();
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(exporter.export(&test_entry())).unwrap();
let (mut conn, _) = listener.accept().unwrap();
conn.set_read_timeout(Some(std::time::Duration::from_secs(1))).unwrap();
let mut buf = [0u8; 4096];
let n = conn.read(&mut buf).unwrap();
let received = std::str::from_utf8(&buf[..n]).unwrap();
assert!(received.contains("AUDIT"));
assert!(received.ends_with('\n'), "TCP syslog should end with newline");
}
#[test]
fn test_escape_sd_value() {
assert_eq!(escape_sd_value(r#"a"b"#), r#"a\"b"#);
assert_eq!(escape_sd_value(r"a\b"), r"a\\b");
assert_eq!(escape_sd_value("a]b"), r"a\]b");
}
#[test]
fn test_long_query_truncated() {
let exporter = SyslogAuditExporter {
transport: Mutex::new(Transport::Udp(UdpSocket::bind("0.0.0.0:0").unwrap())),
hostname: "test-host".to_string(),
};
let mut entry = test_entry();
entry.query = "x".repeat(500);
let msg = exporter.format_message(&entry);
assert!(msg.len() < 600, "message should be bounded");
assert!(msg.contains("..."));
}
}
#[cfg(feature = "audit-webhook")]
mod audit_export_webhook_tests {
#![allow(clippy::unwrap_used)]
use chrono::Utc;
use crate::security::{
audit::{AuditEntry, AuditExporter, AuditLevel, WebhookExportConfig},
audit_export_webhook::WebhookAuditExporter,
};
fn test_entry() -> AuditEntry {
AuditEntry {
id: Some(1),
timestamp: Utc::now(),
level: AuditLevel::INFO,
user_id: 123,
tenant_id: 456,
operation: "query".to_string(),
query: "{ users { id name } }".to_string(),
variables: serde_json::json!({}),
ip_address: "192.168.1.1".to_string(),
user_agent: "Mozilla/5.0".to_string(),
error: None,
duration_ms: Some(42),
previous_hash: None,
integrity_hash: None,
}
}
#[test]
fn test_buffer_accumulates_entries() {
let config = WebhookExportConfig {
url: "https://example.com/audit".to_string(),
headers: std::collections::HashMap::new(),
batch_size: 10,
flush_interval_secs: 30,
};
let exporter = WebhookAuditExporter::new(&config).unwrap();
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
for _ in 0..5 {
rt.block_on(exporter.export(&test_entry())).unwrap();
}
assert_eq!(exporter.buffer.lock().len(), 5);
}
#[test]
fn test_flush_empties_buffer() {
let config = WebhookExportConfig {
url: "https://example.com/audit".to_string(),
headers: std::collections::HashMap::new(),
batch_size: 100,
flush_interval_secs: 30,
};
let exporter = WebhookAuditExporter::new(&config).unwrap();
{
let mut buf = exporter.buffer.lock();
for _ in 0..5 {
buf.push(test_entry());
}
}
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
let _ = rt.block_on(exporter.flush());
assert_eq!(exporter.buffer.lock().len(), 0);
}
#[test]
fn test_config_defaults() {
let json = r#"{"url": "https://example.com/audit"}"#;
let config: WebhookExportConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.batch_size, 100);
assert_eq!(config.flush_interval_secs, 30);
assert!(config.headers.is_empty());
}
#[test]
fn test_syslog_config_defaults() {
use crate::security::audit::SyslogExportConfig;
let json = r#"{"address": "syslog.internal"}"#;
let config: SyslogExportConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.port, 514);
assert_eq!(config.protocol, "udp");
}
#[test]
fn test_export_config_optional_fields() {
use crate::security::audit::AuditExportConfig;
let json = "{}";
let config: AuditExportConfig = serde_json::from_str(json).unwrap();
assert!(config.syslog.is_none());
assert!(config.webhook.is_none());
}
}
mod errors_tests {
use crate::security::*;
#[test]
fn test_rate_limit_error_display() {
let err = SecurityError::RateLimitExceeded {
retry_after: 60,
limit: 100,
window_secs: 60,
};
assert!(err.to_string().contains("Rate limit exceeded"));
assert!(err.to_string().contains("100"));
assert!(err.to_string().contains("60"));
}
#[test]
fn test_query_too_deep_display() {
let err = SecurityError::QueryTooDeep {
depth: 20,
max_depth: 10,
};
assert!(err.to_string().contains("Query too deep"));
assert!(err.to_string().contains("20"));
assert!(err.to_string().contains("10"));
}
#[test]
fn test_query_too_complex_display() {
let err = SecurityError::QueryTooComplex {
complexity: 500,
max_complexity: 100,
};
assert!(err.to_string().contains("Query too complex"));
assert!(err.to_string().contains("500"));
assert!(err.to_string().contains("100"));
}
#[test]
fn test_query_too_large_display() {
let err = SecurityError::QueryTooLarge {
size: 100_000,
max_size: 10_000,
};
assert!(err.to_string().contains("Query too large"));
assert!(err.to_string().contains("100000"));
assert!(err.to_string().contains("10000"));
}
#[test]
fn test_cors_errors() {
let origin_err = SecurityError::OriginNotAllowed("https://evil.com".to_string());
assert!(origin_err.to_string().contains("CORS origin"));
let method_err = SecurityError::MethodNotAllowed("DELETE".to_string());
assert!(method_err.to_string().contains("CORS method"));
let header_err = SecurityError::HeaderNotAllowed("X-Custom".to_string());
assert!(header_err.to_string().contains("CORS header"));
}
#[test]
fn test_csrf_errors() {
let invalid = SecurityError::InvalidCSRFToken("expired".to_string());
assert!(invalid.to_string().contains("Invalid CSRF token"));
let mismatch = SecurityError::CSRFSessionMismatch;
assert!(mismatch.to_string().contains("session mismatch"));
}
#[test]
fn test_audit_error() {
let err = SecurityError::AuditLogFailure("connection timeout".to_string());
assert!(err.to_string().contains("Audit logging failed"));
}
#[test]
fn test_config_error() {
let err = SecurityError::SecurityConfigError("missing config key".to_string());
assert!(err.to_string().contains("Security configuration error"));
}
#[test]
fn test_error_equality() {
let err1 = SecurityError::QueryTooDeep {
depth: 20,
max_depth: 10,
};
let err2 = SecurityError::QueryTooDeep {
depth: 20,
max_depth: 10,
};
assert_eq!(err1, err2);
let err3 = SecurityError::QueryTooDeep {
depth: 30,
max_depth: 10,
};
assert_ne!(err1, err3);
}
#[test]
fn test_rate_limit_equality() {
let err1 = SecurityError::RateLimitExceeded {
retry_after: 60,
limit: 100,
window_secs: 60,
};
let err2 = SecurityError::RateLimitExceeded {
retry_after: 60,
limit: 100,
window_secs: 60,
};
assert_eq!(err1, err2);
}
#[test]
fn test_auth_required_error_display() {
let err = SecurityError::AuthRequired;
assert!(err.to_string().contains("Authentication required"));
}
#[test]
fn test_invalid_token_error_display() {
let err = SecurityError::InvalidToken;
assert!(err.to_string().contains("Invalid authentication token"));
}
#[test]
fn test_token_expired_error_display() {
use chrono::{Duration, Utc};
let expired_at = Utc::now() - Duration::hours(1);
let err = SecurityError::TokenExpired { expired_at };
assert!(err.to_string().contains("Token expired"));
}
#[test]
fn test_token_missing_claim_error_display() {
let err = SecurityError::TokenMissingClaim {
claim: "sub".to_string(),
};
assert!(err.to_string().contains("Token missing required claim"));
assert!(err.to_string().contains("sub"));
}
#[test]
fn test_invalid_token_algorithm_error_display() {
let err = SecurityError::InvalidTokenAlgorithm {
algorithm: "HS256".to_string(),
};
assert!(err.to_string().contains("Invalid token algorithm"));
assert!(err.to_string().contains("HS256"));
}
#[test]
fn test_introspection_disabled_error_display() {
let err = SecurityError::IntrospectionDisabled {
detail: "Introspection not allowed in production".to_string(),
};
assert!(err.to_string().contains("Introspection disabled"));
assert!(err.to_string().contains("production"));
}
#[test]
fn test_invalid_token_equality() {
assert_eq!(SecurityError::InvalidToken, SecurityError::InvalidToken);
}
#[test]
fn test_auth_required_equality() {
assert_eq!(SecurityError::AuthRequired, SecurityError::AuthRequired);
}
}
mod field_filter_tests {
#![allow(clippy::unwrap_used)] use crate::security::*;
#[test]
fn test_empty_config_allows_all() {
let filter = FieldFilter::permissive();
let scopes: Vec<String> = vec![];
filter
.can_access("User", "name", &scopes)
.unwrap_or_else(|e| panic!("expected access to User.name: {e}"));
filter
.can_access("User", "email", &scopes)
.unwrap_or_else(|e| panic!("expected access to User.email: {e}"));
filter
.can_access("User", "salary", &scopes)
.unwrap_or_else(|e| panic!("expected access to User.salary: {e}"));
}
#[test]
fn test_protect_single_field() {
let config = FieldFilterConfig::new().protect_field("User", "salary");
let filter = FieldFilter::new(config);
let no_scopes: Vec<String> = vec![];
filter
.can_access("User", "name", &no_scopes)
.unwrap_or_else(|e| panic!("expected access to User.name: {e}"));
filter
.can_access("User", "email", &no_scopes)
.unwrap_or_else(|e| panic!("expected access to User.email: {e}"));
let result = filter.can_access("User", "salary", &no_scopes);
assert_eq!(result.unwrap_err(), FieldAccessError::new("User", "salary"));
}
#[test]
fn test_protect_multiple_fields() {
let config = FieldFilterConfig::new().protect_fields("User", &["salary", "ssn", "bonus"]);
let filter = FieldFilter::new(config);
let no_scopes: Vec<String> = vec![];
filter
.can_access("User", "name", &no_scopes)
.unwrap_or_else(|e| panic!("expected access to User.name: {e}"));
assert_eq!(
filter.can_access("User", "salary", &no_scopes).unwrap_err(),
FieldAccessError::new("User", "salary")
);
assert_eq!(
filter.can_access("User", "ssn", &no_scopes).unwrap_err(),
FieldAccessError::new("User", "ssn")
);
assert_eq!(
filter.can_access("User", "bonus", &no_scopes).unwrap_err(),
FieldAccessError::new("User", "bonus")
);
}
#[test]
fn test_protect_entire_type() {
let config = FieldFilterConfig::new().protect_type("Secret");
let filter = FieldFilter::new(config);
let no_scopes: Vec<String> = vec![];
assert_eq!(
filter.can_access("Secret", "anything", &no_scopes).unwrap_err(),
FieldAccessError::new("Secret", "anything")
);
assert_eq!(
filter.can_access("Secret", "data", &no_scopes).unwrap_err(),
FieldAccessError::new("Secret", "data")
);
filter
.can_access("User", "name", &no_scopes)
.unwrap_or_else(|e| panic!("expected access to User.name: {e}"));
}
#[test]
fn test_exact_scope_match() {
let config = FieldFilterConfig::new().protect_field("User", "salary");
let filter = FieldFilter::new(config);
let scopes = vec!["read:User.salary".to_string()];
filter
.can_access("User", "salary", &scopes)
.unwrap_or_else(|e| panic!("expected exact scope match: {e}"));
}
#[test]
fn test_type_wildcard_scope() {
let config = FieldFilterConfig::new()
.protect_field("User", "salary")
.protect_field("User", "ssn");
let filter = FieldFilter::new(config);
let scopes = vec!["read:User.*".to_string()];
filter
.can_access("User", "salary", &scopes)
.unwrap_or_else(|e| panic!("expected type wildcard to grant access to salary: {e}"));
filter
.can_access("User", "ssn", &scopes)
.unwrap_or_else(|e| panic!("expected type wildcard to grant access to ssn: {e}"));
}
#[test]
fn test_global_wildcard_scope() {
let config = FieldFilterConfig::new()
.protect_field("User", "salary")
.protect_field("Employee", "compensation");
let filter = FieldFilter::new(config);
let scopes = vec!["read:*".to_string()];
filter.can_access("User", "salary", &scopes).unwrap_or_else(|e| {
panic!("expected global wildcard to grant access to User.salary: {e}")
});
filter.can_access("Employee", "compensation", &scopes).unwrap_or_else(|e| {
panic!("expected global wildcard to grant access to Employee.compensation: {e}")
});
}
#[test]
fn test_admin_scope_bypasses_all() {
let config = FieldFilterConfig::new()
.protect_field("User", "salary")
.protect_field("User", "ssn")
.protect_type("Secret");
let filter = FieldFilter::new(config);
let scopes = vec!["admin".to_string()];
filter
.can_access("User", "salary", &scopes)
.unwrap_or_else(|e| panic!("expected admin to bypass User.salary: {e}"));
filter
.can_access("User", "ssn", &scopes)
.unwrap_or_else(|e| panic!("expected admin to bypass User.ssn: {e}"));
filter
.can_access("Secret", "data", &scopes)
.unwrap_or_else(|e| panic!("expected admin to bypass Secret.data: {e}"));
}
#[test]
fn test_custom_admin_scope() {
let config = FieldFilterConfig::new()
.protect_field("User", "salary")
.add_admin_scope("superuser");
let filter = FieldFilter::new(config);
let scopes = vec!["superuser".to_string()];
filter
.can_access("User", "salary", &scopes)
.unwrap_or_else(|e| panic!("expected custom admin scope to bypass: {e}"));
}
#[test]
fn test_wrong_scope_denied() {
let config = FieldFilterConfig::new().protect_field("User", "salary");
let filter = FieldFilter::new(config);
let scopes = vec!["read:Employee.salary".to_string()];
assert_eq!(
filter.can_access("User", "salary", &scopes).unwrap_err(),
FieldAccessError::new("User", "salary")
);
let scopes = vec!["read:User.name".to_string()];
assert_eq!(
filter.can_access("User", "salary", &scopes).unwrap_err(),
FieldAccessError::new("User", "salary")
);
let scopes = vec!["write:User.salary".to_string()];
assert_eq!(
filter.can_access("User", "salary", &scopes).unwrap_err(),
FieldAccessError::new("User", "salary")
);
}
#[test]
fn test_explicit_scope_requirement() {
let config = FieldFilterConfig::new().protect_field("User", "salary").require_scope(
"User",
"salary",
"hr:view_compensation",
);
let filter = FieldFilter::new(config);
let wrong_scope = vec!["read:User.salary".to_string()];
assert_eq!(
filter.can_access("User", "salary", &wrong_scope).unwrap_err(),
FieldAccessError::new("User", "salary")
);
let right_scope = vec!["hr:view_compensation".to_string()];
filter
.can_access("User", "salary", &right_scope)
.unwrap_or_else(|e| panic!("expected explicit scope to grant access: {e}"));
}
#[test]
fn test_admin_still_bypasses_explicit() {
let config = FieldFilterConfig::new().protect_field("User", "salary").require_scope(
"User",
"salary",
"hr:view_compensation",
);
let filter = FieldFilter::new(config);
let admin_scope = vec!["admin".to_string()];
filter
.can_access("User", "salary", &admin_scope)
.unwrap_or_else(|e| panic!("expected admin to bypass explicit scope: {e}"));
}
#[test]
fn test_error_contains_field_info() {
let config = FieldFilterConfig::new().protect_field("User", "salary");
let filter = FieldFilter::new(config);
let no_scopes: Vec<String> = vec![];
let err = filter.can_access("User", "salary", &no_scopes).unwrap_err();
assert_eq!(err.type_name, "User");
assert_eq!(err.field_name, "salary");
assert!(err.message.contains("salary"));
assert!(err.message.contains("User"));
}
#[test]
fn test_error_display() {
let err = FieldAccessError::new("User", "salary");
let display = err.to_string();
assert!(display.contains("Access denied"));
assert!(display.contains("salary"));
assert!(display.contains("User"));
}
#[test]
fn test_validate_multiple_fields() {
let config = FieldFilterConfig::new()
.protect_field("User", "salary")
.protect_field("User", "ssn");
let filter = FieldFilter::new(config);
let fields = ["name", "email", "salary", "ssn"];
let no_scopes: Vec<String> = vec![];
let errors = filter.validate_fields("User", &fields, &no_scopes);
assert_eq!(errors.len(), 2);
let error_fields: Vec<&str> = errors.iter().map(|e| e.field_name.as_str()).collect();
assert!(error_fields.contains(&"salary"));
assert!(error_fields.contains(&"ssn"));
}
#[test]
fn test_validate_all_allowed() {
let config = FieldFilterConfig::new().protect_field("User", "salary");
let filter = FieldFilter::new(config);
let fields = ["name", "email", "salary"];
let scopes = vec!["read:User.salary".to_string()];
let errors = filter.validate_fields("User", &fields, &scopes);
assert!(errors.is_empty(), "expected no validation errors, got: {errors:?}");
}
#[test]
fn test_builder_basic() {
let mut builder = FieldFilterBuilder::new();
builder.add_protected_field("User", "salary");
builder.add_protected_field("User", "ssn");
let filter = builder.build();
let no_scopes: Vec<String> = vec![];
assert_eq!(
filter.can_access("User", "salary", &no_scopes).unwrap_err(),
FieldAccessError::new("User", "salary")
);
assert_eq!(
filter.can_access("User", "ssn", &no_scopes).unwrap_err(),
FieldAccessError::new("User", "ssn")
);
filter
.can_access("User", "name", &no_scopes)
.unwrap_or_else(|e| panic!("expected access to unprotected User.name: {e}"));
}
#[test]
fn test_builder_with_explicit_scopes() {
let mut builder = FieldFilterBuilder::new();
builder.add_scope_requirement("User", "salary", "hr:compensation");
let filter = builder.build();
let wrong = vec!["read:User.salary".to_string()];
let right = vec!["hr:compensation".to_string()];
assert_eq!(
filter.can_access("User", "salary", &wrong).unwrap_err(),
FieldAccessError::new("User", "salary")
);
filter
.can_access("User", "salary", &right)
.unwrap_or_else(|e| panic!("expected explicit scope to grant access: {e}"));
}
#[test]
fn test_builder_custom_admin_scopes() {
let mut builder = FieldFilterBuilder::new();
builder.add_protected_field("User", "salary");
builder.set_admin_scopes(vec!["root".to_string(), "superadmin".to_string()]);
let filter = builder.build();
let admin = vec!["admin".to_string()];
assert_eq!(
filter.can_access("User", "salary", &admin).unwrap_err(),
FieldAccessError::new("User", "salary")
);
let root = vec!["root".to_string()];
filter
.can_access("User", "salary", &root)
.unwrap_or_else(|e| panic!("expected root admin scope to bypass: {e}"));
let superadmin = vec!["superadmin".to_string()];
filter
.can_access("User", "salary", &superadmin)
.unwrap_or_else(|e| panic!("expected superadmin scope to bypass: {e}"));
}
#[test]
fn test_is_protected() {
let config =
FieldFilterConfig::new().protect_field("User", "salary").protect_type("Secret");
assert!(config.is_protected("User", "salary"));
assert!(!config.is_protected("User", "name"));
assert!(config.is_protected("Secret", "anything"));
assert!(!config.is_protected("Public", "data"));
}
#[test]
fn test_config_default_action() {
let config = FieldFilterConfig::new()
.with_default_action("view")
.protect_field("User", "salary");
let filter = FieldFilter::new(config);
let read_scope = vec!["read:User.salary".to_string()];
assert_eq!(
filter.can_access("User", "salary", &read_scope).unwrap_err(),
FieldAccessError::new("User", "salary")
);
let view_scope = vec!["view:User.salary".to_string()];
filter
.can_access("User", "salary", &view_scope)
.unwrap_or_else(|e| panic!("expected view action to grant access: {e}"));
}
#[test]
fn test_empty_scopes() {
let config = FieldFilterConfig::new().protect_field("User", "salary");
let filter = FieldFilter::new(config);
let empty: Vec<String> = vec![];
assert_eq!(
filter.can_access("User", "salary", &empty).unwrap_err(),
FieldAccessError::new("User", "salary")
);
}
#[test]
fn test_multiple_scopes_one_match() {
let config = FieldFilterConfig::new().protect_field("User", "salary");
let filter = FieldFilter::new(config);
let scopes = vec![
"read:Product.*".to_string(),
"write:Order.status".to_string(),
"read:User.salary".to_string(), "other:scope".to_string(),
];
filter
.can_access("User", "salary", &scopes)
.unwrap_or_else(|e| panic!("expected one matching scope to suffice: {e}"));
}
#[test]
fn test_case_sensitive_scopes() {
let config = FieldFilterConfig::new().protect_field("User", "salary");
let filter = FieldFilter::new(config);
let wrong_case = vec!["READ:User.salary".to_string()];
assert_eq!(
filter.can_access("User", "salary", &wrong_case).unwrap_err(),
FieldAccessError::new("User", "salary")
);
let wrong_type_case = vec!["read:user.salary".to_string()];
assert_eq!(
filter.can_access("User", "salary", &wrong_type_case).unwrap_err(),
FieldAccessError::new("User", "salary")
);
}
#[test]
fn test_special_characters_in_names() {
let config =
FieldFilterConfig::new().protect_field("UserProfile", "social_security_number");
let filter = FieldFilter::new(config);
let scopes = vec!["read:UserProfile.social_security_number".to_string()];
filter
.can_access("UserProfile", "social_security_number", &scopes)
.unwrap_or_else(|e| panic!("expected access with special characters in names: {e}"));
}
}
mod field_masking_tests {
use crate::security::*;
#[test]
fn test_id_is_public() {
assert_eq!(FieldMasker::detect_sensitivity("id"), FieldSensitivity::Public);
}
#[test]
fn test_name_is_public() {
assert_eq!(FieldMasker::detect_sensitivity("name"), FieldSensitivity::Public);
}
#[test]
fn test_title_is_public() {
assert_eq!(FieldMasker::detect_sensitivity("title"), FieldSensitivity::Public);
}
#[test]
fn test_description_is_public() {
assert_eq!(FieldMasker::detect_sensitivity("description"), FieldSensitivity::Public);
}
#[test]
fn test_created_at_is_public() {
assert_eq!(FieldMasker::detect_sensitivity("created_at"), FieldSensitivity::Public);
}
#[test]
fn test_email_is_sensitive() {
assert_eq!(FieldMasker::detect_sensitivity("email"), FieldSensitivity::Sensitive);
}
#[test]
fn test_email_address_is_sensitive() {
assert_eq!(FieldMasker::detect_sensitivity("email_address"), FieldSensitivity::Sensitive);
}
#[test]
fn test_user_email_is_sensitive() {
assert_eq!(FieldMasker::detect_sensitivity("user_email"), FieldSensitivity::Sensitive);
}
#[test]
fn test_phone_is_sensitive() {
assert_eq!(FieldMasker::detect_sensitivity("phone"), FieldSensitivity::Sensitive);
}
#[test]
fn test_phone_number_is_sensitive() {
assert_eq!(FieldMasker::detect_sensitivity("phone_number"), FieldSensitivity::Sensitive);
}
#[test]
fn test_mobile_is_sensitive() {
assert_eq!(FieldMasker::detect_sensitivity("mobile"), FieldSensitivity::Sensitive);
}
#[test]
fn test_mobile_phone_is_sensitive() {
assert_eq!(FieldMasker::detect_sensitivity("mobile_phone"), FieldSensitivity::Sensitive);
}
#[test]
fn test_ip_address_is_sensitive() {
assert_eq!(FieldMasker::detect_sensitivity("ip_address"), FieldSensitivity::Sensitive);
}
#[test]
fn test_mac_address_is_sensitive() {
assert_eq!(FieldMasker::detect_sensitivity("mac_address"), FieldSensitivity::Sensitive);
}
#[test]
fn test_ssn_is_pii() {
assert_eq!(FieldMasker::detect_sensitivity("ssn"), FieldSensitivity::PII);
}
#[test]
fn test_social_security_is_pii() {
assert_eq!(
FieldMasker::detect_sensitivity("social_security_number"),
FieldSensitivity::PII
);
}
#[test]
fn test_credit_card_is_pii() {
assert_eq!(FieldMasker::detect_sensitivity("credit_card"), FieldSensitivity::PII);
}
#[test]
fn test_card_number_is_pii() {
assert_eq!(FieldMasker::detect_sensitivity("card_number"), FieldSensitivity::PII);
}
#[test]
fn test_cvv_is_pii() {
assert_eq!(FieldMasker::detect_sensitivity("cvv"), FieldSensitivity::PII);
}
#[test]
fn test_cvc_is_pii() {
assert_eq!(FieldMasker::detect_sensitivity("cvc"), FieldSensitivity::PII);
}
#[test]
fn test_bank_account_is_pii() {
assert_eq!(FieldMasker::detect_sensitivity("bank_account"), FieldSensitivity::PII);
}
#[test]
fn test_pin_is_pii() {
assert_eq!(FieldMasker::detect_sensitivity("pin"), FieldSensitivity::PII);
}
#[test]
fn test_driver_license_is_pii() {
assert_eq!(FieldMasker::detect_sensitivity("driver_license"), FieldSensitivity::PII);
}
#[test]
fn test_passport_is_pii() {
assert_eq!(FieldMasker::detect_sensitivity("passport"), FieldSensitivity::PII);
}
#[test]
fn test_password_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("password"), FieldSensitivity::Secret);
}
#[test]
fn test_password_hash_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("password_hash"), FieldSensitivity::Secret);
}
#[test]
fn test_secret_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("secret"), FieldSensitivity::Secret);
}
#[test]
fn test_secret_key_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("secret_key"), FieldSensitivity::Secret);
}
#[test]
fn test_token_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("token"), FieldSensitivity::Secret);
}
#[test]
fn test_refresh_token_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("refresh_token"), FieldSensitivity::Secret);
}
#[test]
fn test_api_key_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("api_key"), FieldSensitivity::Secret);
}
#[test]
fn test_auth_token_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("auth_token"), FieldSensitivity::Secret);
}
#[test]
fn test_jwt_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("jwt"), FieldSensitivity::Secret);
}
#[test]
fn test_jwt_prefixed_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("jwt_token"), FieldSensitivity::Secret);
}
#[test]
fn test_id_jwt_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("id_jwt"), FieldSensitivity::Secret);
}
#[test]
fn test_nonce_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("nonce"), FieldSensitivity::Secret);
}
#[test]
fn test_nonce_value_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("nonce_value"), FieldSensitivity::Secret);
}
#[test]
fn test_bearer_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("bearer"), FieldSensitivity::Secret);
}
#[test]
fn test_client_secret_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("client_secret"), FieldSensitivity::Secret);
}
#[test]
fn test_oauth_client_secret_is_secret() {
assert_eq!(
FieldMasker::detect_sensitivity("oauth_client_secret"),
FieldSensitivity::Secret
);
}
#[test]
fn test_hash_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("hash"), FieldSensitivity::Secret);
}
#[test]
fn test_signature_is_secret() {
assert_eq!(FieldMasker::detect_sensitivity("signature"), FieldSensitivity::Secret);
}
#[test]
fn test_case_insensitive_email() {
assert_eq!(FieldMasker::detect_sensitivity("EMAIL"), FieldSensitivity::Sensitive);
}
#[test]
fn test_case_insensitive_password() {
assert_eq!(FieldMasker::detect_sensitivity("PASSWORD"), FieldSensitivity::Secret);
}
#[test]
fn test_mixed_case_ssn() {
assert_eq!(FieldMasker::detect_sensitivity("SSN"), FieldSensitivity::PII);
}
#[test]
fn test_public_value_unmasked() {
let result = FieldMasker::mask_value("value", FieldSensitivity::Public);
assert_eq!(result, "value");
}
#[test]
fn test_public_empty_string_unmasked() {
let result = FieldMasker::mask_value("", FieldSensitivity::Public);
assert_eq!(result, "");
}
#[test]
fn test_sensitive_email_masked() {
let result = FieldMasker::mask_value("user@example.com", FieldSensitivity::Sensitive);
assert_eq!(result, "u***");
}
#[test]
fn test_sensitive_phone_masked() {
let result = FieldMasker::mask_value("555-1234", FieldSensitivity::Sensitive);
assert_eq!(result, "5***");
}
#[test]
fn test_sensitive_single_char_masked() {
let result = FieldMasker::mask_value("a", FieldSensitivity::Sensitive);
assert_eq!(result, "a***");
}
#[test]
fn test_sensitive_empty_masked() {
let result = FieldMasker::mask_value("", FieldSensitivity::Sensitive);
assert_eq!(result, "***");
}
#[test]
fn test_pii_ssn_masked() {
let result = FieldMasker::mask_value("123-45-6789", FieldSensitivity::PII);
assert_eq!(result, "[PII]");
}
#[test]
fn test_pii_credit_card_masked() {
let result = FieldMasker::mask_value("4111-1111-1111-1111", FieldSensitivity::PII);
assert_eq!(result, "[PII]");
}
#[test]
fn test_pii_empty_masked() {
let result = FieldMasker::mask_value("", FieldSensitivity::PII);
assert_eq!(result, "[PII]");
}
#[test]
fn test_secret_password_masked() {
let result = FieldMasker::mask_value("mypassword123", FieldSensitivity::Secret);
assert_eq!(result, "****");
}
#[test]
fn test_secret_token_masked() {
let result = FieldMasker::mask_value("token_abc123xyz", FieldSensitivity::Secret);
assert_eq!(result, "****");
}
#[test]
fn test_secret_empty_masked() {
let result = FieldMasker::mask_value("", FieldSensitivity::Secret);
assert_eq!(result, "****");
}
#[test]
fn test_secret_any_value_masked() {
let result = FieldMasker::mask_value("anything", FieldSensitivity::Secret);
assert_eq!(result, "****");
}
#[test]
fn test_standard_profile_no_masking() {
assert!(!FieldMasker::should_mask(FieldSensitivity::Public, false));
assert!(!FieldMasker::should_mask(FieldSensitivity::Sensitive, false));
assert!(!FieldMasker::should_mask(FieldSensitivity::PII, false));
assert!(!FieldMasker::should_mask(FieldSensitivity::Secret, false));
}
#[test]
fn test_regulated_profile_public_no_masking() {
assert!(!FieldMasker::should_mask(FieldSensitivity::Public, true));
}
#[test]
fn test_regulated_profile_sensitive_masked() {
assert!(FieldMasker::should_mask(FieldSensitivity::Sensitive, true));
}
#[test]
fn test_regulated_profile_pii_masked() {
assert!(FieldMasker::should_mask(FieldSensitivity::PII, true));
}
#[test]
fn test_regulated_profile_secret_masked() {
assert!(FieldMasker::should_mask(FieldSensitivity::Secret, true));
}
#[test]
fn test_very_long_email_masked() {
let long_email = "a".repeat(1000) + "@example.com";
let result = FieldMasker::mask_value(&long_email, FieldSensitivity::Sensitive);
assert_eq!(result, "a***");
assert!(result.len() < long_email.len());
}
#[test]
fn test_unicode_email_masked() {
let result = FieldMasker::mask_value("émail@example.com", FieldSensitivity::Sensitive);
assert_eq!(result, "é***");
}
#[test]
fn test_sensitivity_display() {
assert_eq!(FieldSensitivity::Public.to_string(), "public");
assert_eq!(FieldSensitivity::Sensitive.to_string(), "sensitive");
assert_eq!(FieldSensitivity::PII.to_string(), "pii");
assert_eq!(FieldSensitivity::Secret.to_string(), "secret");
}
}
mod introspection_enforcer_tests {
use crate::security::{introspection_enforcer::IntrospectionConfig, *};
fn introspection_schema_query() -> &'static str {
"{ __schema { types { name } } }"
}
fn introspection_type_query() -> &'static str {
"{ __type(name: \"User\") { name fields { name } } }"
}
fn normal_query() -> &'static str {
"{ user { id name email } }"
}
#[test]
fn test_detect_schema_introspection() {
let enforcer = IntrospectionEnforcer::allowed();
let is_introspection = enforcer.is_introspection_query(introspection_schema_query());
assert!(is_introspection);
}
#[test]
fn test_detect_type_introspection() {
let enforcer = IntrospectionEnforcer::allowed();
let is_introspection = enforcer.is_introspection_query(introspection_type_query());
assert!(is_introspection);
}
#[test]
fn test_normal_query_not_introspection() {
let enforcer = IntrospectionEnforcer::allowed();
let is_introspection = enforcer.is_introspection_query(normal_query());
assert!(!is_introspection);
}
#[test]
fn test_detection_is_case_sensitive() {
let enforcer = IntrospectionEnforcer::allowed();
let uppercase_query = "{ __SCHEMA { types { name } } }";
let is_introspection = enforcer.is_introspection_query(uppercase_query);
assert!(!is_introspection);
}
#[test]
fn test_internal_only_allows_authenticated_user() {
let enforcer = IntrospectionEnforcer::internal_only();
enforcer
.validate_query(introspection_schema_query(), Some("user123"))
.unwrap_or_else(|e| panic!("expected authenticated user to be allowed: {e}"));
}
#[test]
fn test_internal_only_rejects_anonymous_user() {
let enforcer = IntrospectionEnforcer::internal_only();
let result = enforcer.validate_query(introspection_schema_query(), None);
assert!(matches!(result, Err(SecurityError::IntrospectionDisabled { .. })));
}
#[test]
fn test_allowed_policy_permits_introspection() {
let enforcer = IntrospectionEnforcer::allowed();
enforcer
.validate_query(introspection_schema_query(), None)
.unwrap_or_else(|e| panic!("expected Allowed policy to permit introspection: {e}"));
}
#[test]
fn test_allowed_policy_permits_anonymous_introspection() {
let enforcer = IntrospectionEnforcer::allowed();
enforcer.validate_query(introspection_schema_query(), None).unwrap_or_else(|e| {
panic!("expected Allowed policy to permit anonymous introspection: {e}")
});
}
#[test]
fn test_disabled_policy_blocks_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
let result = enforcer.validate_query(introspection_schema_query(), None);
assert!(matches!(result, Err(SecurityError::IntrospectionDisabled { .. })));
}
#[test]
fn test_disabled_policy_blocks_authenticated_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
let result = enforcer.validate_query(introspection_schema_query(), Some("user123"));
assert!(matches!(result, Err(SecurityError::IntrospectionDisabled { .. })));
}
#[test]
fn test_policy_allows_normal_queries_always() {
let disabled_enforcer = IntrospectionEnforcer::disabled();
disabled_enforcer.validate_query(normal_query(), None).unwrap_or_else(|e| {
panic!("expected normal queries to pass even with Disabled policy: {e}")
});
}
#[test]
fn test_introspection_config_all() {
let config = IntrospectionConfig::all();
assert!(config.detect_schema);
assert!(config.detect_type);
}
#[test]
fn test_introspection_config_strict_coincides_with_all() {
let config = IntrospectionConfig::strict();
assert!(config.detect_schema);
assert!(config.detect_type);
}
#[test]
fn test_policy_display() {
assert_eq!(IntrospectionPolicy::Allowed.to_string(), "Allowed");
assert_eq!(IntrospectionPolicy::Disabled.to_string(), "Disabled");
assert_eq!(IntrospectionPolicy::InternalOnly.to_string(), "InternalOnly");
}
#[test]
fn test_policy_from_config_truth_table() {
assert_eq!(IntrospectionPolicy::from_config(false, true), IntrospectionPolicy::Disabled);
assert_eq!(IntrospectionPolicy::from_config(false, false), IntrospectionPolicy::Disabled);
assert_eq!(IntrospectionPolicy::from_config(true, true), IntrospectionPolicy::InternalOnly);
assert_eq!(IntrospectionPolicy::from_config(true, false), IntrospectionPolicy::Allowed);
}
#[test]
fn test_enforcer_helpers() {
let allowed = IntrospectionEnforcer::allowed();
assert_eq!(allowed.policy(), IntrospectionPolicy::Allowed);
let disabled = IntrospectionEnforcer::disabled();
assert_eq!(disabled.policy(), IntrospectionPolicy::Disabled);
let internal = IntrospectionEnforcer::internal_only();
assert_eq!(internal.policy(), IntrospectionPolicy::InternalOnly);
}
#[test]
fn test_custom_config_with_selective_detection() {
let config = IntrospectionConfig {
detect_schema: true,
detect_type: false,
};
let enforcer = IntrospectionEnforcer::with_config(IntrospectionPolicy::Disabled, config);
let schema_query = "{ __schema { types { name } } }";
assert!(matches!(
enforcer.validate_query(schema_query, None),
Err(SecurityError::IntrospectionDisabled { .. })
));
let type_query = "{ __type(name: \"User\") { name } }";
enforcer.validate_query(type_query, None).unwrap_or_else(|e| {
panic!("expected __type to pass through when detect_type=false: {e}")
});
}
#[test]
fn test_introspection_in_string_literal_not_detected() {
let enforcer = IntrospectionEnforcer::disabled();
let query = r#"{ user(filter: "__schema") { name } }"#;
let is_introspection = enforcer.is_introspection_query(query);
assert!(!is_introspection);
}
#[test]
fn test_empty_query_not_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
enforcer.validate_query("", None).unwrap_or_else(|e| {
panic!("expected empty query to be allowed (not introspection): {e}")
});
}
#[test]
fn test_whitespace_handled_correctly() {
let enforcer = IntrospectionEnforcer::allowed();
let query = "{\n __schema {\n types { name }\n }\n}";
let is_introspection = enforcer.is_introspection_query(query);
assert!(is_introspection);
}
#[test]
fn test_multiple_introspection_patterns() {
let enforcer = IntrospectionEnforcer::allowed();
let query = "{ __schema { types { name } } __type(name: \"Query\") { name } }";
let is_introspection = enforcer.is_introspection_query(query);
assert!(is_introspection);
}
#[test]
fn test_root_typename_is_not_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
assert!(!enforcer.is_introspection_query("{ __typename }"));
enforcer
.validate_query("{ __typename }", None)
.unwrap_or_else(|e| panic!("__typename must never be blocked: {e}"));
}
#[test]
fn test_nested_typename_is_not_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
assert!(!enforcer.is_introspection_query("{ user { __typename } }"));
}
#[test]
fn introspection_in_any_operation_is_detected() {
let enforcer = IntrospectionEnforcer::disabled();
let decoy = "query Decoy { users { id } } query Peek { __schema { types { name } } }";
assert!(
enforcer.is_introspection_query(decoy),
"an introspection operation anywhere in the document is introspection"
);
enforcer
.validate_query(decoy, None)
.expect_err("a disabled policy must refuse the document that carries `Peek`");
}
#[test]
fn a_multi_operation_document_without_introspection_is_allowed() {
let enforcer = IntrospectionEnforcer::disabled();
let doc = "query A { users { id } } query B { orders { id } }";
assert!(!enforcer.is_introspection_query(doc));
enforcer.validate_query(doc, None).expect("no introspection anywhere — allowed");
}
#[test]
fn test_aliased_typename_is_not_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
assert!(!enforcer.is_introspection_query("{ x: __typename }"));
}
#[test]
fn test_schema_in_string_argument_is_not_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
assert!(!enforcer.is_introspection_query(r#"{ search(q: "__schema") }"#));
}
#[test]
fn test_type_in_comment_is_not_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
let query = "query Foo { a: user { name } } # mentions __type in a comment";
assert!(!enforcer.is_introspection_query(query));
}
#[test]
fn test_aliased_normal_field_is_not_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
assert!(!enforcer.is_introspection_query("{ alias: users { id } }"));
}
#[test]
fn test_aliased_schema_is_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
assert!(enforcer.is_introspection_query("{ foo: __schema { queryType { name } } }"));
}
#[test]
fn test_multi_root_schema_is_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
let query = "{ users { id } __schema { types { name } } }";
assert!(enforcer.is_introspection_query(query));
}
#[test]
fn test_type_field_query_is_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
assert!(enforcer.is_introspection_query(r#"{ __type(name: "X") { name } }"#));
}
#[test]
fn test_malformed_query_is_not_introspection() {
let enforcer = IntrospectionEnforcer::disabled();
assert!(!enforcer.is_introspection_query("{ this is not valid graphql"));
}
}
mod query_validator_tests {
#![allow(clippy::unwrap_used)] use crate::security::*;
fn large_query(size: usize) -> String {
"{ ".to_string() + "field ".repeat(size).as_str() + "}"
}
#[test]
fn test_query_size_within_limit() {
let validator = QueryValidator::standard();
validator
.validate("{ user { id name } }")
.unwrap_or_else(|e| panic!("expected Ok for small query: {e}"));
}
#[test]
fn test_query_size_exceeds_limit() {
let validator = QueryValidator::standard();
let q = large_query(100_000);
let result = validator.validate(&q);
assert!(matches!(result, Err(SecurityError::QueryTooLarge { .. })));
}
#[test]
fn test_malformed_query_returns_error() {
let validator = QueryValidator::standard();
let result = validator.validate("this is not graphql {{{}}}");
assert!(
matches!(result, Err(SecurityError::MalformedQuery(_))),
"malformed query must return MalformedQuery error, got {result:?}"
);
}
#[test]
fn test_valid_query_depth() {
let validator = QueryValidator::standard();
let metrics = validator
.validate("{ user { id name } }")
.unwrap_or_else(|e| panic!("expected Ok for shallow query: {e}"));
assert!(metrics.depth <= validator.config().max_depth);
}
#[test]
fn test_query_depth_exceeds_limit() {
let validator = QueryValidator::strict(); let deep = "{ a { b { c { d { e { f { g } } } } } } }";
let result = validator.validate(deep);
assert!(
matches!(result, Err(SecurityError::QueryTooDeep { .. })),
"depth-7 query must be rejected with strict (max=5), got {result:?}"
);
}
#[test]
fn test_very_deep_query_rejected() {
let validator = QueryValidator::strict(); let deep = "{ a { b { c { d { e { f { g { h } } } } } } } }";
let result = validator.validate(deep);
assert!(
matches!(result, Err(SecurityError::QueryTooDeep { .. })),
"depth-8 query must be rejected, got {result:?}"
);
}
#[test]
fn test_valid_query_complexity() {
let validator = QueryValidator::standard();
let metrics = validator
.validate("{ user { id name } }")
.unwrap_or_else(|e| panic!("expected Ok for simple query: {e}"));
assert!(metrics.complexity <= validator.config().max_complexity);
}
#[test]
fn test_complexity_calculated() {
let validator = QueryValidator::standard();
let metrics = validator.validate("{ user { id } }").unwrap();
assert!(metrics.complexity > 0);
}
#[test]
fn test_alias_amplification_rejected() {
let validator = QueryValidator::standard(); let aliases: String =
(0..31).map(|i| ["a", &i.to_string(), ": user { id } "].concat()).collect();
let query = format!("{{ {aliases} }}");
let result = validator.validate(&query);
assert!(
matches!(
result,
Err(SecurityError::TooManyAliases {
alias_count: 31,
max_aliases: 30,
})
),
"31-alias query must be rejected with TooManyAliases, got {result:?}"
);
}
#[test]
fn test_alias_within_limit_allowed() {
let validator = QueryValidator::standard(); let aliases: String =
(0..5).map(|i| ["a", &i.to_string(), ": user { id } "].concat()).collect();
let query = format!("{{ {aliases} }}");
let result = validator.validate(&query);
assert!(result.is_ok(), "5 aliases should be allowed, got {result:?}");
}
#[test]
fn test_permissive_config() {
let config = QueryValidatorConfig::permissive();
assert_eq!(config.max_depth, 20);
assert_eq!(config.max_complexity, 5000);
assert_eq!(config.max_size_bytes, 1_000_000);
assert_eq!(config.max_aliases, 100);
}
#[test]
fn test_standard_config() {
let config = QueryValidatorConfig::standard();
assert_eq!(config.max_depth, 10);
assert_eq!(config.max_complexity, 1000);
assert_eq!(config.max_size_bytes, 256_000);
assert_eq!(config.max_aliases, 30);
}
#[test]
fn test_strict_config() {
let config = QueryValidatorConfig::strict();
assert_eq!(config.max_depth, 5);
assert_eq!(config.max_complexity, 500);
assert_eq!(config.max_size_bytes, 64_000);
assert_eq!(config.max_aliases, 10);
}
#[test]
fn test_validator_helpers() {
let permissive = QueryValidator::permissive();
assert_eq!(permissive.config().max_depth, 20);
let standard = QueryValidator::standard();
assert_eq!(standard.config().max_depth, 10);
let strict = QueryValidator::strict();
assert_eq!(strict.config().max_depth, 5);
}
#[test]
fn test_metrics_returned_on_valid_query() {
let validator = QueryValidator::standard();
let query = "{ user { id name } }";
let metrics = validator.validate(query).unwrap();
assert!(metrics.depth >= 2); assert!(metrics.complexity > 0);
assert_eq!(metrics.alias_count, 0);
}
#[test]
fn test_alias_count_in_metrics() {
let validator = QueryValidator::standard();
let query = "{ a: user { id } b: user { id } }";
let metrics = validator.validate(query).unwrap();
assert_eq!(metrics.alias_count, 2);
}
}
mod rls_policy_tests {
#![allow(clippy::unwrap_used)]
use std::collections::HashMap;
use crate::{
backend::WhereClause,
security::{
rls_policy::{RLSRule, extract_user_value},
*,
},
utils::clock::Clock,
};
fn make_context(user_id: &str, roles: Vec<&str>, tenant_id: Option<&str>) -> SecurityContext {
SecurityContext {
user_id: user_id.into(),
roles: roles.into_iter().map(String::from).collect(),
tenant_id: tenant_id.map(Into::into),
scopes: vec![],
attributes: HashMap::new(),
request_id: "req1".to_string(),
ip_address: None,
authenticated_at: chrono::Utc::now(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
#[test]
fn trace_id_is_none_without_a_stamp() {
let ctx = make_context("u1", vec![], None);
assert_eq!(ctx.trace_id(), None);
}
#[test]
fn with_trace_id_round_trips_through_the_attribute_bag() {
let tid = "4bf92f3577b34da6a3ce929d0e0e4736";
let ctx = make_context("u1", vec![], None).with_trace_id(tid);
assert_eq!(ctx.trace_id(), Some(tid));
assert_eq!(
ctx.get_attribute(SecurityContext::TRACE_ID_ATTRIBUTE)
.and_then(serde_json::Value::as_str),
Some(tid),
);
}
#[test]
fn trace_context_is_none_without_a_stamp() {
let ctx = make_context("u1", vec![], None);
assert_eq!(ctx.trace_context(), None);
}
#[test]
fn with_trace_context_round_trips_through_the_attribute_bag() {
let tc = serde_json::json!({
"version": "00",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"parent_id": "00f067aa0ba902b7",
"trace_flags": "01"
});
let ctx = make_context("u1", vec![], None).with_trace_context(tc.clone());
assert_eq!(ctx.trace_context(), Some(&tc));
assert_eq!(ctx.get_attribute(SecurityContext::TRACE_CONTEXT_ATTRIBUTE), Some(&tc));
}
fn cacheable_owner_rule() -> RLSRule {
RLSRule {
name: "owner_only".to_string(),
expression: "user.id == object.author_id".to_string(),
cacheable: true,
cache_ttl_seconds: Some(300),
}
}
fn policy_with_rule(rule: RLSRule) -> CompiledRLSPolicy {
let mut rules_by_type = std::collections::HashMap::new();
rules_by_type.insert("Post".to_string(), vec![rule]);
CompiledRLSPolicy::new(rules_by_type, None)
}
fn policy_with_rule_and_clock(
rule: RLSRule,
clock: std::sync::Arc<dyn crate::utils::clock::Clock>,
) -> CompiledRLSPolicy {
let mut rules_by_type = std::collections::HashMap::new();
rules_by_type.insert("Post".to_string(), vec![rule]);
CompiledRLSPolicy::new_with_clock(rules_by_type, None, clock)
}
use crate::security::rls_policy::{RlsTarget, UnmatchedTarget};
#[test]
fn unmatched_target_is_refused_rather_than_read_unfiltered() {
let policy = policy_with_rule(cacheable_owner_rule());
let context = make_context("user1", vec!["viewer"], None);
let result = policy.evaluate(&context, &RlsTarget::query("widgets", "Widget"));
let err = result.expect_err(
"a target matching no rule must be refused; Ok(None) is full access and is \
indistinguishable from a deliberate decision not to filter",
);
let msg = err.to_string();
assert!(msg.contains("Widget"), "error must name the type tried: {msg}");
assert!(msg.contains("widgets"), "error must name the query tried: {msg}");
}
#[test]
fn unmatched_target_reads_unfiltered_only_when_explicitly_allowed() {
let policy =
policy_with_rule(cacheable_owner_rule()).with_unmatched(UnmatchedTarget::Allow);
let context = make_context("user1", vec!["viewer"], None);
let result = policy
.evaluate(&context, &RlsTarget::query("widgets", "Widget"))
.expect("Allow must not error");
assert!(result.is_none(), "UnmatchedTarget::Allow means no filter");
}
#[test]
fn deny_is_the_default_for_a_policy_built_by_new() {
let policy = policy_with_rule(cacheable_owner_rule());
assert_eq!(
policy.on_unmatched,
UnmatchedTarget::Deny,
"an unmatched read must fail closed unless the embedder opts out",
);
}
#[test]
fn a_default_rule_still_answers_for_an_unmatched_target() {
let mut rules_by_type = std::collections::HashMap::new();
rules_by_type.insert("Post".to_string(), vec![cacheable_owner_rule()]);
let policy = CompiledRLSPolicy::new(rules_by_type, Some(cacheable_owner_rule()));
let context = make_context("user1", vec!["viewer"], None);
let result = policy
.evaluate(&context, &RlsTarget::query("widgets", "Widget"))
.expect("a default rule must still apply");
assert!(result.is_some(), "the default rule must produce a filter");
}
#[test]
fn a_rule_resolves_under_whichever_name_the_deployment_keyed_it_by() {
let context = make_context("user1", vec!["viewer"], None);
for (key, target, kind) in [
("Post", RlsTarget::query("posts", "Post"), "type name"),
("posts", RlsTarget::query("posts", "Post"), "query name"),
("tb_post", RlsTarget::fact_table("postStats", "tb_post"), "table name"),
] {
let mut rules_by_type = std::collections::HashMap::new();
rules_by_type.insert(key.to_string(), vec![cacheable_owner_rule()]);
let policy = CompiledRLSPolicy::new(rules_by_type, None);
let result = policy
.evaluate(&context, &target)
.unwrap_or_else(|e| panic!("a policy keyed by {kind} must resolve: {e}"));
assert!(result.is_some(), "a policy keyed by {kind} must produce its filter");
}
}
#[test]
fn the_cache_is_keyed_by_the_name_that_selected_the_rule() {
let mut rules_by_type = std::collections::HashMap::new();
rules_by_type.insert("Post".to_string(), vec![cacheable_owner_rule()]);
rules_by_type.insert(
"Comment".to_string(),
vec![RLSRule {
name: "tenant_only".to_string(),
expression: "user.tenant_id == object.tenant_id".to_string(),
cacheable: true,
cache_ttl_seconds: Some(300),
}],
);
let policy = CompiledRLSPolicy::new(rules_by_type, None);
let context = make_context("user1", vec!["viewer"], Some("org42"));
let post = policy
.evaluate(&context, &RlsTarget::query("feed", "Post"))
.expect("Post must resolve");
let comment = policy
.evaluate(&context, &RlsTarget::query("feed", "Comment"))
.expect("Comment must resolve");
assert_ne!(
format!("{post:?}"),
format!("{comment:?}"),
"two types sharing a query name must not share a cached filter",
);
}
#[test]
fn test_with_tenant_field_sets_field_name() {
let policy = DefaultRLSPolicy::new().with_tenant_field("org_id".to_string());
assert_eq!(policy.tenant_field, "org_id", "with_tenant_field must update tenant_field");
let context = make_context("user1", vec!["viewer"], Some("org42"));
let result = policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap()
.unwrap();
let sql = format!("{:?}", result.into_where_clause());
assert!(sql.contains("org_id"), "custom tenant field must appear in WHERE clause: {sql}");
assert!(!sql.contains("\"tenant_id\""), "default field name must not appear: {sql}");
}
#[test]
fn test_with_owner_field_sets_field_name() {
let policy = DefaultRLSPolicy::new().with_owner_field("creator_id".to_string());
assert_eq!(policy.owner_field, "creator_id", "with_owner_field must update owner_field");
let context = make_context("user1", vec!["viewer"], None);
let result = policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap()
.unwrap();
let sql = format!("{:?}", result.into_where_clause());
assert!(
sql.contains("creator_id"),
"custom owner field must appear in WHERE clause: {sql}"
);
assert!(!sql.contains("author_id"), "default field name must not appear: {sql}");
}
#[test]
fn test_default_rls_policy_admin_bypass() {
let policy = DefaultRLSPolicy::new();
let context = make_context("user123", vec!["admin"], Some("tenant1"));
let result = policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
assert_eq!(result, None, "Admins should bypass RLS");
}
#[test]
fn test_default_rls_policy_tenant_isolation() {
let policy = DefaultRLSPolicy::new();
let context = make_context("user123", vec!["user"], Some("tenant1"));
let result = policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
assert!(result.is_some(), "Non-admin users should have RLS filter applied");
}
#[test]
fn test_no_rls_policy() {
let policy = NoRLSPolicy;
let context = make_context("user123", vec![], None);
let result = policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
assert_eq!(result, None, "NoRLSPolicy should never apply filters");
}
#[test]
fn test_compiled_rls_cache_entry_has_correct_ttl() {
use crate::utils::clock::ManualClock;
let clock = std::sync::Arc::new(ManualClock::new());
let t0 = clock.now_secs();
let policy = policy_with_rule_and_clock(cacheable_owner_rule(), clock);
let context = make_context("user1", vec!["viewer"], Some("t1"));
policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
let cache = policy.cache.read();
let entry =
cache.get("user1:Post").expect("cache should be populated after first evaluate");
assert_eq!(entry.expires_at, t0 + 300, "expires_at must be now_secs + ttl_secs (300)");
}
#[test]
fn test_compiled_rls_cache_hit_does_not_refresh_expiry() {
use crate::utils::clock::ManualClock;
let clock = std::sync::Arc::new(ManualClock::new());
let t0 = clock.now_secs();
let policy = policy_with_rule_and_clock(cacheable_owner_rule(), clock.clone());
let context = make_context("user1", vec!["viewer"], Some("t1"));
policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
let first_expires_at = policy.cache.read().get("user1:Post").unwrap().expires_at;
assert_eq!(first_expires_at, t0 + 300);
clock.advance(std::time::Duration::from_secs(299));
policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
let second_expires_at = policy.cache.read().get("user1:Post").unwrap().expires_at;
assert_eq!(
second_expires_at, first_expires_at,
"Cache hit must not update expires_at (would indicate a miss + re-cache)"
);
}
#[test]
fn test_compiled_rls_cache_miss_after_expiry_refreshes_entry() {
use crate::utils::clock::ManualClock;
let clock = std::sync::Arc::new(ManualClock::new());
let t0 = clock.now_secs();
let policy = policy_with_rule_and_clock(cacheable_owner_rule(), clock.clone());
let context = make_context("user1", vec!["viewer"], Some("t1"));
policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
clock.advance(std::time::Duration::from_secs(301));
policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
let new_expires = policy.cache.read().get("user1:Post").unwrap().expires_at;
assert_eq!(
new_expires,
t0 + 601,
"After TTL expiry, cache must be refreshed with updated expires_at"
);
}
#[test]
fn test_compiled_rls_cache_expires_exactly_at_ttl_boundary() {
use crate::utils::clock::ManualClock;
let clock = std::sync::Arc::new(ManualClock::new());
let t0 = clock.now_secs();
let policy = policy_with_rule_and_clock(cacheable_owner_rule(), clock.clone());
let context = make_context("user1", vec!["viewer"], Some("t1"));
policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
clock.advance(std::time::Duration::from_mins(5));
assert_eq!(clock.now_secs(), t0 + 300);
policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
let refreshed_expires = policy.cache.read().get("user1:Post").unwrap().expires_at;
assert_eq!(
refreshed_expires,
t0 + 600,
"At exact TTL boundary, cache must expire and refresh (< not <=)"
);
}
#[test]
fn test_cache_result_stores_with_300s_default_ttl() {
use crate::utils::clock::ManualClock;
let clock = std::sync::Arc::new(ManualClock::new());
let t0 = clock.now_secs();
let policy =
CompiledRLSPolicy::new_with_clock(std::collections::HashMap::new(), None, clock);
let result = Some(WhereClause::Field {
path: vec!["author_id".to_string()],
operator: crate::backend::WhereOperator::Eq,
value: serde_json::json!("user_x"),
});
policy.cache_result("user_x:Post", &result);
let cache = policy.cache.read();
let entry = cache.get("user_x:Post").expect("cache_result must insert entry");
assert_eq!(entry.expires_at, t0 + 300, "cache_result must use 300s TTL");
assert_eq!(entry.result, result, "cache_result must store the provided result");
}
#[test]
fn test_cache_result_stores_none_result() {
use crate::utils::clock::ManualClock;
let policy = CompiledRLSPolicy::new_with_clock(
std::collections::HashMap::new(),
None,
std::sync::Arc::new(ManualClock::new()),
);
policy.cache_result("user1:Post", &None);
let cache = policy.cache.read();
let entry = cache.get("user1:Post").expect("cache_result must store even None result");
assert!(entry.result.is_none(), "cached None result must remain None");
}
#[test]
fn test_extract_user_value_id_field() {
let ctx = make_context("user_abc_123", vec![], None);
assert_eq!(
extract_user_value("id", &ctx),
Some(serde_json::json!("user_abc_123")),
"'id' must return the actual user_id"
);
}
#[test]
fn test_extract_user_value_user_id_alias() {
let ctx = make_context("user_abc_123", vec![], None);
assert_eq!(
extract_user_value("user_id", &ctx),
Some(serde_json::json!("user_abc_123")),
"'user_id' must return the same user_id as 'id'"
);
}
#[test]
fn test_extract_user_value_tenant_id_present() {
let ctx = make_context("u1", vec![], Some("tenant_xyz"));
assert_eq!(
extract_user_value("tenant_id", &ctx),
Some(serde_json::json!("tenant_xyz")),
"'tenant_id' must return the tenant id when present"
);
}
#[test]
fn test_extract_user_value_tenant_id_absent() {
let ctx = make_context("u1", vec![], None);
assert_eq!(
extract_user_value("tenant_id", &ctx),
None,
"'tenant_id' must return None when absent, not Some(null)"
);
}
#[test]
fn test_extract_user_value_roles_field() {
let ctx = make_context("u1", vec!["editor", "viewer"], None);
assert_eq!(
extract_user_value("roles", &ctx),
Some(serde_json::json!(["editor", "viewer"])),
"'roles' must return the full roles array"
);
}
#[test]
fn test_extract_user_value_custom_attribute() {
let mut attrs = HashMap::new();
attrs.insert("department".to_string(), serde_json::json!("engineering"));
let ctx = SecurityContext {
user_id: "u1".into(),
roles: vec![],
tenant_id: None,
scopes: vec![],
attributes: attrs,
request_id: "r1".to_string(),
ip_address: None,
authenticated_at: chrono::Utc::now(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
issuer: None,
audience: None,
email: None,
display_name: None,
};
assert_eq!(
extract_user_value("department", &ctx),
Some(serde_json::json!("engineering")),
"Custom attribute must be returned by name"
);
}
#[test]
fn test_extract_user_value_unknown_returns_none() {
let ctx = make_context("u1", vec![], None);
assert_eq!(
extract_user_value("nonexistent_field", &ctx),
None,
"Unknown field must return None, not Some(null)"
);
}
#[test]
fn test_user_id_propagated_to_rls_where_clause() {
let policy = policy_with_rule(RLSRule {
name: "owner_only".to_string(),
expression: "user.id == object.author_id".to_string(),
cacheable: false,
cache_ttl_seconds: None,
});
let context = make_context("specific_user_42", vec!["viewer"], None);
let result = policy
.evaluate(&context, &crate::security::rls_policy::RlsTarget::query("posts", "Post"))
.unwrap();
let clause = result.expect("non-admin user must receive an RLS filter").into_where_clause();
match clause {
WhereClause::Field { value, .. } => {
assert_eq!(
value,
serde_json::json!("specific_user_42"),
"RLS WhereClause must embed the actual user_id, not null"
);
},
other => panic!("Expected Field clause, got {other:?}"),
}
}
}
mod security_context_tests {
use std::collections::HashMap;
use chrono::Utc;
use crate::security::*;
#[test]
fn test_has_role() {
let context = SecurityContext {
user_id: "user123".into(),
roles: vec!["admin".to_string(), "moderator".to_string()],
tenant_id: None,
scopes: vec![],
attributes: HashMap::new(),
request_id: "req-1".to_string(),
ip_address: None,
authenticated_at: Utc::now(),
expires_at: Utc::now() + chrono::Duration::hours(1),
issuer: None,
audience: None,
email: None,
display_name: None,
};
assert!(context.has_role("admin"));
assert!(context.has_role("moderator"));
assert!(!context.has_role("superadmin"));
}
#[test]
fn test_has_scope() {
let context = SecurityContext {
user_id: "user123".into(),
roles: vec![],
tenant_id: None,
scopes: vec!["read:user".to_string(), "write:post".to_string()],
attributes: HashMap::new(),
request_id: "req-1".to_string(),
ip_address: None,
authenticated_at: Utc::now(),
expires_at: Utc::now() + chrono::Duration::hours(1),
issuer: None,
audience: None,
email: None,
display_name: None,
};
assert!(context.has_scope("read:user"));
assert!(context.has_scope("write:post"));
assert!(!context.has_scope("admin:*"));
}
#[test]
fn test_wildcard_scopes() {
let context = SecurityContext {
user_id: "user123".into(),
roles: vec![],
tenant_id: None,
scopes: vec!["admin:*".to_string()],
attributes: HashMap::new(),
request_id: "req-1".to_string(),
ip_address: None,
authenticated_at: Utc::now(),
expires_at: Utc::now() + chrono::Duration::hours(1),
issuer: None,
audience: None,
email: None,
display_name: None,
};
assert!(context.has_scope("admin:read"));
assert!(context.has_scope("admin:write"));
assert!(!context.has_scope("user:read"));
}
#[test]
fn test_builder_pattern() {
use crate::types::TenantId;
let now = Utc::now();
let context = SecurityContext {
user_id: "user123".into(),
roles: vec![],
tenant_id: None,
scopes: vec![],
attributes: HashMap::new(),
request_id: "req-1".to_string(),
ip_address: None,
authenticated_at: now,
expires_at: now + chrono::Duration::hours(1),
issuer: None,
audience: None,
email: None,
display_name: None,
}
.with_role("admin".to_string())
.with_scopes(vec!["read:user".to_string()])
.with_tenant("tenant-1".to_string());
assert!(context.has_role("admin"));
assert!(context.has_scope("read:user"));
assert_eq!(context.tenant_id, Some(TenantId::new("tenant-1")));
}
}
mod actor_context_tests {
#![allow(clippy::unwrap_used)] #![allow(clippy::doc_markdown)]
use std::collections::HashMap;
use chrono::Utc;
use serde_json::json;
use uuid::Uuid;
use crate::{
security::{ActorType, AuthenticatedUser, SecurityContext},
types::UserId,
};
fn user(
sub: &str,
scopes: &[&str],
extra: HashMap<String, serde_json::Value>,
) -> AuthenticatedUser {
AuthenticatedUser {
user_id: UserId::new(sub),
scopes: scopes.iter().map(ToString::to_string).collect(),
expires_at: Utc::now() + chrono::Duration::hours(1),
email: None,
display_name: None,
extra_claims: extra,
}
}
#[test]
fn from_user_derives_ai_agent_acting_for_subject() {
let sub = "550e8400-e29b-41d4-a716-446655440000";
let mut extra = HashMap::new();
extra.insert("act".to_string(), json!({ "sub": "agent-7" }));
let ctx = SecurityContext::from_user(&user(sub, &[], extra), "req-1".to_string());
assert_eq!(ctx.actor_type(), ActorType::AiAgent);
assert_eq!(ctx.acting_for(), Some(Uuid::parse_str(sub).unwrap()));
}
#[test]
fn from_user_derives_human_user_by_default() {
let ctx = SecurityContext::from_user(
&user("user-1", &["read:user"], HashMap::new()),
"req-2".to_string(),
);
assert_eq!(ctx.actor_type(), ActorType::HumanUser);
assert_eq!(ctx.acting_for(), None);
}
#[test]
fn from_user_derives_service_account_from_scope() {
let ctx = SecurityContext::from_user(
&user("svc-1", &["service_account"], HashMap::new()),
"req-3".to_string(),
);
assert_eq!(ctx.actor_type(), ActorType::ServiceAccount);
}
#[test]
fn with_actor_type_overrides_and_round_trips() {
let ctx = SecurityContext::from_user(&user("u", &[], HashMap::new()), "r".to_string())
.with_actor_type(ActorType::ServiceAccount);
assert_eq!(ctx.actor_type(), ActorType::ServiceAccount);
}
#[test]
fn with_acting_for_none_clears() {
let uuid = Uuid::new_v4();
let ctx = SecurityContext::from_user(&user("u", &[], HashMap::new()), "r".to_string())
.with_acting_for(Some(uuid))
.with_acting_for(None);
assert_eq!(ctx.acting_for(), None);
}
#[test]
fn unset_actor_type_defaults_to_human_user() {
let ctx = SecurityContext::from_user(&user("u", &[], HashMap::new()), "r".to_string());
assert_eq!(ctx.actor_type(), ActorType::HumanUser);
}
#[test]
fn from_user_populates_roles_from_scalar_role_claim() {
let mut extra = HashMap::new();
extra.insert("role".to_string(), json!("report_reader"));
let ctx = SecurityContext::from_user(&user("u", &[], extra), "r".to_string());
assert_eq!(ctx.roles, vec!["report_reader".to_string()]);
assert!(ctx.has_role("report_reader"));
}
#[test]
fn from_user_populates_roles_from_roles_array_claim() {
let mut extra = HashMap::new();
extra.insert("roles".to_string(), json!(["moderator", "admin", "moderator"]));
let ctx = SecurityContext::from_user(&user("u", &[], extra), "r".to_string());
assert_eq!(ctx.roles, vec!["admin".to_string(), "moderator".to_string()]);
}
#[test]
fn from_user_populates_roles_from_fraiseql_roles_claim() {
let mut extra = HashMap::new();
extra.insert("fraiseql_roles".to_string(), json!(["report_reader"]));
let ctx = SecurityContext::from_user(&user("u", &[], extra), "r".to_string());
assert_eq!(ctx.roles, vec!["report_reader".to_string()]);
}
#[test]
fn from_user_merges_and_dedups_role_sources() {
let mut extra = HashMap::new();
extra.insert("role".to_string(), json!("admin"));
extra.insert("roles".to_string(), json!(["admin", "editor"]));
let ctx = SecurityContext::from_user(&user("u", &[], extra), "r".to_string());
assert_eq!(ctx.roles, vec!["admin".to_string(), "editor".to_string()]);
}
#[test]
fn from_user_roles_empty_without_role_claims() {
let ctx =
SecurityContext::from_user(&user("u", &["read:user"], HashMap::new()), "r".to_string());
assert!(ctx.roles.is_empty());
}
}
mod system_job_tests {
use crate::{
security::{ActorType, SecurityContext},
types::TenantId,
};
#[test]
fn system_job_carries_granted_authority_and_actor_type() {
let ctx = SecurityContext::system_job(
"orders",
"fire-1",
vec!["ingest_writer".to_string()],
vec!["write:order".to_string()],
Some(TenantId::from("acme")),
);
assert_eq!(ctx.actor_type(), ActorType::SystemJob);
assert!(ctx.has_role("ingest_writer"));
assert!(ctx.has_scope("write:order"));
assert_eq!(ctx.tenant_id.as_ref().map(TenantId::as_str), Some("acme"));
assert!(ctx.is_multi_tenant());
assert!(!ctx.is_expired(), "a fresh system-job context is not expired");
assert_eq!(ctx.user_id.as_str(), "system_job:orders");
}
#[test]
fn system_job_without_grants_is_fail_closed() {
let ctx = SecurityContext::system_job("orders", "fire-1", vec![], vec![], None);
assert_eq!(ctx.actor_type(), ActorType::SystemJob);
assert!(!ctx.has_role("admin"));
assert!(!ctx.has_scope("write:order"));
assert!(!ctx.is_multi_tenant(), "no tenant → scoped to nothing");
}
}
mod issuer_tests {
use std::collections::HashMap;
use crate::{
security::{AuthenticatedUser, SecurityContext},
types::UserId,
};
fn user(extra: HashMap<String, serde_json::Value>) -> AuthenticatedUser {
AuthenticatedUser {
user_id: UserId::new("u1"),
scopes: vec![],
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
email: None,
display_name: None,
extra_claims: extra,
}
}
#[test]
fn the_iss_claim_is_the_principals_issuer() {
let extra =
HashMap::from([("iss".to_string(), serde_json::json!("https://exchange.example.com"))]);
let ctx = SecurityContext::from_user(&user(extra), "r1".to_string());
assert_eq!(ctx.issuer.as_deref(), Some("https://exchange.example.com"));
}
#[test]
fn a_token_without_iss_has_no_issuer() {
let ctx = SecurityContext::from_user(&user(HashMap::new()), "r1".to_string());
assert_eq!(ctx.issuer, None);
}
}
mod scope_grant_tests {
use std::collections::HashMap;
use chrono::Utc;
use crate::{
graphql::require_permission_directive::RequirePermissionDirective,
schema::security_config::RoleDefinition,
security::{AuthenticatedUser, SecurityContext},
types::UserId,
};
const CASES: &[(&str, &str, bool)] = &[
("read:x", "read:x", true),
("admin*", "administer:x", false),
("read:*", "readwrite:x", false),
("read:*", "read:x", true),
("read:User.*", "read:User.email", true),
("*", "write:anything", true),
];
fn user(scopes: &[&str]) -> AuthenticatedUser {
AuthenticatedUser {
user_id: UserId::new("u1"),
scopes: scopes.iter().map(ToString::to_string).collect(),
expires_at: Utc::now() + chrono::Duration::hours(1),
email: None,
display_name: None,
extra_claims: HashMap::new(),
}
}
fn assert_follows_the_rule(matcher: &str, grants: impl Fn(&str, &str) -> bool) {
let wrong: Vec<_> = CASES
.iter()
.filter(|(pattern, required, want)| grants(pattern, required) != *want)
.collect();
assert!(wrong.is_empty(), "{matcher} disagrees with the scope rule on {wrong:?}");
}
#[test]
fn security_context_follows_the_scope_rule() {
assert_follows_the_rule("SecurityContext::has_scope", |pattern, required| {
SecurityContext::from_user(&user(&[]), "req".to_string())
.with_scopes(vec![pattern.to_string()])
.has_scope(required)
});
}
#[test]
fn role_definition_follows_the_scope_rule() {
assert_follows_the_rule("RoleDefinition::has_scope", |pattern, required| {
RoleDefinition::new("r", vec![pattern.to_string()]).has_scope(required)
});
}
#[test]
fn require_permission_follows_the_scope_rule() {
assert_follows_the_rule(
"@requirePermission",
RequirePermissionDirective::permission_matches,
);
}
#[test]
fn the_admin_scope_check_stays_exact() {
assert!(user(&["fraiseql:admin"]).has_scope("fraiseql:admin"));
for wildcard in ["*", "fraiseql:*", "fraiseql*"] {
assert!(
!user(&[wildcard]).has_scope("fraiseql:admin"),
"`{wildcard}` must not satisfy the exact admin check"
);
}
}
}