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.
3065 lines
105 KiB
Rust
3065 lines
105 KiB
Rust
//! User service tests
|
|
//!
|
|
//! Tests user registration and login validation using testcontainers.
|
|
//!
|
|
//! Run Docker tests: cargo test --test `user_service_tests` -- --ignored
|
|
use std::sync::Arc;
|
|
|
|
use chrono::Utc;
|
|
use hmac::{Hmac, KeyInit, Mac};
|
|
use sha1::Sha1;
|
|
use sqlx::PgPool;
|
|
use synctv_core::{
|
|
cache::{KeyBuilder, LocalVersionFenceStore, UsernameCache, VersionFenceStore},
|
|
models::{
|
|
DeletionSource, Media, MediaId, NotificationType, Playlist, PlaylistId, Room, RoomId,
|
|
RoomMember, RoomStatus, SignupMethod, SourceProvider, User, UserId, UserRole, UserStatus,
|
|
OPAQUE_CIPHERSUITE_RISTRETTO255_SHA512_ARGON2ID, OPAQUE_SERVER_SETUP_VERSION,
|
|
},
|
|
repository::{
|
|
ChatRepository, MediaRepository, PlaylistRepository, RoomMemberRepository, RoomRepository,
|
|
SettingsRepository, UserEmailRepository, UserPasswordRepository, UserRepository,
|
|
WebAuthnCredentialRepository,
|
|
},
|
|
service::{
|
|
local_passkey_session_store, AccountRegistrationOutcome, AuthFactorMethod,
|
|
AuthenticatedLogin, BruteForceProtection, InMemoryTokenBlacklistStore, JwtService,
|
|
OpaquePasswordService, PasskeyService, PasskeyServiceOptions, PermissionService,
|
|
PermissionServiceRuntime, RuntimeSettingsStore, SecurityPipeline, SecurityPipelineRuntime,
|
|
SensitiveVerificationOutcome, SettingsService, TokenAuthContext, UserRestoreOptions,
|
|
UserService, UserServiceRuntimeOptions,
|
|
},
|
|
validation::PasswordComplexityOptions,
|
|
Error,
|
|
};
|
|
use synctv_core_testing::{
|
|
create_test_pool, opaque_login_user, opaque_login_user_with_challenge, opaque_register_user,
|
|
opaque_register_user_with_client_ip, TestOptionExt, TestResultExt,
|
|
};
|
|
use tokio::sync::Barrier;
|
|
|
|
fn create_jwt_service() -> JwtService {
|
|
JwtService::new("test-secret-key-for-user-service-tests-long-enough-1234567890")
|
|
.checked("test operation should succeed")
|
|
}
|
|
|
|
fn create_user_service(pool: &PgPool) -> UserService {
|
|
create_user_service_with_runtime(pool, UserServiceRuntimeOptions::test_defaults())
|
|
}
|
|
|
|
fn create_user_service_with_runtime(
|
|
pool: &PgPool,
|
|
mut runtime: UserServiceRuntimeOptions,
|
|
) -> UserService {
|
|
let jwt = create_jwt_service();
|
|
let username_cache = UsernameCache::local_only("test:username:".to_string(), 100, 60);
|
|
let password_config = PasswordComplexityOptions::default();
|
|
let token_blacklist: Arc<dyn synctv_core::service::TokenBlacklistStore> =
|
|
Arc::new(InMemoryTokenBlacklistStore::new(1000, 3600, 86400));
|
|
let key_builder = KeyBuilder::new("test");
|
|
let brute_force = BruteForceProtection::in_memory("test".to_string());
|
|
|
|
runtime.password_registration_policy_override =
|
|
Some(synctv_core::service::RegistrationPolicy::Immediate);
|
|
|
|
UserService::new_with_brute_force_service_and_runtime(
|
|
pool,
|
|
synctv_core::service::UserServiceDependencies {
|
|
jwt_service: jwt,
|
|
username_cache,
|
|
password_complexity: password_config,
|
|
token_blacklist,
|
|
key_builder,
|
|
brute_force: Arc::new(brute_force),
|
|
},
|
|
runtime,
|
|
)
|
|
}
|
|
|
|
async fn email_signup_registry(pool: &PgPool) -> Arc<RuntimeSettingsStore> {
|
|
let settings_service = Arc::new(SettingsService::new(
|
|
SettingsRepository::new(pool.clone()),
|
|
pool.clone(),
|
|
));
|
|
let registry = Arc::new(RuntimeSettingsStore::new(settings_service));
|
|
let mut settings = registry
|
|
.runtime_settings()
|
|
.checked("runtime settings should load");
|
|
settings.email.enabled = true;
|
|
settings.email.smtp_host = Some("smtp.example.com".to_string());
|
|
settings.email.from_email = Some("noreply@example.com".to_string());
|
|
settings.user.enable_email_signup = true;
|
|
settings.user.email_signup_need_review = false;
|
|
registry
|
|
.persist_runtime_settings(&settings)
|
|
.await
|
|
.checked("email signup settings should persist");
|
|
registry
|
|
}
|
|
|
|
fn create_user_service_with_security_pipeline(
|
|
pool: &PgPool,
|
|
) -> (Arc<UserService>, JwtService, SecurityPipeline) {
|
|
let jwt = create_jwt_service();
|
|
let username_cache = UsernameCache::local_only("test:username:".to_string(), 100, 60);
|
|
let password_config = PasswordComplexityOptions::default();
|
|
let token_blacklist: Arc<dyn synctv_core::service::TokenBlacklistStore> =
|
|
Arc::new(InMemoryTokenBlacklistStore::new(1000, 3600, 86400));
|
|
let key_builder = KeyBuilder::new("test");
|
|
let brute_force = BruteForceProtection::in_memory("test".to_string());
|
|
|
|
let service = UserService::new_with_brute_force_service_and_runtime(
|
|
pool,
|
|
synctv_core::service::UserServiceDependencies {
|
|
jwt_service: jwt.clone(),
|
|
username_cache,
|
|
password_complexity: password_config,
|
|
token_blacklist: Arc::clone(&token_blacklist),
|
|
key_builder: key_builder.clone(),
|
|
brute_force: Arc::new(brute_force),
|
|
},
|
|
UserServiceRuntimeOptions {
|
|
password_registration_policy_override: Some(
|
|
synctv_core::service::RegistrationPolicy::Immediate,
|
|
),
|
|
..UserServiceRuntimeOptions::test_defaults()
|
|
},
|
|
);
|
|
let service = Arc::new(service);
|
|
let pipeline = SecurityPipeline::new_with_runtime(
|
|
Arc::clone(&service),
|
|
SecurityPipelineRuntime {
|
|
user_cache: None,
|
|
token_blacklist,
|
|
key_builder,
|
|
},
|
|
);
|
|
(service, jwt, pipeline)
|
|
}
|
|
|
|
fn make_user(username: &str) -> User {
|
|
let now = Utc::now();
|
|
User {
|
|
id: UserId::new(),
|
|
username: username.to_string(),
|
|
role: UserRole::User,
|
|
avatar_file_reference_id: None,
|
|
status: UserStatus::Active,
|
|
signup_method: SignupMethod::Email,
|
|
created_at: now,
|
|
updated_at: now,
|
|
version: 0,
|
|
deleted_at: None,
|
|
is_banned: false,
|
|
banned_at: None,
|
|
banned_by: None,
|
|
banned_reason: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_direct_password_registration_stores_opaque_credential() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let opaque_password_service = Arc::new(OpaquePasswordService::derive_from_secret(
|
|
b"direct-password-registration-test",
|
|
));
|
|
let service = create_user_service_with_runtime(
|
|
&pool,
|
|
UserServiceRuntimeOptions {
|
|
opaque_password_service: Arc::clone(&opaque_password_service),
|
|
..UserServiceRuntimeOptions::test_defaults()
|
|
},
|
|
);
|
|
|
|
let username = format!("direct_password_{}", synctv_common::snanoid!(8));
|
|
let password = "StrongPass1";
|
|
let email = format!("{username}@example.com");
|
|
let outcome = service
|
|
.register_with_direct_password_transport_with_control(
|
|
username.clone(),
|
|
Some(email),
|
|
password.to_string(),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("direct password registration should succeed");
|
|
let AccountRegistrationOutcome::Registered {
|
|
user,
|
|
access_token,
|
|
refresh_token,
|
|
..
|
|
} = outcome
|
|
else {
|
|
std::panic::panic_any("direct password registration should complete without review");
|
|
};
|
|
|
|
assert_eq!(user.username, username.to_ascii_lowercase());
|
|
assert!(!access_token.is_empty());
|
|
assert!(!refresh_token.is_empty());
|
|
|
|
let stored_credential = UserPasswordRepository::new(pool.clone())
|
|
.get_opaque_credential(&user.id)
|
|
.await
|
|
.checked("password credential lookup should succeed")
|
|
.checked("password credential should be stored");
|
|
|
|
assert_eq!(
|
|
stored_credential.record.ciphersuite,
|
|
OPAQUE_CIPHERSUITE_RISTRETTO255_SHA512_ARGON2ID
|
|
);
|
|
assert_eq!(
|
|
stored_credential.record.server_setup_version,
|
|
OPAQUE_SERVER_SETUP_VERSION
|
|
);
|
|
assert!(opaque_password_service
|
|
.verify_password(&stored_credential.record, password,)
|
|
.checked("stored OPAQUE credential should verify"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_set_direct_password_allows_opaque_login() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let opaque_password_service = Arc::new(OpaquePasswordService::derive_from_secret(
|
|
b"set-direct-password-opaque-login-test",
|
|
));
|
|
let service = create_user_service_with_runtime(
|
|
&pool,
|
|
UserServiceRuntimeOptions {
|
|
opaque_password_service,
|
|
..UserServiceRuntimeOptions::test_defaults()
|
|
},
|
|
);
|
|
|
|
let username = format!("reset_password_{}", synctv_common::snanoid!(8));
|
|
let old_password = "StrongPass1";
|
|
let new_password = "StrongerPass2";
|
|
let (user, _, _) = opaque_register_user(&service, username.clone(), None, old_password)
|
|
.await
|
|
.checked("initial OPAQUE registration should succeed");
|
|
let target_user_id = user.id;
|
|
|
|
service
|
|
.set_direct_password(&user.id, new_password)
|
|
.await
|
|
.checked("direct password reset should succeed");
|
|
|
|
let login = opaque_login_user(&service, username, new_password)
|
|
.await
|
|
.checked("OPAQUE login should accept reset password");
|
|
match login {
|
|
AuthenticatedLogin::Complete {
|
|
user,
|
|
access_token,
|
|
refresh_token,
|
|
..
|
|
} => {
|
|
assert_eq!(user.id, target_user_id);
|
|
assert!(!access_token.is_empty());
|
|
assert!(!refresh_token.is_empty());
|
|
}
|
|
AuthenticatedLogin::MfaRequired { .. } => {
|
|
std::panic::panic_any("fresh reset-password user should not require MFA")
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_email_registration_confirmation_stores_opaque_credential() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let opaque_password_service = Arc::new(OpaquePasswordService::derive_from_secret(
|
|
b"email-registration-confirmation-test",
|
|
));
|
|
let service = create_user_service_with_runtime(
|
|
&pool,
|
|
UserServiceRuntimeOptions {
|
|
opaque_password_service: Arc::clone(&opaque_password_service),
|
|
runtime_settings_store: Some(email_signup_registry(&pool).await),
|
|
..UserServiceRuntimeOptions::test_defaults()
|
|
},
|
|
);
|
|
|
|
let username = format!("email_registration_{}", synctv_common::snanoid!(8));
|
|
let email = format!("{username}@example.com");
|
|
let password = "StrongPass1";
|
|
|
|
let first_token = service
|
|
.create_email_registration_token_with_control(username.clone(), email.clone(), None, None)
|
|
.await
|
|
.checked("email registration token should be created");
|
|
assert!(service
|
|
.is_email_registration_token_active(&first_token)
|
|
.await
|
|
.checked("first registration token active state should load"));
|
|
let token = service
|
|
.create_email_registration_token_with_control(username.clone(), email.clone(), None, None)
|
|
.await
|
|
.checked("replacement email registration token should be created");
|
|
assert!(!service
|
|
.is_email_registration_token_active(&first_token)
|
|
.await
|
|
.checked("superseded registration token active state should load"));
|
|
assert!(service
|
|
.is_email_registration_token_active(&token)
|
|
.await
|
|
.checked("replacement registration token active state should load"));
|
|
let outcome = service
|
|
.complete_email_registration_with_direct_password_transport_with_control(
|
|
&token,
|
|
password.to_string(),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("email registration confirmation should succeed");
|
|
let AccountRegistrationOutcome::Registered {
|
|
user,
|
|
access_token,
|
|
refresh_token,
|
|
..
|
|
} = outcome
|
|
else {
|
|
std::panic::panic_any("email registration confirmation should complete without review");
|
|
};
|
|
|
|
assert_eq!(user.username, username.to_ascii_lowercase());
|
|
assert!(!access_token.is_empty());
|
|
assert!(!refresh_token.is_empty());
|
|
|
|
let stored_credential = UserPasswordRepository::new(pool.clone())
|
|
.get_opaque_credential(&user.id)
|
|
.await
|
|
.checked("password credential lookup should succeed")
|
|
.checked("password credential should be stored");
|
|
assert!(opaque_password_service
|
|
.verify_password(&stored_credential.record, password,)
|
|
.checked("stored OPAQUE credential should verify"));
|
|
|
|
let reuse = service
|
|
.complete_email_registration_with_direct_password_transport_with_control(
|
|
&token,
|
|
password.to_string(),
|
|
None,
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(matches!(reuse, Err(Error::InvalidInput(_))));
|
|
}
|
|
|
|
async fn password_verification_id(
|
|
service: &UserService,
|
|
user_id: &UserId,
|
|
password: &str,
|
|
) -> String {
|
|
let outcome = service
|
|
.start_sensitive_operation_verification(user_id, None)
|
|
.await
|
|
.checked("start sensitive verification");
|
|
let SensitiveVerificationOutcome::Pending(challenge) = outcome else {
|
|
std::panic::panic_any("password verification should start with a pending challenge");
|
|
};
|
|
match service
|
|
.finish_sensitive_operation_password_verification(
|
|
&challenge.session_id,
|
|
password,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("finish password verification")
|
|
{
|
|
SensitiveVerificationOutcome::Complete { verification_id } => verification_id,
|
|
SensitiveVerificationOutcome::Pending(_) => {
|
|
std::panic::panic_any("single-factor password verification should complete")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn decode_totp_secret(secret: &str) -> Vec<u8> {
|
|
let mut output = Vec::with_capacity(secret.len() * 5 / 8);
|
|
let mut buffer = 0_u32;
|
|
let mut bits = 0_u8;
|
|
for byte in secret.bytes() {
|
|
let value = match byte.to_ascii_uppercase() {
|
|
b'A'..=b'Z' => byte.to_ascii_uppercase() - b'A',
|
|
b'2'..=b'7' => byte - b'2' + 26,
|
|
_ => std::panic::panic_any("test TOTP secret should be valid base32"),
|
|
};
|
|
buffer = (buffer << 5) | u32::from(value);
|
|
bits += 5;
|
|
if bits >= 8 {
|
|
bits -= 8;
|
|
output.push(((buffer >> bits) & 0xff) as u8);
|
|
}
|
|
}
|
|
assert!(!output.is_empty());
|
|
output
|
|
}
|
|
|
|
fn totp_code_with_step_offset(secret: &str, step_offset: i64) -> String {
|
|
let key = decode_totp_secret(secret);
|
|
let step = Utc::now().timestamp() / 30 + step_offset;
|
|
let mut mac = Hmac::<Sha1>::new_from_slice(&key).checked("test TOTP key should be valid");
|
|
mac.update(
|
|
&u64::try_from(step)
|
|
.checked("test TOTP step should be nonnegative")
|
|
.to_be_bytes(),
|
|
);
|
|
let digest = mac.finalize().into_bytes();
|
|
let offset = usize::from(digest[digest.len() - 1] & 0x0f);
|
|
let binary = ((u32::from(digest[offset]) & 0x7f) << 24)
|
|
| (u32::from(digest[offset + 1]) << 16)
|
|
| (u32::from(digest[offset + 2]) << 8)
|
|
| u32::from(digest[offset + 3]);
|
|
format!("{:06}", binary % 1_000_000)
|
|
}
|
|
|
|
async fn two_factor_verification_id_with_recovery_code(
|
|
service: &UserService,
|
|
user_id: &UserId,
|
|
password: &str,
|
|
recovery_code: &str,
|
|
) -> String {
|
|
let outcome = service
|
|
.start_sensitive_operation_verification(user_id, None)
|
|
.await
|
|
.checked("start two-factor sensitive verification");
|
|
let SensitiveVerificationOutcome::Pending(challenge) = outcome else {
|
|
std::panic::panic_any("two-factor sensitive verification should be pending");
|
|
};
|
|
let pending = service
|
|
.finish_sensitive_operation_password_verification(
|
|
&challenge.session_id,
|
|
password,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("verify password factor");
|
|
let SensitiveVerificationOutcome::Pending(challenge) = pending else {
|
|
std::panic::panic_any("sensitive verification should require a second factor");
|
|
};
|
|
match service
|
|
.finish_sensitive_operation_recovery_code_verification(
|
|
&challenge.session_id,
|
|
recovery_code,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("verify recovery-code factor")
|
|
{
|
|
SensitiveVerificationOutcome::Complete { verification_id } => verification_id,
|
|
SensitiveVerificationOutcome::Pending(_) => {
|
|
std::panic::panic_any("two verified factors should complete sensitive verification")
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn insert_trusted_email_identity(pool: &PgPool, user_id: &UserId, email: &str) {
|
|
sqlx::query!(
|
|
r"
|
|
INSERT INTO auth_email_identities (user_id, email, created_at, updated_at)
|
|
VALUES ($1, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
ON CONFLICT (user_id)
|
|
DO UPDATE SET email = EXCLUDED.email, updated_at = EXCLUDED.updated_at
|
|
",
|
|
user_id.as_i64(),
|
|
email
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.checked("trusted email identity should be inserted");
|
|
}
|
|
|
|
async fn insert_test_passkey(pool: &PgPool, user_id: &UserId, credential_id: &[u8]) {
|
|
sqlx::query!(
|
|
r"
|
|
INSERT INTO auth_webauthn_credentials (
|
|
user_id, credential_id, passkey, name
|
|
)
|
|
VALUES ($1, $2, '{}'::jsonb, 'test passkey')
|
|
",
|
|
user_id.as_i64(),
|
|
credential_id
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.checked("insert test passkey");
|
|
}
|
|
|
|
async fn insert_oauth2_identity(
|
|
pool: &PgPool,
|
|
user_id: &UserId,
|
|
provider_instance_name: &str,
|
|
provider_user_id: &str,
|
|
) {
|
|
sqlx::query!(
|
|
"INSERT INTO auth_oauth2_identities (
|
|
provider_type, provider_instance_name, provider_user_id, user_id, username
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5)",
|
|
2_i16,
|
|
provider_instance_name,
|
|
provider_user_id,
|
|
user_id.as_i64(),
|
|
provider_user_id
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.checked("oauth2 identity should be inserted");
|
|
}
|
|
|
|
fn make_passkey_service(pool: PgPool, user_service: Arc<UserService>) -> PasskeyService {
|
|
let mut config = PasskeyServiceOptions::default();
|
|
config.enabled = true;
|
|
config.rp_id = "localhost".to_string();
|
|
config.rp_origin = "http://localhost".to_string();
|
|
config.enumeration_protection_secret = "test-webauthn-enumeration-secret".to_string();
|
|
match PasskeyService::new(
|
|
&config,
|
|
WebAuthnCredentialRepository::new(pool),
|
|
user_service,
|
|
local_passkey_session_store(),
|
|
) {
|
|
Ok(service) => service,
|
|
Err(error) => std::panic::panic_any(format!("passkey service should build: {error:?}")),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_unknown_identified_passkey_login_returns_stable_decoy_challenge() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
let passkey_service = make_passkey_service(pool, user_service);
|
|
let brute_force_key = "missing-passkey-user@example.com";
|
|
|
|
let first = passkey_service
|
|
.start_identified_login(None, brute_force_key, None, None)
|
|
.await
|
|
.checked("unknown account should receive a decoy Passkey challenge");
|
|
let second = passkey_service
|
|
.start_identified_login(None, brute_force_key, None, None)
|
|
.await
|
|
.checked("decoy Passkey challenge should be reusable until authentication");
|
|
|
|
let first_ids = first
|
|
.options
|
|
.public_key
|
|
.allow_credentials
|
|
.iter()
|
|
.map(|credential| credential.id.as_slice())
|
|
.collect::<Vec<_>>();
|
|
let second_ids = second
|
|
.options
|
|
.public_key
|
|
.allow_credentials
|
|
.iter()
|
|
.map(|credential| credential.id.as_slice())
|
|
.collect::<Vec<_>>();
|
|
assert!(!first_ids.is_empty());
|
|
assert_eq!(first_ids, second_ids);
|
|
assert_ne!(first.session_id, second.session_id);
|
|
assert_ne!(
|
|
first.options.public_key.challenge,
|
|
second.options.public_key.challenge
|
|
);
|
|
}
|
|
|
|
fn make_room(name: &str, owner_id: &UserId) -> Room {
|
|
let now = Utc::now();
|
|
Room {
|
|
id: RoomId::new(),
|
|
name: name.to_string(),
|
|
description: String::new(),
|
|
cover_file_reference_id: None,
|
|
category: None,
|
|
labels: Vec::new(),
|
|
created_by: *owner_id,
|
|
status: RoomStatus::Active,
|
|
is_banned: false,
|
|
is_public: true,
|
|
closed_at: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
deleted_at: None,
|
|
version: 0,
|
|
last_activity_at: now,
|
|
}
|
|
}
|
|
|
|
fn make_playlist(room_id: &RoomId, creator_id: &UserId, name: &str, position: i32) -> Playlist {
|
|
let now = Utc::now();
|
|
Playlist {
|
|
id: PlaylistId::new(),
|
|
room_id: *room_id,
|
|
creator_id: Some(*creator_id),
|
|
browse_access_mode: synctv_core::models::PlaylistBrowseAccessMode::Default,
|
|
name: name.to_string(),
|
|
description: String::new(),
|
|
cover_file_reference_id: None,
|
|
parent_id: None,
|
|
position: f64::from(position),
|
|
source_provider: None,
|
|
source_config: None,
|
|
provider_instance_name: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
version: 0,
|
|
}
|
|
}
|
|
|
|
fn make_dynamic_playlist(
|
|
room_id: &RoomId,
|
|
creator_id: &UserId,
|
|
name: &str,
|
|
position: i32,
|
|
) -> Playlist {
|
|
let mut playlist = make_playlist(room_id, creator_id, name, position);
|
|
playlist.source_provider = Some(SourceProvider::Alist);
|
|
playlist.source_config = Some(synctv_core_testing::alist_directory_playlist_source_config(
|
|
"alist-test",
|
|
"/library",
|
|
));
|
|
playlist
|
|
}
|
|
|
|
fn make_media(
|
|
room_id: &RoomId,
|
|
playlist_id: Option<&PlaylistId>,
|
|
creator_id: &UserId,
|
|
name: &str,
|
|
position: i32,
|
|
) -> Media {
|
|
let now = Utc::now();
|
|
Media {
|
|
id: MediaId::new(),
|
|
playlist_id: playlist_id.copied(),
|
|
room_id: *room_id,
|
|
creator_id: Some(*creator_id),
|
|
name: name.to_string(),
|
|
description: String::new(),
|
|
position: f64::from(position),
|
|
source_provider: SourceProvider::DirectUrl,
|
|
source_config: synctv_core_testing::direct_url_media_source_config(
|
|
"https://example.com/video.mp4",
|
|
),
|
|
provider_instance_name: None,
|
|
cover_file_reference_id: None,
|
|
thumbnail_file_reference_id: None,
|
|
added_at: now,
|
|
updated_at: now,
|
|
version: 0,
|
|
}
|
|
}
|
|
|
|
// Integration tests (require Docker)
|
|
|
|
async fn assert_register_duplicate_username_error(service: &UserService) {
|
|
// Register first user
|
|
let result = opaque_register_user(
|
|
service,
|
|
"unique_user_dup",
|
|
Some("dup1@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"First registration should succeed: {result:?}"
|
|
);
|
|
|
|
// Register with same username, different email
|
|
let result = opaque_register_user(
|
|
service,
|
|
"unique_user_dup",
|
|
Some("dup2@example.com".to_string()),
|
|
"StrongPass2",
|
|
)
|
|
.await;
|
|
assert!(result.is_err(), "Duplicate username should be rejected");
|
|
}
|
|
|
|
async fn assert_register_duplicate_email_error(service: &UserService) {
|
|
// Register first user
|
|
let result = opaque_register_user(
|
|
service,
|
|
"email_dup_1",
|
|
Some("same_email@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await;
|
|
assert!(result.is_ok(), "First registration should succeed");
|
|
|
|
// Register with different username, same email
|
|
let result = opaque_register_user(
|
|
service,
|
|
"email_dup_2",
|
|
Some("same_email@example.com".to_string()),
|
|
"StrongPass2",
|
|
)
|
|
.await;
|
|
assert!(result.is_err(), "Duplicate email should be rejected");
|
|
}
|
|
|
|
async fn assert_login_wrong_password(service: &UserService) {
|
|
// Register a user
|
|
opaque_register_user(
|
|
service,
|
|
"login_test_user",
|
|
Some("login@example.com".to_string()),
|
|
"CorrectPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
// Try to login with wrong password
|
|
let result = opaque_login_user(service, "login_test_user", "WrongPass1").await;
|
|
|
|
assert!(result.is_err(), "Login with wrong password should fail");
|
|
}
|
|
|
|
// Validation tests (no Docker needed)
|
|
|
|
#[test]
|
|
fn test_username_validation() {
|
|
let validator = synctv_core::validation::UsernameValidator::new();
|
|
|
|
assert!(validator.validate("good_user").is_ok());
|
|
assert!(validator.validate("ab").is_err()); // too short
|
|
assert!(validator.validate("user@name").is_err()); // invalid chars
|
|
}
|
|
|
|
#[test]
|
|
fn test_password_validation() {
|
|
let validator = synctv_core::validation::PasswordValidator::from_options(
|
|
&PasswordComplexityOptions::default(),
|
|
);
|
|
|
|
assert!(validator.validate("StrongPass1").is_ok());
|
|
assert!(validator.validate("weak").is_err());
|
|
assert!(validator.validate("nouppercase1").is_err());
|
|
}
|
|
|
|
// Delete User Transaction Tests
|
|
|
|
async fn assert_delete_user_already_deleted_returns_error(service: &UserService) {
|
|
// Register a user
|
|
let (user, _, _) = opaque_register_user(
|
|
service,
|
|
"delete_test_user",
|
|
Some("delete@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
let user_id = user.id;
|
|
|
|
// First delete should succeed
|
|
let result = service.delete_user(&user_id).await;
|
|
assert!(result.is_ok(), "First delete should succeed: {result:?}");
|
|
|
|
// Second delete should fail with "already deleted" error
|
|
let result = service.delete_user(&user_id).await;
|
|
assert!(result.is_err(), "Second delete should fail");
|
|
match result {
|
|
Err(Error::InvalidInput(msg)) => {
|
|
assert!(
|
|
msg.contains("already deleted"),
|
|
"Error message should mention 'already deleted': {msg}"
|
|
);
|
|
}
|
|
Err(e) => std::panic::panic_any(format!("expected InvalidInput error, got: {e:?}")),
|
|
Ok(()) => std::panic::panic_any("expected error, got Ok"),
|
|
}
|
|
}
|
|
|
|
/// Test that concurrent `delete_user` calls maintain atomicity - only one should succeed
|
|
async fn assert_delete_user_concurrent_deletion_atomicity(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
|
|
// Register a user
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"concurrent_delete_user",
|
|
Some("concurrent@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("Registration should succeed");
|
|
|
|
let user_id = user.id;
|
|
|
|
// Use a barrier to synchronize both delete attempts
|
|
let barrier = Arc::new(Barrier::new(2));
|
|
let service1 = service.clone();
|
|
let service2 = service.clone();
|
|
let user_id1 = user_id;
|
|
let user_id2 = user_id;
|
|
let barrier1 = barrier.clone();
|
|
let barrier2 = barrier.clone();
|
|
|
|
let handle1 = tokio::spawn(async move {
|
|
barrier1.wait().await;
|
|
service1.delete_user(&user_id1).await
|
|
});
|
|
|
|
let handle2 = tokio::spawn(async move {
|
|
barrier2.wait().await;
|
|
service2.delete_user(&user_id2).await
|
|
});
|
|
|
|
let result1 = handle1.await.checked("Task 1 panicked");
|
|
let result2 = handle2.await.checked("Task 2 panicked");
|
|
|
|
// Exactly one of the two should succeed
|
|
let success_count = [result1.is_ok(), result2.is_ok()]
|
|
.iter()
|
|
.filter(|&&x| x)
|
|
.count();
|
|
assert_eq!(
|
|
success_count, 1,
|
|
"Exactly one delete should succeed, but got {success_count} successes. Results: {result1:?}, {result2:?}"
|
|
);
|
|
|
|
// Verify user is deleted in the database
|
|
let user_repo = UserRepository::new(pool);
|
|
let user_after = user_repo
|
|
.get_by_id(&user_id)
|
|
.await
|
|
.checked("Query should work");
|
|
assert!(
|
|
user_after.is_none(),
|
|
"User should be soft-deleted (not found via get_by_id)"
|
|
);
|
|
}
|
|
|
|
async fn assert_delete_user_removes_owned_resources_and_resets_foreign_room_playback(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
let room_member_repo = RoomMemberRepository::new(pool.clone());
|
|
let playlist_repo = PlaylistRepository::new(pool.clone());
|
|
let media_repo = MediaRepository::new(pool.clone());
|
|
|
|
let doomed_user = user_repo
|
|
.create(&make_user("delete_owner"))
|
|
.await
|
|
.checked("create doomed user");
|
|
let foreign_owner = user_repo
|
|
.create(&make_user("foreign_owner"))
|
|
.await
|
|
.checked("create foreign owner");
|
|
let other_creator = user_repo
|
|
.create(&make_user("other_creator"))
|
|
.await
|
|
.checked("create other creator");
|
|
UserEmailRepository::new(pool.clone())
|
|
.create_for_user_with_executor(&doomed_user, Some("delete-owner@example.com"), &pool)
|
|
.await
|
|
.checked("create retained email identity");
|
|
|
|
let owned_room = room_repo
|
|
.create(&make_room("owned room", &doomed_user.id))
|
|
.await
|
|
.checked("create owned room");
|
|
let foreign_room = room_repo
|
|
.create(&make_room("foreign room", &foreign_owner.id))
|
|
.await
|
|
.checked("create foreign room");
|
|
|
|
room_member_repo
|
|
.add(&RoomMember {
|
|
room_id: foreign_room.id,
|
|
user_id: doomed_user.id,
|
|
role: synctv_core::models::RoomRole::Member,
|
|
added_permissions: 0,
|
|
removed_permissions: 0,
|
|
admin_added_permissions: 0,
|
|
admin_removed_permissions: 0,
|
|
remark_name: String::new(),
|
|
display_tag: String::new(),
|
|
joined_at: Utc::now(),
|
|
version: 0,
|
|
})
|
|
.await
|
|
.checked("create foreign room membership");
|
|
|
|
let owned_playlist = playlist_repo
|
|
.create(&make_playlist(
|
|
&owned_room.id,
|
|
&doomed_user.id,
|
|
"owned playlist",
|
|
0,
|
|
))
|
|
.await
|
|
.checked("create playlist in owned room");
|
|
let owned_media = media_repo
|
|
.create(&make_media(
|
|
&owned_room.id,
|
|
Some(&owned_playlist.id),
|
|
&doomed_user.id,
|
|
"owned media",
|
|
0,
|
|
))
|
|
.await
|
|
.checked("create media in owned room");
|
|
|
|
let foreign_playlist = playlist_repo
|
|
.create(&make_playlist(
|
|
&foreign_room.id,
|
|
&doomed_user.id,
|
|
"foreign doomed playlist",
|
|
0,
|
|
))
|
|
.await
|
|
.checked("create playlist in foreign room");
|
|
let foreign_dynamic_playlist = playlist_repo
|
|
.create(&make_dynamic_playlist(
|
|
&foreign_room.id,
|
|
&doomed_user.id,
|
|
"foreign doomed dynamic playlist",
|
|
1,
|
|
))
|
|
.await
|
|
.checked("create dynamic playlist in foreign room");
|
|
let mut nested_dynamic_template = make_dynamic_playlist(
|
|
&foreign_room.id,
|
|
&doomed_user.id,
|
|
"foreign nested doomed dynamic playlist",
|
|
2,
|
|
);
|
|
nested_dynamic_template.parent_id = Some(foreign_dynamic_playlist.id);
|
|
let nested_dynamic_playlist = playlist_repo
|
|
.create(&nested_dynamic_template)
|
|
.await
|
|
.checked("create nested dynamic playlist in foreign room");
|
|
let foreign_media = media_repo
|
|
.create(&make_media(
|
|
&foreign_room.id,
|
|
Some(&foreign_playlist.id),
|
|
&doomed_user.id,
|
|
"foreign doomed media",
|
|
0,
|
|
))
|
|
.await
|
|
.checked("create media in foreign room");
|
|
|
|
let mut survivor_playlist_template = make_playlist(
|
|
&foreign_room.id,
|
|
&other_creator.id,
|
|
"foreign survivor playlist",
|
|
1,
|
|
);
|
|
survivor_playlist_template.parent_id = Some(nested_dynamic_playlist.id);
|
|
let survivor_playlist = playlist_repo
|
|
.create(&survivor_playlist_template)
|
|
.await
|
|
.checked("create surviving playlist");
|
|
let survivor_media = media_repo
|
|
.create(&make_media(
|
|
&foreign_room.id,
|
|
Some(&survivor_playlist.id),
|
|
&other_creator.id,
|
|
"foreign survivor media",
|
|
0,
|
|
))
|
|
.await
|
|
.checked("create surviving media");
|
|
let nested_survivor_media = media_repo
|
|
.create(&make_media(
|
|
&foreign_room.id,
|
|
Some(&nested_dynamic_playlist.id),
|
|
&other_creator.id,
|
|
"foreign direct nested survivor media",
|
|
1,
|
|
))
|
|
.await
|
|
.checked("create direct media under nested dynamic playlist");
|
|
let explicitly_deleted_media = media_repo
|
|
.create(&make_media(
|
|
&foreign_room.id,
|
|
Some(&survivor_playlist.id),
|
|
&doomed_user.id,
|
|
"explicitly deleted before account closure",
|
|
1,
|
|
))
|
|
.await
|
|
.checked("create explicitly deleted media");
|
|
assert!(media_repo
|
|
.delete(&explicitly_deleted_media.id)
|
|
.await
|
|
.checked("soft-delete media before account closure"));
|
|
|
|
let foreign_progress_id: i64 = sqlx::query_scalar!(
|
|
r#"INSERT INTO room_playback_progress
|
|
(room_id, media_id, playlist_id, target, target_hash, "position", version)
|
|
VALUES ($1, $2, NULL, NULL, $3, 12.5, 0)
|
|
RETURNING id AS "id!""#,
|
|
foreign_room.id.as_i64(),
|
|
foreign_media.id.as_i64(),
|
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("create playback progress");
|
|
|
|
sqlx::query!(
|
|
"INSERT INTO room_playback_state
|
|
(room_id, playing_media_id, playing_playlist_id, target, current_progress_id,
|
|
speed, is_playing, playback_generation, updated_at, version)
|
|
VALUES ($1, $2, NULL, NULL, $3, 1.0, TRUE, 0, NOW(), 0)",
|
|
foreign_room.id.as_i64(),
|
|
foreign_media.id.as_i64(),
|
|
foreign_progress_id
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("create playback state");
|
|
|
|
sqlx::query!(
|
|
"INSERT INTO auth_oauth2_identities (
|
|
provider_type, provider_instance_name, provider_user_id, user_id, username
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5)",
|
|
2_i16,
|
|
"github",
|
|
"delete-owner-gh",
|
|
doomed_user.id.as_i64(),
|
|
"delete_owner"
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("create oauth2 mapping");
|
|
|
|
let notification_data = synctv_core::models::NotificationData::default();
|
|
sqlx::query!(
|
|
"INSERT INTO notifications (user_id, title, content, type, data, is_read, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, FALSE, NOW(), NOW())",
|
|
doomed_user.id.as_i64(),
|
|
"title",
|
|
"body",
|
|
i16::from(NotificationType::SystemAnnouncement),
|
|
¬ification_data as &synctv_core::models::NotificationData
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("create notification");
|
|
|
|
sqlx::query!(
|
|
"INSERT INTO chat_messages (room_id, user_id, content, message_type, created_at)
|
|
VALUES ($1, $2, $3, 1, NOW())",
|
|
foreign_room.id.as_i64(),
|
|
doomed_user.id.as_i64(),
|
|
"hello"
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("create chat message");
|
|
sqlx::query!(
|
|
"INSERT INTO chat_messages (room_id, user_id, content, message_type, created_at)
|
|
VALUES ($1, $2, $3, 1, NOW())",
|
|
owned_room.id.as_i64(),
|
|
doomed_user.id.as_i64(),
|
|
"owned room history"
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("create owned room chat message");
|
|
let foreign_chat_message_id = sqlx::query_scalar!(
|
|
"SELECT id AS \"id!\" FROM chat_messages WHERE room_id = $1 AND user_id = $2 AND content = $3 ORDER BY id DESC LIMIT 1",
|
|
foreign_room.id.as_i64(),
|
|
doomed_user.id.as_i64(),
|
|
"hello",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("load foreign chat message id");
|
|
|
|
let pending_bind_token = service
|
|
.start_email_bind(&doomed_user.id, "pending-delete-bind@example.com")
|
|
.await
|
|
.checked("create pending email bind request");
|
|
|
|
let summary = service
|
|
.delete_user_with_summary(&doomed_user.id)
|
|
.await
|
|
.checked("delete_user_with_summary should succeed");
|
|
|
|
assert_eq!(summary.user_id, doomed_user.id);
|
|
assert_eq!(summary.username, doomed_user.username);
|
|
assert_eq!(summary.deleted_room_ids, vec![owned_room.id]);
|
|
assert_eq!(summary.membership_room_ids, vec![foreign_room.id]);
|
|
assert_eq!(
|
|
summary.affected_room_ids,
|
|
vec![owned_room.id, foreign_room.id]
|
|
);
|
|
assert_eq!(summary.deleted_chat_messages.len(), 1);
|
|
assert_eq!(summary.deleted_chat_messages[0].room_id, foreign_room.id);
|
|
assert_eq!(
|
|
summary.deleted_chat_messages[0].message_id,
|
|
foreign_chat_message_id
|
|
);
|
|
assert_eq!(summary.modified_rooms.len(), 1);
|
|
assert_eq!(summary.modified_rooms[0].room_id, foreign_room.id);
|
|
assert_eq!(
|
|
summary.modified_rooms[0].deleted_media_ids,
|
|
vec![foreign_media.id]
|
|
);
|
|
assert!(
|
|
summary.modified_rooms[0].playback_reset,
|
|
"deleting the currently playing foreign media must reset playback"
|
|
);
|
|
|
|
assert!(
|
|
user_repo
|
|
.get_by_id(&doomed_user.id)
|
|
.await
|
|
.checked("get user")
|
|
.is_none(),
|
|
"deleted user must no longer be visible"
|
|
);
|
|
assert!(
|
|
room_repo
|
|
.get_by_id(&owned_room.id)
|
|
.await
|
|
.checked("get owned room")
|
|
.is_none(),
|
|
"owned room must be soft-deleted"
|
|
);
|
|
assert!(
|
|
room_repo
|
|
.get_by_id(&foreign_room.id)
|
|
.await
|
|
.checked("get foreign room")
|
|
.is_some(),
|
|
"foreign room must survive"
|
|
);
|
|
|
|
assert!(
|
|
playlist_repo
|
|
.get_by_id(&owned_playlist.id)
|
|
.await
|
|
.checked("get owned playlist")
|
|
.is_none(),
|
|
"owned room playlist should be deleted"
|
|
);
|
|
assert!(
|
|
media_repo
|
|
.get_by_id(&owned_media.id)
|
|
.await
|
|
.checked("get owned media")
|
|
.is_none(),
|
|
"owned room media should be deleted"
|
|
);
|
|
let preserved_foreign_playlist = playlist_repo
|
|
.get_by_id(&foreign_playlist.id)
|
|
.await
|
|
.checked("get foreign playlist")
|
|
.checked("ordinary playlist in foreign room should survive");
|
|
assert_eq!(
|
|
preserved_foreign_playlist.creator_id, None,
|
|
"surviving ordinary playlists become room-owned shared directories"
|
|
);
|
|
assert!(
|
|
playlist_repo
|
|
.get_by_id(&foreign_dynamic_playlist.id)
|
|
.await
|
|
.checked("get foreign dynamic playlist")
|
|
.is_none(),
|
|
"user-created dynamic playlist in foreign room should be deleted"
|
|
);
|
|
assert!(
|
|
playlist_repo
|
|
.get_by_id(&nested_dynamic_playlist.id)
|
|
.await
|
|
.checked("get nested foreign dynamic playlist")
|
|
.is_none(),
|
|
"nested user-created dynamic playlist should be deleted"
|
|
);
|
|
assert!(
|
|
media_repo
|
|
.get_by_id(&foreign_media.id)
|
|
.await
|
|
.checked("get foreign media")
|
|
.is_none(),
|
|
"user-created media in foreign room should be deleted"
|
|
);
|
|
let survivor_playlist = playlist_repo
|
|
.get_by_id(&survivor_playlist.id)
|
|
.await
|
|
.checked("get survivor playlist")
|
|
.checked("other users' playlists must survive");
|
|
assert!(
|
|
survivor_playlist.parent_id.is_none(),
|
|
"ordinary playlists below deleted dynamic subtrees move to the room root"
|
|
);
|
|
assert!(
|
|
media_repo
|
|
.get_by_id(&survivor_media.id)
|
|
.await
|
|
.checked("get survivor media")
|
|
.is_some(),
|
|
"other users' media must survive"
|
|
);
|
|
let nested_survivor_media = media_repo
|
|
.get_by_id(&nested_survivor_media.id)
|
|
.await
|
|
.checked("get nested survivor media")
|
|
.checked("other users' direct nested media must survive");
|
|
assert!(
|
|
nested_survivor_media.playlist_id.is_none(),
|
|
"media below deleted dynamic subtrees move to the room root"
|
|
);
|
|
|
|
let member_after = room_member_repo
|
|
.get(&foreign_room.id, &doomed_user.id)
|
|
.await
|
|
.checked("get membership");
|
|
assert!(
|
|
member_after.is_none(),
|
|
"deleted user must no longer be an active member of surviving rooms"
|
|
);
|
|
|
|
let playback_row = sqlx::query!(
|
|
r#"SELECT playing_media_id AS "playing_media_id?: MediaId",
|
|
playing_playlist_id AS "playing_playlist_id?: PlaylistId",
|
|
is_playing AS "is_playing!"
|
|
FROM room_playback_state
|
|
WHERE room_id = $1"#,
|
|
foreign_room.id.as_i64()
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("query playback");
|
|
assert_eq!(
|
|
playback_row.playing_media_id, None,
|
|
"playing media must be cleared"
|
|
);
|
|
assert_eq!(
|
|
playback_row.playing_playlist_id, None,
|
|
"playing playlist must be cleared"
|
|
);
|
|
assert!(!playback_row.is_playing, "playback must be stopped");
|
|
|
|
let oauth2_identity = sqlx::query!(
|
|
r#"SELECT deleted_at,
|
|
deletion_source AS "deletion_source?: DeletionSource"
|
|
FROM auth_oauth2_identities WHERE user_id = $1"#,
|
|
doomed_user.id.as_i64()
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("query retained oauth2 mapping");
|
|
assert!(oauth2_identity.deleted_at.is_some());
|
|
assert_eq!(
|
|
oauth2_identity.deletion_source,
|
|
Some(DeletionSource::Account)
|
|
);
|
|
|
|
assert_eq!(
|
|
service
|
|
.get_email(&doomed_user.id)
|
|
.await
|
|
.checked("load active email after deletion"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
service
|
|
.get_email_for_admin(&doomed_user.id)
|
|
.await
|
|
.checked("load retained recovery email"),
|
|
Some("delete-owner@example.com".to_string())
|
|
);
|
|
|
|
let pending_bind_count: i64 = sqlx::query_scalar!(
|
|
r#"SELECT COUNT(*) AS "count!"
|
|
FROM auth_email_bind_requests
|
|
WHERE user_id = $1"#,
|
|
doomed_user.id.as_i64(),
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("count pending email bind requests after deletion");
|
|
assert_eq!(pending_bind_count, 0);
|
|
assert!(!service
|
|
.is_email_bind_token_active(
|
|
&doomed_user.id,
|
|
"pending-delete-bind@example.com",
|
|
&pending_bind_token,
|
|
)
|
|
.await
|
|
.checked("check revoked email bind token"));
|
|
|
|
let notification_count: i64 = sqlx::query_scalar!(
|
|
r#"SELECT COUNT(*) AS "count!" FROM notifications WHERE user_id = $1"#,
|
|
doomed_user.id.as_i64()
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("count notifications");
|
|
assert_eq!(notification_count, 0, "notifications must be deleted");
|
|
|
|
let chat_user_ids: Vec<Option<UserId>> = sqlx::query_scalar!(
|
|
r#"SELECT user_id AS "user_id?: UserId" FROM chat_messages WHERE room_id = $1"#,
|
|
foreign_room.id.as_i64()
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.checked("query chat messages");
|
|
assert_eq!(
|
|
chat_user_ids,
|
|
vec![Some(doomed_user.id)],
|
|
"surviving-room chat keeps its author during the recovery window"
|
|
);
|
|
let foreign_chat_lifecycle = sqlx::query!(
|
|
r#"SELECT deleted_at,
|
|
deletion_source AS "deletion_source?: DeletionSource",
|
|
deleted_owner_id AS "deleted_owner_id: UserId"
|
|
FROM chat_messages WHERE id = $1"#,
|
|
foreign_chat_message_id,
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("query surviving-room chat lifecycle");
|
|
assert!(foreign_chat_lifecycle.deleted_at.is_some());
|
|
assert_eq!(
|
|
foreign_chat_lifecycle.deletion_source,
|
|
Some(DeletionSource::Account)
|
|
);
|
|
assert_eq!(
|
|
foreign_chat_lifecycle.deleted_owner_id,
|
|
Some(doomed_user.id)
|
|
);
|
|
let chat_repository = ChatRepository::new(pool.clone());
|
|
assert!(chat_repository
|
|
.get_by_room_and_id(&foreign_room.id, foreign_chat_message_id)
|
|
.await
|
|
.checked("account-deleted chat should be hidden")
|
|
.is_none());
|
|
|
|
let owned_chat = sqlx::query!(
|
|
r#"SELECT deleted_at,
|
|
deletion_source AS "deletion_source?: DeletionSource",
|
|
deleted_owner_id AS "deleted_owner_id: UserId"
|
|
FROM chat_messages WHERE room_id = $1"#,
|
|
owned_room.id.as_i64(),
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("query owned room chat lifecycle");
|
|
assert!(owned_chat.deleted_at.is_some());
|
|
assert_eq!(owned_chat.deletion_source, Some(DeletionSource::Account));
|
|
assert_eq!(owned_chat.deleted_owner_id, Some(doomed_user.id));
|
|
|
|
let propagated_resources = sqlx::query!(
|
|
r#"SELECT deletion_source AS "deletion_source?: DeletionSource",
|
|
deleted_owner_id AS "deleted_owner_id: UserId"
|
|
FROM playlists
|
|
WHERE id = ANY($1)
|
|
UNION ALL
|
|
SELECT deletion_source, deleted_owner_id
|
|
FROM media
|
|
WHERE id = ANY($2)"#,
|
|
&[
|
|
owned_playlist.id.as_i64(),
|
|
foreign_dynamic_playlist.id.as_i64(),
|
|
],
|
|
&[owned_media.id.as_i64(), foreign_media.id.as_i64()],
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.checked("query propagated resource lifecycle");
|
|
assert_eq!(propagated_resources.len(), 4);
|
|
for row in propagated_resources {
|
|
assert_eq!(row.deletion_source, Some(DeletionSource::Account));
|
|
assert_eq!(row.deleted_owner_id, Some(doomed_user.id));
|
|
}
|
|
|
|
let explicit_lifecycle = sqlx::query!(
|
|
r#"SELECT deleted_at,
|
|
deletion_source AS "deletion_source?: DeletionSource",
|
|
deleted_owner_id AS "deleted_owner_id: UserId"
|
|
FROM media WHERE id = $1"#,
|
|
explicitly_deleted_media.id.as_i64(),
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("query explicitly deleted media lifecycle");
|
|
assert!(explicit_lifecycle.deleted_at.is_some());
|
|
assert_eq!(
|
|
explicit_lifecycle.deletion_source,
|
|
Some(DeletionSource::User)
|
|
);
|
|
assert_eq!(explicit_lifecycle.deleted_owner_id, None);
|
|
|
|
let restored = service
|
|
.restore_user(&doomed_user.id, UserRestoreOptions::default())
|
|
.await
|
|
.checked("restore account during retention window");
|
|
assert_eq!(restored.user.username, doomed_user.username);
|
|
assert!(restored.released_identities.is_empty());
|
|
assert_eq!(restored.restored_room_ids, vec![owned_room.id]);
|
|
|
|
assert!(room_repo
|
|
.get_by_id(&owned_room.id)
|
|
.await
|
|
.checked("load restored room")
|
|
.is_some());
|
|
assert!(playlist_repo
|
|
.get_by_id(&owned_playlist.id)
|
|
.await
|
|
.checked("load restored owned playlist")
|
|
.is_some());
|
|
assert!(media_repo
|
|
.get_by_id(&owned_media.id)
|
|
.await
|
|
.checked("load restored owned media")
|
|
.is_some());
|
|
assert!(playlist_repo
|
|
.get_by_id(&foreign_playlist.id)
|
|
.await
|
|
.checked("load preserved foreign-room playlist")
|
|
.is_some());
|
|
assert!(playlist_repo
|
|
.get_by_id(&foreign_dynamic_playlist.id)
|
|
.await
|
|
.checked("load restored foreign-room dynamic playlist")
|
|
.is_some());
|
|
assert!(media_repo
|
|
.get_by_id(&foreign_media.id)
|
|
.await
|
|
.checked("load restored foreign-room media")
|
|
.is_some());
|
|
assert!(media_repo
|
|
.get_by_id(&explicitly_deleted_media.id)
|
|
.await
|
|
.checked("load explicitly deleted media after restore")
|
|
.is_none());
|
|
|
|
let restored_owned_chat_deleted_at = sqlx::query_scalar!(
|
|
"SELECT deleted_at FROM chat_messages WHERE room_id = $1",
|
|
owned_room.id.as_i64(),
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("query restored owned room chat");
|
|
assert!(restored_owned_chat_deleted_at.is_none());
|
|
let restored_foreign_chat = chat_repository
|
|
.get_by_room_and_id(&foreign_room.id, foreign_chat_message_id)
|
|
.await
|
|
.checked("restored foreign-room chat should be visible");
|
|
assert_eq!(
|
|
restored_foreign_chat.map(|message| message.content),
|
|
Some("hello".to_string())
|
|
);
|
|
|
|
let creator_membership = room_member_repo
|
|
.get(&owned_room.id, &doomed_user.id)
|
|
.await
|
|
.checked("load restored creator membership")
|
|
.checked("creator membership should be restored");
|
|
assert_eq!(
|
|
creator_membership.role,
|
|
synctv_core::models::RoomRole::Creator
|
|
);
|
|
let lifecycle = sqlx::query!(
|
|
"SELECT version, is_member FROM room_member_versions WHERE room_id = $1 AND user_id = $2",
|
|
owned_room.id.as_i64(),
|
|
doomed_user.id.as_i64(),
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("query restored creator membership lifecycle");
|
|
assert!(lifecycle.is_member);
|
|
assert_eq!(lifecycle.version, creator_membership.version);
|
|
|
|
assert!(room_member_repo
|
|
.get(&foreign_room.id, &doomed_user.id)
|
|
.await
|
|
.checked("load former foreign-room membership after restore")
|
|
.is_none());
|
|
assert_eq!(
|
|
service
|
|
.get_email(&doomed_user.id)
|
|
.await
|
|
.checked("load restored email"),
|
|
Some("delete-owner@example.com".to_string())
|
|
);
|
|
let oauth_active = sqlx::query_scalar!(
|
|
r#"SELECT COUNT(*) AS "count!"
|
|
FROM auth_oauth2_identities
|
|
WHERE user_id = $1 AND deleted_at IS NULL"#,
|
|
doomed_user.id.as_i64(),
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("count restored oauth identity");
|
|
assert_eq!(oauth_active, 1);
|
|
}
|
|
|
|
async fn assert_restore_identity_conflict_modes(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let user_repository = UserRepository::new(pool.clone());
|
|
let original = user_repository
|
|
.create(&make_user("restore_conflict"))
|
|
.await
|
|
.checked("create account to restore");
|
|
insert_trusted_email_identity(&pool, &original.id, "restore-conflict@example.com").await;
|
|
insert_oauth2_identity(&pool, &original.id, "github", "restore-conflict-subject").await;
|
|
|
|
service
|
|
.delete_user(&original.id)
|
|
.await
|
|
.checked("soft-delete account before conflict test");
|
|
|
|
let claimant = user_repository
|
|
.create(&make_user("restore_conflict"))
|
|
.await
|
|
.checked("claim released username");
|
|
insert_trusted_email_identity(&pool, &claimant.id, "restore-conflict@example.com").await;
|
|
insert_oauth2_identity(&pool, &claimant.id, "github", "restore-conflict-subject").await;
|
|
|
|
let strict_error = service
|
|
.restore_user(&original.id, UserRestoreOptions::default())
|
|
.await
|
|
.failed("strict restoration should report occupied identity");
|
|
assert!(matches!(strict_error, Error::AlreadyExists(_)));
|
|
|
|
let restored = service
|
|
.restore_user(
|
|
&original.id,
|
|
UserRestoreOptions {
|
|
ignore_identity_conflicts: true,
|
|
restored_by: None,
|
|
},
|
|
)
|
|
.await
|
|
.checked("conflict-tolerant restoration should restore account body");
|
|
assert!(restored.user.username.starts_with("restored_"));
|
|
assert_eq!(restored.released_identities.len(), 3);
|
|
assert!(restored
|
|
.released_identities
|
|
.contains(&"username:restore_conflict".to_string()));
|
|
assert!(restored
|
|
.released_identities
|
|
.contains(&"email:restore-conflict@example.com".to_string()));
|
|
assert!(restored
|
|
.released_identities
|
|
.contains(&"oauth2:github:restore-conflict-subject".to_string()));
|
|
assert_eq!(
|
|
service
|
|
.get_email(&original.id)
|
|
.await
|
|
.checked("load original account email after conflict restore"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
service
|
|
.get_email(&claimant.id)
|
|
.await
|
|
.checked("load claimant email"),
|
|
Some("restore-conflict@example.com".to_string())
|
|
);
|
|
let active_oauth_owner = sqlx::query_scalar!(
|
|
r#"SELECT user_id AS "user_id: UserId"
|
|
FROM auth_oauth2_identities
|
|
WHERE provider_instance_name = 'github'
|
|
AND provider_user_id = 'restore-conflict-subject'
|
|
AND deleted_at IS NULL"#,
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("load active OAuth2 identity owner");
|
|
assert_eq!(active_oauth_owner, claimant.id);
|
|
|
|
let explicit_identity = user_repository
|
|
.create(&make_user("explicit_identity"))
|
|
.await
|
|
.checked("create explicit identity account");
|
|
insert_trusted_email_identity(&pool, &explicit_identity.id, "explicit@example.com").await;
|
|
sqlx::query!(
|
|
"UPDATE auth_email_identities SET deleted_at = CURRENT_TIMESTAMP, deletion_source = $2 WHERE user_id = $1",
|
|
explicit_identity.id.as_i64(),
|
|
DeletionSource::User as DeletionSource,
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("explicitly unbind email");
|
|
service
|
|
.delete_user(&explicit_identity.id)
|
|
.await
|
|
.checked("delete account with previously unbound identity");
|
|
service
|
|
.restore_user(&explicit_identity.id, UserRestoreOptions::default())
|
|
.await
|
|
.checked("restore account with explicitly unbound identity");
|
|
assert_eq!(
|
|
service
|
|
.get_email(&explicit_identity.id)
|
|
.await
|
|
.checked("load explicitly unbound email after restore"),
|
|
None
|
|
);
|
|
let explicit_source = sqlx::query_scalar!(
|
|
r#"SELECT deletion_source AS "deletion_source?: DeletionSource"
|
|
FROM auth_email_identities WHERE user_id = $1"#,
|
|
explicit_identity.id.as_i64(),
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("load explicit identity deletion source");
|
|
assert_eq!(explicit_source, Some(DeletionSource::User));
|
|
}
|
|
|
|
/// Test that "username taken" errors do NOT count against IP brute-force lockout.
|
|
///
|
|
/// Scenario: User tries to register with a username that already exists.
|
|
/// This should fail with `AlreadyExists`, but should NOT lock out the IP
|
|
/// because it's not a security threat - just an unfortunate choice of username.
|
|
async fn assert_register_username_taken_no_brute_force_lockout(service: &UserService) {
|
|
let client_ip: std::net::IpAddr = "192.168.1.100"
|
|
.parse()
|
|
.checked("test operation should succeed");
|
|
|
|
// Register first user
|
|
opaque_register_user_with_client_ip(
|
|
service,
|
|
"existing_user_42",
|
|
Some("existing_42@test.com".to_string()),
|
|
"StrongPass1",
|
|
Some(client_ip),
|
|
)
|
|
.await
|
|
.checked("First registration should succeed");
|
|
|
|
// Try to register with the same username multiple times (should fail with AlreadyExists)
|
|
for _ in 0..5 {
|
|
let result = opaque_register_user_with_client_ip(
|
|
service,
|
|
"existing_user_42",
|
|
Some("different@test.com".to_string()),
|
|
"StrongPass1",
|
|
Some(client_ip),
|
|
)
|
|
.await;
|
|
|
|
// Should fail with AlreadyExists
|
|
assert!(
|
|
matches!(result, Err(Error::AlreadyExists(_))),
|
|
"Should fail with AlreadyExists"
|
|
);
|
|
|
|
// IMPORTANT: Should NOT be RateLimited even after many attempts
|
|
assert!(
|
|
!matches!(result, Err(Error::RateLimited(_))),
|
|
"Username taken errors should NOT trigger brute-force lockout"
|
|
);
|
|
}
|
|
|
|
// Now try with a DIFFERENT username - should succeed (IP not locked)
|
|
let result = opaque_register_user_with_client_ip(
|
|
service,
|
|
"new_unique_user_42",
|
|
Some("new_42@test.com".to_string()),
|
|
"StrongPass1",
|
|
Some(client_ip),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"Should be able to register with new username - IP should NOT be locked out by 'username taken' errors: {:?}",
|
|
result.err()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_ban_user_preserves_owned_room_memberships_for_unban() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let version_fence: Arc<dyn VersionFenceStore> = Arc::new(LocalVersionFenceStore::new());
|
|
let permission_service = PermissionService::new_with_runtime(
|
|
RoomMemberRepository::new(pool.clone()),
|
|
RoomRepository::new(pool.clone()),
|
|
PermissionServiceRuntime {
|
|
version_fence: version_fence.clone(),
|
|
..PermissionServiceRuntime::local_only()
|
|
},
|
|
)
|
|
.checked("permission service should build");
|
|
let user_service = create_user_service_with_runtime(
|
|
&pool,
|
|
UserServiceRuntimeOptions {
|
|
permission_service: Some(permission_service),
|
|
..UserServiceRuntimeOptions::test_defaults()
|
|
},
|
|
);
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
let room_member_repo = RoomMemberRepository::new(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("banned_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
let member = user_repo
|
|
.create(&make_user("banned_owner_member"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let owned_room = room_repo
|
|
.create(&make_room("owner-room", &owner.id))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
room_member_repo
|
|
.add(&RoomMember::new(
|
|
owned_room.id,
|
|
owner.id,
|
|
synctv_core::models::RoomRole::Creator,
|
|
))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
room_member_repo
|
|
.add(&RoomMember::new(
|
|
owned_room.id,
|
|
member.id,
|
|
synctv_core::models::RoomRole::Member,
|
|
))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
user_service
|
|
.ban_user(&owner.id, None, None)
|
|
.await
|
|
.checked("banning owner should succeed");
|
|
|
|
let owner_membership = room_member_repo
|
|
.get(&owned_room.id, &owner.id)
|
|
.await
|
|
.checked("owner membership lookup should succeed");
|
|
assert_eq!(
|
|
owner_membership.map(|membership| membership.role),
|
|
Some(synctv_core::models::RoomRole::Creator),
|
|
"a global ban preserves the creator membership for reversible moderation"
|
|
);
|
|
|
|
let member_membership = room_member_repo
|
|
.get(&owned_room.id, &member.id)
|
|
.await
|
|
.checked("member membership lookup should succeed");
|
|
assert!(
|
|
member_membership.is_some(),
|
|
"banning a room owner preserves other room memberships"
|
|
);
|
|
|
|
user_service
|
|
.unban_user(&owner.id)
|
|
.await
|
|
.checked("unbanning owner should succeed");
|
|
assert!(
|
|
room_member_repo
|
|
.get(&owned_room.id, &owner.id)
|
|
.await
|
|
.checked("restored owner membership lookup should succeed")
|
|
.is_some(),
|
|
"unban keeps the creator membership available"
|
|
);
|
|
}
|
|
|
|
/// Test that validation errors DO count against IP brute-force lockout.
|
|
///
|
|
/// Scenario: Attacker sends malformed registration requests (validation errors).
|
|
/// These should count against the IP lockout because they indicate automated attacks.
|
|
async fn assert_register_validation_errors_trigger_brute_force_lockout(service: &UserService) {
|
|
let client_ip: std::net::IpAddr = "192.168.1.101"
|
|
.parse()
|
|
.checked("test operation should succeed");
|
|
|
|
// The brute-force lockout thresholds are:
|
|
// - 5 failures: 1 minute lockout
|
|
// - 10 failures: 5 minute lockout
|
|
// - 15+ failures: 15 minute lockout
|
|
// We need to trigger at least 5 validation errors
|
|
|
|
let mut validation_error_count = 0;
|
|
for _ in 0..25 {
|
|
let result = service
|
|
.start_opaque_registration_with_control(
|
|
"ab".to_string(),
|
|
Some("test@example.com".to_string()),
|
|
vec![1, 2, 3].into(),
|
|
Some(client_ip),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
match &result {
|
|
Err(Error::InvalidInput(_)) => {
|
|
validation_error_count += 1;
|
|
}
|
|
Err(Error::RateLimited(_)) => {
|
|
// Expected - IP should be locked out after enough validation errors
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
validation_error_count >= 5,
|
|
"Should have had at least 5 validation errors before lockout, got {validation_error_count}"
|
|
);
|
|
}
|
|
|
|
async fn assert_update_user_rejects_direct_email_changes(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let email_repo = UserEmailRepository::new(pool.clone());
|
|
|
|
let created = user_repo
|
|
.create(&make_user("email_update_guard_user"))
|
|
.await
|
|
.checked("create email signup user");
|
|
let original_email = "email_update_guard_user@example.com";
|
|
email_repo
|
|
.create_for_user_with_executor(&created, Some(original_email), &pool)
|
|
.await
|
|
.checked("create original email identity");
|
|
|
|
let mut profile_update = created.clone();
|
|
profile_update.username = "email_update_guard_renamed".to_string();
|
|
let updated = service
|
|
.update_user(&profile_update, created.version)
|
|
.await
|
|
.checked("profile update should succeed");
|
|
|
|
assert_eq!(updated.username, "email_update_guard_renamed");
|
|
let unchanged_email = email_repo
|
|
.get_email(&created.id)
|
|
.await
|
|
.checked("fetch unchanged email identity");
|
|
assert_eq!(unchanged_email.as_deref(), Some(original_email));
|
|
}
|
|
|
|
async fn assert_email_bind_writes_email_only_after_confirm(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let email_repo = UserEmailRepository::new(pool.clone());
|
|
|
|
let original_email = "email_bind_flow_user@example.com";
|
|
let (created, _, _) = opaque_register_user(
|
|
&service,
|
|
"email_bind_flow_user",
|
|
Some(original_email.to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create email bind flow user");
|
|
let new_email = "email_bind_flow_new@example.com";
|
|
|
|
let first_token = service
|
|
.start_email_bind(&created.id, new_email)
|
|
.await
|
|
.checked("start email bind");
|
|
assert!(service
|
|
.is_email_bind_token_active(&created.id, new_email, &first_token)
|
|
.await
|
|
.checked("first email bind token active state should load"));
|
|
let token = service
|
|
.start_email_bind(&created.id, new_email)
|
|
.await
|
|
.checked("replace email bind token");
|
|
assert!(!service
|
|
.is_email_bind_token_active(&created.id, new_email, &first_token)
|
|
.await
|
|
.checked("superseded email bind token active state should load"));
|
|
assert!(service
|
|
.is_email_bind_token_active(&created.id, new_email, &token)
|
|
.await
|
|
.checked("replacement email bind token active state should load"));
|
|
|
|
let after_start = email_repo
|
|
.get_email(&created.id)
|
|
.await
|
|
.checked("fetch email after bind start");
|
|
assert_eq!(after_start.as_deref(), Some(original_email));
|
|
|
|
let mismatch_result = service
|
|
.confirm_email_bind(
|
|
&created.id,
|
|
"email_bind_flow_other@example.com",
|
|
&token,
|
|
&password_verification_id(&service, &created.id, "StrongPass1").await,
|
|
)
|
|
.await
|
|
.failed("email mismatch must reject pending bind request");
|
|
assert!(
|
|
matches!(mismatch_result, Error::InvalidInput(_)),
|
|
"expected InvalidInput for email mismatch"
|
|
);
|
|
|
|
let after_mismatch = email_repo
|
|
.get_email(&created.id)
|
|
.await
|
|
.checked("fetch email after bind mismatch");
|
|
assert_eq!(after_mismatch.as_deref(), Some(original_email));
|
|
|
|
let updated = service
|
|
.confirm_email_bind(
|
|
&created.id,
|
|
new_email,
|
|
&token,
|
|
&password_verification_id(&service, &created.id, "StrongPass1").await,
|
|
)
|
|
.await
|
|
.checked("confirm email bind");
|
|
assert_eq!(updated.id, created.id);
|
|
let updated_email = email_repo
|
|
.get_email(&created.id)
|
|
.await
|
|
.checked("fetch updated email");
|
|
assert_eq!(updated_email.as_deref(), Some(new_email));
|
|
|
|
let consumed_result = service
|
|
.confirm_email_bind(
|
|
&created.id,
|
|
new_email,
|
|
&token,
|
|
&password_verification_id(&service, &created.id, "StrongPass1").await,
|
|
)
|
|
.await
|
|
.failed("consumed bind token must be rejected");
|
|
assert!(
|
|
matches!(consumed_result, Error::InvalidInput(_)),
|
|
"expected InvalidInput for consumed token"
|
|
);
|
|
|
|
let pending_unbind_token = service
|
|
.start_email_bind(&created.id, "email_bind_flow_pending_unbind@example.com")
|
|
.await
|
|
.checked("start pending bind before unbind");
|
|
assert!(service
|
|
.is_email_bind_token_active(
|
|
&created.id,
|
|
"email_bind_flow_pending_unbind@example.com",
|
|
&pending_unbind_token,
|
|
)
|
|
.await
|
|
.checked("pending bind token should be active before unbind"));
|
|
|
|
service
|
|
.unbind_email(
|
|
&created.id,
|
|
&password_verification_id(&service, &created.id, "StrongPass1").await,
|
|
)
|
|
.await
|
|
.checked("unbind email should succeed");
|
|
assert!(!service
|
|
.is_email_bind_token_active(
|
|
&created.id,
|
|
"email_bind_flow_pending_unbind@example.com",
|
|
&pending_unbind_token,
|
|
)
|
|
.await
|
|
.checked("pending bind token state should load after unbind"));
|
|
|
|
service
|
|
.delete_user(&created.id)
|
|
.await
|
|
.checked("user should be soft-deleted after bind lifecycle");
|
|
let deleted_user_bind = service
|
|
.start_email_bind(&created.id, "email_bind_flow_after_delete@example.com")
|
|
.await
|
|
.failed("deleted users must not create email bind requests");
|
|
assert!(
|
|
matches!(deleted_user_bind, Error::NotFound(_)),
|
|
"deleted user bind creation should report NotFound"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_email_bind_supersession_and_confirmation() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
assert_email_bind_writes_email_only_after_confirm(pool).await;
|
|
}
|
|
|
|
async fn assert_email_bind_rejects_taken_email(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let email_repo = UserEmailRepository::new(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("email_bind_taken_owner"))
|
|
.await
|
|
.checked("create owner user");
|
|
let owner_email = "email_bind_taken_owner@example.com";
|
|
email_repo
|
|
.create_for_user_with_executor(&owner, Some(owner_email), &pool)
|
|
.await
|
|
.checked("create owner email identity");
|
|
let requester = user_repo
|
|
.create(&make_user("email_bind_taken_requester"))
|
|
.await
|
|
.checked("create requester user");
|
|
|
|
let result = service
|
|
.start_email_bind(&requester.id, owner_email)
|
|
.await
|
|
.failed("taken email must be rejected");
|
|
assert!(
|
|
matches!(result, Error::AlreadyExists(_)),
|
|
"expected AlreadyExists for taken email"
|
|
);
|
|
}
|
|
|
|
async fn assert_two_factor_requires_two_usable_methods(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
|
|
let (password_only, _, _) =
|
|
opaque_register_user(&service, "two_factor_password_only", None, "StrongPass1")
|
|
.await
|
|
.checked("create password-only user");
|
|
let result = service
|
|
.set_two_factor_enabled(&password_only.id, true)
|
|
.await
|
|
.failed("single-method users must not enable two-factor authentication");
|
|
assert!(
|
|
matches!(&result, Error::InvalidInput(message) if message.contains("requires at least two")),
|
|
"expected InvalidInput for insufficient auth factors, got {result:?}"
|
|
);
|
|
|
|
let (email_and_password, _, _) = opaque_register_user(
|
|
&service,
|
|
"two_factor_email_password",
|
|
Some("two_factor_email_password@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create email+password user");
|
|
let (preferences, factors) = service
|
|
.set_two_factor_enabled(&email_and_password.id, true)
|
|
.await
|
|
.checked("email+password user can enable two-factor authentication");
|
|
assert!(preferences.two_factor_enabled);
|
|
assert!(factors.password);
|
|
assert!(factors.email);
|
|
assert_eq!(factors.eligible_count(), 2);
|
|
}
|
|
|
|
async fn assert_sensitive_verification_is_one_time(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"sensitive_verification_one_time",
|
|
Some("sensitive_verification_one_time@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create user with password");
|
|
|
|
let verification_id = password_verification_id(&service, &user.id, "StrongPass1").await;
|
|
service
|
|
.consume_sensitive_operation_verification(&user.id, &verification_id)
|
|
.await
|
|
.checked("first verification consumption should succeed");
|
|
let reused = service
|
|
.consume_sensitive_operation_verification(&user.id, &verification_id)
|
|
.await
|
|
.failed("verification id must be single-use");
|
|
assert!(
|
|
matches!(reused, Error::Authentication(_)),
|
|
"expected Authentication for reused verification id, got {reused:?}"
|
|
);
|
|
}
|
|
|
|
async fn assert_two_factor_changes_require_fresh_sensitive_verification(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"two_factor_verified_change",
|
|
Some("two_factor_verified_change@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create email and password user");
|
|
|
|
let rejected = service
|
|
.set_two_factor_enabled_with_verification(&user.id, true, "invalid-verification")
|
|
.await
|
|
.failed("enabling two-factor authentication requires verification");
|
|
assert!(matches!(rejected, Error::Authentication(_)));
|
|
assert!(
|
|
!service
|
|
.get_user_preferences(&user.id)
|
|
.await
|
|
.checked("load preferences after rejected update")
|
|
.0
|
|
.two_factor_enabled
|
|
);
|
|
|
|
let enable_verification = password_verification_id(&service, &user.id, "StrongPass1").await;
|
|
let (preferences, _) = service
|
|
.set_two_factor_enabled_with_verification(&user.id, true, &enable_verification)
|
|
.await
|
|
.checked("fresh verification enables two-factor authentication");
|
|
assert!(preferences.two_factor_enabled);
|
|
|
|
let reused = service
|
|
.set_two_factor_enabled_with_verification(&user.id, false, &enable_verification)
|
|
.await
|
|
.failed("verification cannot be reused to disable two-factor authentication");
|
|
assert!(matches!(reused, Error::Authentication(_)));
|
|
assert!(
|
|
service
|
|
.get_user_preferences(&user.id)
|
|
.await
|
|
.checked("load preferences after reused verification")
|
|
.0
|
|
.two_factor_enabled
|
|
);
|
|
}
|
|
|
|
async fn assert_sensitive_password_verification_is_rate_limited(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"sensitive_verification_rate_limit",
|
|
Some("sensitive_verification_rate_limit@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create user with password");
|
|
let outcome = service
|
|
.start_sensitive_operation_verification(&user.id, None)
|
|
.await
|
|
.checked("start sensitive verification");
|
|
let SensitiveVerificationOutcome::Pending(challenge) = outcome else {
|
|
std::panic::panic_any("password-sensitive verification should start with a challenge");
|
|
};
|
|
|
|
for _ in 0..5 {
|
|
let result = service
|
|
.finish_sensitive_operation_password_verification(
|
|
&challenge.session_id,
|
|
"WrongPass1",
|
|
None,
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
matches!(result, Err(Error::Authentication(_))),
|
|
"wrong password should fail authentication"
|
|
);
|
|
}
|
|
|
|
let locked = service
|
|
.finish_sensitive_operation_password_verification(
|
|
&challenge.session_id,
|
|
"StrongPass1",
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.failed("sensitive password verification should lock out after repeated failures");
|
|
assert!(
|
|
matches!(locked, Error::Authentication(ref message) if message.contains("Too many failed attempts")),
|
|
"expected sensitive verification brute-force lockout, got {locked:?}"
|
|
);
|
|
}
|
|
|
|
async fn assert_sensitive_verification_requires_two_local_factors_when_2fa_enabled(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let email = "sensitive_verification_2fa@example.com";
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"sensitive_verification_2fa",
|
|
Some(email.to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create user with password and email");
|
|
insert_trusted_email_identity(&pool, &user.id, email).await;
|
|
service
|
|
.set_two_factor_enabled(&user.id, true)
|
|
.await
|
|
.checked("password+email user can enable two-factor authentication");
|
|
|
|
let outcome = service
|
|
.start_sensitive_operation_verification(&user.id, None)
|
|
.await
|
|
.checked("start sensitive verification");
|
|
let SensitiveVerificationOutcome::Pending(challenge) = outcome else {
|
|
std::panic::panic_any(
|
|
"2FA-enabled sensitive verification should start with a pending challenge",
|
|
);
|
|
};
|
|
assert_eq!(challenge.required_count, 2);
|
|
assert!(challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::Password));
|
|
assert!(challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::Email));
|
|
|
|
let pending = service
|
|
.finish_sensitive_operation_password_verification(
|
|
&challenge.session_id,
|
|
"StrongPass1",
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("password factor should verify");
|
|
let SensitiveVerificationOutcome::Pending(next_challenge) = pending else {
|
|
std::panic::panic_any("2FA-enabled sensitive verification should require another factor");
|
|
};
|
|
assert_eq!(next_challenge.required_count, 2);
|
|
assert_eq!(
|
|
next_challenge.completed_methods,
|
|
vec![AuthFactorMethod::Password]
|
|
);
|
|
assert!(next_challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::Email));
|
|
|
|
let complete = service
|
|
.finish_sensitive_operation_verified_method(
|
|
&next_challenge.session_id,
|
|
AuthFactorMethod::Email,
|
|
)
|
|
.await
|
|
.checked("email factor should complete");
|
|
let SensitiveVerificationOutcome::Complete { verification_id } = complete else {
|
|
std::panic::panic_any("second factor should complete sensitive verification");
|
|
};
|
|
service
|
|
.consume_sensitive_operation_verification(&user.id, &verification_id)
|
|
.await
|
|
.checked("completed two-factor verification should be consumable");
|
|
}
|
|
|
|
async fn assert_oauth2_session_sensitive_verification_requires_one_local_factor(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let email = "sensitive_verification_oauth2@example.com";
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"sensitive_verification_oauth2",
|
|
Some(email.to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create user with password and email");
|
|
insert_trusted_email_identity(&pool, &user.id, email).await;
|
|
service
|
|
.set_two_factor_enabled(&user.id, true)
|
|
.await
|
|
.checked("password+email user can enable two-factor authentication");
|
|
|
|
let outcome = service
|
|
.start_sensitive_operation_verification(&user.id, Some(TokenAuthContext::OAuth2))
|
|
.await
|
|
.checked("start OAuth2 sensitive verification");
|
|
let SensitiveVerificationOutcome::Pending(challenge) = outcome else {
|
|
std::panic::panic_any(
|
|
"OAuth2-session sensitive verification should start with local factors when present",
|
|
);
|
|
};
|
|
assert_eq!(challenge.required_count, 1);
|
|
assert!(challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::Password));
|
|
|
|
let complete = service
|
|
.finish_sensitive_operation_password_verification(
|
|
&challenge.session_id,
|
|
"StrongPass1",
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("one local factor should complete OAuth2-session sensitive verification");
|
|
let SensitiveVerificationOutcome::Complete { verification_id } = complete else {
|
|
std::panic::panic_any(
|
|
"OAuth2-session sensitive verification should complete after one local factor",
|
|
);
|
|
};
|
|
service
|
|
.consume_sensitive_operation_verification(&user.id, &verification_id)
|
|
.await
|
|
.checked("OAuth2-session verification should be consumable");
|
|
}
|
|
|
|
async fn assert_oauth2_only_session_can_bootstrap_first_local_factor(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let user = user_repo
|
|
.create(&User::new(
|
|
"sensitive_verification_oauth2_only".to_string(),
|
|
SignupMethod::OAuth2,
|
|
))
|
|
.await
|
|
.checked("create OAuth2-only user");
|
|
insert_oauth2_identity(
|
|
&pool,
|
|
&user.id,
|
|
"github",
|
|
"sensitive-verification-oauth2-only",
|
|
)
|
|
.await;
|
|
|
|
let outcome = service
|
|
.start_sensitive_operation_verification(&user.id, Some(TokenAuthContext::OAuth2))
|
|
.await
|
|
.checked("OAuth2-only account should receive a bootstrap verification id");
|
|
let SensitiveVerificationOutcome::Complete { verification_id } = outcome else {
|
|
std::panic::panic_any("OAuth2-only bootstrap should complete from current OAuth2 session");
|
|
};
|
|
service
|
|
.consume_sensitive_operation_verification(&user.id, &verification_id)
|
|
.await
|
|
.checked("OAuth2-only bootstrap verification should be consumable");
|
|
}
|
|
|
|
async fn assert_two_factor_blocks_deleting_required_passkey(pool: PgPool) {
|
|
let user_service = Arc::new(create_user_service(&pool));
|
|
let (user, _, _) = opaque_register_user(
|
|
user_service.as_ref(),
|
|
"two_factor_passkey_user",
|
|
None,
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create password+passkey user");
|
|
let credential_id = b"two-factor-required-passkey";
|
|
insert_test_passkey(&pool, &user.id, credential_id).await;
|
|
|
|
user_service
|
|
.set_two_factor_enabled(&user.id, true)
|
|
.await
|
|
.checked("password+passkey user can enable two-factor authentication");
|
|
|
|
let passkey_service = make_passkey_service(pool, user_service);
|
|
let result = passkey_service
|
|
.delete_credential(&user.id, credential_id)
|
|
.await
|
|
.failed("deleting the passkey would leave fewer than two auth methods");
|
|
assert!(
|
|
matches!(&result, Error::InvalidInput(message) if message.contains("remaining verification methods are insufficient")),
|
|
"expected InvalidInput for deleting required passkey, got {result:?}"
|
|
);
|
|
}
|
|
|
|
async fn assert_two_factor_blocks_single_factor_token_issuance(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"two_factor_login_blocked",
|
|
Some("two_factor_login_blocked@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create user with password");
|
|
|
|
insert_trusted_email_identity(&pool, &user.id, "two_factor_login_blocked@example.com").await;
|
|
let refresh_token = match opaque_login_user(&service, "two_factor_login_blocked", "StrongPass1")
|
|
.await
|
|
.checked("single-factor login should work before 2FA is enabled")
|
|
{
|
|
AuthenticatedLogin::Complete { refresh_token, .. } => refresh_token,
|
|
AuthenticatedLogin::MfaRequired { .. } => {
|
|
std::panic::panic_any("2FA is disabled, login should be complete")
|
|
}
|
|
};
|
|
|
|
service
|
|
.set_two_factor_enabled(&user.id, true)
|
|
.await
|
|
.checked("password+verified email user can enable two-factor authentication");
|
|
|
|
let login_result =
|
|
opaque_login_user_with_challenge(&service, "two_factor_login_blocked", "StrongPass1")
|
|
.await
|
|
.checked("first factor should return an MFA challenge after 2FA is enabled");
|
|
let AuthenticatedLogin::MfaRequired { challenge, .. } = login_result else {
|
|
std::panic::panic_any("single-factor login must not issue tokens after 2FA is enabled");
|
|
};
|
|
assert!(
|
|
challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::Email),
|
|
"password first-factor login should expose email as a remaining factor"
|
|
);
|
|
assert!(
|
|
!challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::Password),
|
|
"same password factor must not be offered twice"
|
|
);
|
|
let mfa_refresh_token = match service
|
|
.complete_mfa_session_with_control(
|
|
&challenge.session_id,
|
|
AuthFactorMethod::Email,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("verified second factor should complete MFA login")
|
|
{
|
|
AuthenticatedLogin::Complete { refresh_token, .. } => refresh_token,
|
|
AuthenticatedLogin::MfaRequired { .. } => {
|
|
std::panic::panic_any("completed MFA must issue tokens")
|
|
}
|
|
};
|
|
let (rotated_access, rotated_refresh) = service
|
|
.refresh_token(mfa_refresh_token)
|
|
.await
|
|
.checked("refresh token issued after MFA should rotate successfully");
|
|
assert!(!rotated_access.is_empty());
|
|
assert!(!rotated_refresh.is_empty());
|
|
|
|
let refresh_result = service
|
|
.refresh_token(refresh_token)
|
|
.await
|
|
.failed("refresh token rotation must not issue tokens after 2FA is enabled");
|
|
assert!(
|
|
matches!(&refresh_result, Error::Authentication(message) if message.contains("Two-factor authentication is required")),
|
|
"expected Authentication error requiring 2FA during refresh, got {refresh_result:?}"
|
|
);
|
|
}
|
|
|
|
async fn assert_two_factor_access_token_context_is_enforced(pool: PgPool) {
|
|
let (service, jwt, pipeline) = create_user_service_with_security_pipeline(&pool);
|
|
let (user, old_access_token, old_refresh_token) = opaque_register_user(
|
|
service.as_ref(),
|
|
"two_factor_access_context",
|
|
Some("two_factor_access_context@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create user with password");
|
|
let old_access_token =
|
|
old_access_token.checked("2FA-disabled registration issues access token");
|
|
let old_refresh_token =
|
|
old_refresh_token.checked("2FA-disabled registration issues refresh token");
|
|
let old_access_claims = jwt
|
|
.verify_access_token(&old_access_token)
|
|
.checked("old access token should be syntactically valid");
|
|
pipeline
|
|
.check(&old_access_claims)
|
|
.await
|
|
.checked("single-factor access token should work before 2FA is enabled");
|
|
|
|
insert_trusted_email_identity(&pool, &user.id, "two_factor_access_context@example.com").await;
|
|
service
|
|
.set_two_factor_enabled(&user.id, true)
|
|
.await
|
|
.checked("password+verified email user can enable two-factor authentication");
|
|
|
|
let result = pipeline
|
|
.check(&old_access_claims)
|
|
.await
|
|
.failed("old single-factor access token must be rejected while 2FA is enabled");
|
|
assert!(
|
|
matches!(&result, Error::Authentication(message) if message.contains("Two-factor authentication is required")),
|
|
"expected old access token to require 2FA context, got {result:?}"
|
|
);
|
|
let refresh_result = service
|
|
.refresh_token(old_refresh_token)
|
|
.await
|
|
.failed("old single-factor refresh token must also be rejected while 2FA is enabled");
|
|
assert!(
|
|
matches!(&refresh_result, Error::Authentication(message) if message.contains("Two-factor authentication is required")),
|
|
"expected old refresh token to require 2FA context, got {refresh_result:?}"
|
|
);
|
|
|
|
let login_result = opaque_login_user_with_challenge(
|
|
service.as_ref(),
|
|
"two_factor_access_context",
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("password first factor should start MFA challenge");
|
|
let AuthenticatedLogin::MfaRequired { challenge, .. } = login_result else {
|
|
std::panic::panic_any("2FA-enabled password login should require email second factor");
|
|
};
|
|
let mfa_access_token = match service
|
|
.complete_mfa_session_with_control(
|
|
&challenge.session_id,
|
|
AuthFactorMethod::Email,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("verified email second factor should complete MFA login")
|
|
{
|
|
AuthenticatedLogin::Complete { access_token, .. } => access_token,
|
|
AuthenticatedLogin::MfaRequired { .. } => {
|
|
std::panic::panic_any("completed MFA must issue tokens")
|
|
}
|
|
};
|
|
let mfa_access_claims = jwt
|
|
.verify_access_token(&mfa_access_token)
|
|
.checked("MFA access token should be syntactically valid");
|
|
assert!(
|
|
mfa_access_claims.satisfies_two_factor_requirement(),
|
|
"MFA-completed token must carry a 2FA auth context"
|
|
);
|
|
pipeline
|
|
.check(&mfa_access_claims)
|
|
.await
|
|
.checked("MFA access token should work while 2FA is enabled");
|
|
|
|
insert_oauth2_identity(&pool, &user.id, "github", "oauth2-provider-user-id").await;
|
|
let oauth_access_token = match service
|
|
.login_oauth2(&user.id, "github", "oauth2-provider-user-id", None)
|
|
.await
|
|
.checked("OAuth2 login should stay independent from local 2FA")
|
|
{
|
|
AuthenticatedLogin::Complete { access_token, .. } => access_token,
|
|
AuthenticatedLogin::MfaRequired { .. } => {
|
|
std::panic::panic_any("OAuth2 login must not start a local MFA challenge")
|
|
}
|
|
};
|
|
let oauth_access_claims = jwt
|
|
.verify_access_token(&oauth_access_token)
|
|
.checked("OAuth2 access token should be syntactically valid");
|
|
assert!(
|
|
oauth_access_claims.satisfies_two_factor_requirement(),
|
|
"OAuth2 token must carry its independent auth context"
|
|
);
|
|
pipeline
|
|
.check(&oauth_access_claims)
|
|
.await
|
|
.checked("OAuth2 access token should work while 2FA is enabled");
|
|
|
|
service
|
|
.set_two_factor_enabled(&user.id, false)
|
|
.await
|
|
.checked("2FA can be disabled once the caller has a valid strong context");
|
|
pipeline
|
|
.check(&old_access_claims)
|
|
.await
|
|
.checked("single-factor access token should work again after 2FA is disabled");
|
|
pipeline
|
|
.check(&mfa_access_claims)
|
|
.await
|
|
.checked("MFA access token should remain valid after 2FA is disabled");
|
|
}
|
|
|
|
async fn assert_two_factor_allows_oauth2_without_local_mfa(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"two_factor_oauth2_allowed",
|
|
Some("two_factor_oauth2_allowed@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create user with password");
|
|
|
|
insert_trusted_email_identity(&pool, &user.id, "two_factor_oauth2_allowed@example.com").await;
|
|
service
|
|
.set_two_factor_enabled(&user.id, true)
|
|
.await
|
|
.checked("password+verified email user can enable two-factor authentication");
|
|
|
|
insert_oauth2_identity(&pool, &user.id, "github", "oauth2-provider-user-id-mfa").await;
|
|
let (access_token, refresh_token) = match service
|
|
.login_oauth2(&user.id, "github", "oauth2-provider-user-id-mfa", None)
|
|
.await
|
|
.checked("OAuth2 login should stay independent from local 2FA")
|
|
{
|
|
AuthenticatedLogin::Complete {
|
|
access_token,
|
|
refresh_token,
|
|
..
|
|
} => (access_token, refresh_token),
|
|
AuthenticatedLogin::MfaRequired { .. } => {
|
|
std::panic::panic_any("OAuth2 login must not start a local MFA challenge")
|
|
}
|
|
};
|
|
assert!(!access_token.is_empty());
|
|
let (rotated_access, rotated_refresh) = service
|
|
.refresh_token(refresh_token)
|
|
.await
|
|
.checked("OAuth2 refresh token should rotate for 2FA-enabled users");
|
|
assert!(!rotated_access.is_empty());
|
|
assert!(!rotated_refresh.is_empty());
|
|
}
|
|
|
|
async fn assert_refresh_token_rejects_unbound_oauth2_identity(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"oauth_refresh_binding",
|
|
Some("oauth_refresh_binding@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create user with password");
|
|
|
|
insert_oauth2_identity(&pool, &user.id, "github", "oauth-refresh-provider-user").await;
|
|
let refresh_token = match service
|
|
.login_oauth2(&user.id, "github", "oauth-refresh-provider-user", None)
|
|
.await
|
|
.checked("OAuth2 login should issue tokens")
|
|
{
|
|
AuthenticatedLogin::Complete { refresh_token, .. } => refresh_token,
|
|
AuthenticatedLogin::MfaRequired { .. } => {
|
|
std::panic::panic_any("OAuth2 login should complete")
|
|
}
|
|
};
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM auth_oauth2_identities
|
|
WHERE user_id = $1 AND provider_instance_name = $2 AND provider_user_id = $3",
|
|
user.id.as_i64(),
|
|
"github",
|
|
"oauth-refresh-provider-user"
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("delete oauth2 identity");
|
|
|
|
let result = service.refresh_token(refresh_token).await;
|
|
assert!(
|
|
matches!(result, Err(Error::Authentication(_))),
|
|
"OAuth2-bound refresh token should be rejected after unlink"
|
|
);
|
|
}
|
|
|
|
async fn assert_refresh_token_rejects_unbound_email_identity(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"email_refresh_binding",
|
|
Some("email_refresh_binding@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create user with password");
|
|
let refresh_token = match service
|
|
.login_with_verified_email_with_control(
|
|
&user.id,
|
|
"email_refresh_binding@example.com",
|
|
"email-refresh-binding",
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("verified email login should issue tokens")
|
|
{
|
|
AuthenticatedLogin::Complete { refresh_token, .. } => refresh_token,
|
|
AuthenticatedLogin::MfaRequired { .. } => {
|
|
std::panic::panic_any("email login should complete")
|
|
}
|
|
};
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM auth_email_identities WHERE user_id = $1",
|
|
user.id.as_i64()
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("delete email identity");
|
|
|
|
let result = service.refresh_token(refresh_token).await;
|
|
assert!(
|
|
matches!(result, Err(Error::Authentication(_))),
|
|
"Email-bound refresh token should be rejected after unlink"
|
|
);
|
|
}
|
|
|
|
async fn assert_refresh_token_rejects_deleted_passkey_binding(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let (user, _, _) = opaque_register_user(
|
|
&service,
|
|
"passkey_refresh_binding",
|
|
Some("passkey_refresh_binding@example.com".to_string()),
|
|
"StrongPass1",
|
|
)
|
|
.await
|
|
.checked("create user with password");
|
|
let credential_id = b"passkey-refresh-binding";
|
|
insert_test_passkey(&pool, &user.id, credential_id).await;
|
|
|
|
let refresh_token = match service
|
|
.login_with_verified_external_credential_with_control(
|
|
&user.id,
|
|
credential_id,
|
|
"passkey-refresh-binding",
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("passkey login should issue tokens")
|
|
{
|
|
AuthenticatedLogin::Complete { refresh_token, .. } => refresh_token,
|
|
AuthenticatedLogin::MfaRequired { .. } => {
|
|
std::panic::panic_any("passkey login should complete")
|
|
}
|
|
};
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM auth_webauthn_credentials WHERE user_id = $1 AND credential_id = $2",
|
|
user.id.as_i64(),
|
|
credential_id.as_slice()
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("delete passkey credential");
|
|
|
|
let result = service.refresh_token(refresh_token).await;
|
|
assert!(
|
|
matches!(result, Err(Error::Authentication(_))),
|
|
"Passkey-bound refresh token should be rejected after credential deletion"
|
|
);
|
|
}
|
|
|
|
async fn assert_totp_lifecycle_and_replay_protection(pool: PgPool) {
|
|
let service = create_user_service(&pool);
|
|
let (user, _, _) = opaque_register_user(&service, "totp_lifecycle_user", None, "StrongPass1")
|
|
.await
|
|
.checked("create TOTP lifecycle user");
|
|
|
|
let setup = service
|
|
.start_totp_setup(
|
|
&user.id,
|
|
&password_verification_id(&service, &user.id, "StrongPass1").await,
|
|
)
|
|
.await
|
|
.checked("start TOTP setup");
|
|
assert!(setup.otpauth_uri.starts_with("otpauth://totp/"));
|
|
assert!(setup.otpauth_uri.contains("issuer=SyncTV"));
|
|
assert!(setup
|
|
.otpauth_uri
|
|
.contains(&format!("secret={}", setup.secret)));
|
|
assert!(setup.expires_at > Utc::now().timestamp());
|
|
|
|
let encrypted_secret: serde_json::Value = sqlx::query_scalar!(
|
|
"SELECT encrypted_secret FROM auth_totp_credentials WHERE user_id = $1",
|
|
user.id.as_i64()
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("load encrypted TOTP secret");
|
|
assert!(!encrypted_secret.to_string().contains(&setup.secret));
|
|
|
|
let setup_code = totp_code_with_step_offset(&setup.secret, 0);
|
|
let initial_codes = service
|
|
.finish_totp_setup(&user.id, &setup.setup_id, &setup_code)
|
|
.await
|
|
.checked("finish TOTP setup")
|
|
.recovery_codes;
|
|
assert_eq!(initial_codes.len(), 10);
|
|
let unique_initial_codes = initial_codes
|
|
.iter()
|
|
.collect::<std::collections::HashSet<_>>();
|
|
assert_eq!(unique_initial_codes.len(), initial_codes.len());
|
|
|
|
let factors = service
|
|
.get_user_preferences(&user.id)
|
|
.await
|
|
.checked("load TOTP authentication factors")
|
|
.1;
|
|
assert!(factors.password);
|
|
assert!(factors.totp);
|
|
assert_eq!(factors.totp_recovery_codes_remaining, 10);
|
|
service
|
|
.set_two_factor_enabled(&user.id, true)
|
|
.await
|
|
.checked("enable password and TOTP two-factor authentication");
|
|
|
|
let replay_login =
|
|
opaque_login_user_with_challenge(&service, "totp_lifecycle_user", "StrongPass1")
|
|
.await
|
|
.checked("start replay-protection MFA challenge");
|
|
let AuthenticatedLogin::MfaRequired { challenge, .. } = replay_login else {
|
|
std::panic::panic_any("TOTP lifecycle login should require MFA");
|
|
};
|
|
assert!(challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::Totp));
|
|
assert!(challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::RecoveryCode));
|
|
let replayed_setup_code = service
|
|
.complete_mfa_totp_with_control(&challenge.session_id, &setup_code, None, None)
|
|
.await
|
|
.failed("the setup TOTP step must not be accepted twice");
|
|
assert!(matches!(replayed_setup_code, Error::Authentication(_)));
|
|
service
|
|
.complete_mfa_recovery_code_with_control(
|
|
&challenge.session_id,
|
|
&initial_codes[0],
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("recovery code should complete the existing MFA challenge");
|
|
|
|
let verification_id = two_factor_verification_id_with_recovery_code(
|
|
&service,
|
|
&user.id,
|
|
"StrongPass1",
|
|
&initial_codes[1],
|
|
)
|
|
.await;
|
|
let regenerated_codes = service
|
|
.regenerate_totp_recovery_codes(&user.id, &verification_id)
|
|
.await
|
|
.checked("regenerate TOTP recovery codes")
|
|
.recovery_codes;
|
|
assert_eq!(regenerated_codes.len(), 10);
|
|
|
|
for (index, recovery_code) in regenerated_codes[..regenerated_codes.len() - 1]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
let login =
|
|
opaque_login_user_with_challenge(&service, "totp_lifecycle_user", "StrongPass1")
|
|
.await
|
|
.checked("start recovery-code MFA challenge");
|
|
let AuthenticatedLogin::MfaRequired { challenge, .. } = login else {
|
|
std::panic::panic_any("recovery-code login should require MFA");
|
|
};
|
|
if index == 0 {
|
|
let invalidated = service
|
|
.complete_mfa_recovery_code_with_control(
|
|
&challenge.session_id,
|
|
&initial_codes[2],
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.failed("regeneration must invalidate previous recovery codes");
|
|
assert!(matches!(invalidated, Error::Authentication(_)));
|
|
}
|
|
let completed = service
|
|
.complete_mfa_recovery_code_with_control(
|
|
&challenge.session_id,
|
|
recovery_code,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("each recovery code, including the final code, should complete MFA");
|
|
assert!(matches!(completed, AuthenticatedLogin::Complete { .. }));
|
|
}
|
|
|
|
let final_verification_id = two_factor_verification_id_with_recovery_code(
|
|
&service,
|
|
&user.id,
|
|
"StrongPass1",
|
|
regenerated_codes
|
|
.last()
|
|
.checked("generated recovery codes should contain a final code"),
|
|
)
|
|
.await;
|
|
service
|
|
.consume_sensitive_operation_verification(&user.id, &final_verification_id)
|
|
.await
|
|
.checked("the final recovery code should complete sensitive verification");
|
|
|
|
let factors = service
|
|
.get_user_preferences(&user.id)
|
|
.await
|
|
.checked("load depleted recovery-code state")
|
|
.1;
|
|
assert!(factors.totp);
|
|
assert_eq!(factors.totp_recovery_codes_remaining, 0);
|
|
|
|
let totp_login =
|
|
opaque_login_user_with_challenge(&service, "totp_lifecycle_user", "StrongPass1")
|
|
.await
|
|
.checked("start TOTP MFA challenge after recovery-code depletion");
|
|
let AuthenticatedLogin::MfaRequired { challenge, .. } = totp_login else {
|
|
std::panic::panic_any("TOTP login should require MFA");
|
|
};
|
|
assert!(!challenge
|
|
.available_methods
|
|
.contains(&AuthFactorMethod::RecoveryCode));
|
|
let next_totp_code = totp_code_with_step_offset(&setup.secret, 1);
|
|
let completed = service
|
|
.complete_mfa_totp_with_control(&challenge.session_id, &next_totp_code, None, None)
|
|
.await
|
|
.checked("a fresh TOTP step should complete MFA");
|
|
assert!(matches!(completed, AuthenticatedLogin::Complete { .. }));
|
|
|
|
let replay_login =
|
|
opaque_login_user_with_challenge(&service, "totp_lifecycle_user", "StrongPass1")
|
|
.await
|
|
.checked("start second TOTP replay challenge");
|
|
let AuthenticatedLogin::MfaRequired { challenge, .. } = replay_login else {
|
|
std::panic::panic_any("TOTP replay login should require MFA");
|
|
};
|
|
let replayed = service
|
|
.complete_mfa_totp_with_control(&challenge.session_id, &next_totp_code, None, None)
|
|
.await
|
|
.failed("a TOTP step must be single-use");
|
|
assert!(matches!(replayed, Error::Authentication(_)));
|
|
|
|
let sensitive = service
|
|
.start_sensitive_operation_verification(&user.id, None)
|
|
.await
|
|
.checked("start protected TOTP deletion verification");
|
|
let SensitiveVerificationOutcome::Pending(challenge) = sensitive else {
|
|
std::panic::panic_any("TOTP deletion verification should require factors");
|
|
};
|
|
let pending = service
|
|
.finish_sensitive_operation_password_verification(
|
|
&challenge.session_id,
|
|
"StrongPass1",
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("verify password before protected TOTP deletion");
|
|
let SensitiveVerificationOutcome::Pending(challenge) = pending else {
|
|
std::panic::panic_any("protected TOTP deletion should require a second factor");
|
|
};
|
|
let verification_id = match service
|
|
.finish_sensitive_operation_verified_method(&challenge.session_id, AuthFactorMethod::Totp)
|
|
.await
|
|
.checked("complete protected TOTP deletion verification")
|
|
{
|
|
SensitiveVerificationOutcome::Complete { verification_id } => verification_id,
|
|
SensitiveVerificationOutcome::Pending(_) => {
|
|
std::panic::panic_any("two factors should authorize protected TOTP deletion")
|
|
}
|
|
};
|
|
let protected = service
|
|
.delete_totp(&user.id, &verification_id)
|
|
.await
|
|
.failed("active two-factor settings must retain the required TOTP factor");
|
|
assert!(matches!(protected, Error::InvalidInput(_)));
|
|
service
|
|
.set_two_factor_enabled(&user.id, false)
|
|
.await
|
|
.checked("disable two-factor authentication before TOTP removal");
|
|
assert!(service
|
|
.delete_totp(&user.id, &verification_id)
|
|
.await
|
|
.checked("remove TOTP after disabling two-factor authentication"));
|
|
|
|
let expired_setup = service
|
|
.start_totp_setup(
|
|
&user.id,
|
|
&password_verification_id(&service, &user.id, "StrongPass1").await,
|
|
)
|
|
.await
|
|
.checked("start setup used for expiry test");
|
|
sqlx::query!(
|
|
"UPDATE auth_totp_credentials SET setup_expires_at = NOW() - INTERVAL '1 second' WHERE user_id = $1",
|
|
user.id.as_i64()
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("expire pending TOTP setup");
|
|
let expired = service
|
|
.finish_totp_setup(
|
|
&user.id,
|
|
&expired_setup.setup_id,
|
|
&totp_code_with_step_offset(&expired_setup.secret, 0),
|
|
)
|
|
.await
|
|
.failed("expired TOTP setup must be rejected");
|
|
assert!(matches!(expired, Error::Authentication(_)));
|
|
}
|
|
|
|
async fn assert_totp_missing_encryption_preserves_verification(pool: PgPool) {
|
|
let mut runtime = UserServiceRuntimeOptions::test_defaults();
|
|
runtime.credential_encryption = None;
|
|
let service = create_user_service_with_runtime(&pool, runtime);
|
|
let (user, _, _) =
|
|
opaque_register_user(&service, "totp_missing_encryption", None, "StrongPass1")
|
|
.await
|
|
.checked("create TOTP encryption configuration test user");
|
|
let verification_id = password_verification_id(&service, &user.id, "StrongPass1").await;
|
|
let unavailable = service
|
|
.start_totp_setup(&user.id, &verification_id)
|
|
.await
|
|
.failed("TOTP setup requires credential encryption");
|
|
assert!(matches!(unavailable, Error::ServiceUnavailable(_)));
|
|
service
|
|
.consume_sensitive_operation_verification(&user.id, &verification_id)
|
|
.await
|
|
.checked("configuration failure should preserve sensitive verification");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_user_service_registration_login_and_delete_flows() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
|
|
let duplicate_username_service = create_user_service(&pool);
|
|
assert_register_duplicate_username_error(&duplicate_username_service).await;
|
|
|
|
let duplicate_email_service = create_user_service(&pool);
|
|
assert_register_duplicate_email_error(&duplicate_email_service).await;
|
|
|
|
let wrong_password_service = create_user_service(&pool);
|
|
assert_login_wrong_password(&wrong_password_service).await;
|
|
|
|
let delete_twice_service = create_user_service(&pool);
|
|
assert_delete_user_already_deleted_returns_error(&delete_twice_service).await;
|
|
|
|
assert_delete_user_removes_owned_resources_and_resets_foreign_room_playback(pool.clone()).await;
|
|
|
|
assert_update_user_rejects_direct_email_changes(pool.clone()).await;
|
|
assert_email_bind_writes_email_only_after_confirm(pool.clone()).await;
|
|
assert_email_bind_rejects_taken_email(pool.clone()).await;
|
|
|
|
assert_two_factor_requires_two_usable_methods(pool.clone()).await;
|
|
assert_sensitive_verification_is_one_time(pool.clone()).await;
|
|
assert_two_factor_changes_require_fresh_sensitive_verification(pool.clone()).await;
|
|
assert_sensitive_password_verification_is_rate_limited(pool.clone()).await;
|
|
assert_sensitive_verification_requires_two_local_factors_when_2fa_enabled(pool.clone()).await;
|
|
assert_oauth2_session_sensitive_verification_requires_one_local_factor(pool.clone()).await;
|
|
assert_oauth2_only_session_can_bootstrap_first_local_factor(pool.clone()).await;
|
|
assert_two_factor_blocks_deleting_required_passkey(pool.clone()).await;
|
|
assert_two_factor_blocks_single_factor_token_issuance(pool.clone()).await;
|
|
assert_two_factor_access_token_context_is_enforced(pool.clone()).await;
|
|
assert_two_factor_allows_oauth2_without_local_mfa(pool.clone()).await;
|
|
assert_refresh_token_rejects_unbound_oauth2_identity(pool.clone()).await;
|
|
assert_refresh_token_rejects_unbound_email_identity(pool.clone()).await;
|
|
assert_refresh_token_rejects_deleted_passkey_binding(pool.clone()).await;
|
|
assert_totp_lifecycle_and_replay_protection(pool.clone()).await;
|
|
assert_totp_missing_encryption_preserves_verification(pool.clone()).await;
|
|
|
|
assert_delete_user_concurrent_deletion_atomicity(pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_user_service_registration_brute_force_flows() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
|
|
let username_taken_service = create_user_service(&pool);
|
|
assert_register_username_taken_no_brute_force_lockout(&username_taken_service).await;
|
|
|
|
let validation_error_service = create_user_service(&pool);
|
|
assert_register_validation_errors_trigger_brute_force_lockout(&validation_error_service).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_user_restore_identity_conflicts() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
assert_restore_identity_conflict_modes(pool).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_user_delete_restore_resource_lifecycle() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
assert_delete_user_removes_owned_resources_and_resets_foreign_room_playback(pool).await;
|
|
}
|