diff --git a/databases.json b/databases.json index 70eface..40c2f90 100644 --- a/databases.json +++ b/databases.json @@ -111,6 +111,15 @@ "port": 1433, "host": "db-mssql", "generated_id": "16706125-ff7e-4c97-8c83-0adeff214682" + }, + { + "name": "Test database - PostgreSQL cluster", + "type": "postgresql-cluster", + "username": "nextclouddbuser", + "password": "50AL2Oh5IXajbOAxfJ", + "port": 5432, + "host": "nextcloud-db", + "generated_id": "16678199-ff7e-4c97-8c83-0adeff214681" } ] } diff --git a/src/domain/factory.rs b/src/domain/factory.rs index df5651d..a2f107b 100644 --- a/src/domain/factory.rs +++ b/src/domain/factory.rs @@ -1,5 +1,6 @@ use crate::domain::mongodb::database::MongoDatabase; use crate::domain::mysql::database::MySQLDatabase; +use crate::domain::postgres::cluster::database::PostgresClusterDatabase; use crate::domain::postgres::database::PostgresDatabase; use crate::domain::postgres::{detect_format_from_file, detect_format_from_size}; use crate::domain::redis::database::RedisDatabase; @@ -31,6 +32,7 @@ impl DatabaseFactory { let format = detect_format_from_size(&cfg).await; Arc::new(PostgresDatabase::new(cfg, format)) } + DbType::PostgresqlCluster => Arc::new(PostgresClusterDatabase::new(cfg)), DbType::Mysql => Arc::new(MySQLDatabase::new(cfg)), DbType::Mariadb => Arc::new(MariaDBDatabase::new(cfg)), DbType::MongoDB => Arc::new(MongoDatabase::new(cfg)), @@ -48,6 +50,7 @@ impl DatabaseFactory { let format = detect_format_from_file(restore_file); Arc::new(PostgresDatabase::new(cfg, format)) } + DbType::PostgresqlCluster => Arc::new(PostgresClusterDatabase::new(cfg)), DbType::Mysql => Arc::new(MySQLDatabase::new(cfg)), DbType::Mariadb => Arc::new(MariaDBDatabase::new(cfg)), DbType::MongoDB => Arc::new(MongoDatabase::new(cfg)), diff --git a/src/domain/postgres/cluster/backup.rs b/src/domain/postgres/cluster/backup.rs new file mode 100644 index 0000000..ecaf240 --- /dev/null +++ b/src/domain/postgres/cluster/backup.rs @@ -0,0 +1,79 @@ +use anyhow::Result; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; +use std::time::Instant; + +use super::super::connection::{ + is_superuser, pg_dumpall_binary_name, select_pg_path, server_version, +}; +use crate::services::backup::logger::JobLogger; +use crate::services::config::DatabaseConfig; + +pub async fn run( + cfg: DatabaseConfig, + backup_dir: PathBuf, + env: HashMap, + logger: Arc, +) -> Result { + tokio::task::spawn_blocking(move || -> Result { + logger.log("info", format!("Starting cluster backup for {}", cfg.name)); + + let version = match futures::executor::block_on(server_version(&cfg)) { + Ok(v) => v, + Err(e) => { + logger.log("error", format!("Failed to get server version for {}: {:?}", cfg.name, e)); + return Err(e.into()); + } + }; + + match futures::executor::block_on(is_superuser(&cfg)) { + Ok(true) => {} + Ok(false) => { + logger.log("error", format!("postgresql-cluster backup requires a superuser role for {}", cfg.name)); + anyhow::bail!("postgresql-cluster backup requires a superuser role for {}", cfg.name); + } + Err(e) => { + logger.log("error", format!("Failed to check superuser status for {}: {:?}", cfg.name, e)); + return Err(e.into()); + } + } + + let pg_dumpall = select_pg_path(&version).join(pg_dumpall_binary_name()); + let file_path = backup_dir.join(format!("{}.sql", cfg.generated_id)); + + logger.log("info", format!("Running pg_dumpall for cluster {} via {:?}", cfg.name, pg_dumpall)); + + let start = Instant::now(); + let output = Command::new(&pg_dumpall) + .arg("--host").arg(&cfg.host) + .arg("--port").arg(cfg.port.to_string()) + .arg("--username").arg(&cfg.username) + .arg("-v") + .arg("-f").arg(&file_path) + .envs(env) + .output(); + let duration_ms = start.elapsed().as_millis() as f64; + + match output { + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr).to_string(); + let exit_code = o.status.code().unwrap_or(-1); + if o.status.success() { + logger.log_command("pg_dumpall", if stderr.is_empty() { None } else { Some(stderr) }, Some(0), Some(duration_ms)); + logger.log("info", format!("Cluster backup completed for {} at {:?}", cfg.name, file_path)); + Ok(file_path) + } else { + logger.log_command("pg_dumpall", Some(stderr), Some(exit_code), Some(duration_ms)); + anyhow::bail!("Cluster backup (pg_dumpall) failed for {}", cfg.name); + } + } + Err(e) => { + logger.log_command("pg_dumpall", Some(e.to_string()), Some(-1), Some(duration_ms)); + Err(e.into()) + } + } + }) + .await? +} diff --git a/src/domain/postgres/cluster/database.rs b/src/domain/postgres/cluster/database.rs new file mode 100644 index 0000000..fd677c4 --- /dev/null +++ b/src/domain/postgres/cluster/database.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use async_trait::async_trait; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::super::ping; +use super::{backup, restore}; +use crate::domain::factory::Database; +use crate::services::backup::logger::JobLogger; +use crate::services::config::DatabaseConfig; +use crate::utils::locks::{DbOpLock, FileLock}; + +pub struct PostgresClusterDatabase { + pub cfg: DatabaseConfig, +} + +impl PostgresClusterDatabase { + pub fn new(cfg: DatabaseConfig) -> Self { + Self { cfg } + } + + fn build_env(&self) -> HashMap { + let mut envs = std::env::vars().collect::>(); + envs.insert("PGPASSWORD".to_string(), self.cfg.password.to_string()); + envs + } +} + +#[async_trait] +impl Database for PostgresClusterDatabase { + fn file_extension(&self) -> &'static str { + ".sql" + } + + async fn ping(&self) -> Result { + ping::run(self.cfg.clone()).await + } + + async fn backup(&self, dir: &Path, logger: Arc) -> Result { + FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?; + let res = backup::run(self.cfg.clone(), dir.to_path_buf(), self.build_env(), logger).await; + FileLock::release(&self.cfg.generated_id).await?; + res + } + + async fn restore(&self, file: &Path, logger: Arc) -> Result<()> { + FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?; + let res = restore::run(self.cfg.clone(), file.to_path_buf(), self.build_env(), logger).await; + FileLock::release(&self.cfg.generated_id).await?; + res + } +} diff --git a/src/domain/postgres/cluster/mod.rs b/src/domain/postgres/cluster/mod.rs new file mode 100644 index 0000000..4e99356 --- /dev/null +++ b/src/domain/postgres/cluster/mod.rs @@ -0,0 +1,3 @@ +pub mod backup; +pub mod database; +pub mod restore; diff --git a/src/domain/postgres/cluster/restore.rs b/src/domain/postgres/cluster/restore.rs new file mode 100644 index 0000000..e24efa8 --- /dev/null +++ b/src/domain/postgres/cluster/restore.rs @@ -0,0 +1,78 @@ +use anyhow::Result; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; +use std::time::Instant; + +use super::super::connection::{is_superuser, psql_binary_name, select_pg_path, server_version}; +use crate::services::backup::logger::JobLogger; +use crate::services::config::DatabaseConfig; + +pub async fn run( + cfg: DatabaseConfig, + restore_file: PathBuf, + env: HashMap, + logger: Arc, +) -> Result<()> { + tokio::task::spawn_blocking(move || -> Result<()> { + logger.log("info", format!("Starting cluster restore for {}", cfg.name)); + + let version = match futures::executor::block_on(server_version(&cfg)) { + Ok(v) => v, + Err(e) => { + logger.log("error", format!("Failed to get server version for {}: {:?}", cfg.name, e)); + return Err(e.into()); + } + }; + + match futures::executor::block_on(is_superuser(&cfg)) { + Ok(true) => {} + Ok(false) => { + logger.log("error", format!("postgresql-cluster restore requires a superuser role for {}", cfg.name)); + anyhow::bail!("postgresql-cluster restore requires a superuser role for {}", cfg.name); + } + Err(e) => { + logger.log("error", format!("Failed to check superuser status for {}: {:?}", cfg.name, e)); + return Err(e.into()); + } + } + + let psql = select_pg_path(&version).join(psql_binary_name()); + + logger.log("info", format!("Replaying cluster dump for {} via {:?}", cfg.name, psql)); + + let start = Instant::now(); + let output = Command::new(&psql) + .arg("--host").arg(&cfg.host) + .arg("--port").arg(cfg.port.to_string()) + .arg("--username").arg(&cfg.username) + .arg("--dbname").arg("postgres") + .arg("-f").arg(&restore_file) + .envs(env) + .output(); + let duration_ms = start.elapsed().as_millis() as f64; + + match output { + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr).to_string(); + let stdout = String::from_utf8_lossy(&o.stdout).to_string(); + let combined = format!("{}{}", stdout, stderr); + let exit_code = o.status.code().unwrap_or(-1); + if o.status.success() { + logger.log_command("psql", if combined.is_empty() { None } else { Some(combined) }, Some(0), Some(duration_ms)); + logger.log("info", format!("Cluster restore completed for {}", cfg.name)); + Ok(()) + } else { + logger.log_command("psql", if combined.is_empty() { None } else { Some(combined) }, Some(exit_code), Some(duration_ms)); + anyhow::bail!("Cluster restore (psql) failed for {}", cfg.name); + } + } + Err(e) => { + logger.log_command("psql", Some(e.to_string()), Some(-1), Some(duration_ms)); + Err(e.into()) + } + } + }) + .await? +} diff --git a/src/domain/postgres/connection.rs b/src/domain/postgres/connection.rs index a8bb098..0b0aebd 100644 --- a/src/domain/postgres/connection.rs +++ b/src/domain/postgres/connection.rs @@ -33,32 +33,21 @@ pub async fn server_version(cfg: &DatabaseConfig) -> Result { Ok(version) } -/// Resolves the `bin` directory of a PostgreSQL installation for the given -/// major version, in a cross-platform way. -/// -/// Resolution order: -/// 1. The `PG_BIN_DIR` environment variable, if set, is used as-is. This -/// allows users/CI to override detection for non-standard installs -/// (e.g. portable PostgreSQL distributions, custom install locations). -/// 2. Platform-specific default install locations (Debian/Ubuntu packages, -/// the official Windows installer, Homebrew/Postgres.app on macOS, and -/// common RPM-based layouts on other Linux distros). -/// 3. A `PATH` lookup for `pg_dump` (`pg_dump.exe` on Windows), returning -/// its parent directory. -/// 4. The historical Debian/Ubuntu path as a last-resort fallback, so the -/// function keeps returning a `PathBuf` (never panics) even when nothing -/// was found, preserving the previous behavior for callers. -/// -/// The override is sourced from `CONFIG.pg_bin_dir` (the `PG_BIN_DIR` -/// environment variable). An empty value means "unset" and falls through to -/// detection. +pub async fn is_superuser(cfg: &DatabaseConfig) -> Result { + let client = connect(cfg).await?; + let is_super: bool = client + .query_one("SELECT current_setting('is_superuser') = 'on';", &[]) + .await? + .get(0); + + Ok(is_super) +} + + pub fn select_pg_path(version: &str) -> std::path::PathBuf { select_pg_path_with(version, &CONFIG.pg_bin_dir) } -/// Inner resolver behind [`select_pg_path`], parameterized over the -/// `PG_BIN_DIR` override. Kept pure (no env / no `CONFIG` access) so it is -/// unit-testable without mutating process-global state. pub(crate) fn select_pg_path_with(version: &str, pg_bin_dir: &str) -> std::path::PathBuf { let major = version.split('.').next().unwrap_or("17"); @@ -109,6 +98,22 @@ pub(crate) fn pg_dump_binary_name() -> &'static str { } } +pub(crate) fn pg_dumpall_binary_name() -> &'static str { + if cfg!(target_os = "windows") { + "pg_dumpall.exe" + } else { + "pg_dumpall" + } +} + +pub(crate) fn psql_binary_name() -> &'static str { + if cfg!(target_os = "windows") { + "psql.exe" + } else { + "psql" + } +} + pub(crate) fn pg_dump_exists_in(dir: &std::path::Path) -> bool { dir.join(pg_dump_binary_name()).is_file() } diff --git a/src/domain/postgres/mod.rs b/src/domain/postgres/mod.rs index c7cd4cb..0f8aee3 100644 --- a/src/domain/postgres/mod.rs +++ b/src/domain/postgres/mod.rs @@ -1,4 +1,5 @@ pub mod backup; +pub(crate) mod cluster; pub(crate) mod connection; pub mod database; mod format; diff --git a/src/services/config.rs b/src/services/config.rs index fe9f3c6..eea6eb6 100644 --- a/src/services/config.rs +++ b/src/services/config.rs @@ -17,6 +17,8 @@ pub enum DbType { Mysql, Mariadb, Postgresql, + #[serde(rename = "postgresql-cluster")] + PostgresqlCluster, MongoDB, Sqlite, Redis, @@ -31,6 +33,7 @@ impl DbType { DbType::Mysql => "mysql", DbType::Mariadb => "mariadb", DbType::Postgresql => "postgresql", + DbType::PostgresqlCluster => "postgresql-cluster", DbType::MongoDB => "mongodb", DbType::Sqlite => "sqlite", DbType::Redis => "redis", @@ -169,21 +172,26 @@ impl ConfigService { } let username = match db.db_type { - DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::Mssql => { - required(&db.username, &db.name, "username")? - } + DbType::Postgresql + | DbType::PostgresqlCluster + | DbType::Mysql + | DbType::Mariadb + | DbType::Mssql => required(&db.username, &db.name, "username")?, _ => optional(&db.username), }; let password = match db.db_type { - DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::Mssql => { - required(&db.password, &db.name, "password")? - } + DbType::Postgresql + | DbType::PostgresqlCluster + | DbType::Mysql + | DbType::Mariadb + | DbType::Mssql => required(&db.password, &db.name, "password")?, _ => optional(&db.password), }; let host = match db.db_type { DbType::Postgresql + | DbType::PostgresqlCluster | DbType::Mysql | DbType::Mariadb | DbType::MongoDB @@ -196,6 +204,7 @@ impl ConfigService { let port = match db.db_type { DbType::Postgresql + | DbType::PostgresqlCluster | DbType::Mysql | DbType::Mariadb | DbType::MongoDB @@ -208,6 +217,10 @@ impl ConfigService { let database_name = match db.db_type { DbType::Sqlite | DbType::Redis | DbType::Valkey => optional(&db.database), + DbType::PostgresqlCluster => db + .database + .clone() + .unwrap_or_else(|| "postgres".to_string()), _ => required(&db.database, &db.name, "database")?, }; diff --git a/src/tests/domain/cluster/backup.rs b/src/tests/domain/cluster/backup.rs new file mode 100644 index 0000000..32adebd --- /dev/null +++ b/src/tests/domain/cluster/backup.rs @@ -0,0 +1,53 @@ +use super::{env_for, start_cluster}; +use crate::domain::postgres::{cluster, connection}; +use crate::services::backup::logger::JobLogger; +use crate::tests::init_tracing_for_test; +use std::sync::Arc; +use tempfile::TempDir; + +#[tokio::test] +async fn produces_sql_with_roles_and_databases() { + init_tracing_for_test(); + let (_c, cfg) = start_cluster("testuser").await; + + let dir = TempDir::new().unwrap(); + let logger = Arc::new(JobLogger::new()); + let sql = cluster::backup::run(cfg.clone(), dir.path().to_path_buf(), env_for(&cfg), logger) + .await + .unwrap(); + + assert!(sql.is_file()); + let contents = std::fs::read_to_string(&sql).unwrap(); + assert!(contents.contains("CREATE ROLE"), "expected CREATE ROLE in dump"); + assert!( + contents.contains("CREATE DATABASE") || contents.contains("\\connect"), + "expected database statements in dump" + ); +} + +#[tokio::test] +async fn requires_superuser() { + init_tracing_for_test(); + let (_c, super_cfg) = start_cluster("testuser").await; + + // Create a NON-superuser login role on the cluster. + let client = connection::connect(&super_cfg).await.unwrap(); + client + .batch_execute("CREATE ROLE appuser LOGIN PASSWORD 'changeme' NOSUPERUSER;") + .await + .unwrap(); + + let mut weak = super_cfg.clone(); + weak.username = "appuser".to_string(); + + let dir = TempDir::new().unwrap(); + let logger = Arc::new(JobLogger::new()); + let err = cluster::backup::run(weak.clone(), dir.path().to_path_buf(), env_for(&weak), logger) + .await + .unwrap_err(); + + assert!( + err.to_string().contains("superuser"), + "expected a superuser error, got: {err}" + ); +} diff --git a/src/tests/domain/cluster/database.rs b/src/tests/domain/cluster/database.rs new file mode 100644 index 0000000..2f40cfd --- /dev/null +++ b/src/tests/domain/cluster/database.rs @@ -0,0 +1,30 @@ +use crate::domain::factory::DatabaseFactory; +use crate::services::config::{DatabaseConfig, DbType}; +use std::path::Path; + +fn cluster_config() -> DatabaseConfig { + DatabaseConfig { + name: "cluster".to_string(), + database: "postgres".to_string(), + db_type: DbType::PostgresqlCluster, + username: "postgres".to_string(), + password: "changeme".to_string(), + port: 5432, + host: "localhost".to_string(), + generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(), + path: String::new(), + max_packet_size: String::new(), + } +} + +#[tokio::test] +async fn factory_routes_cluster_for_backup_with_sql_extension() { + let db = DatabaseFactory::create_for_backup(cluster_config()).await; + assert_eq!(db.file_extension(), ".sql"); +} + +#[tokio::test] +async fn factory_routes_cluster_for_restore_with_sql_extension() { + let db = DatabaseFactory::create_for_restore(cluster_config(), Path::new("dump.sql")).await; + assert_eq!(db.file_extension(), ".sql"); +} diff --git a/src/tests/domain/cluster/mod.rs b/src/tests/domain/cluster/mod.rs new file mode 100644 index 0000000..e26cd04 --- /dev/null +++ b/src/tests/domain/cluster/mod.rs @@ -0,0 +1,47 @@ +mod backup; +mod database; +mod restore; + +use crate::services::config::{DatabaseConfig, DbType}; +use std::collections::HashMap; +use testcontainers::runners::AsyncRunner; +use testcontainers::{ContainerAsync, ImageExt}; +use testcontainers_modules::postgres::Postgres; +use url::Host; + +async fn start_cluster(user: &str) -> (ContainerAsync, DatabaseConfig) { + let container = Postgres::default() + .with_env_var("POSTGRES_DB", "postgres") + .with_env_var("POSTGRES_USER", user) + .with_env_var("POSTGRES_PASSWORD", "changeme") + .with_tag("17") + .start() + .await + .unwrap(); + + let host = container + .get_host() + .await + .unwrap_or(Host::parse("127.0.0.1").unwrap()); + let port = container.get_host_port_ipv4(5432).await.unwrap_or(5432); + + let config = DatabaseConfig { + name: format!("cluster-{}", user), + database: "postgres".to_string(), + db_type: DbType::PostgresqlCluster, + username: user.to_string(), + password: "changeme".to_string(), + port, + host: host.to_string(), + generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(), + path: "".to_string(), + max_packet_size: "".to_string(), + }; + (container, config) +} + +fn env_for(cfg: &DatabaseConfig) -> HashMap { + let mut env = std::env::vars().collect::>(); + env.insert("PGPASSWORD".to_string(), cfg.password.clone()); + env +} diff --git a/src/tests/domain/cluster/restore.rs b/src/tests/domain/cluster/restore.rs new file mode 100644 index 0000000..1bcd46c --- /dev/null +++ b/src/tests/domain/cluster/restore.rs @@ -0,0 +1,83 @@ +use super::{env_for, start_cluster}; +use crate::domain::postgres::{cluster, connection}; +use crate::services::backup::logger::JobLogger; +use crate::tests::init_tracing_for_test; +use std::sync::Arc; +use tempfile::TempDir; + +#[tokio::test] +async fn backup_restore_round_trip_preserves_ownership() { + init_tracing_for_test(); + + // Source cluster A: seed a role + a table owned by that role. + let (_a, src) = start_cluster("testuser").await; + let client = connection::connect(&src).await.unwrap(); + client + .batch_execute( + "CREATE ROLE appowner LOGIN PASSWORD 'changeme' NOSUPERUSER;\n\ + CREATE TABLE owned_tbl (id int);\n\ + ALTER TABLE owned_tbl OWNER TO appowner;", + ) + .await + .unwrap(); + + let dir = TempDir::new().unwrap(); + let sql = cluster::backup::run(src.clone(), dir.path().to_path_buf(), env_for(&src), Arc::new(JobLogger::new())) + .await + .unwrap(); + + // Target cluster B: fresh, same bootstrap user. + let (_b, mut dst) = start_cluster("testuser").await; + + cluster::restore::run(dst.clone(), sql.clone(), env_for(&dst), Arc::new(JobLogger::new())) + .await + .unwrap(); + + // Verify the seeded role exists and the table's owner was preserved on B. + dst.database = "postgres".to_string(); + let bclient = connection::connect(&dst).await.unwrap(); + let role_exists: bool = bclient + .query_one("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'appowner');", &[]) + .await + .unwrap() + .get(0); + assert!(role_exists, "appowner role must be recreated on the target"); + + let owner: String = bclient + .query_one( + "SELECT tableowner FROM pg_tables WHERE tablename = 'owned_tbl';", + &[], + ) + .await + .unwrap() + .get(0); + assert_eq!(owner, "appowner", "table ownership must be preserved"); +} + +#[tokio::test] +async fn requires_superuser() { + init_tracing_for_test(); + let (_c, super_cfg) = start_cluster("testuser").await; + + // A non-superuser login role must be rejected before psql runs. + let client = connection::connect(&super_cfg).await.unwrap(); + client + .batch_execute("CREATE ROLE appuser LOGIN PASSWORD 'changeme' NOSUPERUSER;") + .await + .unwrap(); + + let mut weak = super_cfg.clone(); + weak.username = "appuser".to_string(); + + // The superuser pre-check happens before the dump file is read, so a + // non-existent restore path is fine — it must never be touched. + let missing = std::path::PathBuf::from("/nonexistent/cluster.sql"); + let err = cluster::restore::run(weak.clone(), missing, env_for(&weak), Arc::new(JobLogger::new())) + .await + .unwrap_err(); + + assert!( + err.to_string().contains("superuser"), + "expected a superuser error, got: {err}" + ); +} diff --git a/src/tests/domain/mod.rs b/src/tests/domain/mod.rs index 641e097..9dc6940 100644 --- a/src/tests/domain/mod.rs +++ b/src/tests/domain/mod.rs @@ -2,6 +2,7 @@ mod mariadb; mod mongodb; mod mysql; mod postgres; +mod cluster; mod redis; mod valkey; mod firebird; diff --git a/src/tests/domain/postgres.rs b/src/tests/domain/postgres.rs index c03bdcd..6e48d05 100644 --- a/src/tests/domain/postgres.rs +++ b/src/tests/domain/postgres.rs @@ -56,6 +56,20 @@ async fn postgres_ping_test() { assert_eq!(reachable, true); } +#[tokio::test] +async fn is_superuser_detects_superuser_role() { + init_tracing_for_test(); + + // The testcontainer's POSTGRES_USER ("testuser") is the bootstrap superuser. + let (_container, config) = create_config().await; + + let is_super = crate::domain::postgres::connection::is_superuser(&config) + .await + .unwrap(); + + assert!(is_super); +} + #[tokio::test] async fn postgres_backup_restore_test() { init_tracing_for_test(); @@ -152,7 +166,8 @@ async fn postgres_password_with_slash_test() { mod select_pg_path_tests { use crate::domain::postgres::connection::{ - pg_dump_binary_name, pg_dump_exists_in, select_pg_path_with, + pg_dump_binary_name, pg_dump_exists_in, pg_dumpall_binary_name, psql_binary_name, + select_pg_path_with, }; // `select_pg_path_with` takes the `PG_BIN_DIR` override as a plain @@ -209,4 +224,24 @@ mod select_pg_path_tests { let dir = std::path::Path::new("this/path/almost-certainly/does-not-exist-12345"); assert!(!pg_dump_exists_in(dir)); } + + #[test] + fn pg_dumpall_binary_name_is_platform_specific() { + let name = pg_dumpall_binary_name(); + if cfg!(target_os = "windows") { + assert_eq!(name, "pg_dumpall.exe"); + } else { + assert_eq!(name, "pg_dumpall"); + } + } + + #[test] + fn psql_binary_name_is_platform_specific() { + let name = psql_binary_name(); + if cfg!(target_os = "windows") { + assert_eq!(name, "psql.exe"); + } else { + assert_eq!(name, "psql"); + } + } } diff --git a/src/tests/services/config_tests.rs b/src/tests/services/config_tests.rs new file mode 100644 index 0000000..6c5f79b --- /dev/null +++ b/src/tests/services/config_tests.rs @@ -0,0 +1,79 @@ +use crate::core::context::Context; +use crate::services::api::ApiClient; +use crate::services::config::ConfigService; +use crate::utils::edge_key::EdgeKey; +use std::io::Write; +use std::sync::Arc; +use tempfile::NamedTempFile; + +// `ConfigService::load` never touches `self.ctx` on the `Some(file_path)` path, +// so the values here don't matter — but `Context::new()` panics without an +// `EDGE_KEY` env var, so build the struct directly (mirrors +// backup_uploader_tests.rs's `ctx_pointing_at`). +fn test_context() -> Arc { + Arc::new(Context { + edge_key: EdgeKey { + server_url: String::new(), + agent_id: "agent-1".to_string(), + master_key_b64: String::new(), + }, + api: ApiClient::new(String::new()), + }) +} + +fn write_json(contents: &str) -> NamedTempFile { + let mut file = NamedTempFile::with_suffix(".json").unwrap(); + file.write_all(contents.as_bytes()).unwrap(); + file +} + +#[test] +fn parses_postgresql_cluster_type() { + let file = write_json( + r#"{ + "databases": [ + { + "name": "cluster1", + "type": "postgresql-cluster", + "username": "postgres", + "password": "p", + "port": 5432, + "host": "localhost", + "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681" + } + ] + }"#, + ); + + let service = ConfigService::new(test_context()); + let cfg = service.load(Some(file.path().to_str().unwrap())).unwrap(); + + assert_eq!(cfg.databases[0].db_type.as_str(), "postgresql-cluster"); + // `database` is optional for cluster entries and defaults to "postgres". + assert_eq!(cfg.databases[0].database, "postgres"); +} + +#[test] +fn postgresql_cluster_respects_explicit_database() { + let file = write_json( + r#"{ + "databases": [ + { + "name": "cluster1", + "type": "postgresql-cluster", + "database": "maintenance", + "username": "postgres", + "password": "p", + "port": 5432, + "host": "localhost", + "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681" + } + ] + }"#, + ); + + let service = ConfigService::new(test_context()); + let cfg = service.load(Some(file.path().to_str().unwrap())).unwrap(); + + assert_eq!(cfg.databases[0].database, "maintenance"); +} diff --git a/src/tests/services/mod.rs b/src/tests/services/mod.rs index 1780d61..af4f3eb 100644 --- a/src/tests/services/mod.rs +++ b/src/tests/services/mod.rs @@ -1,2 +1,3 @@ mod api_models_tests; mod backup_uploader_tests; +mod config_tests;