306 lines
13 KiB
Rust
306 lines
13 KiB
Rust
//! Tests for el-aop.
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use crate::{
|
|
aspects::*,
|
|
chain::AspectChain,
|
|
registry::AspectRegistry,
|
|
AopError, Aspect, InvocationContext, InvocationResult,
|
|
};
|
|
|
|
fn succeed_proceed(value: impl Into<String> + Clone) -> crate::ProceedFn {
|
|
let v = value.into();
|
|
Box::new(move |_ctx| Ok(InvocationResult::new(v.clone())))
|
|
}
|
|
|
|
fn fail_proceed(msg: impl Into<String> + Clone) -> crate::ProceedFn {
|
|
let m = msg.into();
|
|
Box::new(move |_ctx| Err(AopError::Aspect(m.clone())))
|
|
}
|
|
|
|
fn ctx(target: &str, method: &str) -> InvocationContext {
|
|
InvocationContext::new(target, method)
|
|
}
|
|
|
|
fn authed_ctx(target: &str, method: &str) -> InvocationContext {
|
|
ctx(target, method).with_meta("user_id", "user-123")
|
|
}
|
|
|
|
fn admin_ctx(target: &str, method: &str) -> InvocationContext {
|
|
ctx(target, method)
|
|
.with_meta("user_id", "admin-1")
|
|
.with_meta("roles", "admin,user")
|
|
}
|
|
|
|
// ── Test 1: AuthenticateAspect rejects unauthenticated calls ─────────────
|
|
#[test]
|
|
fn test_authenticate_rejects_unauthenticated() {
|
|
let aspect = AuthenticateAspect;
|
|
let mut ctx = ctx("AdminDashboard", "load");
|
|
let result = aspect.before(&mut ctx);
|
|
assert!(result.is_err());
|
|
assert!(matches!(result, Err(AopError::Unauthenticated)));
|
|
}
|
|
|
|
// ── Test 2: AuthenticateAspect allows authenticated calls ─────────────────
|
|
#[test]
|
|
fn test_authenticate_allows_authenticated() {
|
|
let aspect = AuthenticateAspect;
|
|
let mut ctx = authed_ctx("AdminDashboard", "load");
|
|
let result = aspect.before(&mut ctx);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
// ── Test 3: AuthorizeAspect rejects wrong role ────────────────────────────
|
|
#[test]
|
|
fn test_authorize_rejects_wrong_role() {
|
|
let aspect = AuthorizeAspect::new("admin");
|
|
let mut ctx = authed_ctx("Dashboard", "delete").with_meta("roles", "user");
|
|
let result = aspect.before(&mut ctx);
|
|
assert!(matches!(result, Err(AopError::Forbidden { .. })));
|
|
}
|
|
|
|
// ── Test 4: AuthorizeAspect allows correct role ───────────────────────────
|
|
#[test]
|
|
fn test_authorize_allows_correct_role() {
|
|
let aspect = AuthorizeAspect::new("admin");
|
|
let mut ctx = admin_ctx("Dashboard", "delete");
|
|
let result = aspect.before(&mut ctx);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
// ── Test 5: CacheAspect returns cached result on second call ──────────────
|
|
#[test]
|
|
fn test_cache_returns_cached_result() {
|
|
let aspect = CacheAspect::new(300);
|
|
let ctx = authed_ctx("OrderService", "get_orders");
|
|
let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
|
let cc = call_count.clone();
|
|
let proceed: crate::ProceedFn = Box::new(move |_ctx| {
|
|
let n = cc.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
|
Ok(InvocationResult::new(format!("result-{}", n)))
|
|
});
|
|
// First call — executes proceed
|
|
let r1 = aspect.around(ctx.clone(), &proceed).unwrap();
|
|
// Second call — should return cached (proceed not called again)
|
|
let proceed2: crate::ProceedFn = Box::new(|_ctx| {
|
|
panic!("proceed should not be called on cache hit");
|
|
});
|
|
let r2 = aspect.around(ctx.clone(), &proceed2).unwrap();
|
|
assert_eq!(r1.value, r2.value, "cached value should be returned");
|
|
}
|
|
|
|
// ── Test 6: RateLimitAspect blocks after limit exceeded ───────────────────
|
|
#[test]
|
|
fn test_rate_limit_blocks_after_limit() {
|
|
let aspect = RateLimitAspect::new(2, 60);
|
|
let mut ctx = authed_ctx("OrderService", "create_order");
|
|
|
|
// First two calls succeed
|
|
assert!(aspect.before(&mut ctx).is_ok());
|
|
assert!(aspect.before(&mut ctx).is_ok());
|
|
// Third call should be blocked
|
|
let result = aspect.before(&mut ctx);
|
|
assert!(matches!(result, Err(AopError::RateLimited { .. })));
|
|
}
|
|
|
|
// ── Test 7: LogAspect passes through to proceed ───────────────────────────
|
|
#[test]
|
|
fn test_log_aspect_passthrough() {
|
|
let aspect = LogAspect::new("info");
|
|
let ctx = authed_ctx("UserService", "get_user");
|
|
let result = aspect.around(ctx, &succeed_proceed("user-data")).unwrap();
|
|
assert_eq!(result.value, "user-data");
|
|
}
|
|
|
|
// ── Test 8: ValidateAspect rejects required field missing ────────────────
|
|
#[test]
|
|
fn test_validate_required_field() {
|
|
let aspect = ValidateAspect::new();
|
|
aspect.add_rule("UserService", "create_user", "name", "required");
|
|
let mut ctx = authed_ctx("UserService", "create_user");
|
|
// No "name" arg
|
|
let result = aspect.before(&mut ctx);
|
|
assert!(matches!(result, Err(AopError::ValidationFailed(_))));
|
|
}
|
|
|
|
// ── Test 9: ValidateAspect passes when field is present ──────────────────
|
|
#[test]
|
|
fn test_validate_required_field_present() {
|
|
let aspect = ValidateAspect::new();
|
|
aspect.add_rule("UserService", "create_user", "email", "email");
|
|
let mut ctx = authed_ctx("UserService", "create_user")
|
|
.with_arg("email", "alice@example.com");
|
|
assert!(aspect.before(&mut ctx).is_ok());
|
|
}
|
|
|
|
// ── Test 10: ValidateAspect rejects invalid email ─────────────────────────
|
|
#[test]
|
|
fn test_validate_email_rule() {
|
|
let aspect = ValidateAspect::new();
|
|
aspect.add_rule("UserService", "create_user", "email", "email");
|
|
let mut ctx = authed_ctx("UserService", "create_user")
|
|
.with_arg("email", "not-an-email");
|
|
let result = aspect.before(&mut ctx);
|
|
assert!(matches!(result, Err(AopError::ValidationFailed(_))));
|
|
}
|
|
|
|
// ── Test 11: RetryAspect retries on failure ───────────────────────────────
|
|
#[test]
|
|
fn test_retry_succeeds_on_third_attempt() {
|
|
let aspect = RetryAspect::new(3);
|
|
let ctx = authed_ctx("OrderService", "place_order");
|
|
let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
|
|
let attempt_clone = attempt.clone();
|
|
let proceed: crate::ProceedFn = Box::new(move |_ctx| {
|
|
let n = attempt_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
if n < 2 {
|
|
Err(AopError::Aspect("transient error".into()))
|
|
} else {
|
|
Ok(InvocationResult::new("success"))
|
|
}
|
|
});
|
|
let result = aspect.around(ctx, &proceed).unwrap();
|
|
assert_eq!(result.value, "success");
|
|
}
|
|
|
|
// ── Test 12: RetryAspect exhausts all attempts ───────────────────────────
|
|
#[test]
|
|
fn test_retry_exhausted() {
|
|
let aspect = RetryAspect::new(3);
|
|
let ctx = authed_ctx("OrderService", "place_order");
|
|
let result = aspect.around(ctx, &fail_proceed("always fails"));
|
|
assert!(matches!(result, Err(AopError::RetriesExhausted { attempts: 3, .. })));
|
|
}
|
|
|
|
// ── Test 13: TraceAspect injects trace/span IDs ───────────────────────────
|
|
#[test]
|
|
fn test_trace_aspect_injects_ids() {
|
|
let aspect = TraceAspect::new("el-ui");
|
|
let ctx = authed_ctx("UserService", "get_user");
|
|
let result = aspect.around(ctx, &succeed_proceed("data")).unwrap();
|
|
assert!(
|
|
result.metadata.contains_key("trace_id"),
|
|
"should inject trace_id"
|
|
);
|
|
assert!(
|
|
result.metadata.contains_key("span_id"),
|
|
"should inject span_id"
|
|
);
|
|
}
|
|
|
|
// ── Test 14: AspectChain executes aspects in order ────────────────────────
|
|
#[test]
|
|
fn test_aspect_chain_ordering() {
|
|
let order = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
|
|
struct OrderTracker {
|
|
name: &'static str,
|
|
order: Arc<std::sync::Mutex<Vec<&'static str>>>,
|
|
}
|
|
impl Aspect for OrderTracker {
|
|
fn name(&self) -> &'static str { self.name }
|
|
fn around(&self, ctx: InvocationContext, proceed: &crate::ProceedFn) -> crate::AopResult<InvocationResult> {
|
|
self.order.lock().unwrap().push(self.name);
|
|
proceed(ctx)
|
|
}
|
|
}
|
|
|
|
let chain = AspectChain::new()
|
|
.add(Arc::new(OrderTracker { name: "first", order: order.clone() }))
|
|
.add(Arc::new(OrderTracker { name: "second", order: order.clone() }))
|
|
.add(Arc::new(OrderTracker { name: "third", order: order.clone() }));
|
|
|
|
let ctx = authed_ctx("MyService", "my_method");
|
|
chain.execute(ctx, succeed_proceed("ok")).unwrap();
|
|
|
|
let recorded = order.lock().unwrap();
|
|
assert_eq!(*recorded, vec!["first", "second", "third"]);
|
|
}
|
|
|
|
// ── Test 15: AspectChain with auth + authorize rejects unauthenticated ────
|
|
#[test]
|
|
fn test_aspect_chain_auth_flow() {
|
|
let chain = AspectChain::new()
|
|
.add(Arc::new(AuthenticateAspect))
|
|
.add(Arc::new(AuthorizeAspect::new("admin")));
|
|
|
|
// Unauthenticated — should fail at authenticate
|
|
let ctx = ctx("AdminDashboard", "load");
|
|
let result = chain.execute(ctx, succeed_proceed("ok"));
|
|
assert!(matches!(result, Err(AopError::Unauthenticated)));
|
|
|
|
// Authenticated but wrong role — should fail at authorize
|
|
let ctx = authed_ctx("AdminDashboard", "load").with_meta("roles", "user");
|
|
let result = chain.execute(ctx, succeed_proceed("ok"));
|
|
assert!(matches!(result, Err(AopError::Forbidden { .. })));
|
|
|
|
// Admin — should succeed
|
|
let ctx = admin_ctx("AdminDashboard", "load");
|
|
let result = chain.execute(ctx, succeed_proceed("ok"));
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
// ── Test 16: AspectRegistry registers all builtins ────────────────────────
|
|
#[test]
|
|
fn test_registry_has_builtins() {
|
|
let registry = AspectRegistry::with_builtins();
|
|
for name in ["authenticate", "authorize", "cache", "rate_limit", "log", "validate", "retry", "trace"] {
|
|
assert!(registry.contains(name), "should have built-in: {}", name);
|
|
}
|
|
}
|
|
|
|
// ── Test 17: AspectRegistry creates aspects from params ───────────────────
|
|
#[test]
|
|
fn test_registry_creates_aspect() {
|
|
let registry = AspectRegistry::with_builtins();
|
|
let mut params = HashMap::new();
|
|
params.insert("role".into(), "admin".into());
|
|
let aspect = registry.create("authorize", ¶ms);
|
|
assert!(aspect.is_some(), "should create authorize aspect");
|
|
assert_eq!(aspect.unwrap().name(), "authorize");
|
|
}
|
|
|
|
// ── Test 18: AspectRegistry::create returns None for unknown aspect ───────
|
|
#[test]
|
|
fn test_registry_unknown_aspect() {
|
|
let registry = AspectRegistry::with_builtins();
|
|
let result = registry.create("unknown_aspect", &HashMap::new());
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
// ── Test 19: CacheAspect with zero TTL doesn't serve stale data ──────────
|
|
#[test]
|
|
fn test_cache_zero_ttl() {
|
|
let aspect = CacheAspect::new(0); // Immediate expiry
|
|
let ctx = authed_ctx("Service", "method");
|
|
let n = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
|
let nc = n.clone();
|
|
// Both calls should hit proceed since ttl=0 means instant expiry
|
|
let p1: crate::ProceedFn = Box::new(move |_ctx| {
|
|
let v = nc.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
Ok(InvocationResult::new(v.to_string()))
|
|
});
|
|
// Zero TTL will expire immediately; we just verify it doesn't panic
|
|
let _ = aspect.around(ctx.clone(), &p1);
|
|
// Second call should also execute proceed
|
|
let p2: crate::ProceedFn = Box::new(|_ctx| Ok(InvocationResult::new("fresh")));
|
|
let r = aspect.around(ctx, &p2).unwrap();
|
|
assert_eq!(r.value, "fresh");
|
|
}
|
|
|
|
// ── Test 20: Empty AspectChain calls proceed directly ─────────────────────
|
|
#[test]
|
|
fn test_empty_chain_calls_proceed() {
|
|
let chain = AspectChain::new();
|
|
assert!(chain.is_empty());
|
|
let ctx = authed_ctx("Service", "method");
|
|
let result = chain.execute(ctx, succeed_proceed("direct")).unwrap();
|
|
assert_eq!(result.value, "direct");
|
|
}
|
|
}
|