Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions SysManager/SysManager.Tests/FileShredderServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// SysManager · FileShredderServiceTests
// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager
// License: MIT

using System.IO;
using System.Security;
using SysManager.Services;

namespace SysManager.Tests;

/// <summary>
/// Regression coverage for <see cref="FileShredderService"/> — the most
/// data-loss-sensitive guard in the app (audit finding tests #1, P0).
/// <para>
/// <see cref="FileShredderService.ShredFileAsync"/> /
/// <c>ShredFolderAsync</c> reject any path under a system-protected root
/// (Windows, System32, Program Files, Program Files (x86)) by throwing
/// <see cref="SecurityException"/>. Before these tests a refactor of the
/// prefix list or the <c>StartsWith</c> comparison could have silently
/// allowed shredding system files with no test failing.
/// </para>
/// The positive paths operate on real temp files because the API is
/// filesystem-bound; each test cleans up after itself.
/// </summary>
public class FileShredderServiceTests
{
private static FileShredderService NewService() => new();

private static string ProtectedRoot(Environment.SpecialFolder folder) =>
Environment.GetFolderPath(folder);

// ---------- denylist: protected roots rejected (P0) ----------

public static IEnumerable<object[]> ProtectedRoots()
{
// A file directly under each protected root.
yield return [Path.Combine(ProtectedRoot(Environment.SpecialFolder.Windows), "smtest_should_never_shred.dat")];
yield return [Path.Combine(ProtectedRoot(Environment.SpecialFolder.Windows), "System32", "smtest_should_never_shred.dat")];
yield return [Path.Combine(ProtectedRoot(Environment.SpecialFolder.ProgramFiles), "smtest_should_never_shred.dat")];
yield return [Path.Combine(ProtectedRoot(Environment.SpecialFolder.ProgramFilesX86), "smtest_should_never_shred.dat")];
}

[Theory]
[MemberData(nameof(ProtectedRoots))]
public async Task ShredFileAsync_UnderProtectedRoot_ThrowsSecurityException(string path)
{
var svc = NewService();
await Assert.ThrowsAsync<SecurityException>(
() => svc.ShredFileAsync(path, ShredMethod.Quick, null, CancellationToken.None));
}

[Theory]
[MemberData(nameof(ProtectedRoots))]
public async Task ShredFolderAsync_UnderProtectedRoot_ThrowsSecurityException(string path)
{
var svc = NewService();
await Assert.ThrowsAsync<SecurityException>(
() => svc.ShredFolderAsync(path, ShredMethod.Quick, null, CancellationToken.None));
}

[Fact]
public async Task ShredFileAsync_ProtectedRoot_IsCaseInsensitive()
{
// The guard uses OrdinalIgnoreCase; a lowercased system path must still be blocked.
var svc = NewService();
var sys32 = Path.Combine(ProtectedRoot(Environment.SpecialFolder.Windows), "System32");
var lowered = Path.Combine(sys32.ToLowerInvariant(), "smtest_should_never_shred.dat");

await Assert.ThrowsAsync<SecurityException>(
() => svc.ShredFileAsync(lowered, ShredMethod.Quick, null, CancellationToken.None));
}

// ---------- argument / existence guards ----------

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task ShredFileAsync_NullOrWhitespace_ThrowsArgumentException(string? path)
{
var svc = NewService();
// ThrowIfNullOrWhiteSpace throws ArgumentNullException for null and
// ArgumentException for empty/whitespace — ThrowsAny accepts both.
await Assert.ThrowsAnyAsync<ArgumentException>(
() => svc.ShredFileAsync(path!, ShredMethod.Quick, null, CancellationToken.None));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task ShredFolderAsync_NullOrWhitespace_ThrowsArgumentException(string? path)
{
var svc = NewService();
await Assert.ThrowsAnyAsync<ArgumentException>(
() => svc.ShredFolderAsync(path!, ShredMethod.Quick, null, CancellationToken.None));
}

[Fact]
public async Task ShredFileAsync_MissingFile_ThrowsFileNotFound()
{
var svc = NewService();
var missing = Path.Combine(Path.GetTempPath(), "smtest_missing_" + Guid.NewGuid().ToString("N") + ".dat");

await Assert.ThrowsAsync<FileNotFoundException>(
() => svc.ShredFileAsync(missing, ShredMethod.Quick, null, CancellationToken.None));
}

[Fact]
public async Task ShredFolderAsync_MissingFolder_ThrowsDirectoryNotFound()
{
var svc = NewService();
var missing = Path.Combine(Path.GetTempPath(), "smtest_missingdir_" + Guid.NewGuid().ToString("N"));

await Assert.ThrowsAsync<DirectoryNotFoundException>(
() => svc.ShredFolderAsync(missing, ShredMethod.Quick, null, CancellationToken.None));
}

// ---------- positive path: a real temp file is overwritten and removed ----------

[Fact]
public async Task ShredFileAsync_TempFile_QuickMethod_DeletesFile()
{
var svc = NewService();
var file = Path.Combine(Path.GetTempPath(), "smtest_shred_" + Guid.NewGuid().ToString("N") + ".dat");
await File.WriteAllTextAsync(file, "sensitive data that must be destroyed");

try
{
Assert.True(File.Exists(file));

await svc.ShredFileAsync(file, ShredMethod.Quick, null, CancellationToken.None);

Assert.False(File.Exists(file), "Quick shred did not remove the file");
}
finally
{
if (File.Exists(file)) File.Delete(file);
}
}

[Fact]
public async Task ShredFileAsync_ReportsProgressToCompletion()
{
var svc = NewService();
var file = Path.Combine(Path.GetTempPath(), "smtest_progress_" + Guid.NewGuid().ToString("N") + ".dat");
await File.WriteAllTextAsync(file, "some bytes");
var reports = new List<int>();
var progress = new Progress<int>(reports.Add);

try
{
await svc.ShredFileAsync(file, ShredMethod.Standard, progress, CancellationToken.None);

// Progress is marshaled via the captured SynchronizationContext; give it a beat.
await Task.Delay(50);
Assert.Contains(100, reports);
}
finally
{
if (File.Exists(file)) File.Delete(file);
}
}

[Fact]
public async Task ShredFolderAsync_TempFolder_RemovesFolderAndContents()
{
var svc = NewService();
var dir = Path.Combine(Path.GetTempPath(), "smtest_shreddir_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var nested = Path.Combine(dir, "nested");
Directory.CreateDirectory(nested);
await File.WriteAllTextAsync(Path.Combine(dir, "a.dat"), "aaa");
await File.WriteAllTextAsync(Path.Combine(nested, "b.dat"), "bbb");

try
{
await svc.ShredFolderAsync(dir, ShredMethod.Quick, null, CancellationToken.None);

Assert.False(Directory.Exists(dir), "Folder was not removed after shred");
}
finally
{
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}

[Fact]
public async Task ShredFileAsync_AlreadyCancelledToken_DoesNotDeleteFile()
{
var svc = NewService();
var file = Path.Combine(Path.GetTempPath(), "smtest_cancel_" + Guid.NewGuid().ToString("N") + ".dat");
await File.WriteAllTextAsync(file, "keep me, the operation was cancelled");
using var cts = new CancellationTokenSource();
cts.Cancel();

try
{
await Assert.ThrowsAsync<OperationCanceledException>(
() => svc.ShredFileAsync(file, ShredMethod.Quick, null, cts.Token));

Assert.True(File.Exists(file), "File was deleted despite cancellation before any pass ran");
}
finally
{
if (File.Exists(file)) File.Delete(file);
}
}
}
89 changes: 88 additions & 1 deletion SysManager/SysManager.Tests/FileShredderViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager
// License: MIT

using System.IO;
using NSubstitute;
using SysManager.Models;
using SysManager.Services;
using SysManager.ViewModels;
Expand All @@ -11,7 +13,9 @@ namespace SysManager.Tests;

/// <summary>
/// Tests for <see cref="FileShredderViewModel"/>. Verifies initial state,
/// item management, and default configuration without touching the file system.
/// item management, default configuration, and that the irreversible
/// ShredAll command routes through <see cref="DialogService.Instance"/>.Confirm
/// (audit finding tests #2 — the "every destructive op needs Confirm" contract).
/// </summary>
public class FileShredderViewModelTests
{
Expand Down Expand Up @@ -89,4 +93,87 @@ public void Items_CanAddMultiple()
vm.Items.Add(new ShredItem { Path = @"C:\folder", Name = "folder", SizeBytes = 5000, IsFolder = true });
Assert.Equal(3, vm.Items.Count);
}

// ---------- irreversible-shred confirmation gate (audit tests #2) ----------

[Fact]
public async Task ShredAll_WhenUserDeclinesConfirm_ShredsNothing()
{
var file = Path.Combine(Path.GetTempPath(), "smtest_shred_no_" + Guid.NewGuid().ToString("N") + ".dat");
File.WriteAllText(file, "must survive — user declined");

var prevDialog = DialogService.Instance;
var dialog = Substitute.For<IDialogService>();
dialog.Confirm(Arg.Any<string>(), Arg.Any<string>()).Returns(false); // user clicks "No"
DialogService.Instance = dialog;
try
{
var vm = CreateVm();
vm.Items.Add(new ShredItem
{
Path = file, Name = Path.GetFileName(file), SizeBytes = 1, IsFolder = false
});

await vm.ShredAllCommand.ExecuteAsync(null);

dialog.Received(1).Confirm(Arg.Any<string>(), Arg.Any<string>());
Assert.True(File.Exists(file), "File was shredded even though the user declined the confirmation");
Assert.Single(vm.Items); // item left in place — nothing happened
}
finally
{
DialogService.Instance = prevDialog;
if (File.Exists(file)) File.Delete(file);
}
}

[Fact]
public async Task ShredAll_WhenUserConfirms_ShredsSelectedFile()
{
var file = Path.Combine(Path.GetTempPath(), "smtest_shred_yes_" + Guid.NewGuid().ToString("N") + ".dat");
File.WriteAllText(file, "destroy me — user confirmed");

var prevDialog = DialogService.Instance;
var dialog = Substitute.For<IDialogService>();
dialog.Confirm(Arg.Any<string>(), Arg.Any<string>()).Returns(true); // user clicks "Yes"
DialogService.Instance = dialog;
try
{
var vm = CreateVm();
vm.Items.Add(new ShredItem
{
Path = file, Name = Path.GetFileName(file), SizeBytes = 1, IsFolder = false
});

await vm.ShredAllCommand.ExecuteAsync(null);

dialog.Received(1).Confirm(Arg.Any<string>(), Arg.Any<string>());
Assert.False(File.Exists(file), "File survived even though the user confirmed the shred");
}
finally
{
DialogService.Instance = prevDialog;
if (File.Exists(file)) File.Delete(file);
}
}

[Fact]
public async Task ShredAll_WithNoItems_NeverPromptsConfirm()
{
var prevDialog = DialogService.Instance;
var dialog = Substitute.For<IDialogService>();
DialogService.Instance = dialog;
try
{
var vm = CreateVm(); // Items empty

await vm.ShredAllCommand.ExecuteAsync(null);

dialog.DidNotReceive().Confirm(Arg.Any<string>(), Arg.Any<string>());
}
finally
{
DialogService.Instance = prevDialog;
}
}
}
Loading