el-ui v2: universal platform, service bindings, AOP, auth, publish pipeline

This commit is contained in:
Will Anderson
2026-04-27 19:52:29 -05:00
parent 3bf3c02854
commit 69d1085d2d
51 changed files with 6653 additions and 1 deletions
+78
View File
@@ -0,0 +1,78 @@
//! Auth context — the current authenticated user and their roles/permissions.
/// The authenticated user.
#[derive(Debug, Clone)]
pub struct AuthUser {
pub id: String,
pub email: String,
pub name: String,
}
impl AuthUser {
pub fn new(
id: impl Into<String>,
email: impl Into<String>,
name: impl Into<String>,
) -> Self {
Self {
id: id.into(),
email: email.into(),
name: name.into(),
}
}
}
/// The auth context — populated by `AuthMiddleware` and available to all
/// components and services downstream in the request.
///
/// Passed as `ctx.metadata["user_id"]`, `ctx.metadata["roles"]` in the AOP
/// layer (see `el-aop`).
#[derive(Debug, Clone)]
pub struct AuthContext {
pub user: Option<AuthUser>,
pub roles: Vec<String>,
pub permissions: Vec<String>,
/// The raw token/session ID that was verified.
pub token: String,
}
impl AuthContext {
pub fn anonymous() -> Self {
Self {
user: None,
roles: Vec::new(),
permissions: Vec::new(),
token: String::new(),
}
}
pub fn authenticated(user: AuthUser, roles: Vec<String>, token: impl Into<String>) -> Self {
Self {
user: Some(user),
roles,
permissions: Vec::new(),
token: token.into(),
}
}
pub fn with_permissions(mut self, perms: Vec<String>) -> Self {
self.permissions = perms;
self
}
pub fn is_authenticated(&self) -> bool {
self.user.is_some()
}
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
pub fn has_permission(&self, permission: &str) -> bool {
self.permissions.iter().any(|p| p == permission)
}
pub fn user_id(&self) -> Option<&str> {
self.user.as_ref().map(|u| u.id.as_str())
}
}
+287
View File
@@ -0,0 +1,287 @@
//! JWT provider — sign and verify JSON Web Tokens.
//!
//! Uses HMAC-SHA256 (HS256) for signing. Does NOT use the `jsonwebtoken` crate
//! to keep dependencies minimal; implements the JWT spec directly.
//!
//! Format: base64url(header).base64url(payload).base64url(signature)
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// JWT claims payload.
#[derive(Debug, Clone)]
pub struct JwtClaims {
pub sub: String, // user ID
pub email: String,
pub name: String,
pub roles: Vec<String>,
pub iat: u64, // issued-at (unix seconds)
pub exp: u64, // expiry (unix seconds)
}
impl JwtClaims {
pub fn new(user: &AuthUser, roles: Vec<String>, ttl_seconds: u64) -> Self {
let now = unix_now();
Self {
sub: user.id.clone(),
email: user.email.clone(),
name: user.name.clone(),
roles,
iat: now,
exp: now + ttl_seconds,
}
}
pub fn is_expired(&self) -> bool {
unix_now() > self.exp
}
/// Serialize claims to JSON (manual, no serde dependency).
pub fn to_json(&self) -> String {
let roles_json = self
.roles
.iter()
.map(|r| format!("\"{}\"", r))
.collect::<Vec<_>>()
.join(",");
format!(
"{{\"sub\":\"{}\",\"email\":\"{}\",\"name\":\"{}\",\"roles\":[{}],\"iat\":{},\"exp\":{}}}",
self.sub, self.email, self.name, roles_json, self.iat, self.exp
)
}
/// Deserialize claims from JSON (manual parser).
pub fn from_json(json: &str) -> Option<Self> {
let sub = extract_str(json, "sub")?;
let email = extract_str(json, "email").unwrap_or_default();
let name = extract_str(json, "name").unwrap_or_default();
let iat = extract_u64(json, "iat").unwrap_or(0);
let exp = extract_u64(json, "exp").unwrap_or(0);
let roles = extract_str_array(json, "roles");
Some(Self { sub, email, name, roles, iat, exp })
}
}
/// JWT provider — issues and verifies HS256 JWTs.
pub struct JwtProvider {
secret: Vec<u8>,
/// Token TTL in seconds (default: 3600 = 1 hour).
pub ttl_seconds: u64,
}
impl JwtProvider {
pub fn new(secret: impl Into<Vec<u8>>) -> Self {
Self { secret: secret.into(), ttl_seconds: 3600 }
}
pub fn from_env(env_var: &str) -> AuthResult<Self> {
let secret = std::env::var(env_var).map_err(|_| {
AuthError::Config(format!("env var {} not set", env_var))
})?;
Ok(Self::new(secret.into_bytes()))
}
pub fn with_ttl(mut self, seconds: u64) -> Self {
self.ttl_seconds = seconds;
self
}
/// Sign a token with HMAC-SHA256.
fn sign(&self, header_payload: &str) -> String {
let mut mac = HmacSha256::new_from_slice(&self.secret)
.expect("HMAC can take key of any size");
mac.update(header_payload.as_bytes());
let result = mac.finalize();
base64url_encode(&result.into_bytes())
}
/// Encode a JWT token from claims.
pub fn encode(&self, claims: &JwtClaims) -> String {
let header = base64url_encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
let payload = base64url_encode(claims.to_json().as_bytes());
let header_payload = format!("{}.{}", header, payload);
let signature = self.sign(&header_payload);
format!("{}.{}", header_payload, signature)
}
/// Decode and verify a JWT token.
pub fn decode(&self, token: &str) -> AuthResult<JwtClaims> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err(AuthError::TokenInvalid("not a valid JWT".into()));
}
let header_payload = format!("{}.{}", parts[0], parts[1]);
let expected_sig = self.sign(&header_payload);
if !constant_time_eq(parts[2], &expected_sig) {
return Err(AuthError::TokenInvalid("signature mismatch".into()));
}
let payload_bytes = base64url_decode(parts[1])
.ok_or_else(|| AuthError::TokenInvalid("payload decode failed".into()))?;
let payload_str = String::from_utf8(payload_bytes)
.map_err(|_| AuthError::TokenInvalid("payload not utf8".into()))?;
let claims = JwtClaims::from_json(&payload_str)
.ok_or_else(|| AuthError::TokenInvalid("claims parse failed".into()))?;
if claims.is_expired() {
return Err(AuthError::TokenExpired);
}
Ok(claims)
}
}
impl AuthProvider for JwtProvider {
fn name(&self) -> &'static str {
"jwt"
}
fn verify(&self, token: &str) -> AuthResult<AuthContext> {
let claims = self.decode(token)?;
let user = AuthUser::new(&claims.sub, &claims.email, &claims.name);
Ok(AuthContext::authenticated(user, claims.roles, token))
}
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
let claims = JwtClaims::new(&user, Vec::new(), self.ttl_seconds);
Ok(self.encode(&claims))
}
fn revoke(&self, _token: &str) -> AuthResult<()> {
// JWTs are stateless — revocation requires a blocklist.
// TODO: maintain a revocation list (in-memory or Redis).
Ok(())
}
}
// ── Crypto helpers ─────────────────────────────────────────────────────────────
fn base64url_encode(input: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[((n >> 18) & 63) as usize] as char);
out.push(CHARS[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(CHARS[((n >> 6) & 63) as usize] as char);
}
if chunk.len() > 2 {
out.push(CHARS[(n & 63) as usize] as char);
}
}
out
}
fn base64url_decode(input: &str) -> Option<Vec<u8>> {
// Pad if needed
let mut s = input.replace('-', "+").replace('_', "/");
while s.len() % 4 != 0 {
s.push('=');
}
base64_decode_standard(&s)
}
fn base64_decode_standard(input: &str) -> Option<Vec<u8>> {
const TABLE: [u8; 128] = {
let mut t = [255u8; 128];
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut i = 0usize;
while i < chars.len() {
t[chars[i] as usize] = i as u8;
i += 1;
}
t
};
let input = input.trim_end_matches('=');
let mut out = Vec::new();
let bytes = input.as_bytes();
let mut i = 0;
while i + 3 < bytes.len() {
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?;
let d = TABLE.get(bytes[i+3] as usize).copied().filter(|&v| v != 255)?;
let n = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | (d as u32);
out.push((n >> 16) as u8);
out.push((n >> 8) as u8);
out.push(n as u8);
i += 4;
}
// Handle remaining bytes
if i + 2 == bytes.len() {
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
out.push(((a as u32) << 2 | (b as u32) >> 4) as u8);
} else if i + 3 == bytes.len() {
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?;
let n = ((a as u32) << 10) | ((b as u32) << 4) | ((c as u32) >> 2);
out.push((n >> 8) as u8);
out.push(n as u8);
}
Some(out)
}
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.bytes()
.zip(b.bytes())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
// ── Minimal JSON field extractors ─────────────────────────────────────────────
fn extract_str(json: &str, key: &str) -> Option<String> {
let pattern = format!("\"{}\":\"", key);
let start = json.find(&pattern)? + pattern.len();
let rest = &json[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn extract_u64(json: &str, key: &str) -> Option<u64> {
let pattern = format!("\"{}\":", key);
let start = json.find(&pattern)? + pattern.len();
let rest = &json[start..];
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
rest[..end].parse().ok()
}
fn extract_str_array(json: &str, key: &str) -> Vec<String> {
let pattern = format!("\"{}\":[", key);
let start = match json.find(&pattern) {
None => return Vec::new(),
Some(s) => s + pattern.len(),
};
let rest = &json[start..];
let end = rest.find(']').unwrap_or(rest.len());
let content = &rest[..end];
content
.split(',')
.filter_map(|s| {
let s = s.trim().trim_matches('"');
if s.is_empty() { None } else { Some(s.to_string()) }
})
.collect()
}
+60
View File
@@ -0,0 +1,60 @@
//! el-auth — Built-in authentication and authorization for el-ui.
//!
//! Not a library you add. Native to the framework.
//!
//! ```toml
//! [auth]
//! provider = "jwt"
//! jwt_secret_env = "JWT_SECRET"
//! session_store = "memory"
//! ```
pub mod context;
pub mod jwt;
pub mod middleware;
pub mod roles;
pub mod session;
pub use context::{AuthContext, AuthUser};
pub use jwt::{JwtClaims, JwtProvider};
pub use middleware::AuthMiddleware;
pub use roles::{Permission, Role, RoleRegistry};
pub use session::SessionProvider;
#[cfg(test)]
mod tests;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("invalid credentials")]
InvalidCredentials,
#[error("token expired")]
TokenExpired,
#[error("token invalid: {0}")]
TokenInvalid(String),
#[error("session not found")]
SessionNotFound,
#[error("forbidden: requires permission '{0}'")]
Forbidden(String),
#[error("auth configuration error: {0}")]
Config(String),
}
pub type AuthResult<T> = Result<T, AuthError>;
/// The AuthProvider trait — implemented by JWT, Session, OAuth providers.
pub trait AuthProvider: Send + Sync {
/// The provider name (e.g., "jwt", "session").
fn name(&self) -> &'static str;
/// Verify a token/session string and return the auth context.
fn verify(&self, token: &str) -> AuthResult<AuthContext>;
/// Issue a new token/session for an authenticated user.
fn issue(&self, user: AuthUser, role_registry: &RoleRegistry) -> AuthResult<String>;
/// Revoke a token/session (for logout).
fn revoke(&self, token: &str) -> AuthResult<()>;
}
+59
View File
@@ -0,0 +1,59 @@
//! Auth middleware — extracts and verifies auth tokens from requests.
//!
//! In an axum application:
//! ```text
//! let app = Router::new()
//! .route("/api/users", get(list_users))
//! .layer(AuthMiddleware::new(jwt_provider));
//! ```
//!
//! The middleware populates `AuthContext` from the `Authorization` header.
use crate::{AuthContext, AuthProvider, AuthResult};
use std::sync::Arc;
/// Auth middleware — wraps an auth provider to extract context from HTTP headers.
pub struct AuthMiddleware {
provider: Arc<dyn AuthProvider>,
}
impl AuthMiddleware {
pub fn new(provider: Arc<dyn AuthProvider>) -> Self {
Self { provider }
}
/// Extract and verify the auth token from an Authorization header value.
///
/// Supported formats:
/// - `Bearer <token>` — JWT or opaque token
/// - `Session <session_id>` — server-side session
pub fn authenticate_from_header(&self, authorization: Option<&str>) -> AuthResult<AuthContext> {
match authorization {
None => Ok(AuthContext::anonymous()),
Some(header) => {
let token = if let Some(t) = header.strip_prefix("Bearer ") {
t.trim()
} else if let Some(t) = header.strip_prefix("Session ") {
t.trim()
} else {
header.trim()
};
self.provider.verify(token)
}
}
}
/// Authenticate from a query parameter (for WebSocket upgrades where
/// Authorization headers can't be set from JS).
pub fn authenticate_from_query_param(&self, token: Option<&str>) -> AuthResult<AuthContext> {
match token {
None => Ok(AuthContext::anonymous()),
Some(t) => self.provider.verify(t),
}
}
/// Get the underlying provider name.
pub fn provider_name(&self) -> &'static str {
self.provider.name()
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Role and permission model.
use std::collections::HashMap;
/// A fine-grained permission (e.g., "read", "write", "delete").
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Permission(pub String);
impl Permission {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Permission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// A role that grants a set of permissions.
#[derive(Debug, Clone)]
pub struct Role {
pub name: String,
pub permissions: Vec<Permission>,
}
impl Role {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
permissions: Vec::new(),
}
}
pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
self.permissions.push(Permission::new(perm));
self
}
pub fn with_permissions(mut self, perms: Vec<impl Into<String>>) -> Self {
self.permissions.extend(perms.into_iter().map(Permission::new));
self
}
pub fn has_permission(&self, perm: &str) -> bool {
self.permissions.iter().any(|p| p.0 == perm)
}
}
/// The registry of all roles in the application.
#[derive(Debug, Default)]
pub struct RoleRegistry {
roles: HashMap<String, Role>,
}
impl RoleRegistry {
pub fn new() -> Self {
Self::default()
}
/// Register a role.
pub fn register(&mut self, role: Role) {
self.roles.insert(role.name.clone(), role);
}
/// Get a role by name.
pub fn get(&self, name: &str) -> Option<&Role> {
self.roles.get(name)
}
/// Check if the given role names grant the given permission.
pub fn has_permission(&self, role_names: &[String], permission: &str) -> bool {
role_names.iter().any(|role_name| {
self.roles
.get(role_name)
.map(|r| r.has_permission(permission))
.unwrap_or(false)
})
}
/// List all registered role names.
pub fn role_names(&self) -> Vec<&str> {
self.roles.keys().map(|s| s.as_str()).collect()
}
}
+105
View File
@@ -0,0 +1,105 @@
//! Session provider — server-side sessions stored in memory.
//!
//! In production, sessions are stored in Redis or Engram (configured via
//! `session_store = "redis"` or `session_store = "engram"` in `el.toml`).
//! This implementation uses in-memory storage for simplicity and testing.
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant},
};
struct SessionEntry {
context: AuthContext,
created_at: Instant,
ttl: Duration,
}
impl SessionEntry {
fn is_expired(&self) -> bool {
self.created_at.elapsed() > self.ttl
}
}
/// In-memory session store.
pub struct SessionProvider {
sessions: Mutex<HashMap<String, SessionEntry>>,
pub ttl: Duration,
}
impl SessionProvider {
pub fn new() -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
ttl: Duration::from_secs(3600),
}
}
pub fn with_ttl(mut self, seconds: u64) -> Self {
self.ttl = Duration::from_secs(seconds);
self
}
fn generate_session_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
format!("sess-{:016x}", nanos as u64 ^ 0x7b5e3f1a2c4d6890)
}
/// Count active (non-expired) sessions.
pub fn active_session_count(&self) -> usize {
let sessions = self.sessions.lock().expect("session lock poisoned");
sessions.values().filter(|s| !s.is_expired()).count()
}
}
impl Default for SessionProvider {
fn default() -> Self {
Self::new()
}
}
impl AuthProvider for SessionProvider {
fn name(&self) -> &'static str {
"session"
}
fn verify(&self, session_id: &str) -> AuthResult<AuthContext> {
let mut sessions = self.sessions.lock().expect("session lock poisoned");
// Clean expired sessions
sessions.retain(|_, v| !v.is_expired());
sessions
.get(session_id)
.filter(|s| !s.is_expired())
.map(|s| s.context.clone())
.ok_or(AuthError::SessionNotFound)
}
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
let session_id = Self::generate_session_id();
let ctx = AuthContext::authenticated(user, Vec::new(), &session_id);
let entry = SessionEntry {
context: ctx,
created_at: Instant::now(),
ttl: self.ttl,
};
self.sessions
.lock()
.expect("session lock poisoned")
.insert(session_id.clone(), entry);
Ok(session_id)
}
fn revoke(&self, session_id: &str) -> AuthResult<()> {
self.sessions
.lock()
.expect("session lock poisoned")
.remove(session_id);
Ok(())
}
}
+252
View File
@@ -0,0 +1,252 @@
//! Tests for el-auth.
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::{
context::{AuthContext, AuthUser},
jwt::{JwtClaims, JwtProvider},
middleware::AuthMiddleware,
roles::{Permission, Role, RoleRegistry},
session::SessionProvider,
AuthError, AuthProvider,
};
fn test_user() -> AuthUser {
AuthUser::new("user-1", "alice@example.com", "Alice")
}
fn test_provider() -> JwtProvider {
JwtProvider::new(b"super-secret-key-for-testing-only".to_vec())
.with_ttl(3600)
}
fn registry() -> RoleRegistry {
let mut r = RoleRegistry::new();
r.register(
Role::new("admin")
.with_permissions(vec!["read", "write", "delete"]),
);
r.register(
Role::new("user")
.with_permissions(vec!["read"]),
);
r
}
// ── Test 1: JWT round-trip — sign and verify ──────────────────────────────
#[test]
fn test_jwt_sign_and_verify() {
let provider = test_provider();
let user = test_user();
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
let token = provider.encode(&claims);
let decoded = provider.decode(&token).unwrap();
assert_eq!(decoded.sub, "user-1");
assert_eq!(decoded.email, "alice@example.com");
assert_eq!(decoded.roles, vec!["user"]);
}
// ── Test 2: JWT invalid signature is rejected ─────────────────────────────
#[test]
fn test_jwt_invalid_signature() {
let provider = test_provider();
let other_provider = JwtProvider::new(b"different-secret".to_vec());
let user = test_user();
let claims = JwtClaims::new(&user, vec![], 3600);
let token = other_provider.encode(&claims);
let result = provider.decode(&token);
assert!(matches!(result, Err(AuthError::TokenInvalid(_))));
}
// ── Test 3: JWT expired token is rejected ────────────────────────────────
#[test]
fn test_jwt_expired_token() {
let provider = test_provider();
let user = test_user();
// TTL of 0 — expires immediately
let claims = JwtClaims::new(&user, vec![], 0);
let token = provider.encode(&claims);
// Wait a moment (in tests, just check the claims are_expired)
assert!(claims.is_expired() || {
std::thread::sleep(std::time::Duration::from_millis(1100));
true
});
let result = provider.decode(&token);
assert!(matches!(result, Err(AuthError::TokenExpired) | Err(AuthError::TokenInvalid(_))));
}
// ── Test 4: JWT verify returns correct AuthContext ───────────────────────
#[test]
fn test_jwt_verify_returns_auth_context() {
let provider = test_provider();
let user = test_user();
let reg = registry();
let token = provider.issue(user, &reg).unwrap();
let ctx = provider.verify(&token).unwrap();
assert!(ctx.is_authenticated());
assert_eq!(ctx.user_id().unwrap(), "user-1");
}
// ── Test 5: AuthContext::has_role works ──────────────────────────────────
#[test]
fn test_auth_context_has_role() {
let ctx = AuthContext::authenticated(
test_user(),
vec!["admin".into(), "user".into()],
"tok",
);
assert!(ctx.has_role("admin"));
assert!(ctx.has_role("user"));
assert!(!ctx.has_role("superadmin"));
}
// ── Test 6: AuthContext::has_permission works ─────────────────────────────
#[test]
fn test_auth_context_has_permission() {
let ctx = AuthContext::authenticated(test_user(), vec!["admin".into()], "tok")
.with_permissions(vec!["read".into(), "write".into(), "delete".into()]);
assert!(ctx.has_permission("read"));
assert!(ctx.has_permission("delete"));
assert!(!ctx.has_permission("sudo"));
}
// ── Test 7: AuthContext::anonymous is not authenticated ───────────────────
#[test]
fn test_anonymous_context() {
let ctx = AuthContext::anonymous();
assert!(!ctx.is_authenticated());
assert!(ctx.user_id().is_none());
}
// ── Test 8: Role has_permission ──────────────────────────────────────────
#[test]
fn test_role_has_permission() {
let role = Role::new("editor")
.with_permissions(vec!["read", "write"]);
assert!(role.has_permission("read"));
assert!(role.has_permission("write"));
assert!(!role.has_permission("delete"));
}
// ── Test 9: RoleRegistry::has_permission checks across roles ─────────────
#[test]
fn test_role_registry_permission_check() {
let reg = registry();
let roles = vec!["user".to_string()];
assert!(reg.has_permission(&roles, "read"));
assert!(!reg.has_permission(&roles, "delete"));
let admin_roles = vec!["admin".to_string()];
assert!(reg.has_permission(&admin_roles, "delete"));
}
// ── Test 10: SessionProvider issue and verify ─────────────────────────────
#[test]
fn test_session_issue_and_verify() {
let provider = SessionProvider::new();
let reg = registry();
let session_id = provider.issue(test_user(), &reg).unwrap();
let ctx = provider.verify(&session_id).unwrap();
assert!(ctx.is_authenticated());
assert_eq!(ctx.user_id().unwrap(), "user-1");
}
// ── Test 11: SessionProvider revoke removes session ───────────────────────
#[test]
fn test_session_revoke() {
let provider = SessionProvider::new();
let reg = registry();
let session_id = provider.issue(test_user(), &reg).unwrap();
provider.revoke(&session_id).unwrap();
let result = provider.verify(&session_id);
assert!(matches!(result, Err(AuthError::SessionNotFound)));
}
// ── Test 12: SessionProvider unknown session returns error ────────────────
#[test]
fn test_session_unknown() {
let provider = SessionProvider::new();
let result = provider.verify("nonexistent-session-id");
assert!(matches!(result, Err(AuthError::SessionNotFound)));
}
// ── Test 13: AuthMiddleware extracts Bearer token ─────────────────────────
#[test]
fn test_middleware_extracts_bearer_token() {
let provider = Arc::new(test_provider());
let user = test_user();
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
let token = provider.encode(&claims);
let middleware = AuthMiddleware::new(provider);
let header = format!("Bearer {}", token);
let ctx = middleware.authenticate_from_header(Some(&header)).unwrap();
assert!(ctx.is_authenticated());
}
// ── Test 14: AuthMiddleware with no header returns anonymous ──────────────
#[test]
fn test_middleware_no_header_anonymous() {
let provider = Arc::new(test_provider());
let middleware = AuthMiddleware::new(provider);
let ctx = middleware.authenticate_from_header(None).unwrap();
assert!(!ctx.is_authenticated());
}
// ── Test 15: Permission Display ───────────────────────────────────────────
#[test]
fn test_permission_display() {
let perm = Permission::new("write");
assert_eq!(perm.to_string(), "write");
assert_eq!(perm.as_str(), "write");
}
// ── Test 16: JwtClaims::to_json and from_json round-trip ─────────────────
#[test]
fn test_jwt_claims_json_round_trip() {
let user = test_user();
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
let json = claims.to_json();
let decoded = JwtClaims::from_json(&json).unwrap();
assert_eq!(decoded.sub, "user-1");
assert_eq!(decoded.email, "alice@example.com");
assert_eq!(decoded.roles, vec!["admin", "user"]);
}
// ── Test 17: JWT with multiple roles ─────────────────────────────────────
#[test]
fn test_jwt_multiple_roles() {
let provider = test_provider();
let user = test_user();
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
let token = provider.encode(&claims);
let decoded = provider.decode(&token).unwrap();
assert_eq!(decoded.roles.len(), 2);
assert!(decoded.roles.contains(&"admin".to_string()));
}
// ── Test 18: RoleRegistry::role_names lists all roles ────────────────────
#[test]
fn test_role_registry_names() {
let reg = registry();
let mut names = reg.role_names();
names.sort();
assert_eq!(names, vec!["admin", "user"]);
}
// ── Test 19: SessionProvider TTL configuration ────────────────────────────
#[test]
fn test_session_ttl_config() {
let provider = SessionProvider::new().with_ttl(7200);
assert_eq!(provider.ttl.as_secs(), 7200);
}
// ── Test 20: AuthMiddleware provider_name returns correct name ────────────
#[test]
fn test_middleware_provider_name() {
let jwt_provider = Arc::new(test_provider());
let middleware = AuthMiddleware::new(jwt_provider);
assert_eq!(middleware.provider_name(), "jwt");
}
}