From cf26074df08d051980a6c0ad205bbe5f05e33353 Mon Sep 17 00:00:00 2001 From: laurentiu021 Date: Mon, 8 Jun 2026 17:01:16 +0300 Subject: [PATCH 1/2] test: cover FileShredder denylist and ShredAll confirmation gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the highest-priority test gap from the audit (tests #1 P0, #2 P1): - New FileShredderServiceTests: asserts SecurityException for paths under every protected root (Windows, System32, Program Files, Program Files x86), a case-insensitive variant, ArgumentException on null/empty, FileNotFound / DirectoryNotFound on missing targets, a positive temp-file Quick shred, progress-to-100 reporting, recursive folder shred, and no-delete on a pre-cancelled token. - FileShredderViewModelTests: verifies ShredAll routes through DialogService.Instance.Confirm — file survives on decline, is removed on confirm, and Confirm is never raised when the queue is empty. The denylist is the most data-loss-sensitive guard in the app and previously had zero regression coverage; a refactor of the prefix list could have silently allowed shredding system files with no test failing. --- .../FileShredderServiceTests.cs | 207 ++++++++++++++++++ .../FileShredderViewModelTests.cs | 89 +++++++- 2 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 SysManager/SysManager.Tests/FileShredderServiceTests.cs diff --git a/SysManager/SysManager.Tests/FileShredderServiceTests.cs b/SysManager/SysManager.Tests/FileShredderServiceTests.cs new file mode 100644 index 0000000..99aea1e --- /dev/null +++ b/SysManager/SysManager.Tests/FileShredderServiceTests.cs @@ -0,0 +1,207 @@ +// 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(); + await Assert.ThrowsAsync( + () => 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.ThrowsAsync( + () => 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; + } + } } From 6b93dcb60c12846aca6a100547f70b92a89142ee Mon Sep 17 00:00:00 2001 From: laurentiu021 Date: Mon, 8 Jun 2026 17:08:46 +0300 Subject: [PATCH 2/2] test: accept ArgumentNullException in shredder null-path assertions ThrowIfNullOrWhiteSpace throws ArgumentNullException (a subclass of ArgumentException) for null input; Assert.ThrowsAsync requires an exact type match, so the null cases failed. Use Assert.ThrowsAnyAsync to accept both ArgumentNullException (null) and ArgumentException (empty/whitespace). --- SysManager/SysManager.Tests/FileShredderServiceTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/SysManager/SysManager.Tests/FileShredderServiceTests.cs b/SysManager/SysManager.Tests/FileShredderServiceTests.cs index 99aea1e..ceb0ad0 100644 --- a/SysManager/SysManager.Tests/FileShredderServiceTests.cs +++ b/SysManager/SysManager.Tests/FileShredderServiceTests.cs @@ -79,7 +79,9 @@ await Assert.ThrowsAsync( public async Task ShredFileAsync_NullOrWhitespace_ThrowsArgumentException(string? path) { var svc = NewService(); - await Assert.ThrowsAsync( + // ThrowIfNullOrWhiteSpace throws ArgumentNullException for null and + // ArgumentException for empty/whitespace — ThrowsAny accepts both. + await Assert.ThrowsAnyAsync( () => svc.ShredFileAsync(path!, ShredMethod.Quick, null, CancellationToken.None)); } @@ -90,7 +92,7 @@ await Assert.ThrowsAsync( public async Task ShredFolderAsync_NullOrWhitespace_ThrowsArgumentException(string? path) { var svc = NewService(); - await Assert.ThrowsAsync( + await Assert.ThrowsAnyAsync( () => svc.ShredFolderAsync(path!, ShredMethod.Quick, null, CancellationToken.None)); }