diff --git a/SysManager/SysManager.Tests/FileShredderServiceTests.cs b/SysManager/SysManager.Tests/FileShredderServiceTests.cs
new file mode 100644
index 0000000..ceb0ad0
--- /dev/null
+++ b/SysManager/SysManager.Tests/FileShredderServiceTests.cs
@@ -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;
+
+///
+/// Regression coverage for — the most
+/// data-loss-sensitive guard in the app (audit finding tests #1, P0).
+///
+/// /
+/// ShredFolderAsync reject any path under a system-protected root
+/// (Windows, System32, Program Files, Program Files (x86)) by throwing
+/// . Before these tests a refactor of the
+/// prefix list or the StartsWith comparison could have silently
+/// allowed shredding system files with no test failing.
+///
+/// The positive paths operate on real temp files because the API is
+/// filesystem-bound; each test cleans up after itself.
+///
+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 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(
+ () => 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(
+ () => 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(
+ () => 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(
+ () => 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(
+ () => 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(
+ () => 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(
+ () => 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();
+ var progress = new Progress(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(
+ () => 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);
+ }
+ }
+}
diff --git a/SysManager/SysManager.Tests/FileShredderViewModelTests.cs b/SysManager/SysManager.Tests/FileShredderViewModelTests.cs
index 48c1de5..ed723cd 100644
--- a/SysManager/SysManager.Tests/FileShredderViewModelTests.cs
+++ b/SysManager/SysManager.Tests/FileShredderViewModelTests.cs
@@ -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;
@@ -11,7 +13,9 @@ namespace SysManager.Tests;
///
/// Tests for . 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 .Confirm
+/// (audit finding tests #2 — the "every destructive op needs Confirm" contract).
///
public class FileShredderViewModelTests
{
@@ -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();
+ dialog.Confirm(Arg.Any(), Arg.Any()).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(), Arg.Any());
+ 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();
+ dialog.Confirm(Arg.Any(), Arg.Any()).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(), Arg.Any());
+ 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();
+ DialogService.Instance = dialog;
+ try
+ {
+ var vm = CreateVm(); // Items empty
+
+ await vm.ShredAllCommand.ExecuteAsync(null);
+
+ dialog.DidNotReceive().Confirm(Arg.Any(), Arg.Any());
+ }
+ finally
+ {
+ DialogService.Instance = prevDialog;
+ }
+ }
}