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; + } + } }