diff --git a/Duplicati/UnitTest/RemoteSynchronizationModuleTests.cs b/Duplicati/UnitTest/RemoteSynchronizationModuleTests.cs new file mode 100644 index 000000000..acee784be --- /dev/null +++ b/Duplicati/UnitTest/RemoteSynchronizationModuleTests.cs @@ -0,0 +1,346 @@ +// Copyright (C) 2025, 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 Duplicati.Library.Modules.Builtin; +using Duplicati.Library.Interface; +using Duplicati.Library.Utility; +using Duplicati.Library.Main; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Linq; +using Duplicati.Library.Main.Database; +using Duplicati.Library.SQLiteHelper; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace Duplicati.UnitTest +{ + [TestFixture] + public class RemoteSynchronizationModuleTests : BasicSetupHelper + { + [Test] + [Category("RemoteSync")] + public void TestConfigure_SingleDestination() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest" + }; + + module.Configure(options); + + var destinations = module.GetType().GetField("m_destinations", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(module) as List; + Assert.AreEqual(1, destinations.Count); + Assert.AreEqual("file:///test/dest", destinations[0]); + } + + [Test] + [Category("RemoteSync")] + public void TestConfigure_MultipleDestinations() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest1,file:///test/dest2" + }; + + module.Configure(options); + + var destinations = module.GetType().GetField("m_destinations", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(module) as List; + Assert.AreEqual(2, destinations.Count); + Assert.AreEqual("file:///test/dest1", destinations[0]); + Assert.AreEqual("file:///test/dest2", destinations[1]); + } + + [Test] + [Category("RemoteSync")] + public void TestConfigure_Modes() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest1,file:///test/dest2", + ["remote-sync-mode"] = "inline,scheduled" + }; + + module.Configure(options); + + var modes = module.GetType().GetField("m_modes", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(module) as List; + Assert.AreEqual(2, modes.Count); + Assert.AreEqual(RemoteSyncTriggerMode.Inline, modes[0]); + Assert.AreEqual(RemoteSyncTriggerMode.Scheduled, modes[1]); + } + + [Test] + [Category("RemoteSync")] + public void TestConfigure_Schedules() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest1,file:///test/dest2", + ["remote-sync-schedule"] = "1:00:00,2:00:00" + }; + + module.Configure(options); + + var schedules = module.GetType().GetField("m_schedules", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(module) as List; + Assert.AreEqual(2, schedules.Count); + Assert.AreEqual(TimeSpan.FromHours(1), schedules[0]); + Assert.AreEqual(TimeSpan.FromHours(2), schedules[1]); + } + + [Test] + [Category("RemoteSync")] + public void TestConfigure_Counts() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest1,file:///test/dest2", + ["remote-sync-count"] = "5,10" + }; + + module.Configure(options); + + var counts = module.GetType().GetField("m_counts", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(module) as List; + Assert.AreEqual(2, counts.Count); + Assert.AreEqual(5, counts[0]); + Assert.AreEqual(10, counts[1]); + } + + [Test] + [Category("RemoteSync")] + public void TestOnStart_SetsSource() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest" + }; + module.Configure(options); + + string remoteurl = "file:///source"; + string[] localpath = new string[0]; + module.OnStart("Backup", ref remoteurl, ref localpath); + + var source = module.GetType().GetField("m_source", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(module) as string; + Assert.AreEqual("file:///source", source); + } + + [Test] + [Category("RemoteSync")] + public void TestShouldTriggerSync_Inline() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest", + ["remote-sync-mode"] = "inline", + ["dbpath"] = DBFILE + }; + module.Configure(options); + + var shouldTrigger = (bool)module.GetType().GetMethod("ShouldTriggerSync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).Invoke(module, new object[] { 0 }); + Assert.IsTrue(shouldTrigger); + } + + [Test] + [Category("RemoteSync")] + public void TestShouldTriggerSync_Scheduled_FirstTime() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest", + ["remote-sync-mode"] = "scheduled", + ["remote-sync-schedule"] = "1:00:00", + ["dbpath"] = DBFILE + }; + module.Configure(options); + + // Create Operation table + using var db = SQLiteLoader.LoadConnection(DBFILE); + using var cmd = db.CreateCommand(); + cmd.CommandText = "CREATE TABLE IF NOT EXISTS \"Operation\" (\"Description\" TEXT, \"Timestamp\" INTEGER)"; + cmd.ExecuteNonQuery(); + + var shouldTrigger = (bool)module.GetType().GetMethod("ShouldTriggerSync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).Invoke(module, new object[] { 0 }); + Assert.IsTrue(shouldTrigger); + } + + [Test] + [Category("RemoteSync")] + public void TestShouldTriggerSync_Counting_FirstTime() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest", + ["remote-sync-mode"] = "counting", + ["remote-sync-count"] = "2", + ["dbpath"] = DBFILE + }; + module.Configure(options); + + // Create Operation table and simulate 2 backups + using var db = SQLiteLoader.LoadConnection(DBFILE); + using var cmd = db.CreateCommand(); + cmd.CommandText = "CREATE TABLE IF NOT EXISTS \"Operation\" (\"Description\" TEXT, \"Timestamp\" INTEGER)"; + cmd.ExecuteNonQuery(); + cmd.CommandText = "INSERT INTO \"Operation\" (\"Description\", \"Timestamp\") VALUES ('Backup', @ts)"; + cmd.AddNamedParameter("@ts", Duplicati.Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow)); + cmd.ExecuteNonQuery(); + cmd.ExecuteNonQuery(); + + var shouldTrigger = (bool)module.GetType().GetMethod("ShouldTriggerSync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).Invoke(module, new object[] { 0 }); + Assert.IsTrue(shouldTrigger); + } + + [Test] + [Category("RemoteSync")] + public void TestRecordSyncOperation() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest", + ["dbpath"] = DBFILE + }; + module.Configure(options); + + // Create Operation table + using var db = SQLiteLoader.LoadConnection(DBFILE); + using var cmd = db.CreateCommand(); + cmd.CommandText = "CREATE TABLE IF NOT EXISTS \"Operation\" (\"Description\" TEXT, \"Timestamp\" INTEGER)"; + cmd.ExecuteNonQuery(); + + module.GetType().GetMethod("RecordSyncOperation", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).Invoke(module, new object[] { 0 }); + + cmd.CommandText = "SELECT COUNT(*) FROM \"Operation\" WHERE \"Description\" = 'Rsync 0'"; + var count = (long)cmd.ExecuteScalar(); + Assert.AreEqual(1, count); + } + + [Test] + [Category("RemoteSync")] + public void TestBuildArguments() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest", + ["remote-sync-force"] = "true", + ["remote-sync-retention"] = "true" + }; + module.Configure(options); + + // Set m_source + module.GetType().GetField("m_source", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).SetValue(module, "file:///source"); + + var args = module.GetType().GetMethod("BuildArguments", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).Invoke(module, new object[] { "file:///dest" }) as string[]; + + Assert.IsTrue(args.Contains("file:///source")); + Assert.IsTrue(args.Contains("file:///dest")); + Assert.IsTrue(args.Contains("--force")); + Assert.IsTrue(args.Contains("--retention")); + Assert.IsTrue(args.Contains("--auto-create-folders")); + } + + [Test] + [Category("RemoteSync")] + public void TestOnFinish_SuccessfulBackup_TriggersSync() + { + // Create some test files + System.IO.File.WriteAllText(System.IO.Path.Combine(DATAFOLDER, "testfile.txt"), "test content"); + + var options = TestOptions; + options["remote-sync-dst"] = "file://" + System.IO.Path.Combine(BASEFOLDER, "syncdest"); + options["dbpath"] = DBFILE; + + // Run actual backup + using (var console = new CommandLine.ConsoleOutput(Console.Out, options)) + using (var controller = new Duplicati.Library.Main.Controller("file://" + TARGETFOLDER, options, console)) + { + var result = controller.Backup(new string[] { DATAFOLDER }); + + // Now check if sync was triggered (assuming module was loaded) + // But since we can't easily get the module instance, check if Operation table has sync entry + using var db = SQLiteLoader.LoadConnection(DBFILE); + using var cmd = db.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM \"Operation\" WHERE \"Description\" LIKE 'Rsync %'"; + var count = (long)cmd.ExecuteScalar(); + Assert.AreEqual(1, count); + } + } + + [Test] + [Category("RemoteSync")] + public void TestOnFinish_FailedBackup_SkipsSync() + { + var module = new RemoteSynchronizationModule(); + var options = new Dictionary + { + ["remote-sync-dst"] = "file:///test/dest", + ["dbpath"] = DBFILE + }; + module.Configure(options); + string remoteurl = "file:///source"; + string[] localpath = new string[0]; + module.OnStart("Backup", ref remoteurl, ref localpath); + + // Create Operation table + using var db = SQLiteLoader.LoadConnection(DBFILE); + using var cmd = db.CreateCommand(); + cmd.CommandText = "CREATE TABLE IF NOT EXISTS \"Operation\" (\"Description\" TEXT, \"Timestamp\" INTEGER)"; + cmd.ExecuteNonQuery(); + + var result = new TestBasicResults(ParsedResultType.Error); + module.OnFinish(result, null); + + // Check if sync was not recorded + cmd.CommandText = "SELECT COUNT(*) FROM \"Operation\" WHERE \"Description\" = 'Rsync 0'"; + var count = (long)cmd.ExecuteScalar(); + Assert.AreEqual(0, count); + } + } + + /// + /// Simple implementation of IBasicResults for testing. + /// + public class TestBasicResults : IBasicResults + { + public TestBasicResults(ParsedResultType parsedResult) + { + ParsedResult = parsedResult; + } + + public DateTime BeginTime => DateTime.UtcNow; + public DateTime EndTime => DateTime.UtcNow; + public TimeSpan Duration => TimeSpan.Zero; + public IEnumerable Errors => new string[0]; + public IEnumerable Warnings => new string[0]; + public IEnumerable Messages => new string[0]; + public ParsedResultType ParsedResult { get; } + public bool Interrupted => false; + } +} \ No newline at end of file