This repository has been archived on 2026-08-20. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
el-retired/ui/vessels/el-identity/src/tests.rs
T

564 lines
22 KiB
Rust

//! 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<MockEngramClient> {
Arc::new(MockEngramClient::new())
}
fn make_session_manager(client: Arc<MockEngramClient>) -> Arc<SessionManager> {
Arc::new(SessionManager::new(client as Arc<dyn crate::EngramClient>))
}
fn setup_auth_guard() -> (Arc<MockEngramClient>, Arc<SessionManager>, AuthGuard) {
let client = make_client();
let sm = make_session_manager(client.clone());
let guard = AuthGuard::new(
client.clone() as Arc<dyn crate::EngramClient>,
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<dyn crate::EngramClient>).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");
}
}