Files
duplicati/Duplicati/UnitTest/TestMSSQLParser.cs
T

422 lines
18 KiB
C#

// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Versioning;
using Duplicati.Library.Logging;
using Duplicati.Library.Snapshots;
using Duplicati.Library.Snapshots.Windows;
using NUnit.Framework;
namespace Duplicati.UnitTest
{
[TestFixture]
public class TestMSSQLParser
{
private const char PS = ';'; // Path separator between filters/sources
private const char DS = '\\'; // Directory separator
private const string MSSQL = "%MSSQL%";
/// <summary>
/// Mock that returns a pre-populated set of databases without touching VSS.
/// </summary>
private sealed class MockMSSQLUtility : IMSSQLUtility
{
public Guid MSSQLWriterGuid { get; set; } = Guid.Parse("a65faa63-5ea8-4ebc-9dbd-a0c4db26912a");
public bool IsMSSQLInstalled { get; set; } = true;
public List<MSSQLDB> DBs { get; set; } = new();
public void QueryDBsInfo(WindowsSnapshotProvider provider)
{
// No-op; DBs are injected directly by the test
}
}
private sealed class LogSink : ILogDestination
{
public List<LogEntry> Entries { get; } = [];
public void WriteMessage(LogEntry entry) => Entries.Add(entry);
}
private static MSSQLDB MakeDb(string server, string instance, string database, params string[] paths)
=> new MSSQLDB
{
Server = server,
InstanceId = instance,
Database = database,
DataPaths = paths.ToList()
};
private static Dictionary<string, string> RequiredPolicyOptions()
=> new(StringComparer.OrdinalIgnoreCase) { ["snapshot-policy"] = "required" };
[SupportedOSPlatform("windows")]
private static Dictionary<string, string> Run(MockMSSQLUtility util, ref string[] paths, ref string filter, Dictionary<string, string> options = null)
{
options ??= RequiredPolicyOptions();
using var _ = Log.StartScope(new LogSink());
return new Duplicati.Library.Modules.Builtin.MSSQLOptions()
.RealParseSourcePaths(ref paths, ref filter, options, util);
}
[Test]
public void Returns_empty_change_set_when_MSSQL_not_installed()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var util = new MockMSSQLUtility { IsMSSQLInstalled = false };
var paths = new[] { MSSQL };
var filter = string.Empty;
var result = Run(util, ref paths, ref filter);
Assert.That(result, Is.Empty);
Assert.That(paths, Is.EqualTo(new[] { MSSQL }), "Paths must stay untouched when MSSQL is not installed.");
}
[Test]
public void All_marker_includes_every_database()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var dbDefault = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf");
var dbInstance = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf");
var util = new MockMSSQLUtility { DBs = [dbDefault, dbInstance] };
var paths = new[] { MSSQL };
var filter = string.Empty;
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(dbDefault.DataPaths[0]));
Assert.That(paths, Does.Contain(dbInstance.DataPaths[0]));
Assert.That(paths, Does.Not.Contain(MSSQL), "Virtual marker must be replaced by real paths.");
}
[Test]
public void Server_path_includes_all_databases_on_server()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var dbDefault = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf");
var dbInstance = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf");
var dbOther = MakeDb("SRV2", "", "Other", @$"C:{DS}Data{DS}Other.mdf");
var util = new MockMSSQLUtility { DBs = [dbDefault, dbInstance, dbOther] };
var paths = new[] { $"{MSSQL}{DS}SRV1" };
var filter = string.Empty;
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(dbDefault.DataPaths[0]), "default-instance db on server missing");
Assert.That(paths, Does.Contain(dbInstance.DataPaths[0]), "named-instance db on server missing");
Assert.That(paths, Does.Not.Contain(dbOther.DataPaths[0]), "db from another server leaked in");
}
[Test]
public void Default_instance_database_path_resolves_single_database()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
// %MSSQL%\SRV1\Sales : 3 segments, default instance => Sales database
var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf");
var dbHr = MakeDb("SRV1", "", "HR", @$"C:{DS}Data{DS}HR.mdf");
var util = new MockMSSQLUtility { DBs = [dbSales, dbHr] };
var paths = new[] { $"{MSSQL}{DS}SRV1{DS}Sales" };
var filter = string.Empty;
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(dbSales.DataPaths[0]), "selected default-instance db missing");
Assert.That(paths, Does.Not.Contain(dbHr.DataPaths[0]), "non-selected db leaked in");
}
[Test]
public void Server_instance_path_includes_all_databases_in_instance()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
// %MSSQL%\SRV1\INST : 3 segments, matches a named instance => all its databases
var dbInst1 = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf");
var dbInst2 = MakeDb("SRV1", "INST", "Payroll", @$"C:{DS}Data{DS}Payroll.mdf");
var dbDefault = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf");
var util = new MockMSSQLUtility { DBs = [dbInst1, dbInst2, dbDefault] };
var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST" };
var filter = string.Empty;
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(dbInst1.DataPaths[0]));
Assert.That(paths, Does.Contain(dbInst2.DataPaths[0]));
Assert.That(paths, Does.Not.Contain(dbDefault.DataPaths[0]), "default-instance db leaked in");
}
[Test]
public void Fully_qualified_path_resolves_single_database_on_named_instance()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
// %MSSQL%\SRV1\INST\HR : 4 segments
var dbHr = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf");
var dbPayroll = MakeDb("SRV1", "INST", "Payroll", @$"C:{DS}Data{DS}Payroll.mdf");
var util = new MockMSSQLUtility { DBs = [dbHr, dbPayroll] };
var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST{DS}HR" };
var filter = string.Empty;
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(dbHr.DataPaths[0]));
Assert.That(paths, Does.Not.Contain(dbPayroll.DataPaths[0]));
}
[Test]
[SupportedOSPlatform("windows")]
public void Ambiguous_instance_and_default_database_name_throws()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
// A named instance "INST" exists, plus a default-instance database also called "INST".
// %MSSQL%\SRV1\INST is ambiguous (could mean the instance or the default-instance db),
// so the parser must reject it rather than silently guessing.
var dbInst = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf");
var dbDefaultNamedInst = MakeDb("SRV1", "", "INST", @$"C:{DS}Data{DS}DefaultInst.mdf");
var util = new MockMSSQLUtility { DBs = [dbInst, dbDefaultNamedInst] };
var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST" };
var filter = string.Empty;
var ex = Assert.Throws<Duplicati.Library.Interface.UserInformationException>(() =>
{
var p = paths; var f = filter;
Run(util, ref p, ref f);
});
Assert.That(ex.HelpID, Is.EqualTo("MsSqlServerInstanceAmbiguous"));
}
[Test]
public void Server_instance_match_wins_when_no_competing_default_database()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
// Only the named instance "INST" matches %MSSQL%\SRV1\INST; there is no
// default-instance database called "INST", so the instance is selected unambiguously.
var dbInst = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf");
var dbDefault = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf");
var util = new MockMSSQLUtility { DBs = [dbInst, dbDefault] };
var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST" };
var filter = string.Empty;
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(dbInst.DataPaths[0]), "instance match missing");
Assert.That(paths, Does.Not.Contain(dbDefault.DataPaths[0]), "unrelated default-instance db leaked in");
}
[Test]
[SupportedOSPlatform("windows")]
public void Unknown_server_throws()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var util = new MockMSSQLUtility { DBs = [MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf")] };
var paths = new[] { $"{MSSQL}{DS}NOPE" };
var filter = string.Empty;
var ex = Assert.Throws<Duplicati.Library.Interface.UserInformationException>(() =>
{
var p = paths; var f = filter;
Run(util, ref p, ref f);
});
Assert.That(ex.HelpID, Is.EqualTo("MsSqlServerNotFound"));
}
[Test]
[SupportedOSPlatform("windows")]
public void Unknown_instance_or_database_throws()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
// %MSSQL%\SRV1\NOPE matches neither a named instance nor a default-instance database
var util = new MockMSSQLUtility { DBs = [MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf")] };
var paths = new[] { $"{MSSQL}{DS}SRV1{DS}NOPE" };
var filter = string.Empty;
var ex = Assert.Throws<Duplicati.Library.Interface.UserInformationException>(() =>
{
var p = paths; var f = filter;
Run(util, ref p, ref f);
});
Assert.That(ex.HelpID, Is.EqualTo("MsSqlServerInstanceNotFound"));
}
[Test]
[SupportedOSPlatform("windows")]
public void Unknown_database_on_named_instance_throws()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var util = new MockMSSQLUtility { DBs = [MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf")] };
var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST{DS}NOPE" };
var filter = string.Empty;
var ex = Assert.Throws<Duplicati.Library.Interface.UserInformationException>(() =>
{
var p = paths; var f = filter;
Run(util, ref p, ref f);
});
Assert.That(ex.HelpID, Is.EqualTo("MsSqlDatabaseNotFound"));
}
[Test]
public void Exclude_filter_removes_matching_database()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf");
var dbHr = MakeDb("SRV1", "", "HR", @$"C:{DS}Data{DS}HR.mdf");
var util = new MockMSSQLUtility { DBs = [dbSales, dbHr] };
var paths = new[] { MSSQL };
// Exclude HR on the default instance (no instance segment in the virtual path)
var filter = $"-{MSSQL}{DS}SRV1{DS}HR";
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(dbSales.DataPaths[0]), "unaffected db missing");
Assert.That(paths, Does.Not.Contain(dbHr.DataPaths[0]), "excluded db leaked in");
}
[Test]
public void Include_filter_keeps_only_matching_database()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf");
var dbHr = MakeDb("SRV1", "", "HR", @$"C:{DS}Data{DS}HR.mdf");
var util = new MockMSSQLUtility { DBs = [dbSales, dbHr] };
var paths = new[] { MSSQL };
// Include-only filter: anything not matched is excluded
var filter = $"+{MSSQL}{DS}SRV1{DS}Sales";
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(dbSales.DataPaths[0]), "included db missing");
Assert.That(paths, Does.Not.Contain(dbHr.DataPaths[0]), "non-included db leaked in");
}
[Test]
public void Exclude_filter_on_named_instance_database()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var dbHr = MakeDb("SRV1", "INST", "HR", @$"C:{DS}Data{DS}HR.mdf");
var dbPayroll = MakeDb("SRV1", "INST", "Payroll", @$"C:{DS}Data{DS}Payroll.mdf");
var util = new MockMSSQLUtility { DBs = [dbHr, dbPayroll] };
var paths = new[] { $"{MSSQL}{DS}SRV1{DS}INST" };
var filter = $"-{MSSQL}{DS}SRV1{DS}INST{DS}Payroll";
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(dbHr.DataPaths[0]), "unaffected named-instance db missing");
Assert.That(paths, Does.Not.Contain(dbPayroll.DataPaths[0]), "excluded named-instance db leaked in");
}
[Test]
public void Non_MSSQL_paths_and_filters_pass_through_untouched()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf");
var util = new MockMSSQLUtility { DBs = [dbSales] };
var ordinaryPath = @$"C:{DS}Users{DS}data";
var ordinaryFilter = $"-C:{DS}Users{DS}data{DS}temp";
var paths = new[] { MSSQL, ordinaryPath };
var filter = ordinaryFilter;
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(ordinaryPath), "ordinary source path must survive");
Assert.That(paths, Does.Contain(dbSales.DataPaths[0]), "db path must be added");
Assert.That(filter, Is.EqualTo(ordinaryFilter), "non-MSSQL filter must be left in the filter string");
}
[Test]
public void Handles_null_filter_gracefully()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf");
var util = new MockMSSQLUtility { DBs = [dbSales] };
var paths = new[] { MSSQL };
string filter = null;
Run(util, ref paths, ref filter);
Assert.That(paths, Does.Contain(dbSales.DataPaths[0]));
}
[Test]
public void Enforces_required_snapshot_policy()
{
if (!OperatingSystem.IsWindows())
return; // MSSQL is only available on Windows
var dbSales = MakeDb("SRV1", "", "Sales", @$"C:{DS}Data{DS}Sales.mdf");
var util = new MockMSSQLUtility { DBs = [dbSales] };
var paths = new[] { MSSQL };
var filter = string.Empty;
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var result = Run(util, ref paths, ref filter, options);
Assert.That(result.ContainsKey("snapshot-policy"), Is.True);
Assert.That(result["snapshot-policy"], Is.EqualTo("required"));
}
}
}