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
122 changes: 122 additions & 0 deletions SysManager/SysManager.Tests/BulkInstallerServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// SysManager · BulkInstallerServiceTests
// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager
// License: MIT

using NSubstitute;
using SysManager.Services;

namespace SysManager.Tests;

/// <summary>
/// Tests for <see cref="BulkInstallerService"/> (audit finding tests #4, #9).
/// <para>
/// The service is the only barrier between preset/user package IDs and a
/// shelled-out winget process: <c>InstallAsync</c> rejects anything that
/// fails <c>PackageIdPattern</c> 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
/// <see cref="IPowerShellRunner"/> seam so no winget process ever runs.
/// </para>
/// </summary>
public class BulkInstallerServiceTests
{
// ---------- injection / validation guard (#4) ----------

public static IEnumerable<object[]> 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<IPowerShellRunner>();
var svc = new BulkInstallerService(runner);

await Assert.ThrowsAsync<ArgumentException>(() => 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<IPowerShellRunner>();
var svc = new BulkInstallerService(runner);

await Assert.ThrowsAsync<ArgumentException>(() => svc.InstallAsync(badId!));
}

// ---------- happy path: exact winget invocation (#9) ----------

[Fact]
public async Task InstallAsync_ValidId_InvokesWingetWithExpectedArgs()
{
var runner = Substitute.For<IPowerShellRunner>();
runner.RunProcessAsync("winget", Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<System.Text.Encoding?>())
.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<string>(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<CancellationToken>(),
Arg.Any<System.Text.Encoding?>());
}

[Theory]
[InlineData("7zip.7zip")]
[InlineData("Mozilla.Firefox")]
[InlineData("Valve.Steam")]
public async Task InstallAsync_AcceptsValidPublicIds(string id)
{
var runner = Substitute.For<IPowerShellRunner>();
runner.RunProcessAsync("winget", Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<System.Text.Encoding?>())
.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<string>(a => a.Contains($"--id \"{id}\"")),
Arg.Any<CancellationToken>(),
Arg.Any<System.Text.Encoding?>());
}

[Fact]
public async Task InstallAsync_PropagatesNonZeroExitCode()
{
var runner = Substitute.For<IPowerShellRunner>();
runner.RunProcessAsync("winget", Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<System.Text.Encoding?>())
.Returns(1);
var svc = new BulkInstallerService(runner);

var exit = await svc.InstallAsync("Git.Git");

Assert.Equal(1, exit);
}
}
159 changes: 159 additions & 0 deletions SysManager/SysManager.Tests/DnsServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for <see cref="DnsService"/> (audit finding tests #5).
/// <para>
/// <c>SetDnsAsync</c> validates both addresses with <c>IPAddress.TryParse</c>
/// and throws <see cref="ArgumentException"/> before any PowerShell runs — the
/// guard that stops a malformed or injected value from reaching
/// <c>Set-DnsClientServerAddress</c>. 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
/// <see cref="IPowerShellRunner"/> seam, so no live DNS state is touched.
/// </para>
/// </summary>
public class DnsServiceTests
{
private static Collection<PSObject> Result(string value) =>
new() { PSObject.AsPSObject(value) };

// ---------- IP-validation guard (#5) ----------

public static IEnumerable<object[]> 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<IPowerShellRunner>();
using var svc = new DnsService(runner);

await Assert.ThrowsAsync<ArgumentException>(() => 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<IPowerShellRunner>();
using var svc = new DnsService(runner);

await Assert.ThrowsAsync<ArgumentException>(() => 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<IPowerShellRunner>();
// First RunAsync resolves the active interface index; subsequent calls
// (the Set script) can return anything — the result is not consumed.
runner.RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.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<string>(s =>
s.Contains("Set-DnsClientServerAddress") &&
s.Contains("-InterfaceIndex 5") &&
s.Contains("1.1.1.1") &&
s.Contains("1.0.0.1")),
Arg.Any<IDictionary<string, object?>?>(),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task ResetToDhcpAsync_RunsResetScript()
{
var runner = Substitute.For<IPowerShellRunner>();
runner.RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(Result("3"));
using var svc = new DnsService(runner);

await svc.ResetToDhcpAsync();

await runner.Received(1).RunAsync(
Arg.Is<string>(s =>
s.Contains("Set-DnsClientServerAddress") &&
s.Contains("-InterfaceIndex 3") &&
s.Contains("-ResetServerAddresses")),
Arg.Any<IDictionary<string, object?>?>(),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task GetCurrentDnsAsync_ReturnsFirstResultLine()
{
var runner = Substitute.For<IPowerShellRunner>();
runner.RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.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<IPowerShellRunner>();
runner.RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new Collection<PSObject>());
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<IPowerShellRunner>());

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<IPowerShellRunner>());

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);
}
}
Loading
Loading