mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
808 lines
26 KiB
Rust
808 lines
26 KiB
Rust
//! `SecurityPipeline` integration tests
|
|
//!
|
|
//! Tests the post-JWT security pipeline: password version checks, user status
|
|
//! checks, cache fast-path, and DB error propagation.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use sqlx::PgPool;
|
|
use synctv_core::{
|
|
cache::{
|
|
self,
|
|
user_cache::{CachedUser, CachedUserSnapshot},
|
|
UserCache,
|
|
},
|
|
models::{SignupMethod, User, UserId, UserRole, UserStatus},
|
|
repository::UserRepository,
|
|
service::{
|
|
AuthenticatedToken, BruteForceProtection, Claims, InMemoryTokenBlacklistStore, JwtService,
|
|
SecurityPipeline, SecurityPipelineRuntime, TokenBlacklistStore, UserService,
|
|
},
|
|
Error, KeyBuilder,
|
|
};
|
|
use synctv_core_testing::{create_test_pool, err, ok, some};
|
|
fn create_jwt_service() -> JwtService {
|
|
ok(
|
|
JwtService::new("test-secret-key-for-integration-tests-minimum-length-32-chars"),
|
|
"JWT service should be created",
|
|
)
|
|
}
|
|
|
|
fn create_user_service(pool: &PgPool) -> UserService {
|
|
let jwt_service = create_jwt_service();
|
|
let username_cache = cache::UsernameCache::local_only("test:username:".to_string(), 1000, 0);
|
|
let token_blacklist = Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let key_builder = KeyBuilder::new("test");
|
|
let brute_force = BruteForceProtection::in_memory("test".to_string());
|
|
|
|
UserService::new_for_tests(
|
|
pool,
|
|
jwt_service,
|
|
username_cache,
|
|
token_blacklist,
|
|
key_builder,
|
|
brute_force,
|
|
)
|
|
}
|
|
|
|
async fn insert_user(pool: &PgPool, user: &User) -> User {
|
|
let repo = UserRepository::new(pool.clone());
|
|
let created = ok(repo.create(user).await, "user should be created");
|
|
some(
|
|
ok(
|
|
repo.get_by_id(&created.id).await,
|
|
"created user should be reloaded",
|
|
),
|
|
"created user should exist",
|
|
)
|
|
}
|
|
|
|
async fn set_version(pool: &PgPool, user_id: &UserId, version: i32) {
|
|
ok(
|
|
sqlx::query!(
|
|
r"
|
|
INSERT INTO auth_password_credentials (
|
|
user_id, opaque_record, opaque_credential_identifier, opaque_ciphersuite,
|
|
opaque_server_setup_version,
|
|
changed_at, version, created_at, updated_at
|
|
)
|
|
VALUES ($1, 'test-opaque-record'::bytea, 'test-opaque-id'::bytea,
|
|
'opaque-ristretto255-sha512-argon2id', 1, NOW(), $2, NOW(), NOW())
|
|
ON CONFLICT (user_id) DO UPDATE
|
|
SET changed_at = EXCLUDED.changed_at,
|
|
version = EXCLUDED.version,
|
|
updated_at = EXCLUDED.updated_at
|
|
",
|
|
user_id.as_i64(),
|
|
version
|
|
)
|
|
.execute(pool)
|
|
.await,
|
|
"password credential version should be set",
|
|
);
|
|
}
|
|
|
|
async fn insert_banned_user(pool: &PgPool, version: i32) -> User {
|
|
let repo = UserRepository::new(pool.clone());
|
|
let user = ok(
|
|
repo.create(&make_user(UserStatus::Active, version)).await,
|
|
"user should be created before ban",
|
|
);
|
|
ok(
|
|
repo.ban(&user.id, None, Some("security pipeline test".to_string()))
|
|
.await,
|
|
"user should be banned",
|
|
)
|
|
}
|
|
|
|
fn security_pipeline_with_cache(
|
|
user_service: Arc<UserService>,
|
|
user_cache: Arc<UserCache>,
|
|
) -> SecurityPipeline {
|
|
let token_blacklist = user_service.token_blacklist_store();
|
|
let key_builder = user_service.key_builder().clone();
|
|
SecurityPipeline::new_with_runtime(
|
|
user_service,
|
|
SecurityPipelineRuntime {
|
|
user_cache: Some(user_cache),
|
|
token_blacklist,
|
|
key_builder,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn security_pipeline_with_blacklist(
|
|
user_service: Arc<UserService>,
|
|
token_blacklist: Arc<dyn TokenBlacklistStore>,
|
|
key_builder: KeyBuilder,
|
|
) -> SecurityPipeline {
|
|
SecurityPipeline::new_with_runtime(
|
|
user_service,
|
|
SecurityPipelineRuntime {
|
|
user_cache: None,
|
|
token_blacklist,
|
|
key_builder,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn security_pipeline_with_cache_and_blacklist(
|
|
user_service: Arc<UserService>,
|
|
user_cache: Arc<UserCache>,
|
|
token_blacklist: Arc<dyn TokenBlacklistStore>,
|
|
key_builder: KeyBuilder,
|
|
) -> SecurityPipeline {
|
|
SecurityPipeline::new_with_runtime(
|
|
user_service,
|
|
SecurityPipelineRuntime {
|
|
user_cache: Some(user_cache),
|
|
token_blacklist,
|
|
key_builder,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn make_user(status: UserStatus, _version: i32) -> User {
|
|
User {
|
|
id: UserId::new(),
|
|
username: format!("test_user_{}", synctv_common::snanoid!(8)),
|
|
role: UserRole::User,
|
|
avatar_file_reference_id: None,
|
|
status,
|
|
signup_method: SignupMethod::Email,
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: chrono::Utc::now(),
|
|
version: 0,
|
|
deleted_at: None,
|
|
is_banned: false,
|
|
banned_at: None,
|
|
banned_by: None,
|
|
banned_reason: None,
|
|
}
|
|
}
|
|
|
|
fn make_claims(user_id: &UserId, pv: i32) -> Claims {
|
|
let now = chrono::Utc::now();
|
|
serde_json::from_value(serde_json::json!({
|
|
"sub": user_id.to_string(),
|
|
"typ": "access",
|
|
"jti": synctv_common::snanoid!(16),
|
|
"iat": now.timestamp(),
|
|
"exp": (now + chrono::Duration::hours(1)).timestamp(),
|
|
"pv": pv,
|
|
"cbm": "password"
|
|
}))
|
|
.expect("test access claims should deserialize")
|
|
}
|
|
|
|
async fn cache_user(user_cache: &UserCache, user: &User, status: UserStatus) {
|
|
let cached = CachedUser::from_snapshot(CachedUserSnapshot {
|
|
id: user.id,
|
|
username: user.username.clone(),
|
|
role: user.role,
|
|
status,
|
|
created_at: user.created_at,
|
|
updated_at: user.updated_at,
|
|
is_banned: false,
|
|
is_deleted: false,
|
|
});
|
|
ok(
|
|
user_cache.set(&user.id, cached).await,
|
|
"user cache should be populated",
|
|
);
|
|
}
|
|
|
|
fn checked_user(result: synctv_core::Result<AuthenticatedToken>) -> AuthenticatedToken {
|
|
ok(result, "security pipeline check should pass")
|
|
}
|
|
|
|
fn auth_error(result: synctv_core::Result<AuthenticatedToken>) -> Error {
|
|
err(result, "security pipeline check should fail")
|
|
}
|
|
|
|
// SecurityPipeline tests (DB-backed, no cache)
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_cache_miss_falls_through_to_db() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
|
|
let pipeline = SecurityPipeline::new(&user_service);
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
let result = checked_user(pipeline.check(&claims).await);
|
|
assert_eq!(result.user_id(), user.id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_db_error_propagates_not_swallowed() {
|
|
// When get_user returns a non-NotFound error, it should propagate instead
|
|
// of being swallowed as Authentication("User not found").
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
let pipeline = SecurityPipeline::new(&user_service);
|
|
|
|
// Use a user_id that doesn't exist -> get_user returns NotFound,
|
|
// which should be mapped to Authentication error
|
|
let missing_user_id = UserId::new();
|
|
let claims = make_claims(&missing_user_id, 0);
|
|
|
|
let err = auth_error(pipeline.check(&claims).await);
|
|
assert!(
|
|
matches!(&err, Error::Authentication(msg) if msg == "Authentication failed"),
|
|
"NotFound should become generic Authentication failure, got: {err}"
|
|
);
|
|
|
|
let db_error_user_id = UserId::new();
|
|
let claims2 = make_claims(&db_error_user_id, 0);
|
|
// Now close the pool to simulate a DB error (not NotFound).
|
|
// Using a closed real pool fails immediately and avoids slow network
|
|
// connection timeouts from an invalid DSN.
|
|
pool.close().await;
|
|
let user_service2 = Arc::new(create_user_service(&pool));
|
|
let pipeline2 = SecurityPipeline::new(&user_service2);
|
|
|
|
let err2 = auth_error(pipeline2.check(&claims2).await);
|
|
// A DB connection error should not become "User not found"; it should be a
|
|
// Database error or Internal error.
|
|
assert!(
|
|
!matches!(&err2, Error::Authentication(msg) if msg.contains("User not found")),
|
|
"DB errors should NOT be swallowed as 'User not found', got: {err2}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_banned_user_rejected_via_db() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_banned_user(&pool, 0).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
let pipeline = SecurityPipeline::new(&user_service);
|
|
|
|
let claims = make_claims(&user.id, 0);
|
|
assert!(matches!(
|
|
auth_error(pipeline.check(&claims).await),
|
|
Error::Authentication(_)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_banned_user_rejected_via_db_second_path() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_banned_user(&pool, 0).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
|
|
let pipeline = SecurityPipeline::new(&user_service);
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
assert!(matches!(
|
|
auth_error(pipeline.check(&claims).await),
|
|
Error::Authentication(_)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_deleted_user_rejected() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
// UserRepository::create does not write deleted_at, so set it via raw SQL
|
|
ok(
|
|
sqlx::query!(
|
|
"UPDATE users SET deleted_at = NOW() WHERE id = $1",
|
|
user.id.as_i64()
|
|
)
|
|
.execute(&pool)
|
|
.await,
|
|
"user should be soft-deleted",
|
|
);
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
let pipeline = SecurityPipeline::new(&user_service);
|
|
|
|
let claims = make_claims(&user.id, 0);
|
|
assert!(matches!(
|
|
auth_error(pipeline.check(&claims).await),
|
|
Error::Authentication(_)
|
|
));
|
|
}
|
|
|
|
// SecurityPipeline tests with UserCache (fast path)
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_cache_hit_active_user_passes() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
|
|
let user_cache = Arc::new(UserCache::local_only(100, 5, 0, "test:user:".to_string()));
|
|
|
|
cache_user(&user_cache, &user, UserStatus::Active).await;
|
|
|
|
let pipeline = security_pipeline_with_cache(user_service, user_cache);
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
checked_user(pipeline.check(&claims).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_cache_hit_outdated_version_rejected() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 5)).await;
|
|
set_version(&pool, &user.id, 5).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
|
|
let user_cache = Arc::new(UserCache::local_only(100, 5, 0, "test:user:".to_string()));
|
|
|
|
cache_user(&user_cache, &user, UserStatus::Active).await;
|
|
|
|
let pipeline = security_pipeline_with_cache(user_service, user_cache);
|
|
|
|
// Token has pv=3 < DB auth pv=5 -> should be rejected
|
|
let claims = make_claims(&user.id, 3);
|
|
let err = auth_error(pipeline.check(&claims).await);
|
|
assert!(
|
|
matches!(&err, Error::Authentication(msg) if msg.contains("password change")),
|
|
"Should reject outdated pv, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_cache_hit_banned_user_rejected_from_status_cache() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_banned_user(&pool, 0).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
|
|
let user_cache = Arc::new(UserCache::local_only(100, 5, 0, "test:user:".to_string()));
|
|
|
|
cache_user(&user_cache, &user, UserStatus::Banned).await;
|
|
|
|
let pipeline = security_pipeline_with_cache(user_service, user_cache);
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
assert!(matches!(
|
|
auth_error(pipeline.check(&claims).await),
|
|
Error::Authentication(_)
|
|
));
|
|
}
|
|
|
|
// SEC4: Banned user rejected (DB and cache paths)
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_banned_user_rejected_via_db_again() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_banned_user(&pool, 0).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
let pipeline = SecurityPipeline::new(&user_service);
|
|
|
|
let claims = make_claims(&user.id, 0);
|
|
assert!(
|
|
matches!(
|
|
auth_error(pipeline.check(&claims).await),
|
|
Error::Authentication(_)
|
|
),
|
|
"Should be an Authentication error"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_cache_hit_banned_user_rejected() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_banned_user(&pool, 0).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
|
|
let user_cache = Arc::new(UserCache::local_only(100, 5, 0, "test:user:".to_string()));
|
|
|
|
cache_user(&user_cache, &user, UserStatus::Banned).await;
|
|
|
|
let pipeline = security_pipeline_with_cache(user_service, user_cache);
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
assert!(matches!(
|
|
auth_error(pipeline.check(&claims).await),
|
|
Error::Authentication(_)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_cache_hit_stale_active_status_does_not_bypass_ban() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
|
|
let user_cache = Arc::new(UserCache::local_only(100, 5, 0, "test:user:".to_string()));
|
|
|
|
cache_user(&user_cache, &user, UserStatus::Active).await;
|
|
|
|
ok(
|
|
UserRepository::new(pool.clone())
|
|
.ban(&user.id, None, Some("security pipeline test".to_string()))
|
|
.await,
|
|
"user should be banned in DB",
|
|
);
|
|
|
|
let pipeline = security_pipeline_with_cache(user_service, user_cache);
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
assert!(matches!(
|
|
auth_error(pipeline.check(&claims).await),
|
|
Error::Authentication(_)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_cache_hit_stale_version_does_not_bypass_password_change() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
|
|
let user_cache = Arc::new(UserCache::local_only(100, 5, 0, "test:user:".to_string()));
|
|
|
|
cache_user(&user_cache, &user, UserStatus::Active).await;
|
|
|
|
set_version(&pool, &user.id, 3).await;
|
|
|
|
let pipeline = security_pipeline_with_cache(user_service, user_cache);
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
let err = auth_error(pipeline.check(&claims).await);
|
|
assert!(
|
|
matches!(&err, Error::Authentication(msg) if msg.contains("password change")),
|
|
"Should reject stale password version via fresh auth state, got: {err}"
|
|
);
|
|
}
|
|
|
|
// SEC5: Cache population after DB miss
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_cache_populated_with_correct_version_after_db_miss() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let version = 3;
|
|
// UserRepository::create doesn't insert version (DB defaults to 0),
|
|
// so we insert the user first and then update version via raw SQL.
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, version)).await;
|
|
set_version(&pool, &user.id, version).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
|
|
let user_cache = Arc::new(UserCache::local_only(100, 5, 0, "test:user:".to_string()));
|
|
|
|
// Cache is empty -- no entry for this user
|
|
assert!(
|
|
ok(
|
|
user_cache.get(&user.id).await,
|
|
"user cache should be readable"
|
|
)
|
|
.is_none(),
|
|
"Cache should be empty initially"
|
|
);
|
|
|
|
let pipeline = security_pipeline_with_cache(user_service, user_cache.clone());
|
|
let claims = make_claims(&user.id, version);
|
|
|
|
// This call should fall through to DB and then populate the cache
|
|
checked_user(pipeline.check(&claims).await);
|
|
|
|
// Verify the cache was populated
|
|
let cached = some(
|
|
ok(
|
|
user_cache.get(&user.id).await,
|
|
"user cache should be readable",
|
|
),
|
|
"cache should be populated after DB lookup",
|
|
);
|
|
assert_eq!(
|
|
cached.status(),
|
|
UserStatus::Active,
|
|
"Cached status should match the DB value"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_cache_populated_then_subsequent_check_fails_closed_when_db_unavailable() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
|
|
let user_cache = Arc::new(UserCache::local_only(100, 5, 0, "test:user:".to_string()));
|
|
|
|
let pipeline = security_pipeline_with_cache(user_service, user_cache.clone());
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
// First check: DB hit, populates cache
|
|
checked_user(pipeline.check(&claims).await);
|
|
|
|
// Close the pool to prove cache hits do not bypass the fresh DB confirmation.
|
|
pool.close().await;
|
|
|
|
// Second check: authentication must fail closed because current security
|
|
// state can no longer be confirmed from the database.
|
|
let result2 = pipeline.check(&claims).await;
|
|
assert!(
|
|
matches!(result2, Err(Error::Database(sqlx::Error::PoolClosed))),
|
|
"Second check should fail closed when DB is unavailable, got: {:?}",
|
|
result2.err()
|
|
);
|
|
}
|
|
|
|
// Access Token Blacklist tests (logout token invalidation)
|
|
|
|
/// Test that a blacklisted access token is rejected by the security pipeline.
|
|
///
|
|
/// This simulates the logout flow:
|
|
/// 1. User has a valid access token
|
|
/// 2. User logs out, token JTI is added to blacklist
|
|
/// 3. Subsequent requests with that token should be rejected
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_blacklisted_access_token_rejected() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
|
|
let token_blacklist = Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let key_builder = KeyBuilder::new("test");
|
|
let user_service = Arc::new(create_user_service_with_blacklist(
|
|
&pool,
|
|
token_blacklist.clone(),
|
|
key_builder.clone(),
|
|
));
|
|
|
|
let pipeline = security_pipeline_with_blacklist(
|
|
user_service,
|
|
token_blacklist.clone(),
|
|
key_builder.clone(),
|
|
);
|
|
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
// First check: token should be valid
|
|
checked_user(pipeline.check(&claims).await);
|
|
|
|
// Blacklist the token (simulating logout)
|
|
let blacklist_key = key_builder.access_token_blacklist(claims.token_id());
|
|
ok(
|
|
token_blacklist.blacklist(&blacklist_key, 3600).await,
|
|
"token should be blacklisted",
|
|
);
|
|
|
|
// Second check: token should be rejected
|
|
let err = auth_error(pipeline.check(&claims).await);
|
|
assert!(
|
|
matches!(&err, Error::Authentication(_)),
|
|
"Should be an Authentication error, got: {err}"
|
|
);
|
|
}
|
|
|
|
/// Test that access token blacklist check works with cached user data.
|
|
///
|
|
/// This ensures the fast path (cache hit) also checks the blacklist.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_blacklisted_access_token_rejected_via_cache_path() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
|
|
let token_blacklist = Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let key_builder = KeyBuilder::new("test");
|
|
let user_service = Arc::new(create_user_service_with_blacklist(
|
|
&pool,
|
|
token_blacklist.clone(),
|
|
key_builder.clone(),
|
|
));
|
|
|
|
let user_cache = Arc::new(UserCache::local_only(100, 5, 0, "test:user:".to_string()));
|
|
|
|
cache_user(&user_cache, &user, UserStatus::Active).await;
|
|
|
|
let pipeline = security_pipeline_with_cache_and_blacklist(
|
|
user_service,
|
|
user_cache,
|
|
token_blacklist.clone(),
|
|
key_builder.clone(),
|
|
);
|
|
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
// First check: cache hit, token should be valid
|
|
checked_user(pipeline.check(&claims).await);
|
|
|
|
// Blacklist the token (simulating logout)
|
|
let blacklist_key = key_builder.access_token_blacklist(claims.token_id());
|
|
ok(
|
|
token_blacklist.blacklist(&blacklist_key, 3600).await,
|
|
"token should be blacklisted",
|
|
);
|
|
|
|
// Second check: cache hit, but token should still be rejected
|
|
auth_error(pipeline.check(&claims).await);
|
|
}
|
|
|
|
/// Test that non-blacklisted tokens are allowed through.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_non_blacklisted_access_token_allowed() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
|
|
let token_blacklist = Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let key_builder = KeyBuilder::new("test");
|
|
let user_service = Arc::new(create_user_service_with_blacklist(
|
|
&pool,
|
|
token_blacklist.clone(),
|
|
key_builder.clone(),
|
|
));
|
|
|
|
let pipeline = security_pipeline_with_blacklist(
|
|
user_service,
|
|
token_blacklist.clone(),
|
|
key_builder.clone(),
|
|
);
|
|
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
// Token not in blacklist should be allowed
|
|
checked_user(pipeline.check(&claims).await);
|
|
}
|
|
|
|
// Helper function to create UserService with custom blacklist store
|
|
fn create_user_service_with_blacklist(
|
|
pool: &PgPool,
|
|
token_blacklist: Arc<InMemoryTokenBlacklistStore>,
|
|
key_builder: KeyBuilder,
|
|
) -> UserService {
|
|
let jwt_service = create_jwt_service();
|
|
let username_cache = cache::UsernameCache::local_only("test:username:".to_string(), 1000, 0);
|
|
let brute_force = BruteForceProtection::in_memory("test".to_string());
|
|
|
|
UserService::new_for_tests(
|
|
pool,
|
|
jwt_service,
|
|
username_cache,
|
|
token_blacklist,
|
|
key_builder,
|
|
brute_force,
|
|
)
|
|
}
|
|
|
|
fn create_user_service_with_dyn_blacklist(
|
|
pool: &PgPool,
|
|
token_blacklist: Arc<dyn TokenBlacklistStore>,
|
|
key_builder: KeyBuilder,
|
|
) -> UserService {
|
|
let jwt_service = create_jwt_service();
|
|
let username_cache = cache::UsernameCache::local_only("test:username:".to_string(), 1000, 0);
|
|
let brute_force = BruteForceProtection::in_memory("test".to_string());
|
|
|
|
UserService::new_for_tests(
|
|
pool,
|
|
jwt_service,
|
|
username_cache,
|
|
token_blacklist,
|
|
key_builder,
|
|
brute_force,
|
|
)
|
|
}
|
|
|
|
// Fail-closed blacklist store error handling tests (Issue: storage errors)
|
|
|
|
/// A blacklist store whose `is_blacklisted_checked` always returns an error,
|
|
/// simulating a database/Redis outage.
|
|
struct ErroringBlacklistStore;
|
|
|
|
#[async_trait::async_trait]
|
|
impl TokenBlacklistStore for ErroringBlacklistStore {
|
|
async fn is_blacklisted_checked(&self, _key: &str) -> synctv_core::Result<bool> {
|
|
Err(synctv_core::Error::Internal(
|
|
"Simulated storage outage".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn blacklist(&self, _key: &str, _ttl_secs: u64) -> synctv_core::Result<()> {
|
|
Err(synctv_core::Error::Internal(
|
|
"Simulated storage outage".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn blacklist_if_not_exists(
|
|
&self,
|
|
_key: &str,
|
|
_ttl_secs: u64,
|
|
) -> synctv_core::Result<bool> {
|
|
Err(synctv_core::Error::Internal(
|
|
"Simulated storage outage".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn get_family_revoked_at_checked(&self, _key: &str) -> synctv_core::Result<Option<i64>> {
|
|
Ok(None)
|
|
}
|
|
|
|
async fn set_family_revoked(
|
|
&self,
|
|
_key: &str,
|
|
_timestamp: i64,
|
|
_ttl_secs: u64,
|
|
) -> synctv_core::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Test that when the blacklist store encounters a storage error during
|
|
/// `is_blacklisted_checked`, the security pipeline rejects the request
|
|
/// (fail-closed behavior).
|
|
///
|
|
/// This prevents blacklisted tokens (e.g., from logout) from being accepted
|
|
/// during database/Redis outages.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_blacklist_store_error_rejects_request_fail_closed() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
|
|
let erroring_store: Arc<dyn TokenBlacklistStore> = Arc::new(ErroringBlacklistStore);
|
|
let key_builder = KeyBuilder::new("test");
|
|
let user_service = Arc::new(create_user_service_with_dyn_blacklist(
|
|
&pool,
|
|
erroring_store.clone(),
|
|
key_builder.clone(),
|
|
));
|
|
|
|
let pipeline = security_pipeline_with_blacklist(user_service, erroring_store, key_builder);
|
|
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
// The store will return an error from is_blacklisted_checked.
|
|
// The pipeline should fail-closed and reject the request.
|
|
let err = auth_error(pipeline.check(&claims).await);
|
|
assert!(
|
|
matches!(&err, Error::ServiceUnavailable(msg) if msg.contains("temporarily unavailable")),
|
|
"Error should indicate temporary unavailability via ServiceUnavailable, got: {err}"
|
|
);
|
|
}
|
|
|
|
/// Test that the default `is_blacklisted_checked` (which delegates to `is_blacklisted`)
|
|
/// works correctly for in-memory stores that cannot fail.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_in_memory_blacklist_store_is_blacklisted_checked_ok() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user = insert_user(&pool, &make_user(UserStatus::Active, 0)).await;
|
|
|
|
let store = Arc::new(InMemoryTokenBlacklistStore::new(10_000, 3600, 86400));
|
|
let key_builder = KeyBuilder::new("test");
|
|
let user_service = Arc::new(create_user_service_with_blacklist(
|
|
&pool,
|
|
store.clone(),
|
|
key_builder.clone(),
|
|
));
|
|
|
|
let pipeline =
|
|
security_pipeline_with_blacklist(user_service, store.clone(), key_builder.clone());
|
|
|
|
let claims = make_claims(&user.id, 0);
|
|
|
|
// Non-blacklisted token should pass
|
|
checked_user(pipeline.check(&claims).await);
|
|
|
|
// Blacklist the token
|
|
let bl_key = key_builder.access_token_blacklist(claims.token_id());
|
|
ok(
|
|
store.blacklist(&bl_key, 3600).await,
|
|
"token should be blacklisted",
|
|
);
|
|
|
|
// Blacklisted token should be rejected
|
|
auth_error(pipeline.check(&claims).await);
|
|
}
|