miroir/crates/miroir-proxy/tests/p10_admin_session_revocation.rs
jedarden 88e890c5cd fix(tests): integration tests skip gracefully when Docker unavailable
- Add check_docker_available() to integration.rs and docker_compose_integration.rs
- Add skip_if_no_miroir! macro for graceful test skipping
- Fix helm_schema_rejects_local_backend_with_replicas_gt_1 test path
- Fix uninlined format args for clippy compliance
- Fix unused variable warning in p10_2_node_master_key_rotation.rs
- Add #[allow] attributes for unused code in p10_5_scoped_key_rotation.rs

Resolves: bf-1lyu5 (integration tests skip gracefully)
Resolves: bf-e0595 (Phase 10 acceptance tests - p10_7 fix)

All 1777 tests pass when Docker is unavailable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:42:28 -04:00

479 lines
15 KiB
Rust

//! P10 Admin session revocation acceptance tests (plan §9).
//!
//! Tests the login → logout → revoked-cookie replay flow:
//! 1. Create admin session in Redis (simulates login)
//! 2. Revoke session (simulates logout, publishes to Pub/Sub)
//! 3. Verify session rejected on the originating pod
//! 4. Verify session rejected on a second pod via Pub/Sub propagation
//! 5. Verify session lookup in Redis returns revoked=true
//! 6. Verify non-revoked sessions remain valid
//!
//! Run with:
//! cargo nextest run -E 'test(p10_admin_session_revocation)'
//!
//! Prerequisites:
//! Option 1: Docker available for testcontainers Redis
//! Option 2: Set MIROIR_TEST_REDIS_URL to point to a running Redis instance
//! Option 3: Set MIROIR_TEST_SKIP_DOCKER=1 to skip these tests
//!
//! Environment variables:
//! - `MIROIR_TEST_REDIS_URL`: If set, use this Redis URL instead of testcontainers
//! - `MIROIR_TEST_SKIP_DOCKER`: If set, skip tests that require Docker/Redis
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use miroir_core::task_store::{NewAdminSession, RedisTaskStore, TaskStore};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Check if Docker tests should skip and optionally get external Redis URL.
///
/// Environment variables:
/// - `MIROIR_TEST_SKIP_DOCKER`: If set, return Err (test should skip)
/// - `MIROIR_TEST_REDIS_URL`: If set, use this Redis URL instead of testcontainers
fn check_docker_or_redis_url() -> Result<Option<String>, String> {
// Check if Docker tests are explicitly skipped
if std::env::var("MIROIR_TEST_SKIP_DOCKER").is_ok() {
return Err("Docker tests skipped via MIROIR_TEST_SKIP_DOCKER. \
Set MIROIR_TEST_REDIS_URL=redis://localhost:6379 to test against external Redis, \
or unset MIROIR_TEST_SKIP_DOCKER and ensure Docker is available."
.to_string());
}
// Use external Redis URL if provided
if let Ok(url) = std::env::var("MIROIR_TEST_REDIS_URL") {
return Ok(Some(url));
}
// Check for Docker socket
let docker_sock = std::path::Path::new("/var/run/docker.sock");
if !docker_sock.exists() {
return Err(
"Docker socket not found at /var/run/docker.sock. \
Set MIROIR_TEST_SKIP_DOCKER=1 to skip, or set MIROIR_TEST_REDIS_URL to use external Redis."
.to_string(),
);
}
// Default to testcontainers (requires Docker)
Ok(None)
}
/// Macro to get Redis URL or skip test if Docker/Redis is unavailable
/// Returns the Redis URL to use (external URL or None for testcontainers)
macro_rules! redis_url_or_skip {
() => {
match check_docker_or_redis_url() {
Ok(url) => url,
Err(e) => {
eprintln!("Skipping test: {e}");
return;
}
}
};
}
async fn redis_store(
maybe_url: Option<String>,
) -> Result<(RedisTaskStore, String), Box<dyn std::error::Error>> {
let url = match maybe_url {
Some(url) => url,
None => {
// Use testcontainers to spin up Redis
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::redis::Redis;
let node = Redis::default();
let container = node
.start()
.await
.map_err(|e| format!("start redis: {e}"))?;
let port = container
.get_host_port_ipv4(6379)
.await
.map_err(|e| format!("get port: {e}"))?;
format!("redis://localhost:{port}")
}
};
let store = RedisTaskStore::open(&url)
.await
.map_err(|e| format!("redis connect: {e}"))?;
Ok((store, url))
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
fn make_session(id: &str) -> NewAdminSession {
NewAdminSession {
session_id: id.to_string(),
csrf_token: format!("csrf-{id}"),
admin_key_hash: format!("hash-{id}"),
created_at: now_ms(),
expires_at: now_ms() + 3_600_000, // 1 hour
user_agent: Some("test-agent".to_string()),
source_ip: Some("127.0.0.1".to_string()),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
/// Login → logout → replay: session must be rejected after revocation.
#[tokio::test]
async fn test_login_logout_replay_rejected() {
let redis_url = redis_url_or_skip!();
let (store, _url) = match redis_store(redis_url).await {
Ok(store) => store,
Err(e) => {
eprintln!("Skipping test: {e}");
return;
}
};
// Step 1: Login — insert admin session
let session = make_session("sess-login-logout-test");
store
.insert_admin_session(&session)
.expect("insert session");
// Verify session is NOT revoked initially
let loaded = store
.get_admin_session(&session.session_id)
.expect("get session")
.expect("session exists");
assert!(!loaded.revoked, "new session should not be revoked");
// Step 2: Logout — revoke session
let revoked = store
.revoke_admin_session(&session.session_id)
.expect("revoke session");
assert!(revoked, "session should have been revoked");
// Step 3: Replay — verify session is now revoked
let loaded = store
.get_admin_session(&session.session_id)
.expect("get session")
.expect("session exists");
assert!(loaded.revoked, "revoked session must have revoked=true");
}
/// Cross-pod revocation: session revoked on pod A is rejected on pod B
/// via Pub/Sub propagation within 200ms.
#[tokio::test]
async fn test_cross_pod_revocation_via_pubsub() {
let redis_url = redis_url_or_skip!();
let (store, redis_url) = match redis_store(redis_url).await {
Ok(store) => store,
Err(e) => {
eprintln!("Skipping test: {e}");
return;
}
};
// Simulate two pods, each with their own in-memory revocation cache
let pod_a_revoked: Arc<DashMap<String, ()>> = Arc::new(DashMap::new());
let pod_b_revoked: Arc<DashMap<String, ()>> = Arc::new(DashMap::new());
let pod_a_clone = pod_a_revoked.clone();
let pod_b_clone = pod_b_revoked.clone();
let redis_url_a = redis_url.clone();
let redis_url_b = redis_url.clone();
// Start Pub/Sub subscribers for both "pods"
let sub_a = tokio::spawn(async move {
let _ = RedisTaskStore::subscribe_session_revocations(
&redis_url_a,
"miroir",
move |session_id: String| {
pod_a_clone.insert(session_id, ());
},
)
.await;
});
let sub_b = tokio::spawn(async move {
let _ = RedisTaskStore::subscribe_session_revocations(
&redis_url_b,
"miroir",
move |session_id: String| {
pod_b_clone.insert(session_id, ());
},
)
.await;
});
// Give subscribers time to connect
tokio::time::sleep(Duration::from_millis(150)).await;
// Create session on pod A
let session = make_session("sess-cross-pod-test");
store
.insert_admin_session(&session)
.expect("insert session");
// Verify not revoked on either pod
assert!(
!pod_a_revoked.contains_key(&session.session_id),
"pod A should not have revoked session before logout"
);
assert!(
!pod_b_revoked.contains_key(&session.session_id),
"pod B should not have revoked session before logout"
);
// Revoke on pod A (simulates logout)
store
.revoke_admin_session(&session.session_id)
.expect("revoke session");
// Wait for Pub/Sub propagation
let deadline = Duration::from_millis(500);
let start = std::time::Instant::now();
loop {
let a_has = pod_a_revoked.contains_key(&session.session_id);
let b_has = pod_b_revoked.contains_key(&session.session_id);
if a_has && b_has {
break;
}
if start.elapsed() > deadline {
panic!(
"Pub/Sub propagation did not complete within {deadline:?}: pod_a={a_has}, pod_b={b_has}"
);
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let elapsed = start.elapsed();
assert!(
elapsed < deadline,
"Propagation should be fast, took {elapsed:?}"
);
// Both pods must now reject the session
assert!(
pod_a_revoked.contains_key(&session.session_id),
"pod A must reject revoked session"
);
assert!(
pod_b_revoked.contains_key(&session.session_id),
"pod B must reject revoked session (received via Pub/Sub)"
);
sub_a.abort();
sub_b.abort();
}
/// Multiple sessions: revoking one does not affect others.
#[tokio::test]
async fn test_revocation_is_per_session() {
let redis_url = redis_url_or_skip!();
let (store, _url) = match redis_store(redis_url).await {
Ok(store) => store,
Err(e) => {
eprintln!("Skipping test: {e}");
return;
}
};
let session_a = make_session("sess-per-a");
let session_b = make_session("sess-per-b");
store.insert_admin_session(&session_a).expect("insert a");
store.insert_admin_session(&session_b).expect("insert b");
// Revoke only session A
store
.revoke_admin_session(&session_a.session_id)
.expect("revoke a");
// Session A is revoked
let loaded_a = store
.get_admin_session(&session_a.session_id)
.expect("get a")
.expect("a exists");
assert!(loaded_a.revoked, "session A should be revoked");
// Session B is NOT revoked
let loaded_b = store
.get_admin_session(&session_b.session_id)
.expect("get b")
.expect("b exists");
assert!(!loaded_b.revoked, "session B should not be affected");
}
/// Revoking a non-existent session returns false but does not error.
#[tokio::test]
async fn test_revoke_nonexistent_session() {
let redis_url = redis_url_or_skip!();
let (store, _url) = match redis_store(redis_url).await {
Ok(store) => store,
Err(e) => {
eprintln!("Skipping test: {e}");
return;
}
};
let result = store
.revoke_admin_session("sess-does-not-exist")
.expect("revoke should not error");
assert!(!result, "revoking nonexistent session should return false");
}
/// Expired session is invalid regardless of revocation status.
#[tokio::test]
async fn test_expired_session_is_invalid() {
let redis_url = redis_url_or_skip!();
let (store, _url) = match redis_store(redis_url).await {
Ok(store) => store,
Err(e) => {
eprintln!("Skipping test: {e}");
return;
}
};
let mut session = make_session("sess-expired");
session.expires_at = now_ms() - 1000; // expired 1 second ago
store
.insert_admin_session(&session)
.expect("insert expired");
let loaded = store
.get_admin_session(&session.session_id)
.expect("get expired")
.expect("exists");
// Session exists but is expired
let now = now_ms();
assert!(
loaded.expires_at < now,
"session should be expired: expires_at={}, now={}",
loaded.expires_at,
now
);
}
/// CSRF token rotation on session refresh does not affect revocation.
#[tokio::test]
async fn test_csrf_refresh_preserves_revocation() {
let redis_url = redis_url_or_skip!();
let (store, _url) = match redis_store(redis_url).await {
Ok(store) => store,
Err(e) => {
eprintln!("Skipping test: {e}");
return;
}
};
let session = make_session("sess-csrf-refresh");
store.insert_admin_session(&session).expect("insert");
// Refresh CSRF token (simulates GET /_miroir/admin/session)
let refreshed = NewAdminSession {
session_id: session.session_id.clone(),
csrf_token: "new-csrf-token".to_string(),
admin_key_hash: session.admin_key_hash.clone(),
created_at: session.created_at,
expires_at: session.expires_at,
user_agent: session.user_agent.clone(),
source_ip: session.source_ip.clone(),
};
store.insert_admin_session(&refreshed).expect("refresh");
// Revoke after refresh
store
.revoke_admin_session(&session.session_id)
.expect("revoke");
let loaded = store
.get_admin_session(&session.session_id)
.expect("get")
.expect("exists");
assert!(loaded.revoked, "session must be revoked after logout");
assert_eq!(
loaded.csrf_token, "new-csrf-token",
"CSRF token should reflect last refresh"
);
}
/// Pub/Sub delivers multiple revocations in order.
#[tokio::test]
async fn test_pubsub_multiple_revocations() {
let redis_url = redis_url_or_skip!();
let (store, redis_url) = match redis_store(redis_url).await {
Ok(store) => store,
Err(e) => {
eprintln!("Skipping test: {e}");
return;
}
};
let received: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let received_clone = received.clone();
let sub = tokio::spawn(async move {
let _ = RedisTaskStore::subscribe_session_revocations(
&redis_url,
"miroir",
move |session_id: String| {
received_clone.lock().unwrap().push(session_id);
},
)
.await;
});
// Give subscriber time to connect
tokio::time::sleep(Duration::from_millis(150)).await;
// Create and revoke 3 sessions
for i in 0..3 {
let session = make_session(&format!("sess-multi-{i}"));
store.insert_admin_session(&session).expect("insert");
store
.revoke_admin_session(&session.session_id)
.expect("revoke");
}
// Wait for all 3 revocations
let deadline = Duration::from_millis(500);
let start = std::time::Instant::now();
loop {
let count = received.lock().unwrap().len();
if count >= 3 {
break;
}
if start.elapsed() > deadline {
panic!(
"Expected 3 revocations, got {} after {:?}",
count,
start.elapsed()
);
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let ids = received.lock().unwrap().clone();
assert_eq!(ids.len(), 3);
assert!(ids.contains(&"sess-multi-0".to_string()));
assert!(ids.contains(&"sess-multi-1".to_string()));
assert!(ids.contains(&"sess-multi-2".to_string()));
sub.abort();
}