circus/crates/common/tests/user_management_tests.rs
NotAShelf caadb52f64
common: enhance user repository with validation
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic96bef36e3b4d1ea6b2db9752b26dd3a6a6a6964
2026-02-07 22:09:22 +03:00

829 lines
21 KiB
Rust

//! Integration tests for user management - CRUD, authentication, and
//! relationships. Requires TEST_DATABASE_URL to be set to a PostgreSQL
//! connection string.
use fc_common::{models::*, repo};
use uuid::Uuid;
async fn get_pool() -> Option<sqlx::PgPool> {
let url = match std::env::var("TEST_DATABASE_URL") {
Ok(url) => url,
Err(_) => {
println!("Skipping repo test: TEST_DATABASE_URL not set");
return None;
},
};
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.ok()?;
// Run migrations
sqlx::migrate!("./migrations").run(&pool).await.ok()?;
Some(pool)
}
#[tokio::test]
async fn test_user_crud() {
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
let username = format!("test-user-{}", Uuid::new_v4().simple());
let email = format!("{}@example.com", username);
// Create user
let user = repo::users::create(&pool, &CreateUser {
username: username.clone(),
email: email.clone(),
full_name: Some("Test User".to_string()),
password: "secure_password_123".to_string(),
role: Some("admin".to_string()),
})
.await
.expect("create user");
assert_eq!(user.username, username);
assert_eq!(user.email, email);
assert_eq!(user.full_name.as_deref(), Some("Test User"));
assert_eq!(user.role, "admin");
assert!(user.enabled);
assert!(user.password_hash.is_some());
// Get by ID
let fetched = repo::users::get(&pool, user.id).await.expect("get user");
assert_eq!(fetched.id, user.id);
assert_eq!(fetched.username, username);
// Get by username
let by_username = repo::users::get_by_username(&pool, &username)
.await
.expect("get by username")
.expect("user should exist");
assert_eq!(by_username.id, user.id);
// Get by email
let by_email = repo::users::get_by_email(&pool, &email)
.await
.expect("get by email")
.expect("user should exist");
assert_eq!(by_email.id, user.id);
// List users
let users = repo::users::list(&pool, 100, 0).await.expect("list users");
assert!(users.iter().any(|u| u.id == user.id));
// Count users
let count = repo::users::count(&pool).await.expect("count users");
assert!(count > 0);
// Update email
let new_email = format!("updated-{}", email);
let updated = repo::users::update_email(&pool, user.id, &new_email)
.await
.expect("update email");
assert_eq!(updated.email, new_email);
// Update full name
repo::users::update_full_name(&pool, user.id, Some("Updated Name"))
.await
.expect("update full name");
let updated = repo::users::get(&pool, user.id).await.expect("get updated");
assert_eq!(updated.full_name.as_deref(), Some("Updated Name"));
// Update role
repo::users::update_role(&pool, user.id, "read-only")
.await
.expect("update role");
let updated = repo::users::get(&pool, user.id).await.expect("get updated");
assert_eq!(updated.role, "read-only");
// Disable user
repo::users::set_enabled(&pool, user.id, false)
.await
.expect("disable user");
let updated = repo::users::get(&pool, user.id).await.expect("get updated");
assert!(!updated.enabled);
// Enable user
repo::users::set_enabled(&pool, user.id, true)
.await
.expect("enable user");
let updated = repo::users::get(&pool, user.id).await.expect("get updated");
assert!(updated.enabled);
// Set public dashboard
repo::users::set_public_dashboard(&pool, user.id, true)
.await
.expect("set public dashboard");
let updated = repo::users::get(&pool, user.id).await.expect("get updated");
assert!(updated.public_dashboard);
// Delete user
repo::users::delete(&pool, user.id)
.await
.expect("delete user");
// Verify deleted
let result = repo::users::get(&pool, user.id).await;
assert!(matches!(result, Err(fc_common::CiError::NotFound(_))));
}
#[tokio::test]
async fn test_user_authentication() {
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
let username = format!("auth-test-{}", Uuid::new_v4().simple());
let password = "my_secret_password";
// Create user
let user = repo::users::create(&pool, &CreateUser {
username: username.clone(),
email: format!("{}@example.com", username),
full_name: None,
password: password.to_string(),
role: None,
})
.await
.expect("create user");
// Authenticate with correct credentials
let auth_result = repo::users::authenticate(&pool, &LoginCredentials {
username: username.clone(),
password: password.to_string(),
})
.await;
assert!(auth_result.is_ok());
let auth_user = auth_result.unwrap();
assert_eq!(auth_user.id, user.id);
assert!(auth_user.last_login_at.is_some());
// Authenticate with wrong password
let wrong_auth = repo::users::authenticate(&pool, &LoginCredentials {
username: username.clone(),
password: "wrong_password".to_string(),
})
.await;
assert!(matches!(
wrong_auth,
Err(fc_common::CiError::Unauthorized(_))
));
// Authenticate with wrong username
let wrong_user = repo::users::authenticate(&pool, &LoginCredentials {
username: "nonexistent".to_string(),
password: password.to_string(),
})
.await;
assert!(matches!(
wrong_user,
Err(fc_common::CiError::Unauthorized(_))
));
// Authenticate disabled user
repo::users::set_enabled(&pool, user.id, false)
.await
.expect("disable user");
let disabled_auth = repo::users::authenticate(&pool, &LoginCredentials {
username: username.clone(),
password: password.to_string(),
})
.await;
assert!(matches!(
disabled_auth,
Err(fc_common::CiError::Unauthorized(_))
));
// Cleanup
repo::users::delete(&pool, user.id).await.ok();
}
#[tokio::test]
async fn test_password_hashing() {
use fc_common::repo::users::{hash_password, verify_password};
let password = "test_password_123";
// Hash password
let hash = hash_password(password).expect("hash password");
assert!(!hash.is_empty());
assert_ne!(hash, password);
// Verify correct password
let verified = verify_password(password, &hash).expect("verify password");
assert!(verified);
// Verify wrong password
let wrong = verify_password("wrong_password", &hash).expect("verify wrong");
assert!(!wrong);
// Hash is different each time (due to salt)
let hash2 = hash_password(password).expect("hash again");
assert_ne!(hash, hash2);
// But both verify correctly
assert!(verify_password(password, &hash2).expect("verify hash2"));
}
#[tokio::test]
async fn test_user_unique_constraints() {
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
let username = format!("unique-{}", Uuid::new_v4().simple());
let email = format!("{}@example.com", username);
// Create first user
let _ = repo::users::create(&pool, &CreateUser {
username: username.clone(),
email: email.clone(),
full_name: None,
password: "password".to_string(),
role: None,
})
.await
.expect("create first user");
// Try to create with same username
let result = repo::users::create(&pool, &CreateUser {
username: username.clone(),
email: format!("other-{}", email),
full_name: None,
password: "password".to_string(),
role: None,
})
.await;
assert!(matches!(result, Err(fc_common::CiError::Conflict(_))));
// Try to create with same email
let result = repo::users::create(&pool, &CreateUser {
username: format!("other-{}", username),
email: email.clone(),
full_name: None,
password: "password".to_string(),
role: None,
})
.await;
assert!(matches!(result, Err(fc_common::CiError::Conflict(_))));
// Cleanup
let user = repo::users::get_by_username(&pool, &username)
.await
.unwrap()
.unwrap();
repo::users::delete(&pool, user.id).await.ok();
}
#[tokio::test]
async fn test_oauth_user_creation() {
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
let username = format!("oauth-user-{}", Uuid::new_v4().simple());
let email = format!("{}@github.com", username);
let oauth_provider_id = format!("github_{}", Uuid::new_v4().simple());
// Create OAuth user
let user = repo::users::upsert_oauth_user(
&pool,
&username,
Some(email.as_str()),
UserType::Github,
&oauth_provider_id,
)
.await
.expect("create OAuth user");
assert!(user.username.contains(&username));
assert_eq!(user.user_type, UserType::Github);
assert!(user.password_hash.is_none()); // OAuth users have no password
// Update same user (should not create duplicate)
let updated = repo::users::upsert_oauth_user(
&pool,
&username,
Some(email.as_str()),
UserType::Github,
&oauth_provider_id,
)
.await
.expect("update OAuth user");
assert_eq!(updated.id, user.id);
// Cleanup
repo::users::delete(&pool, user.id).await.ok();
}
// Starred Jobs Tests
#[tokio::test]
async fn test_starred_jobs_crud() {
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
// Create prerequisite data
let user = repo::users::create(&pool, &CreateUser {
username: format!("star-user-{}", Uuid::new_v4().simple()),
email: format!("star-{}@example.com", Uuid::new_v4().simple()),
full_name: None,
password: "password".to_string(),
role: None,
})
.await
.expect("create user");
let project = repo::projects::create(&pool, CreateProject {
name: format!("star-project-{}", Uuid::new_v4().simple()),
description: None,
repository_url: "https://github.com/test/repo".to_string(),
})
.await
.expect("create project");
let jobset = repo::jobsets::create(&pool, CreateJobset {
project_id: project.id,
name: "default".to_string(),
nix_expression: "packages".to_string(),
enabled: Some(true),
flake_mode: None,
check_interval: None,
branch: None,
scheduling_shares: None,
})
.await
.expect("create jobset");
// Star a job
let starred = repo::starred_jobs::create(&pool, user.id, &CreateStarredJob {
project_id: project.id,
jobset_id: Some(jobset.id),
job_name: "hello-world".to_string(),
})
.await
.expect("star job");
assert_eq!(starred.user_id, user.id);
assert_eq!(starred.project_id, project.id);
assert_eq!(starred.jobset_id, Some(jobset.id));
assert_eq!(starred.job_name, "hello-world");
// Check is starred
let is_starred = repo::starred_jobs::is_starred(
&pool,
user.id,
project.id,
Some(jobset.id),
"hello-world",
)
.await
.expect("check is starred");
assert!(is_starred);
// List starred jobs
let starred_list = repo::starred_jobs::list_for_user(&pool, user.id, 100, 0)
.await
.expect("list starred");
assert_eq!(starred_list.len(), 1);
assert_eq!(starred_list[0].id, starred.id);
// Count starred jobs
let count = repo::starred_jobs::count_for_user(&pool, user.id)
.await
.expect("count starred");
assert_eq!(count, 1);
// Can't star same job twice
let duplicate =
repo::starred_jobs::create(&pool, user.id, &CreateStarredJob {
project_id: project.id,
jobset_id: Some(jobset.id),
job_name: "hello-world".to_string(),
})
.await;
assert!(matches!(duplicate, Err(fc_common::CiError::Conflict(_))));
// Delete starred job
repo::starred_jobs::delete(&pool, starred.id)
.await
.expect("unstar job");
// Verify deleted
let is_starred = repo::starred_jobs::is_starred(
&pool,
user.id,
project.id,
Some(jobset.id),
"hello-world",
)
.await
.expect("check is starred");
assert!(!is_starred);
// Cleanup
repo::projects::delete(&pool, project.id).await.ok();
repo::users::delete(&pool, user.id).await.ok();
}
#[tokio::test]
async fn test_starred_jobs_delete_by_job() {
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
// Setup
let user = repo::users::create(&pool, &CreateUser {
username: format!("del-user-{}", Uuid::new_v4().simple()),
email: format!("del-{}@example.com", Uuid::new_v4().simple()),
full_name: None,
password: "password".to_string(),
role: None,
})
.await
.expect("create user");
let project = repo::projects::create(&pool, CreateProject {
name: format!("del-project-{}", Uuid::new_v4().simple()),
description: None,
repository_url: "https://github.com/test/repo".to_string(),
})
.await
.expect("create project");
let jobset = repo::jobsets::create(&pool, CreateJobset {
project_id: project.id,
name: "default".to_string(),
nix_expression: "packages".to_string(),
enabled: Some(true),
flake_mode: None,
check_interval: None,
branch: None,
scheduling_shares: None,
})
.await
.expect("create jobset");
// Star a job
let _ = repo::starred_jobs::create(&pool, user.id, &CreateStarredJob {
project_id: project.id,
jobset_id: Some(jobset.id),
job_name: "test-job".to_string(),
})
.await
.expect("star job");
// Delete by job details
repo::starred_jobs::delete_by_job(
&pool,
user.id,
project.id,
Some(jobset.id),
"test-job",
)
.await
.expect("delete by job");
// Verify deleted
let count = repo::starred_jobs::count_for_user(&pool, user.id)
.await
.expect("count");
assert_eq!(count, 0);
// Cleanup
repo::projects::delete(&pool, project.id).await.ok();
repo::users::delete(&pool, user.id).await.ok();
}
// Project Members Tests
#[tokio::test]
async fn test_project_members_crud() {
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
// Setup
let user = repo::users::create(&pool, &CreateUser {
username: format!("member-user-{}", Uuid::new_v4().simple()),
email: format!("member-{}@example.com", Uuid::new_v4().simple()),
full_name: None,
password: "password".to_string(),
role: None,
})
.await
.expect("create user");
let project = repo::projects::create(&pool, CreateProject {
name: format!("member-project-{}", Uuid::new_v4().simple()),
description: None,
repository_url: "https://github.com/test/repo".to_string(),
})
.await
.expect("create project");
// Add member
let member =
repo::project_members::create(&pool, project.id, &CreateProjectMember {
user_id: user.id,
role: "maintainer".to_string(),
})
.await
.expect("add member");
assert_eq!(member.project_id, project.id);
assert_eq!(member.user_id, user.id);
assert_eq!(member.role, "maintainer");
// Get by ID
let fetched = repo::project_members::get(&pool, member.id)
.await
.expect("get member");
assert_eq!(fetched.id, member.id);
// Get by project and user
let by_ids =
repo::project_members::get_by_project_and_user(&pool, project.id, user.id)
.await
.expect("get by ids")
.expect("member should exist");
assert_eq!(by_ids.id, member.id);
// List for project
let members = repo::project_members::list_for_project(&pool, project.id)
.await
.expect("list members");
assert_eq!(members.len(), 1);
assert_eq!(members[0].id, member.id);
// List for user
let user_projects = repo::project_members::list_for_user(&pool, user.id)
.await
.expect("list user projects");
assert_eq!(user_projects.len(), 1);
assert_eq!(user_projects[0].project_id, project.id);
// Update role
let updated =
repo::project_members::update(&pool, member.id, &UpdateProjectMember {
role: Some("admin".to_string()),
})
.await
.expect("update role");
assert_eq!(updated.role, "admin");
// Can't add duplicate member
let duplicate =
repo::project_members::create(&pool, project.id, &CreateProjectMember {
user_id: user.id,
role: "member".to_string(),
})
.await;
assert!(matches!(duplicate, Err(fc_common::CiError::Conflict(_))));
// Delete member
repo::project_members::delete(&pool, member.id)
.await
.expect("remove member");
// Verify deleted
let result = repo::project_members::get(&pool, member.id).await;
assert!(matches!(result, Err(fc_common::CiError::NotFound(_))));
// Cleanup
repo::projects::delete(&pool, project.id).await.ok();
repo::users::delete(&pool, user.id).await.ok();
}
#[tokio::test]
async fn test_project_members_permissions() {
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
// Setup
let admin_user = repo::users::create(&pool, &CreateUser {
username: format!("admin-user-{}", Uuid::new_v4().simple()),
email: format!("admin-{}@example.com", Uuid::new_v4().simple()),
full_name: None,
password: "password".to_string(),
role: None,
})
.await
.expect("create admin user");
let maintainer_user = repo::users::create(&pool, &CreateUser {
username: format!("maint-user-{}", Uuid::new_v4().simple()),
email: format!("maint-{}@example.com", Uuid::new_v4().simple()),
full_name: None,
password: "password".to_string(),
role: None,
})
.await
.expect("create maintainer user");
let member_user = repo::users::create(&pool, &CreateUser {
username: format!("member-user-{}", Uuid::new_v4().simple()),
email: format!("mem-{}@example.com", Uuid::new_v4().simple()),
full_name: None,
password: "password".to_string(),
role: None,
})
.await
.expect("create member user");
let project = repo::projects::create(&pool, CreateProject {
name: format!("perm-project-{}", Uuid::new_v4().simple()),
description: None,
repository_url: "https://github.com/test/repo".to_string(),
})
.await
.expect("create project");
// Add members with different roles
repo::project_members::create(&pool, project.id, &CreateProjectMember {
user_id: admin_user.id,
role: "admin".to_string(),
})
.await
.expect("add admin");
repo::project_members::create(&pool, project.id, &CreateProjectMember {
user_id: maintainer_user.id,
role: "maintainer".to_string(),
})
.await
.expect("add maintainer");
repo::project_members::create(&pool, project.id, &CreateProjectMember {
user_id: member_user.id,
role: "member".to_string(),
})
.await
.expect("add member");
// Check permissions - admin has all permissions
assert!(
repo::project_members::check_permission(
&pool,
project.id,
admin_user.id,
"member"
)
.await
.expect("check admin")
);
assert!(
repo::project_members::check_permission(
&pool,
project.id,
admin_user.id,
"maintainer"
)
.await
.expect("check admin maintainer")
);
assert!(
repo::project_members::check_permission(
&pool,
project.id,
admin_user.id,
"admin"
)
.await
.expect("check admin admin")
);
// Maintainer has member and maintainer permissions
assert!(
repo::project_members::check_permission(
&pool,
project.id,
maintainer_user.id,
"member"
)
.await
.expect("check maintainer member")
);
assert!(
repo::project_members::check_permission(
&pool,
project.id,
maintainer_user.id,
"maintainer"
)
.await
.expect("check maintainer maintainer")
);
assert!(
!repo::project_members::check_permission(
&pool,
project.id,
maintainer_user.id,
"admin"
)
.await
.expect("check maintainer admin")
);
// Regular member only has member permission
assert!(
repo::project_members::check_permission(
&pool,
project.id,
member_user.id,
"member"
)
.await
.expect("check member")
);
assert!(
!repo::project_members::check_permission(
&pool,
project.id,
member_user.id,
"maintainer"
)
.await
.expect("check member maintainer")
);
assert!(
!repo::project_members::check_permission(
&pool,
project.id,
member_user.id,
"admin"
)
.await
.expect("check member admin")
);
// Non-member has no permissions
let non_member = repo::users::create(&pool, &CreateUser {
username: format!("non-member-{}", Uuid::new_v4().simple()),
email: format!("non-{}@example.com", Uuid::new_v4().simple()),
full_name: None,
password: "password".to_string(),
role: None,
})
.await
.expect("create non-member");
assert!(
!repo::project_members::check_permission(
&pool,
project.id,
non_member.id,
"member"
)
.await
.expect("check non-member")
);
// Cleanup
repo::projects::delete(&pool, project.id).await.ok();
repo::users::delete(&pool, admin_user.id).await.ok();
repo::users::delete(&pool, maintainer_user.id).await.ok();
repo::users::delete(&pool, member_user.id).await.ok();
repo::users::delete(&pool, non_member.id).await.ok();
}
#[tokio::test]
async fn test_user_not_found_errors() {
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
let fake_id = Uuid::new_v4();
assert!(matches!(
repo::users::get(&pool, fake_id).await,
Err(fc_common::CiError::NotFound(_))
));
assert!(matches!(
repo::starred_jobs::get(&pool, fake_id).await,
Err(fc_common::CiError::NotFound(_))
));
assert!(matches!(
repo::project_members::get(&pool, fake_id).await,
Err(fc_common::CiError::NotFound(_))
));
}