Compare commits

..

8 Commits

Author SHA1 Message Date
github-actions[bot] 92817beea3 chore: release 1.22.0 2026-10-13 14:04:45 +00:00
Charles Gauthereau 0aeb88f645 Merge pull request #118 from Portabase/feat/sqlserver-dacpac-backup
feat: sqlserver-dacpac-backup
2026-10-13 16:02:56 +02:00
Charles Gauthereau df34765e0f fix: refactoring 2026-10-09 18:12:15 +02:00
Charles Gauthereau a1a84bb76d refactor(mssql): move restore drop into the connection module 2026-10-09 17:54:08 +02:00
Charles Gauthereau 7e9b276685 fix(mssql): keep the target usable when the restore drop fails 2026-10-09 17:38:32 +02:00
Charles Gauthereau c4f4cd86cd fix(mssql): refuse restore drop when the login cannot recreate the database 2026-10-09 16:58:12 +02:00
Charles Gauthereau 1e4a07964a feat(mssql): restore dacpac and bacpac over an existing database 2026-10-09 16:49:03 +02:00
Charles Gauthereau d174d86d2d feat(mssql): back up as dacpac with a backup_format option 2026-10-09 16:39:40 +02:00
12 changed files with 468 additions and 22 deletions
+1 -1
View File
@@ -27,5 +27,5 @@ keywords:
- self-hosted - self-hosted
- portabase - portabase
license: Apache-2.0 license: Apache-2.0
version: 1.21.3 version: 1.22.0
date-released: '2026-02-24' date-released: '2026-02-24'
Generated
+1 -1
View File
@@ -3642,7 +3642,7 @@ dependencies = [
[[package]] [[package]]
name = "portabase-agent" name = "portabase-agent"
version = "1.21.3" version = "1.22.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portabase-agent" name = "portabase-agent"
version = "1.21.3" version = "1.22.0"
edition = "2024" edition = "2024"
license = "Apache-2.0" license = "Apache-2.0"
+5 -5
View File
@@ -12,16 +12,16 @@ services:
- ./databases.json:/config/config.json - ./databases.json:/config/config.json
#- ./databases.toml:/config/config.toml #- ./databases.toml:/config/config.toml
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
# - cargo-target:/app/target # - cargo-target:/app/target
- databases_sqlite-data:/sqlite-data/workspace/data - databases_sqlite-data:/sqlite-data/workspace/data
- ./scripts/sqlite/test-db:/sqlite-data-2/workspace/data - ./scripts/sqlite/test-db:/sqlite-data-2/workspace/data
# - /bigdisk:/scratch # - /bigdisk:/scratch
environment: environment:
APP_ENV: development APP_ENV: development
LOG: debug LOG: debug
TZ: "Europe/Paris" TZ: "Europe/Paris"
# TMPDIR: /scratch # TMPDIR: /scratch
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiYTQyNjQzNTQtZGE3Ni00OWFkLWJkYjctZDVjMjMwYzhjYmViIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMjcyMWFiNGQtZDkzNi00NmFkLWE1MWEtOWVhYTk3ZjJhMjAwIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
#CHUNK_SIZE_MB: "1" #CHUNK_SIZE_MB: "1"
#POOLING: 1 #POOLING: 1
#RETRY_ATTEMPTS: 3 #RETRY_ATTEMPTS: 3
@@ -40,7 +40,7 @@ services:
volumes: volumes:
cargo-registry: cargo-registry:
cargo-git: cargo-git:
# cargo-target: # cargo-target:
databases_sqlite-data: databases_sqlite-data:
external: true external: true
+19 -3
View File
@@ -1,3 +1,4 @@
use super::format::MssqlFormat;
use crate::services::backup::logger::JobLogger; use crate::services::backup::logger::JobLogger;
use crate::services::config::DatabaseConfig; use crate::services::config::DatabaseConfig;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -13,7 +14,12 @@ pub async fn run(
logger: Arc<JobLogger>, logger: Arc<JobLogger>,
) -> Result<PathBuf> { ) -> Result<PathBuf> {
tokio::task::spawn_blocking(move || -> Result<PathBuf> { tokio::task::spawn_blocking(move || -> Result<PathBuf> {
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 file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension));
let connection_string = format!( 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())); logger.log("info", format!("MSSQL backup: {}:{}/{} → {}", cfg.host, cfg.port, cfg.database, file_path.display()));
let start = Instant::now(); let start = Instant::now();
let output = Command::new("sqlpackage") let mut command = Command::new("sqlpackage");
.arg("/a:Export") 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!("/scs:{}", connection_string))
.arg(format!("/tf:{}", file_path.display())) .arg(format!("/tf:{}", file_path.display()))
.output() .output()
+39 -2
View File
@@ -1,13 +1,39 @@
use crate::services::config::DatabaseConfig; use crate::services::config::DatabaseConfig;
use anyhow::Result; use anyhow::{Context, Result};
use tiberius::{AuthMethod, Client, Config}; use tiberius::{AuthMethod, Client, Config};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
pub async fn build_client(cfg: &DatabaseConfig) -> Result<Client<Compat<TcpStream>>> { 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<Client<Compat<TcpStream>>> {
let mut config = Config::new(); let mut config = Config::new();
config.host(&cfg.host); config.host(&cfg.host);
config.port(cfg.port); config.port(cfg.port);
if let Some(database) = database {
config.database(database);
}
config.authentication(AuthMethod::sql_server(&cfg.username, &cfg.password)); config.authentication(AuthMethod::sql_server(&cfg.username, &cfg.password));
config.trust_cert(); config.trust_cert();
@@ -16,3 +42,14 @@ pub async fn build_client(cfg: &DatabaseConfig) -> Result<Client<Compat<TcpStrea
let client = Client::connect(config, tcp.compat_write()).await?; let client = Client::connect(config, tcp.compat_write()).await?;
Ok(client) Ok(client)
} }
pub async fn drop_database(cfg: &DatabaseConfig) -> 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(())
}
+2 -1
View File
@@ -1,3 +1,4 @@
use super::format::MssqlFormat;
use super::{backup, ping, restore}; use super::{backup, ping, restore};
use crate::domain::factory::Database; use crate::domain::factory::Database;
use crate::services::backup::logger::JobLogger; use crate::services::backup::logger::JobLogger;
@@ -21,7 +22,7 @@ impl MssqlDatabase {
#[async_trait] #[async_trait]
impl Database for MssqlDatabase { impl Database for MssqlDatabase {
fn file_extension(&self) -> &'static str { fn file_extension(&self) -> &'static str {
".bacpac" MssqlFormat::from_config(&self.cfg).0.extension()
} }
async fn ping(&self) -> Result<bool> { async fn ping(&self) -> Result<bool> {
+33
View File
@@ -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<String>) {
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<Self> {
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",
}
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod database; pub mod database;
pub(crate) mod format;
mod connection; mod connection;
mod ping; mod ping;
mod backup; mod backup;
+1 -1
View File
@@ -5,7 +5,7 @@ use tracing::{error, info};
pub async fn run(cfg: DatabaseConfig) -> Result<bool> { pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
info!("Running ping for MSSQL database {}", cfg.name); 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(mut client) => match client.simple_query("SELECT 1").await {
Ok(_) => { Ok(_) => {
info!("MSSQL ping succeeded for {}", cfg.name); info!("MSSQL ping succeeded for {}", cfg.name);
+22 -3
View File
@@ -1,3 +1,4 @@
use super::format::MssqlFormat;
use crate::services::backup::logger::JobLogger; use crate::services::backup::logger::JobLogger;
use crate::services::config::DatabaseConfig; use crate::services::config::DatabaseConfig;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -7,9 +8,23 @@ use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc<JobLogger>) -> Result<()> { pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc<JobLogger>) -> 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!( let connection_string = format!(
"Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False", "Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False",
cfg.host, cfg.port, cfg.database, cfg.username, cfg.password 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<JobLogg
)); ));
let start = Instant::now(); let start = Instant::now();
let output = Command::new("sqlpackage") let mut command = Command::new("sqlpackage");
.arg("/a:Import") match format {
MssqlFormat::Dacpac => command.args(["/a:Publish", "/p:AllowIncompatiblePlatform=True"]),
MssqlFormat::Bacpac => command.arg("/a:Import"),
};
let output = command
.arg(format!("/tcs:{}", connection_string)) .arg(format!("/tcs:{}", connection_string))
.arg(format!("/sf:{}", restore_file.display())) .arg(format!("/sf:{}", restore_file.display()))
.output() .output()
+342 -3
View File
@@ -1,8 +1,10 @@
use crate::domain::factory::DatabaseFactory; use crate::domain::factory::DatabaseFactory;
use crate::services::backup::logger::JobLogger;
use crate::services::config::{DatabaseConfig, DbType}; use crate::services::config::{DatabaseConfig, DbType};
use crate::tests::init_tracing_for_test; use crate::tests::init_tracing_for_test;
use crate::utils::compress::{compress_to_tar_gz_large, decompress_large_tar_gz}; use crate::utils::compress::{compress_to_tar_gz_large, decompress_large_tar_gz};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tempfile::TempDir; use tempfile::TempDir;
use tiberius::{AuthMethod, Client, Config}; use tiberius::{AuthMethod, Client, Config};
@@ -10,7 +12,7 @@ use testcontainers::runners::AsyncRunner;
use testcontainers::{ContainerAsync, GenericImage, ImageExt}; use testcontainers::{ContainerAsync, GenericImage, ImageExt};
use testcontainers::core::IntoContainerPort; use testcontainers::core::IntoContainerPort;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio_util::compat::TokioAsyncWriteCompatExt; use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
use tracing::{error, info}; use tracing::{error, info};
const SA_PASSWORD: &str = "Test!Str0ng1"; 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<GenericImage>, 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<Compat<TcpStream>> {
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::<i32, _>(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] #[tokio::test]
async fn mssql_ping_test() { async fn mssql_ping_test() {
init_tracing_for_test(); init_tracing_for_test();
@@ -102,8 +141,8 @@ async fn mssql_backup_test() {
"backup file should be non-empty" "backup file should be non-empty"
); );
assert!( assert!(
file_path.extension().and_then(|e| e.to_str()) == Some("bacpac"), file_path.extension().and_then(|e| e.to_str()) == Some("dacpac"),
"backup file should have .bacpac extension" "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");
}
}