//! Comprehensive tests for el-identity. //! //! All tests use `MockEngramClient` — no running Engram instance needed. #[cfg(test)] mod tests { use std::sync::Arc; use uuid::Uuid; use crate::{ context::IdentityContext, engram::{EngramClient, MockEngramClient}, error::IdentityError, guard::AuthGuard, nodes::{ OAuthToken, Role, Scope, Session, User, EDGE_GRANTS, EDGE_HAS_ROLE, EDGE_HAS_SESSION, NODE_USER, NODE_ROLE, NODE_SCOPE, }, oauth::{PkceChallenge, hash_token}, provider::{GoogleOAuth, AppleOAuth, GithubOAuth, OAuthProvider}, session::SessionManager, }; // ── Test helpers ────────────────────────────────────────────────────────── fn make_client() -> Arc { Arc::new(MockEngramClient::new()) } fn make_session_manager(client: Arc) -> Arc { Arc::new(SessionManager::new(client as Arc)) } fn setup_auth_guard() -> (Arc, Arc, AuthGuard) { let client = make_client(); let sm = make_session_manager(client.clone()); let guard = AuthGuard::new( client.clone() as Arc, sm.clone(), ); (client, sm, guard) } fn make_user() -> User { User::new("alice@example.com", "Alice") } fn make_role_admin() -> Role { Role::new("admin") .with_permissions(vec!["users:read", "users:write", "orders:delete"]) } fn make_role_viewer() -> Role { Role::new("viewer") .with_permissions(vec!["users:read", "orders:read"]) } fn make_scope_email() -> Scope { Scope::new("email", "Access email address") } fn make_scope_profile() -> Scope { Scope::new("profile", "Access profile information") } // ── Node tests ──────────────────────────────────────────────────────────── // Test 1: User node creation and serialization round-trip #[test] fn test_user_node_round_trip() { let user = User::new("bob@example.com", "Bob"); let value = user.to_value(); let decoded = User::from_value(&value).expect("should decode User"); assert_eq!(decoded.email, "bob@example.com"); assert_eq!(decoded.display_name, "Bob"); assert_eq!(decoded.id, user.id); } // Test 2: Role node with permissions serializes correctly #[test] fn test_role_node_permissions() { let role = make_role_admin(); assert!(role.has_permission("users:write")); assert!(role.has_permission("orders:delete")); assert!(!role.has_permission("superadmin")); let value = role.to_value(); let decoded = Role::from_value(&value).expect("should decode Role"); assert_eq!(decoded.name, "admin"); assert_eq!(decoded.permissions.len(), 3); } // Test 3: Scope node round-trip #[test] fn test_scope_node_round_trip() { let scope = make_scope_email(); let value = scope.to_value(); let decoded = Scope::from_value(&value).expect("should decode Scope"); assert_eq!(decoded.name, "email"); assert_eq!(decoded.description, "Access email address"); } // Test 4: Session expiry detection #[test] fn test_session_expiry() { let user_id = Uuid::new_v4(); // TTL = -1 → already expired let session = Session::new(user_id, -1, None); assert!(session.is_expired(), "session with negative TTL should be expired"); let fresh = Session::new(user_id, 3600, None); assert!(!fresh.is_expired(), "fresh session should not be expired"); } // Test 5: OAuthToken expiry detection #[test] fn test_oauth_token_expiry() { use chrono::{Duration, Utc}; let past = Utc::now() - Duration::seconds(1); let token = OAuthToken::new("google", "hash123", None, past, vec![]); assert!(token.is_expired()); let future = Utc::now() + Duration::seconds(3600); let fresh = OAuthToken::new("google", "hash456", None, future, vec!["email".into()]); assert!(!fresh.is_expired()); assert!(fresh.has_scope("email")); assert!(!fresh.has_scope("openid")); } // ── MockEngramClient tests ───────────────────────────────────────────────── // Test 6: MockEngramClient create and retrieve node #[test] fn test_mock_client_create_and_get() { let client = make_client(); let user = make_user(); let id = client.create_node(NODE_USER, user.to_value()).unwrap(); assert_eq!(id, user.id.to_string()); let retrieved = client.get_node(&id).unwrap().expect("should find node"); let decoded = User::from_value(&retrieved).expect("should decode"); assert_eq!(decoded.email, user.email); } // Test 7: MockEngramClient get_node returns None for unknown ID #[test] fn test_mock_client_get_unknown_returns_none() { let client = make_client(); let result = client.get_node("nonexistent-id").unwrap(); assert!(result.is_none()); } // Test 8: MockEngramClient find_nodes with query #[test] fn test_mock_client_find_nodes() { let client = make_client(); let u1 = User::new("alice@example.com", "Alice"); let u2 = User::new("bob@example.com", "Bob"); client.create_node(NODE_USER, u1.to_value()).unwrap(); client.create_node(NODE_USER, u2.to_value()).unwrap(); let query = serde_json::json!({"email": "alice@example.com"}); let results = client.find_nodes(NODE_USER, query).unwrap(); assert_eq!(results.len(), 1); let found = User::from_value(&results[0]).unwrap(); assert_eq!(found.display_name, "Alice"); } // Test 9: MockEngramClient delete_node removes node and edges #[test] fn test_mock_client_delete_node() { let client = make_client(); let user = make_user(); let role = make_role_admin(); client.create_node(NODE_USER, user.to_value()).unwrap(); client.create_node(NODE_ROLE, role.to_value()).unwrap(); client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap(); assert_eq!(client.count_edges(EDGE_HAS_ROLE), 1); client.delete_node(&user.id.to_string()).unwrap(); assert!(client.get_node(&user.id.to_string()).unwrap().is_none()); // Edge should also be gone assert_eq!(client.count_edges(EDGE_HAS_ROLE), 0); } // Test 10: MockEngramClient find_connected traverses edges #[test] fn test_mock_client_find_connected() { let client = make_client(); let user = make_user(); let role1 = make_role_admin(); let role2 = make_role_viewer(); client.create_node(NODE_USER, user.to_value()).unwrap(); client.create_node(NODE_ROLE, role1.to_value()).unwrap(); client.create_node(NODE_ROLE, role2.to_value()).unwrap(); client.create_edge(&user.id.to_string(), &role1.id.to_string(), EDGE_HAS_ROLE).unwrap(); client.create_edge(&user.id.to_string(), &role2.id.to_string(), EDGE_HAS_ROLE).unwrap(); let roles = client.find_connected(&user.id.to_string(), EDGE_HAS_ROLE).unwrap(); assert_eq!(roles.len(), 2); } // ── SessionManager tests ─────────────────────────────────────────────────── // Test 11: SessionManager creates session and connects to user #[test] fn test_session_manager_create() { let client = make_client(); let user = make_user(); client.create_node(NODE_USER, user.to_value()).unwrap(); let sm = make_session_manager(client.clone()); let session = sm.create(user.id, Some("127.0.0.1".into())).unwrap(); assert_eq!(session.user_id, user.id); assert_eq!(session.ip_address, Some("127.0.0.1".to_string())); // Should have created a has_session edge assert_eq!(client.count_edges(EDGE_HAS_SESSION), 1); } // Test 12: SessionManager validate succeeds for fresh session #[test] fn test_session_manager_validate_fresh() { let client = make_client(); let user = make_user(); client.create_node(NODE_USER, user.to_value()).unwrap(); let sm = make_session_manager(client.clone()); let session = sm.create(user.id, None).unwrap(); let validated = sm.validate(&session.id.to_string()).unwrap(); assert_eq!(validated.id, session.id); } // Test 13: SessionManager validate fails for expired session #[test] fn test_session_manager_validate_expired() { let client = make_client(); let sm = Arc::new(SessionManager::new(client.clone() as Arc).with_ttl(-1)); let user = make_user(); client.create_node(NODE_USER, user.to_value()).unwrap(); let session = sm.create(user.id, None).unwrap(); let result = sm.validate(&session.id.to_string()); assert!(matches!(result, Err(IdentityError::SessionExpired))); } // Test 14: SessionManager validate fails for missing session #[test] fn test_session_manager_validate_missing() { let client = make_client(); let sm = make_session_manager(client.clone()); let result = sm.validate("nonexistent-session-id"); assert!(matches!(result, Err(IdentityError::SessionNotFound))); } // Test 15: SessionManager invalidate removes session #[test] fn test_session_manager_invalidate() { let client = make_client(); let user = make_user(); client.create_node(NODE_USER, user.to_value()).unwrap(); let sm = make_session_manager(client.clone()); let session = sm.create(user.id, None).unwrap(); let session_id = session.id.to_string(); sm.invalidate(&session_id).unwrap(); assert!(matches!(sm.validate(&session_id), Err(IdentityError::SessionNotFound))); } // Test 16: SessionManager list_for_user returns active sessions #[test] fn test_session_manager_list_for_user() { let client = make_client(); let user = make_user(); client.create_node(NODE_USER, user.to_value()).unwrap(); let sm = make_session_manager(client.clone()); sm.create(user.id, Some("1.1.1.1".into())).unwrap(); sm.create(user.id, Some("2.2.2.2".into())).unwrap(); let sessions = sm.list_for_user(&user.id.to_string()).unwrap(); assert_eq!(sessions.len(), 2); } // ── AuthGuard tests ─────────────────────────────────────────────────────── // Test 17: AuthGuard authenticates user with role and scope #[test] fn test_auth_guard_full_resolution() { let (client, sm, guard) = setup_auth_guard(); let user = make_user(); let role = make_role_admin(); let scope = make_scope_email(); // Register user, role, scope, and wire edges client.create_node(NODE_USER, user.to_value()).unwrap(); client.create_node(NODE_ROLE, role.to_value()).unwrap(); client.create_node(NODE_SCOPE, scope.to_value()).unwrap(); client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap(); client.create_edge(&role.id.to_string(), &scope.id.to_string(), EDGE_GRANTS).unwrap(); // Create session let session = sm.create(user.id, None).unwrap(); // Authenticate let ctx = guard.authenticate(&session.id.to_string()).unwrap(); assert_eq!(ctx.user.email, "alice@example.com"); assert_eq!(ctx.roles.len(), 1); assert!(ctx.has_role("admin")); assert!(ctx.has_scope("email")); assert!(ctx.has_permission("users:write")); } // Test 18: AuthGuard rejects unknown session #[test] fn test_auth_guard_rejects_unknown_session() { let (_, _, guard) = setup_auth_guard(); let result = guard.authenticate("no-such-session"); assert!(matches!(result, Err(IdentityError::SessionNotFound))); } // Test 19: AuthGuard require_role succeeds for correct role #[test] fn test_auth_guard_require_role_success() { let (client, sm, guard) = setup_auth_guard(); let user = make_user(); let role = make_role_admin(); client.create_node(NODE_USER, user.to_value()).unwrap(); client.create_node(NODE_ROLE, role.to_value()).unwrap(); client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap(); let session = sm.create(user.id, None).unwrap(); let ctx = guard.authenticate(&session.id.to_string()).unwrap(); assert!(guard.require_role(&ctx, "admin").is_ok()); } // Test 20: AuthGuard require_role fails for missing role #[test] fn test_auth_guard_require_role_forbidden() { let (client, sm, guard) = setup_auth_guard(); let user = make_user(); client.create_node(NODE_USER, user.to_value()).unwrap(); let session = sm.create(user.id, None).unwrap(); let ctx = guard.authenticate(&session.id.to_string()).unwrap(); let result = guard.require_role(&ctx, "superadmin"); assert!(matches!(result, Err(IdentityError::Forbidden(_)))); } // Test 21: AuthGuard require_scope fails for missing scope #[test] fn test_auth_guard_require_scope_forbidden() { let (client, sm, guard) = setup_auth_guard(); let user = make_user(); client.create_node(NODE_USER, user.to_value()).unwrap(); let session = sm.create(user.id, None).unwrap(); let ctx = guard.authenticate(&session.id.to_string()).unwrap(); let result = guard.require_scope(&ctx, "admin:write"); assert!(matches!(result, Err(IdentityError::ScopeForbidden(_)))); } // Test 22: AuthGuard register_user and assign_role #[test] fn test_auth_guard_register_and_assign_role() { let (_client, sm, guard) = setup_auth_guard(); let user = make_user(); let role = make_role_viewer(); guard.register_user(&user).unwrap(); guard.assign_role(&user.id.to_string(), &role).unwrap(); let session = sm.create(user.id, None).unwrap(); let ctx = guard.authenticate(&session.id.to_string()).unwrap(); assert!(ctx.has_role("viewer")); assert!(ctx.has_permission("users:read")); assert!(!ctx.has_permission("orders:delete")); } // ── PKCE tests ──────────────────────────────────────────────────────────── // Test 23: PkceChallenge generates valid verifier/challenge pair #[test] fn test_pkce_challenge_generates_valid_pair() { let pkce = PkceChallenge::generate(); assert!(!pkce.verifier.is_empty(), "verifier should not be empty"); assert!(!pkce.challenge.is_empty(), "challenge should not be empty"); assert_ne!(pkce.verifier, pkce.challenge, "verifier and challenge must differ"); assert_eq!(pkce.method, "S256"); } // Test 24: PkceChallenge verify matches correct verifier #[test] fn test_pkce_challenge_verify_correct() { let pkce = PkceChallenge::generate(); assert!(pkce.verify(&pkce.verifier.clone()), "should verify correct verifier"); } // Test 25: PkceChallenge verify rejects wrong verifier #[test] fn test_pkce_challenge_verify_rejects_wrong() { let pkce = PkceChallenge::generate(); assert!(!pkce.verify("wrong-verifier-value"), "should reject wrong verifier"); } // Test 26: hash_token is deterministic #[test] fn test_hash_token_deterministic() { let token = "my-secret-access-token"; let h1 = hash_token(token); let h2 = hash_token(token); assert_eq!(h1, h2, "hash must be deterministic"); assert_ne!(h1, token, "hash must differ from input"); assert_eq!(h1.len(), 64, "SHA-256 hex output is 64 chars"); } // ── Provider tests ──────────────────────────────────────────────────────── // Test 27: GoogleOAuth builds correct authorization URL #[test] fn test_google_oauth_authorization_url() { let provider = GoogleOAuth::new("client-id-123", "secret"); let url = provider.authorization_url( "https://myapp.com/callback", "pkce-challenge-abc", "state-xyz", &[], ); assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth")); assert!(url.contains("client_id=client-id-123")); assert!(url.contains("code_challenge=pkce-challenge-abc")); assert!(url.contains("code_challenge_method=S256")); assert!(url.contains("state=state-xyz")); assert!(url.contains("access_type=offline"), "Google needs offline for refresh tokens"); } // Test 28: GoogleOAuth default scopes include openid, email, profile #[test] fn test_google_oauth_default_scopes() { let provider = GoogleOAuth::new("id", "secret"); let scopes = provider.default_scopes(); assert!(scopes.contains(&"openid")); assert!(scopes.contains(&"email")); assert!(scopes.contains(&"profile")); } // Test 29: AppleOAuth builds correct authorization URL with form_post #[test] fn test_apple_oauth_authorization_url() { let provider = AppleOAuth::new("com.example.app", "jwt-secret", "TEAMID"); let url = provider.authorization_url( "https://myapp.com/callback", "challenge", "state", &[], ); assert!(url.starts_with("https://appleid.apple.com/auth/authorize")); assert!(url.contains("response_mode=form_post"), "Apple requires form_post"); } // Test 30: GithubOAuth ignores PKCE in URL (GitHub doesn't support it) #[test] fn test_github_oauth_no_pkce_in_url() { let provider = GithubOAuth::new("gh-client-id", "gh-secret"); let url = provider.authorization_url( "https://myapp.com/callback", "pkce-challenge-ignored", "state", &[], ); assert!(url.starts_with("https://github.com/login/oauth/authorize")); assert!(!url.contains("code_challenge"), "GitHub doesn't support PKCE"); assert!(url.contains("scope=user%3Aemail+read%3Auser") || url.contains("scope=")); } // Test 31: GithubOAuth refresh_token returns error #[test] fn test_github_oauth_refresh_returns_error() { let provider = GithubOAuth::new("id", "secret"); let result = provider.refresh_token("some-refresh-token"); assert!(matches!(result, Err(IdentityError::TokenRefreshFailed(_)))); } // ── IdentityContext tests ───────────────────────────────────────────────── // Test 32: IdentityContext role_names and scope_names #[test] fn test_identity_context_names() { let user = make_user(); let session = Session::new(user.id, 3600, None); let roles = vec![make_role_admin(), make_role_viewer()]; let scopes = vec![make_scope_email(), make_scope_profile()]; let ctx = IdentityContext::new(user.clone(), session, roles, scopes); let mut role_names = ctx.role_names(); role_names.sort(); assert_eq!(role_names, vec!["admin", "viewer"]); let mut scope_names = ctx.scope_names(); scope_names.sort(); assert_eq!(scope_names, vec!["email", "profile"]); } // Test 33: IdentityContext has_permission across multiple roles #[test] fn test_identity_context_permission_across_roles() { let user = make_user(); let session = Session::new(user.id, 3600, None); let roles = vec![make_role_viewer()]; // viewer has users:read and orders:read let ctx = IdentityContext::new(user, session, roles, vec![]); assert!(ctx.has_permission("users:read")); assert!(ctx.has_permission("orders:read")); assert!(!ctx.has_permission("users:write")); assert!(!ctx.has_permission("orders:delete")); } // Test 34: AuthGuard assign_scope_to_role wires scope into context #[test] fn test_auth_guard_assign_scope_to_role() { let (_client, sm, guard) = setup_auth_guard(); let user = make_user(); let role = make_role_admin(); let scope = make_scope_profile(); guard.register_user(&user).unwrap(); guard.assign_role(&user.id.to_string(), &role).unwrap(); guard.assign_scope_to_role(&role, &scope).unwrap(); let session = sm.create(user.id, None).unwrap(); let ctx = guard.authenticate(&session.id.to_string()).unwrap(); assert!(ctx.has_scope("profile")); } // Test 35: Two different users have independent sessions #[test] fn test_independent_user_sessions() { let (client, sm, guard) = setup_auth_guard(); let alice = User::new("alice@example.com", "Alice"); let bob = User::new("bob@example.com", "Bob"); let admin_role = make_role_admin(); client.create_node(NODE_USER, alice.to_value()).unwrap(); client.create_node(NODE_USER, bob.to_value()).unwrap(); client.create_node(NODE_ROLE, admin_role.to_value()).unwrap(); // Only Alice gets the admin role client.create_edge(&alice.id.to_string(), &admin_role.id.to_string(), EDGE_HAS_ROLE).unwrap(); let alice_session = sm.create(alice.id, None).unwrap(); let bob_session = sm.create(bob.id, None).unwrap(); let alice_ctx = guard.authenticate(&alice_session.id.to_string()).unwrap(); let bob_ctx = guard.authenticate(&bob_session.id.to_string()).unwrap(); assert!(alice_ctx.has_role("admin"), "Alice should have admin"); assert!(!bob_ctx.has_role("admin"), "Bob should not have admin"); assert_eq!(alice_ctx.email(), "alice@example.com"); assert_eq!(bob_ctx.email(), "bob@example.com"); } }