402 lines
21 KiB
C#
402 lines
21 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.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Duplicati.Library.Interface;
|
|
using Duplicati.Library.Modules.Builtin;
|
|
using NUnit.Framework;
|
|
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
|
|
|
#nullable enable
|
|
|
|
namespace Duplicati.UnitTest
|
|
{
|
|
/// <summary>
|
|
/// Unit tests for the <see cref="HttpReportStatus"/> reporting module.
|
|
/// These tests exercise the module's logic directly (no network I/O) by
|
|
/// overriding <see cref="HttpReportStatus.SendAsync"/> to capture the
|
|
/// posted JSON payloads.
|
|
/// </summary>
|
|
[Category("ReportModule")]
|
|
public class HttpReportStatusTests
|
|
{
|
|
/// <summary>
|
|
/// A subclass of <see cref="HttpReportStatus"/> that records every JSON
|
|
/// payload posted via <see cref="HttpReportStatus.SendAsync"/> instead of
|
|
/// performing real network I/O.
|
|
/// </summary>
|
|
private sealed class CapturingHttpReportStatus : HttpReportStatus
|
|
{
|
|
public List<string> PostedJsons { get; } = new();
|
|
public List<HttpReportStatus.StatusReport> Reports { get; } = new();
|
|
|
|
private static readonly JsonSerializerOptions DeserializerOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
protected override Task SendAsync(string url, string json, CancellationToken cancellationToken)
|
|
{
|
|
PostedJsons.Add(json);
|
|
Reports.Add(JsonSerializer.Deserialize<HttpReportStatus.StatusReport>(json, DeserializerOptions)!);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private static CapturingHttpReportStatus CreateConfigured(string interval = "1s", int maxLogLines = 20)
|
|
{
|
|
var module = new CapturingHttpReportStatus();
|
|
module.Configure(new Dictionary<string, string>
|
|
{
|
|
["http-report-status-url"] = "http://localhost/example",
|
|
["http-report-status-interval"] = interval,
|
|
["http-report-status-max-log-lines"] = maxLogLines.ToString(),
|
|
});
|
|
return module;
|
|
}
|
|
|
|
private static ReportProgressSnapshot SampleSnapshot(string phase = "Backup_ProcessingFiles", float progress = 0.5f)
|
|
=> new ReportProgressSnapshot(
|
|
phase, progress,
|
|
3, 100, 10, 1000,
|
|
false,
|
|
"/some/file.txt", 100, 50,
|
|
Array.Empty<ReportBackendEvent>());
|
|
|
|
[Test]
|
|
public async Task InactiveWithoutUrlAsync()
|
|
{
|
|
var module = new CapturingHttpReportStatus();
|
|
module.Configure(new Dictionary<string, string>());
|
|
// An unconfigured module reports itself as inactive, so the engine skips it.
|
|
Assert.IsFalse(module.IsActive, "Module should be inactive without a URL");
|
|
// All callbacks should be no-ops without a configured URL.
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
await module.OnBackendEventAsync(new ReportBackendEvent("Put", "Started", "/p", 1), CancellationToken.None);
|
|
await module.OnLogEntryAsync(new ReportLogEntry("hi", "Information", "tag", "id", DateTime.UtcNow, null), CancellationToken.None);
|
|
await module.OnProgressTickAsync(SampleSnapshot(), CancellationToken.None);
|
|
await module.OnOperationCompletedAsync(null!, null, CancellationToken.None);
|
|
Assert.AreEqual(0, module.PostedJsons.Count);
|
|
}
|
|
|
|
[Test]
|
|
public void IsActiveReflectsConfiguration()
|
|
{
|
|
var unconfigured = new HttpReportStatus();
|
|
unconfigured.Configure(new Dictionary<string, string>());
|
|
Assert.IsFalse(unconfigured.IsActive);
|
|
|
|
using var configured = new HttpReportStatus();
|
|
configured.Configure(new Dictionary<string, string>
|
|
{
|
|
["http-report-status-url"] = "http://localhost/example",
|
|
});
|
|
Assert.IsTrue(configured.IsActive);
|
|
}
|
|
|
|
[Test]
|
|
public async Task StartedAndCompletedArePostedAsync()
|
|
{
|
|
using var module = CreateConfigured();
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
await module.OnOperationCompletedAsync(null!, null, CancellationToken.None);
|
|
|
|
Assert.AreEqual(2, module.Reports.Count);
|
|
Assert.AreEqual("Started", module.Reports[0].Status);
|
|
Assert.AreEqual("Backup", module.Reports[0].Operation);
|
|
Assert.AreEqual("Completed", module.Reports[1].Status);
|
|
// The completed report carries a final progress snapshot at 100%.
|
|
Assert.IsNotNull(module.Reports[1].Progress);
|
|
Assert.AreEqual(1f, module.Reports[1].Progress!.Progress);
|
|
// The Started report is not completed, the Completed report is.
|
|
Assert.IsFalse(module.Reports[0].IsCompleted, "Started report should not be completed");
|
|
Assert.IsTrue(module.Reports[1].IsCompleted, "Completed report should be marked completed");
|
|
// The JSON is serialized with camelCase property names.
|
|
Assert.That(module.PostedJsons[0], Does.Contain("\"operation\""), "JSON keys should be camelCase");
|
|
Assert.That(module.PostedJsons[0], Does.Contain("\"startedUtc\""));
|
|
Assert.That(module.PostedJsons[0], Does.Not.Contain("\"StartedUtc\""));
|
|
}
|
|
|
|
[Test]
|
|
public async Task FailedReportIsCompletedAsync()
|
|
{
|
|
using var module = CreateConfigured();
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
await module.OnOperationCompletedAsync(null!, new InvalidOperationException("boom"), CancellationToken.None);
|
|
|
|
Assert.AreEqual("Failed", module.Reports[1].Status);
|
|
Assert.IsTrue(module.Reports[1].IsCompleted, "Failed report should be marked completed");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CompletedCarriesFailureMessageAsync()
|
|
{
|
|
using var module = CreateConfigured();
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
await module.OnOperationCompletedAsync(null!, new InvalidOperationException("boom"), CancellationToken.None);
|
|
|
|
Assert.AreEqual("Failed", module.Reports[1].Status);
|
|
Assert.AreEqual("boom", module.Reports[1].ErrorMessage);
|
|
}
|
|
|
|
[Test]
|
|
public async Task BackendEventsAndLogEntriesAreCountedAsync()
|
|
{
|
|
using var module = CreateConfigured();
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
await module.OnBackendEventAsync(new ReportBackendEvent("Put", "Started", "/a", 1), CancellationToken.None);
|
|
await module.OnBackendEventAsync(new ReportBackendEvent("Put", "Completed", "/a", 1), CancellationToken.None);
|
|
await module.OnLogEntryAsync(new ReportLogEntry("line1", "Information", "t", "i", DateTime.UtcNow, null), CancellationToken.None);
|
|
await module.OnLogEntryAsync(new ReportLogEntry("line2", "Warning", "t", "i", DateTime.UtcNow, null), CancellationToken.None);
|
|
await module.OnProgressTickAsync(SampleSnapshot(), CancellationToken.None);
|
|
|
|
// The progress tick should have produced one Progress report (interval is 1s).
|
|
var progressReport = module.Reports.Find(r => r.Status == "Progress");
|
|
Assert.IsNotNull(progressReport, "A progress report should be posted on the first tick");
|
|
Assert.AreEqual(2, progressReport!.BackendEvents, "Two backend events were observed");
|
|
Assert.AreEqual(2, progressReport.LogEntries, "Two log entries were observed");
|
|
Assert.AreEqual(2, progressReport.RecentLogLines.Count, "Both log lines are buffered");
|
|
Assert.AreEqual("line1", progressReport.RecentLogLines[0]);
|
|
Assert.AreEqual("line2", progressReport.RecentLogLines[1]);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ProgressSnapshotIsForwardedAsync()
|
|
{
|
|
using var module = CreateConfigured();
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
var snapshot = SampleSnapshot("Backup_ProcessingFiles", 0.25f);
|
|
await module.OnProgressTickAsync(snapshot, CancellationToken.None);
|
|
|
|
var progressReport = module.Reports.Find(r => r.Status == "Progress")!;
|
|
Assert.IsNotNull(progressReport.Progress);
|
|
Assert.AreEqual("Backup_ProcessingFiles", progressReport.Progress!.Phase);
|
|
Assert.AreEqual(0.25f, progressReport.Progress.Progress);
|
|
Assert.AreEqual(3, progressReport.Progress.FilesProcessed);
|
|
Assert.AreEqual("/some/file.txt", progressReport.Progress.CurrentFilename);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ProgressReportsAreThrottledByIntervalAsync()
|
|
{
|
|
var module = CreateConfigured(interval: "1h"); // very long interval => only the first tick posts
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
await module.OnProgressTickAsync(SampleSnapshot(), CancellationToken.None); // posts
|
|
await module.OnProgressTickAsync(SampleSnapshot(), CancellationToken.None); // throttled
|
|
await module.OnProgressTickAsync(SampleSnapshot(), CancellationToken.None); // throttled
|
|
|
|
var progressReports = module.Reports.FindAll(r => r.Status == "Progress");
|
|
Assert.AreEqual(1, progressReports.Count, "Only the first tick should post within the long interval");
|
|
}
|
|
|
|
[Test]
|
|
public async Task RecentLogLinesAreCappedAsync()
|
|
{
|
|
var module = CreateConfigured(maxLogLines: 2);
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
await module.OnLogEntryAsync(new ReportLogEntry("a", "Information", "t", "i", DateTime.UtcNow, null), CancellationToken.None);
|
|
await module.OnLogEntryAsync(new ReportLogEntry("b", "Information", "t", "i", DateTime.UtcNow, null), CancellationToken.None);
|
|
await module.OnLogEntryAsync(new ReportLogEntry("c", "Information", "t", "i", DateTime.UtcNow, null), CancellationToken.None);
|
|
await module.OnProgressTickAsync(SampleSnapshot(), CancellationToken.None);
|
|
|
|
var progressReport = module.Reports.Find(r => r.Status == "Progress")!;
|
|
Assert.AreEqual(2, progressReport.RecentLogLines.Count, "Only the last 2 log lines are kept");
|
|
Assert.AreEqual("b", progressReport.RecentLogLines[0]);
|
|
Assert.AreEqual("c", progressReport.RecentLogLines[1]);
|
|
}
|
|
|
|
[Test]
|
|
public void SupportedCommandsIncludeUrlAndInterval()
|
|
{
|
|
var module = new HttpReportStatus();
|
|
var keys = new HashSet<string>();
|
|
foreach (var cmd in module.SupportedCommands)
|
|
keys.Add(cmd.Name);
|
|
|
|
Assert.IsTrue(keys.Contains("http-report-status-url"));
|
|
Assert.IsTrue(keys.Contains("http-report-status-interval"));
|
|
Assert.IsTrue(keys.Contains("http-report-status-max-log-lines"));
|
|
Assert.IsTrue(keys.Contains("http-report-status-allow-paths-in-log-messages"));
|
|
}
|
|
|
|
[Test]
|
|
public void ModuleMetadata()
|
|
{
|
|
var module = new HttpReportStatus();
|
|
Assert.AreEqual("httpreportstatus", module.Key);
|
|
// The module is loaded by default but stays inactive (see IsActive) until a URL
|
|
// is configured, so there is no overhead unless it actually has something to do.
|
|
Assert.IsTrue(module.LoadAsDefault, "The module should load by default");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReportIncludesEnvironmentMetadataAsync()
|
|
{
|
|
var module = new CapturingHttpReportStatus();
|
|
module.Configure(new Dictionary<string, string>
|
|
{
|
|
["http-report-status-url"] = "http://localhost/example",
|
|
["http-report-status-interval"] = "1s",
|
|
["machine-id"] = "test-machine-id",
|
|
});
|
|
|
|
// The remote URL is captured via the IGenericCallbackModule.OnStart hook, mirroring
|
|
// ReportHelper. Drive it to populate the backup id and destination type.
|
|
var remoteUrl = "s3://mybucket/path";
|
|
var localPaths = new[] { "/data/source" };
|
|
module.OnStart("Backup", ref remoteUrl, ref localPaths);
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
|
|
var report = module.Reports[0];
|
|
Assert.IsNotNull(report.Metadata, "Report should include environment metadata");
|
|
var metadata = report.Metadata!;
|
|
Assert.IsFalse(string.IsNullOrEmpty(metadata.DuplicatiVersion), "DuplicatiVersion should be populated");
|
|
Assert.IsFalse(string.IsNullOrEmpty(metadata.MachineId), "MachineId should be populated");
|
|
Assert.IsFalse(string.IsNullOrEmpty(metadata.BackupId), "BackupId should be populated from the remote URL");
|
|
Assert.IsFalse(string.IsNullOrEmpty(metadata.BackupName), "BackupName should be populated");
|
|
Assert.IsFalse(string.IsNullOrEmpty(metadata.MachineName), "MachineName should be populated");
|
|
Assert.AreEqual("s3", metadata.DestinationType, "DestinationType should be the remote URL scheme");
|
|
Assert.IsFalse(string.IsNullOrEmpty(metadata.OperatingSystem), "OperatingSystem should be populated");
|
|
Assert.IsFalse(string.IsNullOrEmpty(metadata.OperatingSystemDetailed), "OperatingSystemDetailed should be populated");
|
|
Assert.IsFalse(string.IsNullOrEmpty(metadata.InstallationType), "InstallationType should be populated");
|
|
|
|
// The backup id is a stable hex string derived from the remote URL.
|
|
Assert.That(metadata.BackupId, Does.Match("^[0-9a-fA-F]+$"));
|
|
|
|
// An s3 URL against a known public-cloud host yields a safe host suffix; an
|
|
// unknown host yields null. The helper is shared with ReportHelper.
|
|
Assert.IsNull(metadata.DestinationHostSuffix, "DestinationHostSuffix should be null for an unknown host");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DestinationHostSuffixKnownCloudAsync()
|
|
{
|
|
using var module = CreateConfigured();
|
|
|
|
// An s3 URL against a recognized public-cloud host suffix yields that suffix.
|
|
var remoteUrl = "s3://mybucket.s3.amazonaws.com/path";
|
|
var localPaths = new[] { "/data/source" };
|
|
module.OnStart("Backup", ref remoteUrl, ref localPaths);
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
|
|
var metadata = module.Reports[0].Metadata!;
|
|
Assert.AreEqual(".amazonaws.com", metadata.DestinationHostSuffix,
|
|
"DestinationHostSuffix should be the matched safe cloud suffix");
|
|
}
|
|
|
|
[Test]
|
|
public async Task MetadataHonorsOptionOverridesAsync()
|
|
{
|
|
// MachineId, BackupId, BackupName and MachineName honor explicit option values
|
|
// before falling back to the computed defaults, mirroring ReportHelper.
|
|
var module = new CapturingHttpReportStatus();
|
|
module.Configure(new Dictionary<string, string>
|
|
{
|
|
["http-report-status-url"] = "http://localhost/example",
|
|
["http-report-status-interval"] = "1s",
|
|
["machine-id"] = "custom-machine-id",
|
|
["backup-id"] = "custom-backup-id",
|
|
["backup-name"] = "custom-backup-name",
|
|
["machine-name"] = "custom-machine-name",
|
|
});
|
|
|
|
var remoteUrl = "s3://mybucket/path";
|
|
var localPaths = new[] { "/data/source" };
|
|
module.OnStart("Backup", ref remoteUrl, ref localPaths);
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
|
|
var metadata = module.Reports[0].Metadata!;
|
|
Assert.AreEqual("custom-machine-id", metadata.MachineId, "MachineId should use the option override");
|
|
Assert.AreEqual("custom-backup-id", metadata.BackupId, "BackupId should use the option override");
|
|
Assert.AreEqual("custom-backup-name", metadata.BackupName, "BackupName should use the option override");
|
|
Assert.AreEqual("custom-machine-name", metadata.MachineName, "MachineName should use the option override");
|
|
}
|
|
|
|
[Test]
|
|
public async Task PathsInLogLinesAreRedactedByDefaultAsync()
|
|
{
|
|
using var module = CreateConfigured();
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
await module.OnLogEntryAsync(
|
|
new ReportLogEntry("Opening /Users/me/secret/file.txt", "Information", "t", "i", DateTime.UtcNow, null),
|
|
CancellationToken.None);
|
|
await module.OnProgressTickAsync(SampleSnapshot(), CancellationToken.None);
|
|
|
|
var progressReport = module.Reports.Find(r => r.Status == "Progress")!;
|
|
Assert.AreEqual(1, progressReport.RecentLogLines.Count);
|
|
Assert.That(progressReport.RecentLogLines[0], Does.Contain("-redacted-"),
|
|
"Paths should be redacted from log lines by default");
|
|
Assert.That(progressReport.RecentLogLines[0], Does.Not.Contain("/Users/me/secret/file.txt"),
|
|
"The original path must not appear in the buffered log line");
|
|
}
|
|
|
|
[Test]
|
|
public async Task PathsInLogLinesKeptWhenAllowedByModuleOptionAsync()
|
|
{
|
|
using var module = new CapturingHttpReportStatus();
|
|
module.Configure(new Dictionary<string, string>
|
|
{
|
|
["http-report-status-url"] = "http://localhost/example",
|
|
["http-report-status-interval"] = "1s",
|
|
["http-report-status-allow-paths-in-log-messages"] = "true",
|
|
});
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
var path = OperatingSystem.IsWindows() ? @"C:\Users\me\secret\file.txt" : "/Users/me/secret/file.txt";
|
|
await module.OnLogEntryAsync(
|
|
new ReportLogEntry("Opening " + path, "Information", "t", "i", DateTime.UtcNow, null),
|
|
CancellationToken.None);
|
|
await module.OnProgressTickAsync(SampleSnapshot(), CancellationToken.None);
|
|
|
|
var progressReport = module.Reports.Find(r => r.Status == "Progress")!;
|
|
Assert.AreEqual(1, progressReport.RecentLogLines.Count);
|
|
Assert.That(progressReport.RecentLogLines[0], Does.Contain(path),
|
|
"The original path should be kept when the module option is enabled");
|
|
}
|
|
|
|
[Test]
|
|
public async Task PathsInLogLinesKeptWhenAllowedByGlobalOptionAsync()
|
|
{
|
|
using var module = new CapturingHttpReportStatus();
|
|
module.Configure(new Dictionary<string, string>
|
|
{
|
|
["http-report-status-url"] = "http://localhost/example",
|
|
["http-report-status-interval"] = "1s",
|
|
["allow-paths-in-log-messages"] = "true",
|
|
});
|
|
await module.OnOperationStartedAsync("Backup", null!, CancellationToken.None);
|
|
var path = OperatingSystem.IsWindows() ? @"C:\Users\me\secret\file.txt" : "/Users/me/secret/file.txt";
|
|
await module.OnLogEntryAsync(
|
|
new ReportLogEntry("Opening " + path, "Information", "t", "i", DateTime.UtcNow, null),
|
|
CancellationToken.None);
|
|
await module.OnProgressTickAsync(SampleSnapshot(), CancellationToken.None);
|
|
|
|
var progressReport = module.Reports.Find(r => r.Status == "Progress")!;
|
|
Assert.AreEqual(1, progressReport.RecentLogLines.Count);
|
|
Assert.That(progressReport.RecentLogLines[0], Does.Contain(path),
|
|
"The original path should be kept when the global option is enabled and the module option is not set");
|
|
}
|
|
}
|
|
}
|