use super::*;
use chrono::{Duration, Utc};
const TEST_PRIVATE_KEY_PEM: &str = r#"-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEA3yFsXfRKUQ8hYgZ/VuEOfuMWm/aqYa3jrViAPJW++Pe+G+HK
2T+SClPrb+nacYVVb+YNunbCfp3YhXBS9OceVfK7D0pD3PXXZGuIuLhjnGYlZmUn
mte7z0YBbxrmqbrI7Fbis0z9JGZaVza9jnaszGw3vkFwaHyB0/ZseJZEvfhpP2Vv
UiLAXqzYsi6wfFaNUmTe5wwgW8SJ7H2dkaqG+Tb/cNgzLxwCIiX7fUdkDKPbv9MX
UPfP2CKdXIdcCjAGFmgEYdEt9D/ZjRI7OtNMI+fbrMSFEFW0wAign9ZPc815k05h
515cTkJ87Sa/9nR/M4SzAfWqARN7bU2O037clQIDAQABAoIBADenBHpipe6V0YO7
jyNCOvVW+pqn6VM3pePkgQebaeh7EkWuCYQqIOjGiaB+OWe7E9Y3ERGC8XvXLtwJ
agd/ZceWJSXpJggEoVaAo7c+9klaCNYDQN+UE1ndYhouIX4QAnFAMob6GuFrTfkW
xCy2WN8b1sNzWvAUreUKP3/MKxUeWxckfmXPaLl3yAmIOiGnjqMH4wWwJ13Y0kS2
BGeaWqkRGdi7kXgambqJbrk0cGkqFAXfvX5nEM/2NB4Wv1aEeEhKtZ6D3Lg3T6+A
KtnjE+iMTpjnKvBTbHJtUZ7LWt478buhe+xagGCAmtJN14+49Ce4ebmiozZ/ZZVl
LUu0ubkCgYEA8W+TKlj11OIltnR4pVjDQoVaz2sUdRT1z8LpN4JkW8vrreW7RnGc
YFZ/9m99kvS0/3G4joD9FCzbrPUpx/vki80lDkOcPnbibkYlGdvmaX3YDzuAO+aR
sfCsA+UPmbykAROj62LiOrEmud735EtIq5C3K2ngopFcqrjFiGYH+zcCgYEA7Jcq
SxOVX13S4THV72uXTANoPzH/C2/mX73DPWioNFUL4Dh+NbIgmgp+sKkhZqJPEK1k
Dv8UdhXaJ37oGJft95CZWcR5u2YffudU9Zy53SxOpS0cwlO7eqcdpyiD92/T3c2D
0VsR15UjGh49vQq6gNbx93IM/0ZTzZeDwSDJRJMCgYEAt2rIJpfGyp+zftUlAphY
XqToxELZG8l8pQWyH1WT4JkextGMYIvW/Ok59YHlqEr3ZkiCqOAdY8JgcRkfUKpw
ijSjPh7nCB1RD+2CKg8BEItmJMxTMy6K6N+qDptqKqVBAwBku2I389a5UOOu92Sq
JIygWv7ohRhhieEtT94TmikCgYEAxpomkJtB2qpB6XQSKEbi3JZHnjTz6b/nXRtI
l3YRLMzviSsjFyQOJgEFVHrFZQh+4nsK8WPC41V4qYrofiybQCQL9sTtgxg4/Cho
szz68OTOp+10pNPxHwbF55olHUKsURbBvq56DcRNkREttlEZOio1OAhvTKLWmlDD
8wz4py0CgYEAtFFj1yy5+sMFltznnbTQRQSHL1RPBW+DDl8L8N4iYNpn2jXdXzHZ
cacKwSEhb/Jfk1hWtMhMz6Rqay+J6L7p0M72+u5B9elsVLw2LxvKNTw4A+Ud5/y+
8psOAgF5tfAPoxS+guVqadLGbnj94dqr5jKl1cZ2q9lovcI1o5SeQpw=
-----END RSA PRIVATE KEY-----"#;
const TEST_PRIVATE_KEY_INVALID: &str = r#"-----BEGIN RSA PRIVATE KEY-----
INVALID KEY DATA HERE
-----END RSA PRIVATE KEY-----"#;
fn test_private_key() -> PrivateKey {
PrivateKey::from_pem(TEST_PRIVATE_KEY_PEM).expect("Test key should be valid")
}
mod jwt_generator_tests {
use super::*;
#[tokio::test]
async fn test_generate_jwt_with_valid_credentials() {
let app_id = GitHubAppId::new(123456);
let private_key = test_private_key();
let generator = RS256JwtGenerator::new(private_key);
let result = generator.generate_jwt(app_id).await;
assert!(result.is_ok(), "JWT generation should succeed");
let jwt = result.unwrap();
assert!(!jwt.is_expired(), "JWT should not be immediately expired");
assert_eq!(jwt.app_id(), app_id, "JWT app_id should match input");
let time_until_expiry = jwt.time_until_expiry();
assert!(
time_until_expiry <= Duration::minutes(10),
"JWT expiration should not exceed 10 minutes (GitHub requirement)"
);
assert!(
time_until_expiry > Duration::minutes(0),
"JWT should have positive time until expiry"
);
}
#[tokio::test]
async fn test_jwt_has_valid_structure() {
let app_id = GitHubAppId::new(789);
let private_key = test_private_key();
let generator = RS256JwtGenerator::new(private_key);
let jwt = generator.generate_jwt(app_id).await.unwrap();
let token_str = jwt.token();
let parts: Vec<&str> = token_str.split('.').collect();
assert_eq!(
parts.len(),
3,
"JWT should have exactly 3 parts (header.payload.signature)"
);
assert!(!parts[0].is_empty(), "JWT header should not be empty");
assert!(!parts[1].is_empty(), "JWT payload should not be empty");
assert!(!parts[2].is_empty(), "JWT signature should not be empty");
}
#[tokio::test]
async fn test_jwt_issued_at_is_current() {
let app_id = GitHubAppId::new(555);
let private_key = test_private_key();
let generator = RS256JwtGenerator::new(private_key);
let before = Utc::now();
let jwt = generator.generate_jwt(app_id).await.unwrap();
let after = Utc::now();
let issued_at = jwt.issued_at();
assert!(
issued_at >= before - Duration::seconds(5),
"JWT issued_at should be recent"
);
assert!(
issued_at <= after + Duration::seconds(5),
"JWT issued_at should not be in the future"
);
}
#[tokio::test]
async fn test_jwt_expiration_matches_duration() {
let app_id = GitHubAppId::new(999);
let private_key = test_private_key();
let generator = RS256JwtGenerator::new(private_key);
let jwt = generator.generate_jwt(app_id).await.unwrap();
let expected_expiry = jwt.issued_at() + generator.expiration_duration();
let actual_expiry = jwt.expires_at();
let diff = (actual_expiry - expected_expiry).num_seconds().abs();
assert!(
diff <= 1,
"JWT expiration should be issued_at + duration (tolerance: 1s)"
);
}
#[tokio::test]
async fn test_jwt_with_custom_expiration() {
let app_id = GitHubAppId::new(111);
let private_key = test_private_key();
let custom_duration = Duration::minutes(8);
let generator = RS256JwtGenerator::with_expiration(private_key, custom_duration);
let jwt = generator.generate_jwt(app_id).await.unwrap();
let time_until_expiry = jwt.time_until_expiry();
assert!(
time_until_expiry <= Duration::minutes(8) + Duration::seconds(5),
"JWT should expire in approximately 8 minutes"
);
assert!(
time_until_expiry >= Duration::minutes(8) - Duration::seconds(5),
"JWT expiration should match custom duration"
);
}
#[tokio::test]
#[should_panic(expected = "JWT expiration cannot exceed 10 minutes")]
async fn test_jwt_rejects_expiration_over_10_minutes() {
let private_key = test_private_key();
let _ = RS256JwtGenerator::with_expiration(private_key, Duration::minutes(11));
}
#[tokio::test]
async fn test_multiple_jwt_generations_are_unique() {
let app_id = GitHubAppId::new(222);
let private_key = test_private_key();
let generator = RS256JwtGenerator::new(private_key);
let jwt1 = generator.generate_jwt(app_id).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
let jwt2 = generator.generate_jwt(app_id).await.unwrap();
assert_ne!(
jwt1.token(),
jwt2.token(),
"Successive JWT generations should produce different tokens"
);
}
#[tokio::test]
async fn test_jwt_generation_with_different_app_ids() {
let private_key = test_private_key();
let generator = RS256JwtGenerator::new(private_key);
let app_id_1 = GitHubAppId::new(111);
let app_id_2 = GitHubAppId::new(222);
let jwt1 = generator.generate_jwt(app_id_1).await.unwrap();
let jwt2 = generator.generate_jwt(app_id_2).await.unwrap();
assert_eq!(jwt1.app_id(), app_id_1);
assert_eq!(jwt2.app_id(), app_id_2);
assert_ne!(jwt1.token(), jwt2.token());
}
}
mod private_key_tests {
use super::*;
#[tokio::test]
async fn test_generate_jwt_with_invalid_private_key() {
let result = PrivateKey::from_pem(TEST_PRIVATE_KEY_INVALID);
assert!(
result.is_err(),
"Invalid private key should be rejected during parsing"
);
let err = result.unwrap_err();
let err_msg = format!("{:?}", err);
assert!(
!err_msg.contains("INVALID KEY DATA"),
"Error message should not expose key content"
);
}
#[test]
fn test_private_key_from_valid_pem() {
let result = PrivateKey::from_pem(TEST_PRIVATE_KEY_PEM);
assert!(result.is_ok(), "Valid PEM key should load successfully");
}
#[test]
fn test_private_key_from_malformed_pem() {
let malformed_pem = "NOT A VALID PEM KEY";
let result = PrivateKey::from_pem(malformed_pem);
assert!(result.is_err(), "Malformed PEM should be rejected");
}
#[test]
fn test_private_key_from_empty_pem() {
let empty_pem = "";
let result = PrivateKey::from_pem(empty_pem);
assert!(result.is_err(), "Empty PEM should be rejected");
}
#[test]
fn test_private_key_from_pem_with_whitespace() {
let pem_with_whitespace = format!("\n\n{}\n\n", TEST_PRIVATE_KEY_PEM);
let result = PrivateKey::from_pem(&pem_with_whitespace);
assert!(result.is_ok(), "PEM parser should handle extra whitespace");
}
#[test]
fn test_private_key_debug_redaction() {
let key = test_private_key();
let debug_output = format!("{:?}", key);
assert!(
!debug_output.contains("MIIEpAIBAAKCAQEA"),
"Debug output should not expose key material"
);
assert!(
debug_output.contains("<REDACTED>") || debug_output.contains("PrivateKey"),
"Debug output should indicate redaction or type"
);
}
}
mod jwt_claims_tests {
use super::*;
#[test]
fn test_jwt_claims_construction() {
let app_id = GitHubAppId::new(12345);
let now = Utc::now();
let iat = now.timestamp();
let exp = (now + Duration::minutes(10)).timestamp();
let claims = JwtClaims {
iss: app_id,
iat,
exp,
};
assert_eq!(claims.iss, app_id);
assert_eq!(claims.iat, iat);
assert_eq!(claims.exp, exp);
}
#[test]
fn test_jwt_claims_serialization() {
let app_id = GitHubAppId::new(67890);
let now = Utc::now();
let claims = JwtClaims {
iss: app_id,
iat: now.timestamp(),
exp: (now + Duration::minutes(10)).timestamp(),
};
let json = serde_json::to_string(&claims);
assert!(json.is_ok(), "JWT claims should serialize to JSON");
let json_str = json.unwrap();
assert!(json_str.contains("\"iss\""));
assert!(json_str.contains("\"iat\""));
assert!(json_str.contains("\"exp\""));
}
}
mod expiration_tests {
use super::*;
#[tokio::test]
async fn test_jwt_expires_soon_detection() {
let app_id = GitHubAppId::new(333);
let private_key = test_private_key();
let generator = RS256JwtGenerator::with_expiration(private_key, Duration::minutes(2));
let jwt = generator.generate_jwt(app_id).await.unwrap();
assert!(
!jwt.expires_soon(Duration::seconds(30)),
"JWT should not expire soon with small margin (30s)"
);
assert!(
jwt.expires_soon(Duration::minutes(3)),
"JWT should expire soon with large margin (3 min)"
);
}
#[tokio::test]
async fn test_jwt_time_until_expiry() {
let app_id = GitHubAppId::new(444);
let private_key = test_private_key();
let duration = Duration::minutes(5);
let generator = RS256JwtGenerator::with_expiration(private_key, duration);
let jwt = generator.generate_jwt(app_id).await.unwrap();
let time_remaining = jwt.time_until_expiry();
assert!(
time_remaining <= Duration::minutes(5),
"Time until expiry should not exceed configured duration"
);
assert!(
time_remaining >= Duration::minutes(4) + Duration::seconds(55),
"Time until expiry should be close to configured duration"
);
}
}
mod trait_implementation_tests {
use super::*;
#[tokio::test]
async fn test_jwt_generator_trait_object() {
let app_id = GitHubAppId::new(555);
let private_key = test_private_key();
let generator = RS256JwtGenerator::new(private_key);
let trait_obj: &dyn JwtGenerator = &generator;
let jwt = trait_obj.generate_jwt(app_id).await.unwrap();
assert_eq!(jwt.app_id(), app_id);
}
#[test]
fn test_jwt_generator_expiration_duration() {
let private_key = test_private_key();
let generator = RS256JwtGenerator::new(private_key);
assert_eq!(
generator.expiration_duration(),
Duration::minutes(10),
"Default expiration should be 10 minutes"
);
}
#[test]
fn test_jwt_generator_custom_expiration_duration() {
let private_key = test_private_key();
let custom_duration = Duration::minutes(7);
let generator = RS256JwtGenerator::with_expiration(private_key, custom_duration);
assert_eq!(
generator.expiration_duration(),
custom_duration,
"Custom expiration should be accessible"
);
}
}