diff --git a/SysManager/SysManager.Tests/BulkInstallerServiceTests.cs b/SysManager/SysManager.Tests/BulkInstallerServiceTests.cs new file mode 100644 index 0000000..dade044 --- /dev/null +++ b/SysManager/SysManager.Tests/BulkInstallerServiceTests.cs @@ -0,0 +1,122 @@ +// SysManager · BulkInstallerServiceTests +// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager +// License: MIT + +using NSubstitute; +using SysManager.Services; + +namespace SysManager.Tests; + +/// +/// Tests for (audit finding tests #4, #9). +/// +/// The service is the only barrier between preset/user package IDs and a +/// shelled-out winget process: InstallAsync rejects anything that +/// fails PackageIdPattern before any process launches, preventing +/// command injection into winget arguments. These tests pin that guard and +/// assert the exact winget invocation on the happy path — using the +/// seam so no winget process ever runs. +/// +/// +public class BulkInstallerServiceTests +{ + // ---------- injection / validation guard (#4) ---------- + + public static IEnumerable InvalidPackageIds() + { + yield return ["App & calc.exe"]; // command chaining via & + yield return ["App; calc.exe"]; // command separator + yield return ["App | calc.exe"]; // pipe + yield return ["App\"--evil"]; // quote break-out + yield return ["App`whoami`"]; // backtick subexpression + yield return ["App$(whoami)"]; // $() subexpression + yield return ["App\nGit.Git"]; // newline injection + yield return [new string('A', 300)]; // exceeds the 256-char cap + yield return [" "]; // whitespace only + } + + [Theory] + [MemberData(nameof(InvalidPackageIds))] + public async Task InstallAsync_InvalidId_ThrowsArgumentException_AndNeverRunsProcess(string badId) + { + var runner = Substitute.For(); + var svc = new BulkInstallerService(runner); + + await Assert.ThrowsAsync(() => svc.InstallAsync(badId)); + + // The guard runs before any process launch — the runner must never be called. + await runner.DidNotReceiveWithAnyArgs() + .RunProcessAsync(default!, default!, default, default); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task InstallAsync_NullOrEmptyId_ThrowsArgumentException(string? badId) + { + var runner = Substitute.For(); + var svc = new BulkInstallerService(runner); + + await Assert.ThrowsAsync(() => svc.InstallAsync(badId!)); + } + + // ---------- happy path: exact winget invocation (#9) ---------- + + [Fact] + public async Task InstallAsync_ValidId_InvokesWingetWithExpectedArgs() + { + var runner = Substitute.For(); + runner.RunProcessAsync("winget", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(0); + var svc = new BulkInstallerService(runner); + + var exit = await svc.InstallAsync("Git.Git"); + + Assert.Equal(0, exit); + await runner.Received(1).RunProcessAsync( + "winget", + Arg.Is(a => + a.Contains("install") && + a.Contains("--id \"Git.Git\"") && + a.Contains("-e") && + a.Contains("--silent") && + a.Contains("--accept-source-agreements") && + a.Contains("--accept-package-agreements")), + Arg.Any(), + Arg.Any()); + } + + [Theory] + [InlineData("7zip.7zip")] + [InlineData("Mozilla.Firefox")] + [InlineData("Valve.Steam")] + public async Task InstallAsync_AcceptsValidPublicIds(string id) + { + var runner = Substitute.For(); + runner.RunProcessAsync("winget", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(0); + var svc = new BulkInstallerService(runner); + + var exit = await svc.InstallAsync(id); + + Assert.Equal(0, exit); + await runner.Received(1).RunProcessAsync( + "winget", + Arg.Is(a => a.Contains($"--id \"{id}\"")), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task InstallAsync_PropagatesNonZeroExitCode() + { + var runner = Substitute.For(); + runner.RunProcessAsync("winget", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(1); + var svc = new BulkInstallerService(runner); + + var exit = await svc.InstallAsync("Git.Git"); + + Assert.Equal(1, exit); + } +} diff --git a/SysManager/SysManager.Tests/DnsServiceTests.cs b/SysManager/SysManager.Tests/DnsServiceTests.cs new file mode 100644 index 0000000..6522851 --- /dev/null +++ b/SysManager/SysManager.Tests/DnsServiceTests.cs @@ -0,0 +1,159 @@ +// SysManager · DnsServiceTests +// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager +// License: MIT + +using System.Collections.ObjectModel; +using System.Management.Automation; +using NSubstitute; +using SysManager.Services; + +namespace SysManager.Tests; + +/// +/// Tests for (audit finding tests #5). +/// +/// SetDnsAsync validates both addresses with IPAddress.TryParse +/// and throws before any PowerShell runs — the +/// guard that stops a malformed or injected value from reaching +/// Set-DnsClientServerAddress. The interface index (an integer) is used +/// rather than the adapter name to avoid command injection. These tests pin the +/// validation guard and assert the exact script on the happy path via the +/// seam, so no live DNS state is touched. +/// +/// +public class DnsServiceTests +{ + private static Collection Result(string value) => + new() { PSObject.AsPSObject(value) }; + + // ---------- IP-validation guard (#5) ---------- + + public static IEnumerable InvalidAddresses() + { + yield return ["not-an-ip"]; + yield return ["8.8.8.8; calc.exe"]; // injection attempt + yield return ["8.8.8.8\")"]; // quote/paren break-out + yield return ["999.999.999.999"]; // out-of-range octets + yield return [""]; + yield return [" "]; + } + + [Theory] + [MemberData(nameof(InvalidAddresses))] + public async Task SetDnsAsync_InvalidPrimary_ThrowsArgumentException_AndNeverRunsScript(string badPrimary) + { + var runner = Substitute.For(); + using var svc = new DnsService(runner); + + await Assert.ThrowsAsync(() => svc.SetDnsAsync(badPrimary, "8.8.4.4")); + + await runner.DidNotReceiveWithAnyArgs().RunAsync(default!, default, default); + } + + [Theory] + [MemberData(nameof(InvalidAddresses))] + public async Task SetDnsAsync_InvalidSecondary_ThrowsArgumentException(string badSecondary) + { + var runner = Substitute.For(); + using var svc = new DnsService(runner); + + await Assert.ThrowsAsync(() => svc.SetDnsAsync("8.8.8.8", badSecondary)); + } + + // ---------- happy path: exact Set script via the seam ---------- + + [Fact] + public async Task SetDnsAsync_ValidAddresses_RunsSetScriptWithThoseAddresses() + { + var runner = Substitute.For(); + // First RunAsync resolves the active interface index; subsequent calls + // (the Set script) can return anything — the result is not consumed. + runner.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(Result("5")); + using var svc = new DnsService(runner); + + await svc.SetDnsAsync("1.1.1.1", "1.0.0.1"); + + await runner.Received(1).RunAsync( + Arg.Is(s => + s.Contains("Set-DnsClientServerAddress") && + s.Contains("-InterfaceIndex 5") && + s.Contains("1.1.1.1") && + s.Contains("1.0.0.1")), + Arg.Any?>(), + Arg.Any()); + } + + [Fact] + public async Task ResetToDhcpAsync_RunsResetScript() + { + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(Result("3")); + using var svc = new DnsService(runner); + + await svc.ResetToDhcpAsync(); + + await runner.Received(1).RunAsync( + Arg.Is(s => + s.Contains("Set-DnsClientServerAddress") && + s.Contains("-InterfaceIndex 3") && + s.Contains("-ResetServerAddresses")), + Arg.Any?>(), + Arg.Any()); + } + + [Fact] + public async Task GetCurrentDnsAsync_ReturnsFirstResultLine() + { + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(Result("8.8.8.8, 8.8.4.4")); + using var svc = new DnsService(runner); + + var current = await svc.GetCurrentDnsAsync(); + + Assert.Equal("8.8.8.8, 8.8.4.4", current); + } + + [Fact] + public async Task GetCurrentDnsAsync_NoResults_ReturnsUnknown() + { + var runner = Substitute.For(); + runner.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(new Collection()); + using var svc = new DnsService(runner); + + var current = await svc.GetCurrentDnsAsync(); + + Assert.Equal("Unknown", current); + } + + // ---------- presets (pure) ---------- + + [Fact] + public void GetPresets_IncludesGoogleCloudflareQuad9OpenDnsAndAutomatic() + { + using var svc = new DnsService(Substitute.For()); + + var presets = svc.GetPresets(); + var names = presets.Select(p => p.Name).ToList(); + + Assert.Contains("Google", names); + Assert.Contains("Cloudflare", names); + Assert.Contains("Quad9", names); + Assert.Contains("OpenDNS", names); + Assert.Contains(names, n => n.Contains("Automatic", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void GetPresets_GoogleHasExpectedAddresses() + { + using var svc = new DnsService(Substitute.For()); + + var google = svc.GetPresets().First(p => p.Name == "Google"); + + Assert.Equal("8.8.8.8", google.Primary); + Assert.Equal("8.8.4.4", google.Secondary); + } +} diff --git a/SysManager/SysManager.Tests/NetworkRepairServiceTests.cs b/SysManager/SysManager.Tests/NetworkRepairServiceTests.cs new file mode 100644 index 0000000..9050c86 --- /dev/null +++ b/SysManager/SysManager.Tests/NetworkRepairServiceTests.cs @@ -0,0 +1,128 @@ +// SysManager · NetworkRepairServiceTests +// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager +// License: MIT + +using NSubstitute; +using SysManager.Services; + +namespace SysManager.Tests; + +/// +/// Tests for (audit finding tests #8). +/// +/// Each repair routes a fixed command through the +/// seam (ipconfig /flushdns, netsh winsock reset, +/// netsh int ip reset) and maps the exit code plus a fixed reboot flag +/// into a . These tests pin +/// the exact invocation and the Success/NeedsReboot mapping with zero OS +/// interaction by substituting the runner. +/// +/// +public class NetworkRepairServiceTests +{ + private static IPowerShellRunner RunnerReturning(int exitCode) + { + var runner = Substitute.For(); + runner.RunProcessAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(exitCode); + return runner; + } + + // ---------- DNS flush — no reboot ---------- + + [Fact] + public async Task FlushDnsAsync_RunsIpconfigFlushDns() + { + var runner = RunnerReturning(0); + using var svc = new NetworkRepairService(runner); + + var result = await svc.FlushDnsAsync(); + + await runner.Received(1).RunProcessAsync( + "ipconfig.exe", "/flushdns", Arg.Any(), Arg.Any()); + Assert.True(result.Success); + Assert.False(result.NeedsReboot); + Assert.Equal("DNS Flush", result.ToolName); + } + + // ---------- Winsock reset — needs reboot ---------- + + [Fact] + public async Task ResetWinsockAsync_RunsNetshWinsockReset_NeedsReboot() + { + var runner = RunnerReturning(0); + using var svc = new NetworkRepairService(runner); + + var result = await svc.ResetWinsockAsync(); + + await runner.Received(1).RunProcessAsync( + "netsh.exe", "winsock reset", Arg.Any(), Arg.Any()); + Assert.True(result.Success); + Assert.True(result.NeedsReboot); + Assert.Equal("Winsock Reset", result.ToolName); + } + + // ---------- TCP/IP reset — needs reboot ---------- + + [Fact] + public async Task ResetTcpIpAsync_RunsNetshIntIpReset_NeedsReboot() + { + var runner = RunnerReturning(0); + using var svc = new NetworkRepairService(runner); + + var result = await svc.ResetTcpIpAsync(); + + await runner.Received(1).RunProcessAsync( + "netsh.exe", "int ip reset", Arg.Any(), Arg.Any()); + Assert.True(result.Success); + Assert.True(result.NeedsReboot); + Assert.Equal("TCP/IP Reset", result.ToolName); + } + + // ---------- non-zero exit maps to failure but keeps the reboot flag ---------- + + [Fact] + public async Task FlushDnsAsync_NonZeroExit_ReportsFailure() + { + var runner = RunnerReturning(1); + using var svc = new NetworkRepairService(runner); + + var result = await svc.FlushDnsAsync(); + + Assert.False(result.Success); + Assert.False(result.NeedsReboot); + } + + [Fact] + public async Task ResetWinsockAsync_NonZeroExit_FailsButStillFlagsReboot() + { + var runner = RunnerReturning(1); + using var svc = new NetworkRepairService(runner); + + var result = await svc.ResetWinsockAsync(); + + Assert.False(result.Success); + Assert.True(result.NeedsReboot); // reboot flag is intrinsic to the operation, not the outcome + } + + // ---------- streamed output is collected into the result ---------- + + [Fact] + public async Task FlushDnsAsync_CollectsStreamedOutputLines() + { + var runner = Substitute.For(); + runner.RunProcessAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => + { + // Simulate the runner streaming a line mid-execution. + runner.LineReceived += Raise.Event>( + SysManager.Models.PowerShellLine.Output("Successfully flushed the DNS Resolver Cache.")); + return 0; + }); + using var svc = new NetworkRepairService(runner); + + var result = await svc.FlushDnsAsync(); + + Assert.Contains("flushed", result.Output, StringComparison.OrdinalIgnoreCase); + } +}