diff --git a/docker-compose.yml b/docker-compose.yml index 699ed22..2adce57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,16 +12,16 @@ services: - ./databases.json:/config/config.json #- ./databases.toml:/config/config.toml - /var/run/docker.sock:/var/run/docker.sock -# - cargo-target:/app/target + # - cargo-target:/app/target - databases_sqlite-data:/sqlite-data/workspace/data - ./scripts/sqlite/test-db:/sqlite-data-2/workspace/data -# - /bigdisk:/scratch + # - /bigdisk:/scratch environment: APP_ENV: development LOG: debug TZ: "Europe/Paris" -# TMPDIR: /scratch - EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiYTQyNjQzNTQtZGE3Ni00OWFkLWJkYjctZDVjMjMwYzhjYmViIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" + # TMPDIR: /scratch + EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMjcyMWFiNGQtZDkzNi00NmFkLWE1MWEtOWVhYTk3ZjJhMjAwIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" #CHUNK_SIZE_MB: "1" #POOLING: 1 #RETRY_ATTEMPTS: 3 @@ -40,7 +40,7 @@ services: volumes: cargo-registry: cargo-git: -# cargo-target: + # cargo-target: databases_sqlite-data: external: true diff --git a/src/domain/mssql/backup.rs b/src/domain/mssql/backup.rs index be564e1..ccef82e 100644 --- a/src/domain/mssql/backup.rs +++ b/src/domain/mssql/backup.rs @@ -1,3 +1,4 @@ +use super::format::MssqlFormat; use crate::services::backup::logger::JobLogger; use crate::services::config::DatabaseConfig; use anyhow::{Context, Result}; @@ -13,7 +14,12 @@ pub async fn run( logger: Arc, ) -> Result { tokio::task::spawn_blocking(move || -> Result { - logger.log("info", format!("Starting MSSQL backup for database {}", cfg.name)); + let (format, unknown_format) = MssqlFormat::from_config(&cfg); + if let Some(v) = unknown_format { + logger.log("warn", format!("Unknown backup_format '{}' for {}, falling back to 'dacpac'", v, cfg.name)); + } + + logger.log("info", format!("Starting MSSQL {} backup for database {}", &format.extension()[1..], cfg.name)); let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension)); let connection_string = format!( @@ -24,8 +30,18 @@ pub async fn run( logger.log("info", format!("MSSQL backup: {}:{}/{} → {}", cfg.host, cfg.port, cfg.database, file_path.display())); let start = Instant::now(); - let output = Command::new("sqlpackage") - .arg("/a:Export") + let mut command = Command::new("sqlpackage"); + match format { + MssqlFormat::Dacpac => command.args([ + "/a:Extract", + "/p:ExtractAllTableData=True", + "/p:ExtractReferencedServerScopedElements=False", + "/p:IgnorePermissions=False", + "/p:IgnoreUserLoginMappings=False", + ]), + MssqlFormat::Bacpac => command.arg("/a:Export"), + }; + let output = command .arg(format!("/scs:{}", connection_string)) .arg(format!("/tf:{}", file_path.display())) .output() diff --git a/src/domain/mssql/connection.rs b/src/domain/mssql/connection.rs index aad6fad..92ee08d 100644 --- a/src/domain/mssql/connection.rs +++ b/src/domain/mssql/connection.rs @@ -1,13 +1,39 @@ use crate::services::config::DatabaseConfig; -use anyhow::Result; +use anyhow::{Context, Result}; use tiberius::{AuthMethod, Client, Config}; use tokio::net::TcpStream; use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; -pub async fn build_client(cfg: &DatabaseConfig) -> Result>> { +const DROP_DATABASE_SQL: &str = "DECLARE @q nvarchar(258) = QUOTENAME(@P1); +IF DB_ID(@P1) IS NOT NULL +BEGIN + IF DB_ID(@P1) <= 4 + THROW 50000, N'Refusing to restore over a system database; nothing was dropped', 1; + IF ISNULL(HAS_PERMS_BY_NAME(NULL, 'DATABASE', 'CREATE DATABASE'), 0) = 0 + THROW 50000, N'Restore needs CREATE DATABASE permission (dbcreator) to recreate the target database; nothing was dropped', 1; + DECLARE @single bit = 0; + IF CAST(SERVERPROPERTY('EngineEdition') AS int) <> 5 + BEGIN + EXEC(N'ALTER DATABASE ' + @q + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE'); + SET @single = 1; + END + BEGIN TRY + EXEC(N'DROP DATABASE ' + @q); + END TRY + BEGIN CATCH + IF @single = 1 AND DB_ID(@P1) IS NOT NULL + EXEC(N'ALTER DATABASE ' + @q + N' SET MULTI_USER'); + THROW; + END CATCH +END"; + +pub async fn build_client(cfg: &DatabaseConfig, database: Option<&str>) -> Result>> { let mut config = Config::new(); config.host(&cfg.host); config.port(cfg.port); + if let Some(database) = database { + config.database(database); + } config.authentication(AuthMethod::sql_server(&cfg.username, &cfg.password)); config.trust_cert(); @@ -16,3 +42,14 @@ pub async fn build_client(cfg: &DatabaseConfig) -> Result Result<()> { + let mut client = build_client(cfg, Some("master")) + .await + .with_context(|| format!("Failed to connect to master for {}", cfg.name))?; + client + .execute(DROP_DATABASE_SQL, &[&cfg.database.as_str()]) + .await + .with_context(|| format!("Failed to drop MSSQL database {}", cfg.database))?; + Ok(()) +} diff --git a/src/domain/mssql/database.rs b/src/domain/mssql/database.rs index fda0652..b15d69b 100644 --- a/src/domain/mssql/database.rs +++ b/src/domain/mssql/database.rs @@ -1,3 +1,4 @@ +use super::format::MssqlFormat; use super::{backup, ping, restore}; use crate::domain::factory::Database; use crate::services::backup::logger::JobLogger; @@ -21,7 +22,7 @@ impl MssqlDatabase { #[async_trait] impl Database for MssqlDatabase { fn file_extension(&self) -> &'static str { - ".bacpac" + MssqlFormat::from_config(&self.cfg).0.extension() } async fn ping(&self) -> Result { diff --git a/src/domain/mssql/format.rs b/src/domain/mssql/format.rs new file mode 100644 index 0000000..dedb542 --- /dev/null +++ b/src/domain/mssql/format.rs @@ -0,0 +1,33 @@ +use crate::services::config::DatabaseConfig; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MssqlFormat { + Dacpac, + Bacpac, +} + +impl MssqlFormat { + pub fn from_config(cfg: &DatabaseConfig) -> (Self, Option) { + match cfg.options.get("backup_format").and_then(|v| v.as_str()) { + None | Some("dacpac") => (Self::Dacpac, None), + Some("bacpac") => (Self::Bacpac, None), + Some(other) => (Self::Dacpac, Some(other.to_string())), + } + } + + pub fn from_path(path: &Path) -> Option { + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "dacpac" => Some(Self::Dacpac), + "bacpac" => Some(Self::Bacpac), + _ => None, + } + } + + pub fn extension(self) -> &'static str { + match self { + Self::Dacpac => ".dacpac", + Self::Bacpac => ".bacpac", + } + } +} diff --git a/src/domain/mssql/mod.rs b/src/domain/mssql/mod.rs index 143be20..c518aaf 100644 --- a/src/domain/mssql/mod.rs +++ b/src/domain/mssql/mod.rs @@ -1,4 +1,5 @@ pub mod database; +pub(crate) mod format; mod connection; mod ping; mod backup; diff --git a/src/domain/mssql/ping.rs b/src/domain/mssql/ping.rs index 34c33a5..14873bf 100644 --- a/src/domain/mssql/ping.rs +++ b/src/domain/mssql/ping.rs @@ -5,7 +5,7 @@ use tracing::{error, info}; pub async fn run(cfg: DatabaseConfig) -> Result { info!("Running ping for MSSQL database {}", cfg.name); - match super::connection::build_client(&cfg).await { + match super::connection::build_client(&cfg, None).await { Ok(mut client) => match client.simple_query("SELECT 1").await { Ok(_) => { info!("MSSQL ping succeeded for {}", cfg.name); diff --git a/src/domain/mssql/restore.rs b/src/domain/mssql/restore.rs index af56d6c..cce459e 100644 --- a/src/domain/mssql/restore.rs +++ b/src/domain/mssql/restore.rs @@ -1,3 +1,4 @@ +use super::format::MssqlFormat; use crate::services::backup::logger::JobLogger; use crate::services::config::DatabaseConfig; use anyhow::{Context, Result}; @@ -7,9 +8,23 @@ use std::sync::Arc; use std::time::Instant; pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc) -> Result<()> { - tokio::task::spawn_blocking(move || -> Result<()> { - logger.log("debug", format!("Starting MSSQL restore for database {}", cfg.name)); + logger.log("debug", format!("Starting MSSQL restore for database {}", cfg.name)); + let Some(format) = MssqlFormat::from_path(&restore_file) else { + let message = format!( + "Unsupported MSSQL backup file {}: expected .bacpac or .dacpac", + restore_file.display() + ); + logger.log("error", message.clone()); + anyhow::bail!(message); + }; + + logger.log("info", format!("Replacing MSSQL database {} (drop if it exists, then recreate from the backup)", cfg.database)); + let start = Instant::now(); + super::connection::drop_database(&cfg).await?; + logger.log_command(format!("DROP DATABASE IF EXISTS [{}]", cfg.database), None, Some(0), Some(start.elapsed().as_millis() as f64)); + + tokio::task::spawn_blocking(move || -> Result<()> { let connection_string = format!( "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False", cfg.host, cfg.port, cfg.database, cfg.username, cfg.password @@ -24,8 +39,12 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc command.args(["/a:Publish", "/p:AllowIncompatiblePlatform=True"]), + MssqlFormat::Bacpac => command.arg("/a:Import"), + }; + let output = command .arg(format!("/tcs:{}", connection_string)) .arg(format!("/sf:{}", restore_file.display())) .output() diff --git a/src/tests/domain/mssql.rs b/src/tests/domain/mssql.rs index 6022834..4aa6f6a 100644 --- a/src/tests/domain/mssql.rs +++ b/src/tests/domain/mssql.rs @@ -1,8 +1,10 @@ use crate::domain::factory::DatabaseFactory; +use crate::services::backup::logger::JobLogger; use crate::services::config::{DatabaseConfig, DbType}; use crate::tests::init_tracing_for_test; use crate::utils::compress::{compress_to_tar_gz_large, decompress_large_tar_gz}; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; use tiberius::{AuthMethod, Client, Config}; @@ -10,7 +12,7 @@ use testcontainers::runners::AsyncRunner; use testcontainers::{ContainerAsync, GenericImage, ImageExt}; use testcontainers::core::IntoContainerPort; use tokio::net::TcpStream; -use tokio_util::compat::TokioAsyncWriteCompatExt; +use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; use tracing::{error, info}; const SA_PASSWORD: &str = "Test!Str0ng1"; @@ -61,6 +63,43 @@ fn make_config(host: String, port: u16, database: &str, generated_id: &str) -> D } } +async fn start() -> (ContainerAsync, String, u16) { + let container = start_container().await; + tokio::time::sleep(Duration::from_secs(30)).await; + let host = container.get_host().await.unwrap().to_string(); + let port = container.get_host_port_ipv4(1433).await.unwrap(); + (container, host, port) +} + +async fn connect_as(host: &str, port: u16, database: &str, user: &str, password: &str) -> Client> { + let mut config = Config::new(); + config.host(host); + config.port(port); + config.database(database); + config.authentication(AuthMethod::sql_server(user, password)); + config.trust_cert(); + + let tcp = TcpStream::connect(config.get_addr()).await.unwrap(); + tcp.set_nodelay(true).unwrap(); + Client::connect(config, tcp.compat_write()).await.unwrap() +} + +async fn exec(host: &str, port: u16, database: &str, sql: &str) { + let mut client = connect_as(host, port, database, "sa", SA_PASSWORD).await; + client.simple_query(sql).await.unwrap().into_results().await.unwrap(); +} + +async fn scalar(host: &str, port: u16, database: &str, user: &str, password: &str, sql: &str) -> i32 { + let mut client = connect_as(host, port, database, user, password).await; + let row = client.simple_query(sql).await.unwrap().into_row().await.unwrap().unwrap(); + row.get::(0).unwrap() +} + +fn with_backup_format(mut config: DatabaseConfig, format: &str) -> DatabaseConfig { + config.options.insert("backup_format".to_string(), serde_json::json!(format)); + config +} + #[tokio::test] async fn mssql_ping_test() { init_tracing_for_test(); @@ -102,8 +141,8 @@ async fn mssql_backup_test() { "backup file should be non-empty" ); assert!( - file_path.extension().and_then(|e| e.to_str()) == Some("bacpac"), - "backup file should have .bacpac extension" + file_path.extension().and_then(|e| e.to_str()) == Some("dacpac"), + "backup file should have .dacpac extension" ); } @@ -153,3 +192,303 @@ async fn mssql_backup_restore_test() { } } } + +#[tokio::test] +async fn mssql_backup_bacpac_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "bacpacdb").await; + + let config = with_backup_format( + make_config(host, port, "bacpacdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d6"), + "bacpac", + ); + let temp_dir = TempDir::new().unwrap(); + let file_path = DatabaseFactory::create_for_backup(config) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await + .unwrap(); + + assert!(file_path.metadata().unwrap().len() > 0); + assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some("bacpac")); +} + +#[tokio::test] +async fn mssql_backup_windows_principal_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "windowsdb").await; + exec( + &host, + port, + "windowsdb", + "CREATE TABLE dbo.t (id INT PRIMARY KEY); INSERT dbo.t VALUES (1); \ + CREATE USER [NT AUTHORITY\\SYSTEM] FOR LOGIN [NT AUTHORITY\\SYSTEM];", + ) + .await; + + let config = make_config(host, port, "windowsdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d7"); + let temp_dir = TempDir::new().unwrap(); + + let bacpac = DatabaseFactory::create_for_backup(with_backup_format(config.clone(), "bacpac")) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await; + assert!(bacpac.unwrap_err().to_string().contains("SQL71627")); + + let file_path = DatabaseFactory::create_for_backup(config) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await + .unwrap(); + assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some("dacpac")); +} + +async fn restore_over_existing(format: &str, generated_id: &str) { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "appdb").await; + exec(&host, port, "master", "CREATE LOGIN app_login WITH PASSWORD = 'App!Str0ng1'").await; + exec( + &host, + port, + "appdb", + "CREATE TABLE dbo.users (id INT PRIMARY KEY, email NVARCHAR(100) NOT NULL); \ + INSERT dbo.users VALUES (1, 'a@x'), (2, 'b@x'), (3, 'c@x'); \ + CREATE USER app_user FOR LOGIN app_login; \ + GRANT SELECT ON dbo.users TO app_user;", + ) + .await; + + let config = with_backup_format(make_config(host.clone(), port, "appdb", generated_id), format); + let temp_dir = TempDir::new().unwrap(); + let file_path = DatabaseFactory::create_for_backup(config.clone()) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await + .unwrap(); + assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some(format)); + + exec( + &host, + port, + "appdb", + "DELETE FROM dbo.users WHERE id = 1; INSERT dbo.users VALUES (4, 'd@x'); CREATE TABLE dbo.extra (id INT);", + ) + .await; + + DatabaseFactory::create_for_restore(config, &file_path) + .await + .restore(&file_path, Arc::new(JobLogger::new())) + .await + .unwrap(); + + assert_eq!(scalar(&host, port, "appdb", "sa", SA_PASSWORD, "SELECT COUNT(*) FROM dbo.users").await, 3); + assert_eq!( + scalar(&host, port, "appdb", "sa", SA_PASSWORD, "SELECT COUNT(*) FROM dbo.users WHERE id IN (1, 2, 3)").await, + 3 + ); + assert_eq!( + scalar(&host, port, "appdb", "sa", SA_PASSWORD, "SELECT CASE WHEN OBJECT_ID('dbo.extra') IS NULL THEN 1 ELSE 0 END").await, + 1 + ); + assert_eq!( + scalar(&host, port, "appdb", "app_login", "App!Str0ng1", "SELECT COUNT(*) FROM dbo.users").await, + 3 + ); +} + +#[tokio::test] +async fn mssql_restore_over_existing_dacpac_test() { + restore_over_existing("dacpac", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d8").await; +} + +#[tokio::test] +async fn mssql_restore_over_existing_bacpac_test() { + restore_over_existing("bacpac", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d9").await; +} + +#[tokio::test] +async fn mssql_restore_rejects_unknown_extension_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "keepdb").await; + + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("backup.dump"); + std::fs::write(&file_path, b"not a package").unwrap(); + + let config = make_config(host.clone(), port, "keepdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6da"); + let result = DatabaseFactory::create_for_restore(config, &file_path) + .await + .restore(&file_path, Arc::new(JobLogger::new())) + .await; + + assert!(result.unwrap_err().to_string().contains("expected .bacpac or .dacpac")); + assert_eq!( + scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('keepdb') IS NULL THEN 0 ELSE 1 END").await, + 1 + ); +} + +#[tokio::test] +async fn mssql_restore_refuses_without_create_database_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "ownerdb").await; + exec(&host, port, "master", "CREATE LOGIN owner_login WITH PASSWORD = 'Owner!Str0ng1'").await; + exec( + &host, + port, + "ownerdb", + "CREATE TABLE dbo.users (id INT PRIMARY KEY, email NVARCHAR(100) NOT NULL); \ + INSERT dbo.users VALUES (1, 'a@x'), (2, 'b@x'), (3, 'c@x'); \ + CREATE USER owner_user FOR LOGIN owner_login; \ + ALTER ROLE db_owner ADD MEMBER owner_user;", + ) + .await; + + let config = make_config(host.clone(), port, "ownerdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6db"); + let temp_dir = TempDir::new().unwrap(); + let file_path = DatabaseFactory::create_for_backup(config.clone()) + .await + .backup(temp_dir.path(), Arc::new(JobLogger::new())) + .await + .unwrap(); + + let mut owner_config = config; + owner_config.username = "owner_login".to_string(); + owner_config.password = "Owner!Str0ng1".to_string(); + let result = DatabaseFactory::create_for_restore(owner_config, &file_path) + .await + .restore(&file_path, Arc::new(JobLogger::new())) + .await; + + assert!(format!("{:#}", result.unwrap_err()).contains("CREATE DATABASE")); + assert_eq!( + scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('ownerdb') IS NULL THEN 0 ELSE 1 END").await, + 1 + ); + assert_eq!(scalar(&host, port, "ownerdb", "sa", SA_PASSWORD, "SELECT COUNT(*) FROM dbo.users").await, 3); +} + +#[tokio::test] +async fn mssql_restore_failed_drop_keeps_multi_user_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + create_user_database(&host, port, "alterdb").await; + exec( + &host, + port, + "master", + "CREATE LOGIN alter_login WITH PASSWORD = 'Alter!Str0ng1'; GRANT CREATE ANY DATABASE TO alter_login;", + ) + .await; + exec( + &host, + port, + "alterdb", + "CREATE TABLE dbo.t (id INT PRIMARY KEY); INSERT dbo.t VALUES (1); \ + CREATE USER alter_user FOR LOGIN alter_login; GRANT ALTER TO alter_user;", + ) + .await; + + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("backup.dacpac"); + std::fs::write(&file_path, b"not a package").unwrap(); + + let mut config = make_config(host.clone(), port, "alterdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6dc"); + config.username = "alter_login".to_string(); + config.password = "Alter!Str0ng1".to_string(); + let result = DatabaseFactory::create_for_restore(config, &file_path) + .await + .restore(&file_path, Arc::new(JobLogger::new())) + .await; + + assert!(!format!("{:#}", result.unwrap_err()).contains("nothing was dropped")); + assert_eq!( + scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('alterdb') IS NULL THEN 0 ELSE 1 END").await, + 1 + ); + assert_eq!( + scalar( + &host, + port, + "master", + "sa", + SA_PASSWORD, + "SELECT CASE WHEN DATABASEPROPERTYEX('alterdb', 'UserAccess') = 'MULTI_USER' THEN 1 ELSE 0 END" + ) + .await, + 1 + ); +} + +#[tokio::test] +async fn mssql_restore_refuses_system_database_test() { + init_tracing_for_test(); + let (_container, host, port) = start().await; + + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("backup.dacpac"); + std::fs::write(&file_path, b"not a package").unwrap(); + + let config = make_config(host.clone(), port, "msdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6dd"); + let result = DatabaseFactory::create_for_restore(config, &file_path) + .await + .restore(&file_path, Arc::new(JobLogger::new())) + .await; + + assert!(format!("{:#}", result.unwrap_err()).contains("system database")); + assert_eq!( + scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('msdb') IS NULL THEN 0 ELSE 1 END").await, + 1 + ); + assert_eq!( + scalar( + &host, + port, + "master", + "sa", + SA_PASSWORD, + "SELECT CASE WHEN DATABASEPROPERTYEX('msdb', 'UserAccess') = 'MULTI_USER' THEN 1 ELSE 0 END" + ) + .await, + 1 + ); +} + +mod format_tests { + use super::{make_config, with_backup_format}; + use crate::domain::mssql::format::MssqlFormat; + use std::path::Path; + + #[test] + fn from_config_defaults_to_dacpac() { + let cfg = make_config("localhost".into(), 1433, "db", "00000000-0000-0000-0000-000000000000"); + assert_eq!(MssqlFormat::from_config(&cfg), (MssqlFormat::Dacpac, None)); + assert_eq!( + MssqlFormat::from_config(&with_backup_format(cfg.clone(), "dacpac")), + (MssqlFormat::Dacpac, None) + ); + assert_eq!( + MssqlFormat::from_config(&with_backup_format(cfg.clone(), "bacpac")), + (MssqlFormat::Bacpac, None) + ); + assert_eq!( + MssqlFormat::from_config(&with_backup_format(cfg, "zip")), + (MssqlFormat::Dacpac, Some("zip".to_string())) + ); + } + + #[test] + fn from_path_reads_extension() { + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/a.dacpac")), Some(MssqlFormat::Dacpac)); + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/a.BACPAC")), Some(MssqlFormat::Bacpac)); + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/backup.dump")), None); + assert_eq!(MssqlFormat::from_path(Path::new("/tmp/noext")), None); + assert_eq!(MssqlFormat::Dacpac.extension(), ".dacpac"); + assert_eq!(MssqlFormat::Bacpac.extension(), ".bacpac"); + } +}