// 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.
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.SQLiteHelper;
using Duplicati.Server.Database;
using Duplicati.Server.Serialization.Interface;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest
{
///
/// Tests for relative DBPath storage and resolution in the server database.
///
[TestFixture]
[Category("RelativeDbPath")]
public class RelativeDbPathTests
{
private string _tempDataFolder = null!;
private string _databasePath = null!;
private Connection _connection = null!;
[SetUp]
public async Task SetUpAsync()
{
_tempDataFolder = Path.Combine(Path.GetTempPath(), $"duplicati-relative-db-test-{Guid.NewGuid()}");
Directory.CreateDirectory(_tempDataFolder);
_databasePath = Path.Combine(_tempDataFolder, DataFolderManager.SERVER_DATABASE_FILENAME);
var dbConnection = await SQLiteLoader.LoadConnectionAsync(_databasePath);
DatabaseUpgrader.UpgradeDatabase(dbConnection, _databasePath, typeof(Library.RestAPI.Database.DatabaseSchemaMarker));
_connection = new Connection(dbConnection, true, null, _tempDataFolder, () => { });
}
[TearDown]
public void TearDown()
{
_connection?.Dispose();
if (Directory.Exists(_tempDataFolder))
{
try
{
Directory.Delete(_tempDataFolder, true);
}
catch
{
// Best effort cleanup
}
}
}
private static Backup CreateTestBackup()
{
return new Backup()
{
ID = null,
Name = "Test Backup",
Description = "Test Description",
Tags = new string[] { "test" },
TargetURL = "file:///test",
Sources = new string[] { "/test" },
Settings = new ISetting[0],
Filters = new IFilter[0],
Metadata = new Dictionary()
};
}
[Test]
public void NewBackup_StoresRelativeDbPath()
{
var backup = CreateTestBackup();
_connection.AddOrUpdateBackupAndSchedule(backup, null);
Assert.IsNotNull(backup.DBPath);
Assert.IsFalse(Path.IsPathRooted(backup.DBPath), "New backup DBPath should be stored as relative");
Assert.IsTrue(backup.DBPath.EndsWith(".sqlite", StringComparison.OrdinalIgnoreCase));
}
[Test]
public void LoadBackup_ResolvesRelativeDbPath()
{
var backup = CreateTestBackup();
_connection.AddOrUpdateBackupAndSchedule(backup, null);
var loadedBackup = _connection.GetBackup(backup.ID);
Assert.IsNotNull(loadedBackup);
Assert.IsTrue(Path.IsPathRooted(loadedBackup!.DBPath), "Loaded backup DBPath should be absolute");
Assert.AreEqual(Path.GetFullPath(Path.Combine(_tempDataFolder, backup.DBPath)), loadedBackup.DBPath);
}
[Test]
public void UpdateBackupDbPath_UnderDataFolder_StoresRelative()
{
var backup = CreateTestBackup();
_connection.AddOrUpdateBackupAndSchedule(backup, null);
var newPath = Path.Combine(_tempDataFolder, "moved-backup.sqlite");
File.WriteAllText(newPath, "");
_connection.UpdateBackupDBPath(backup, newPath);
var loadedBackup = _connection.GetBackup(backup.ID);
Assert.IsNotNull(loadedBackup);
// The loaded DBPath should be resolved to absolute
Assert.IsTrue(Path.IsPathRooted(loadedBackup!.DBPath));
Assert.AreEqual(Path.GetFileName(newPath), Path.GetFileName(loadedBackup.DBPath));
// Verify the raw stored value is relative by querying the database directly
using (var db = SQLiteLoader.LoadConnection(_databasePath))
using (var cmd = db.CreateCommand())
{
cmd.CommandText = @"SELECT ""DBPath"" FROM ""Backup"" WHERE ""ID"" = @id";
var param = cmd.CreateParameter();
param.ParameterName = "@id";
param.Value = long.Parse(backup.ID);
cmd.Parameters.Add(param);
var rawDbPath = cmd.ExecuteScalar()?.ToString() ?? "";
Assert.AreEqual("moved-backup.sqlite", rawDbPath);
}
}
[Test]
public void UpdateBackupDbPath_OutsideDataFolder_StoresAbsolute()
{
var backup = CreateTestBackup();
_connection.AddOrUpdateBackupAndSchedule(backup, null);
var outsideFolder = Path.Combine(Path.GetTempPath(), $"duplicati-outside-{Guid.NewGuid()}");
Directory.CreateDirectory(outsideFolder);
try
{
var newPath = Path.Combine(outsideFolder, "moved-backup.sqlite");
File.WriteAllText(newPath, "");
_connection.UpdateBackupDBPath(backup, newPath);
var loadedBackup = _connection.GetBackup(backup.ID);
Assert.IsNotNull(loadedBackup);
Assert.AreEqual(newPath, loadedBackup!.DBPath);
}
finally
{
Directory.Delete(outsideFolder, true);
}
}
[Test]
public void BackupsProperty_ResolvesRelativeDbPaths()
{
var backup1 = CreateTestBackup();
backup1.Name = "Backup 1";
var backup2 = CreateTestBackup();
backup2.Name = "Backup 2";
_connection.AddOrUpdateBackupAndSchedule(backup1, null);
_connection.AddOrUpdateBackupAndSchedule(backup2, null);
var backups = _connection.Backups;
Assert.AreEqual(2, backups.Length);
foreach (var bk in backups)
{
Assert.IsTrue(Path.IsPathRooted(bk.DBPath), $"Backup '{bk.Name}' DBPath should be absolute");
}
}
[Test]
public void ExistingAbsoluteDbPath_StaysAbsolute()
{
var outsideFolder = Path.Combine(Path.GetTempPath(), $"duplicati-outside-{Guid.NewGuid()}");
Directory.CreateDirectory(outsideFolder);
try
{
var absolutePath = Path.Combine(outsideFolder, "external-backup.sqlite");
File.WriteAllText(absolutePath, "");
var backup = CreateTestBackup();
backup.SetDBPath(absolutePath);
_connection.AddOrUpdateBackupAndSchedule(backup, null);
var loadedBackup = _connection.GetBackup(backup.ID);
Assert.IsNotNull(loadedBackup);
Assert.AreEqual(absolutePath, loadedBackup!.DBPath);
}
finally
{
Directory.Delete(outsideFolder, true);
}
}
}
}