#[cfg(test)]
use std::collections::{HashMap, HashSet};
#[test]
fn test_csrf_token_uniqueness_and_entropy() {
use crate::handlers::generate_secure_state;
let mut tokens = HashSet::new();
let iterations = 100;
for _ in 0..iterations {
let token = generate_secure_state();
assert!(
token.len() >= 64,
"CSRF token too short: {} (should be >= 64 hex chars = 256 bits)",
token.len()
);
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"Token contains non-hex characters: {}",
token
);
tokens.insert(token);
}
assert_eq!(
tokens.len(),
iterations,
"CSRF token collisions detected! Only {} unique out of {}",
tokens.len(),
iterations
);
}
#[test]
fn test_csrf_state_is_cryptographically_random() {
use crate::handlers::generate_secure_state;
let states: Vec<String> = (0..50).map(|_| generate_secure_state()).collect();
let unique_count = states.iter().collect::<HashSet<_>>().len();
assert_eq!(
unique_count, 50,
"CSRF state generator produced duplicates! Only {} unique",
unique_count
);
for state in &states {
assert!(hex::decode(state).is_ok(), "CSRF state is not valid hex: {}", state);
}
}
#[test]
fn test_jwt_expiration_enforcement() {
use std::time::{SystemTime, UNIX_EPOCH};
use crate::jwt::Claims;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("System time error")
.as_secs();
let expired_token = Claims {
iss: "test_issuer".to_string(),
sub: "user123".to_string(),
aud: vec!["api".to_string()],
exp: now - 1,
iat: now - 3600,
nbf: None,
extra: HashMap::default(),
};
assert!(expired_token.is_expired(), "Expired token should be rejected");
let valid_token = Claims {
iss: "test_issuer".to_string(),
sub: "user123".to_string(),
aud: vec!["api".to_string()],
exp: now + 3600,
iat: now,
nbf: None,
extra: HashMap::default(),
};
assert!(!valid_token.is_expired(), "Valid token should not be rejected");
}
#[test]
fn wrong_audience_is_rejected() {
use std::collections::HashMap;
use jsonwebtoken::Algorithm;
use crate::{Claims, jwt::JwtValidator};
let secret = b"audience_enforcement_secret_at_least_32b";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_secs();
let claims = Claims {
sub: "user".to_string(),
iat: now,
exp: now + 3600,
nbf: None,
iss: "https://issuer.example.com".to_string(),
aud: vec!["some-other-service".to_string()],
extra: HashMap::new(),
};
let token = crate::jwt::generate_hs256_token(&claims, secret).expect("token");
let validator = JwtValidator::new("https://issuer.example.com", Algorithm::HS256)
.expect("issuer config")
.with_audiences(&["api", "web"])
.expect("audiences");
let result = validator.validate_hmac(&token, secret);
assert!(
result.is_err(),
"a token whose aud is outside the configured set must be rejected, not accepted: {result:?}"
);
let mut good = claims;
good.aud = vec!["api".to_string()];
let good_token = crate::jwt::generate_hs256_token(&good, secret).expect("token");
assert!(
validator.validate_hmac(&good_token, secret).is_ok(),
"a token with a configured audience must validate"
);
}
#[test]
fn test_jwt_invalid_issuer_rejection() {
use jsonwebtoken::Algorithm;
use crate::jwt::JwtValidator;
let result = JwtValidator::new("", Algorithm::HS256);
assert!(result.is_err(), "Empty issuer should be rejected");
}
#[test]
fn test_csrf_token_url_safe_format() {
use crate::handlers::generate_secure_state;
let tokens: Vec<String> = (0..20).map(|_| generate_secure_state()).collect();
for token in tokens {
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"Token should be hex-safe for URLs: {}",
token
);
assert_eq!(token.len(), 64, "Token length should be consistent: {}", token.len());
}
}
#[test]
fn an_expired_jwt_is_rejected() {
use std::collections::HashMap;
use jsonwebtoken::Algorithm;
use crate::{Claims, error::AuthError, jwt::JwtValidator};
let secret = b"expiry_enforcement_secret_at_least_32byt";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_secs();
let expired = Claims {
sub: "user".to_string(),
iat: now - 7200,
exp: now - 3600, nbf: None,
iss: "https://issuer.example.com".to_string(),
aud: vec!["api".to_string()],
extra: HashMap::new(),
};
let token = crate::jwt::generate_hs256_token(&expired, secret).expect("token");
let validator = JwtValidator::new("https://issuer.example.com", Algorithm::HS256)
.expect("issuer config")
.with_audiences(&["api"])
.expect("audiences");
assert!(
matches!(validator.validate_hmac(&token, secret), Err(AuthError::TokenExpired)),
"an expired JWT must be rejected as TokenExpired"
);
}
mod rs256_negative_paths {
use std::collections::HashMap;
use jsonwebtoken::Algorithm;
use crate::{Claims, error::AuthError, jwt::JwtValidator};
const PRIVATE_KEY: &[u8] = include_bytes!("../test_data/test_rsa_key.pem");
const PUBLIC_KEY: &[u8] = include_bytes!("../test_data/test_rsa_pub.pem");
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_secs()
}
fn validator() -> JwtValidator {
JwtValidator::new("fraiseql", Algorithm::RS256)
.expect("issuer config")
.with_audiences(&["fraiseql-api"])
.expect("audiences")
}
fn claims(exp_offset: i64) -> Claims {
let n = now();
Claims {
sub: "user123".to_string(),
iat: n - 10,
#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] exp: (n as i64 + exp_offset) as u64,
nbf: None,
iss: "fraiseql".to_string(),
aud: vec!["fraiseql-api".to_string()],
extra: HashMap::new(),
}
}
fn sign_rs256(claims: &Claims, private_key_pem: &[u8]) -> String {
let key = jsonwebtoken::EncodingKey::from_rsa_pem(private_key_pem).expect("test RSA key");
jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
claims,
&key,
)
.expect("sign")
}
#[test]
fn a_valid_rs256_token_validates() {
let token = sign_rs256(&claims(3600), PRIVATE_KEY);
assert!(
validator().validate(&token, PUBLIC_KEY).is_ok(),
"a genuinely RS256-signed, in-date token must validate — the control for the \
negative cases below"
);
}
#[test]
fn an_expired_rs256_token_is_rejected() {
let token = sign_rs256(&claims(-3600), PRIVATE_KEY);
assert!(matches!(validator().validate(&token, PUBLIC_KEY), Err(AuthError::TokenExpired)));
}
#[test]
fn a_tampered_rs256_payload_is_rejected() {
let token = sign_rs256(&claims(3600), PRIVATE_KEY);
let mut parts: Vec<&str> = token.split('.').collect();
let tampered_payload = {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
let raw = URL_SAFE_NO_PAD.decode(parts[1]).expect("payload b64");
let mut json: serde_json::Value = serde_json::from_slice(&raw).expect("payload json");
json["sub"] = serde_json::json!("attacker");
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).expect("claims serialize"))
};
parts[1] = &tampered_payload;
let tampered = parts.join(".");
assert!(
matches!(validator().validate(&tampered, PUBLIC_KEY), Err(AuthError::InvalidSignature)),
"a token whose payload was altered after signing must fail signature verification"
);
}
#[test]
fn an_rs256_token_signed_by_a_different_key_is_rejected() {
const OTHER_PRIVATE_KEY: &[u8] = include_bytes!("../test_data/test_rsa_key_other.pem");
let token = sign_rs256(&claims(3600), OTHER_PRIVATE_KEY);
assert!(
matches!(validator().validate(&token, PUBLIC_KEY), Err(AuthError::InvalidSignature)),
"a token signed by an unrelated key must fail signature verification"
);
}
}
#[test]
fn test_randomness_quality() {
use crate::handlers::generate_secure_state;
let states: Vec<String> = (0..10).map(|_| generate_secure_state()).collect();
for state in states {
let bytes = hex::decode(&state).expect("Valid hex");
let mut transitions = 0;
for i in 0..bytes.len() - 1 {
if bytes[i] != bytes[i + 1] {
transitions += 1;
}
}
let byte_count = bytes.len();
let min_transitions = byte_count / 5;
assert!(
transitions > min_transitions,
"Insufficient entropy in random bytes: {} transitions in {} bytes",
transitions,
byte_count
);
}
}