#![allow(dead_code)] use chrono::{Duration, Utc}; use cot::db::query; use meguri::{ access::{AuthConfig, Config, WorkspaceConfig}, admin::{self, NewUser}, domain::*, identity, models::UserRow, service, store::{Store, WorkspaceRecord}, }; use uuid::Uuid; pub struct Fixture { pub db: Store, pub people: [User; 3], pub team: WorkspaceRecord, pub schedule: Uuid, pub start: chrono::DateTime, pub config: Config, } pub fn config_with(workspaces: Vec) -> Config { Config { auth: AuthConfig { password_login: true, bootstrap_admin: None, }, oidc: None, instance_admin_groups: vec![], workspaces, } } pub fn unique_workspace(name: &str) -> WorkspaceConfig { let key = format!("t-{}", &Uuid::new_v4().simple().to_string()[..12]); WorkspaceConfig { admin_groups: vec![format!("{key}-leads")], member_groups: vec![format!("{key}-staff")], name: name.into(), key, } } pub async fn connect() -> Store { let url = std::env::var("MEGURI_TEST_DATABASE_URL") .expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database"); Store::connect(&url).await.unwrap() } /// A password account whose groups are applied through the real access path. pub async fn account(db: &Store, config: &Config, name: &str, groups: &[String]) -> User { let created = admin::create_password_user( db, config, None, NewUser { username: format!("test-{}", Uuid::new_v4().simple()), display_name: name.into(), password: "test-only-password".into(), email: None, groups: groups.to_vec(), }, ) .await .unwrap(); serde_json::from_value(created["user"].clone()).unwrap() } impl Fixture { pub async fn new() -> Self { let db = connect().await; let workspace = unique_workspace("Test team"); let config = config_with(vec![workspace.clone()]); let ensured = identity::ensure_workspace(&db, &workspace).await.unwrap(); let mut people = vec![]; for (index, name) in ["Alice", "Bob", "Carol"].into_iter().enumerate() { let groups = if index == 0 { &workspace.admin_groups } else { &workspace.member_groups }; people.push(account(&db, &config, name, groups).await); } let people: [User; 3] = people.try_into().unwrap(); let mut team = db.workspace(ensured.id).await.unwrap(); // Employees keep the narrow pre-existing grant set that the scheduling regressions assume. team = service::set_member_permissions( &db, people[0].id, team.id, team.revision, Permission::member(), ) .await .unwrap(); let schedule = Uuid::new_v4(); let day = Utc::now().date_naive() + Duration::days(2); let mut doc = team.document.clone(); doc.timezone = "UTC".into(); doc.schedules.push(Schedule { handover: default_handover(), id: schedule, name: "General".into(), kind: ScheduleKind::Rotation { participants: people.iter().map(|p| p.id).collect(), anchor: day, shift_days: 7, }, }); team = service::update_document(&db, people[0].id, team.id, team.revision, doc) .await .unwrap(); let start = meguri::engine::handover(&team.document, day).unwrap(); Self { db, people, team, schedule, start, config, } } /// A second workspace whose administrator group is granted to `admin` only. pub async fn extra_workspace(&self, name: &str, admin: &User) -> WorkspaceRecord { let extra = unique_workspace(name); let mut config = self.config.clone(); config.workspaces.push(extra.clone()); let ensured = identity::ensure_workspace(&self.db, &extra).await.unwrap(); let id = admin.id.to_string(); let row = query!(UserRow,$id==id) .get(&self.db.db) .await .unwrap() .unwrap(); let mut groups = identity::stored_groups(&row).unwrap(); groups.extend(extra.admin_groups); identity::apply_access(&self.db, &config, admin.id, Some(groups)) .await .unwrap(); self.db.workspace(ensured.id).await.unwrap() } pub fn leg(&self) -> ReplacementLeg { ReplacementLeg { schedule_id: self.schedule, start: self.start, end: self.start + Duration::days(1), from_user: self.people[0].id, to_user: self.people[1].id, } } pub async fn propose(&mut self, legs: Vec, force: bool) { self.team = service::request_replacement( &self.db, self.people[0].id, self.team.id, service::NewRequest { remaining: false, revision: self.team.revision, reason: "Test replacement".into(), legs, force, }, ) .await .unwrap(); } pub fn decision(&self, action: &str) -> service::RequestDecision { service::RequestDecision { revision: self.team.revision, action: action.into(), reason: "Test decision".into(), } } pub async fn prioritize_publication(&self) { // The worker takes a bounded batch. Repeated runs leave other test tenants // in this disposable database, so explicitly put this test's job first. self.db .db .raw_with( "UPDATE meguri__sync_jobs SET next_attempt='1970-01-01T00:00:00Z' WHERE workspace_id=$1", &[&self.team.id.to_string()], ) .await .unwrap(); } } /// Demonstration seeding reads the run's generated password from the process-wide notice. pub fn install_demo_notice() { meguri::demo_runtime::install(meguri::demo_runtime::Notice { isolated: false, retention: meguri::demo_runtime::Retention::default(), password: meguri::access::random_password(), accounts: meguri::demo::accounts(), }); } /// A uniquely named database for tests whose subject is whole-instance state (configuration /// sync disables every workspace that is not configured, so shared databases cannot be used). pub struct IsolatedDatabase { pub store: Store, admin: cot::db::Database, name: String, } impl IsolatedDatabase { pub async fn new() -> Self { let base = std::env::var("MEGURI_TEST_DATABASE_URL") .expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database"); let admin = cot::db::Database::new(&base).await.unwrap(); let name = format!("meguri_access_test_{}", Uuid::new_v4().simple()); admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap(); let mut url = reqwest::Url::parse(&base).unwrap(); url.set_path(&format!("/{name}")); let store = Store::connect(url.as_str()).await.unwrap(); Self { store, admin, name } } pub async fn url(&self) -> String { let base = std::env::var("MEGURI_TEST_DATABASE_URL").unwrap(); let mut url = reqwest::Url::parse(&base).unwrap(); url.set_path(&format!("/{}", self.name)); url.to_string() } pub async fn drop(self) { drop(self.store); self.admin .raw(&format!("DROP DATABASE {} WITH (FORCE)", self.name)) .await .unwrap(); } }