diff --git a/.editorconfig b/.editorconfig index 8342e30..f8b4feb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -215,5 +215,5 @@ dotnet_diagnostic.CA1707.severity = none # Tests: Method_Scenario_Expected naming and public [TestClass] types are the # established convention. -[HostsFileEditor.Core.Tests/*.cs] +[HostsFileEditor.{Core,WinForm}.Tests/**.cs] dotnet_diagnostic.CA1707.severity = none diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d5d51b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI + +# Gates every pull request (and pushes to master) on a clean build and a green test run on BOTH +# target architectures — x64 and ARM64 — since the app ships for both. +on: + pull_request: + push: + branches: [master] + +# Cancel superseded runs on the same ref so a force-push doesn't pile up queued jobs. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-test: + name: build & test (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + os: windows-latest + rid: win-x64 + - arch: arm64 + os: windows-11-arm + rid: win-arm64 + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Installs the exact SDK pinned in global.json (10.0.301, rollForward latestFeature). + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + # Build the shared core, the headless CLI launcher, and the elevation helper. These are + # architecture-portable (a library / RID-agnostic builds), so no RID is needed here. + # TreatWarningsAsErrors is on repo-wide, so this also fails on any new analyzer warning. + - name: Build core, CLI and helper + run: | + dotnet build HostsFileEditor.Core/HostsFileEditor.Core.csproj -c Release + dotnet build HostsFileEditor.Cli/HostsFileEditor.Cli.csproj -c Release + dotnet build HostsFileEditor.Elevate/HostsFileEditor.Elevate.csproj -c Release + + # Build the classic (WinForms) edition for THIS architecture. Both app projects default their + # RuntimeIdentifier to win-x64, so the RID must be passed explicitly or the arm64 leg would + # silently build x64 and never validate arm64. + - name: Build classic UI (WinForms) + run: dotnet build HostsFileEditor.WinForm/HostsFileEditor.WinForm.csproj -c Release -p:RuntimeIdentifier=${{ matrix.rid }} + + # Build the modern (WinUI 3) edition for this architecture. A plain build (not publish) skips + # the AOT/trim/MSIX steps, so it needs no C++ toolchain — just the Windows App SDK NuGet. + - name: Build modern UI (WinUI) + run: dotnet build HostsFileEditor.WinUI/HostsFileEditor.WinUI.csproj -c Release -p:Platform=${{ matrix.arch }} -p:RuntimeIdentifier=${{ matrix.rid }} + + # Runs the full Core/CLI test suite with OpenCover coverage (configured in coverage.runsettings). + - name: Test Core/CLI with coverage + run: > + dotnet test HostsFileEditor.Core.Tests/HostsFileEditor.Core.Tests.csproj + -c Release + --logger "trx;LogFileName=core-test-results.trx" + --results-directory ${{ github.workspace }}/TestResults + + # Classic-edition (WinForms) UI-logic tests. The project pins RID win-x64, so it runs on the x64 + # leg only; the modern edition is covered by its build gate above and shared logic lives in Core. + - name: Test classic UI (x64 only) + if: matrix.arch == 'x64' + run: > + dotnet test HostsFileEditor.WinForm.Tests/HostsFileEditor.WinForm.Tests.csproj + -c Release + --logger "trx;LogFileName=winform-test-results.trx" + --results-directory ${{ github.workspace }}/TestResults + + - name: Coverage summary + if: always() + continue-on-error: true + shell: pwsh + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool 2>$null + $report = Get-ChildItem -Path "${{ github.workspace }}/TestResults" -Recurse -Filter coverage.opencover.xml | Select-Object -First 1 + if (-not $report) { Write-Host "No coverage report found."; exit 0 } + reportgenerator -reports:"$($report.FullName)" -targetdir:"${{ github.workspace }}/CoverageReport" -reporttypes:"TextSummary;MarkdownSummaryGithub" + $md = "${{ github.workspace }}/CoverageReport/SummaryGithub.md" + if (Test-Path $md) { Get-Content $md | Add-Content -Path $env:GITHUB_STEP_SUMMARY } + Get-Content "${{ github.workspace }}/CoverageReport/Summary.txt" | Write-Host + + - name: Upload test results and coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.arch }} + path: | + ${{ github.workspace }}/TestResults/**/*.trx + ${{ github.workspace }}/TestResults/**/coverage.opencover.xml + if-no-files-found: warn diff --git a/HostsFileEditor.Core.Tests/CoreExceptionsAndResourcesTests.cs b/HostsFileEditor.Core.Tests/CoreExceptionsAndResourcesTests.cs new file mode 100644 index 0000000..6cafcbc --- /dev/null +++ b/HostsFileEditor.Core.Tests/CoreExceptionsAndResourcesTests.cs @@ -0,0 +1,90 @@ +using HostsFileEditor.Elevation; +using HostsFileEditor.Properties; + +namespace HostsFileEditor.Core.Tests; + +[TestClass] +public sealed class CoreExceptionsAndResourcesTests +{ + [TestMethod] + public void ElevationCancelledException_DefaultMessage() + { + var ex = new ElevationCancelledException(); + ex.Message.ShouldContain("administrator permission"); + } + + [TestMethod] + public void ElevationCancelledException_CustomMessage() + { + var ex = new ElevationCancelledException("custom"); + ex.Message.ShouldBe("custom"); + } + + [TestMethod] + public void ElevationCancelledException_MessageAndInner() + { + var inner = new InvalidOperationException("inner"); + var ex = new ElevationCancelledException("outer", inner); + ex.Message.ShouldBe("outer"); + ex.InnerException.ShouldBe(inner); + } + + [TestMethod] + public void HostsFileConflictException_DefaultConstructs() + { + var ex = new HostsFileConflictException(); + ex.ShouldNotBeNull(); + } + + [TestMethod] + public void HostsFileConflictException_CustomMessage() + { + var ex = new HostsFileConflictException("conflict"); + ex.Message.ShouldBe("conflict"); + } + + [TestMethod] + public void HostsFileConflictException_MessageAndInner() + { + var inner = new IOException("io"); + var ex = new HostsFileConflictException("outer", inner); + ex.Message.ShouldBe("outer"); + ex.InnerException.ShouldBe(inner); + } + + // Touch every strongly-typed resource accessor so the generated getters are exercised. The three + // messages the app actually depends on for its validation/UX carry real text; the rest are read + // for coverage without asserting a value (some are placeholder/empty in the .resx). + [TestMethod] + public void Resources_AllAccessorsExecute() + { + _ = Resources.ArchiveExists; + _ = Resources.ErrorCaption; + _ = Resources.InputArchivePrompt; + _ = Resources.LoseChangesDialogCaption; + _ = Resources.LoseChangesQuestion; + _ = Resources.UnknownException; + + Resources.hosts.ShouldNotBeNullOrEmpty(); + Resources.InvalidHostEntries.ShouldNotBeNullOrEmpty(); + Resources.InvalidHostnames.ShouldNotBeNullOrEmpty(); + Resources.InvalidIPAddress.ShouldNotBeNullOrEmpty(); + Resources.PingFailed.ShouldNotBeNullOrEmpty(); + } + + [TestMethod] + public void Resources_CultureRoundTrips() + { + var original = Resources.Culture; + try + { + Resources.Culture = System.Globalization.CultureInfo.InvariantCulture; + Resources.Culture.ShouldBe(System.Globalization.CultureInfo.InvariantCulture); + Resources.ArchiveExists.ShouldNotBeNull(); + } + finally + { + Resources.Culture = original; + } + } +} diff --git a/HostsFileEditor.Core.Tests/ElevationHelperSelectionTests.cs b/HostsFileEditor.Core.Tests/ElevationHelperSelectionTests.cs new file mode 100644 index 0000000..23cf14d --- /dev/null +++ b/HostsFileEditor.Core.Tests/ElevationHelperSelectionTests.cs @@ -0,0 +1,95 @@ +using HostsFileEditor.Elevation; + +namespace HostsFileEditor.Core.Tests; + +/// +/// Coverage for 's helper-resolution branches. +/// is process-global, so every test restores it. +/// +[TestClass] +public sealed class ElevationHelperSelectionTests +{ + private IPrivilegedFileOperations _originalCurrent = null!; + + [TestInitialize] + public void Init() => _originalCurrent = PrivilegedFileOperations.Current; + + [TestCleanup] + public void Cleanup() => PrivilegedFileOperations.Current = _originalCurrent; + + [TestMethod] + public void UseElevationHelper_ExplicitExistingPath_SelectsHelper() + { + var helper = Path.Combine(Path.GetTempPath(), "HfeHelper_" + Guid.NewGuid().ToString("N") + ".exe"); + File.WriteAllText(helper, "not a real exe"); + try + { + PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations(); + PrivilegedFileOperations.UseElevationHelper(helper); + PrivilegedFileOperations.Current.ShouldBeOfType(); + } + finally + { + File.Delete(helper); + } + } + + [TestMethod] + public void UseElevationHelper_ExplicitMissingPath_LeavesCurrentUnchanged() + { + var inProcess = new InProcessPrivilegedFileOperations(); + PrivilegedFileOperations.Current = inProcess; + + PrivilegedFileOperations.UseElevationHelper(Path.Combine(Path.GetTempPath(), "no-such-helper.exe")); + + PrivilegedFileOperations.Current.ShouldBeSameAs(inProcess); + } + + [TestMethod] + public void UseElevationHelper_NoHelperBesideApp_StaysInProcess() + { + // No HostsFileEditor.Elevate.exe ships in the test bin. Defensively clear any stub a prior + // (interrupted) run of UseElevationHelper_HelperBesideApp_SelectsHelper may have left behind, + // so the default probe genuinely finds nothing regardless of test order. + foreach (var stray in HelperCandidatePaths()) + { + if (File.Exists(stray)) + { + File.Delete(stray); + } + } + + var inProcess = new InProcessPrivilegedFileOperations(); + PrivilegedFileOperations.Current = inProcess; + + PrivilegedFileOperations.UseElevationHelper(); + + PrivilegedFileOperations.Current.ShouldBeSameAs(inProcess); + } + + [TestMethod] + public void UseElevationHelper_HelperBesideApp_SelectsHelper() + { + // Drop a stand-in helper next to the test assembly so the default candidate probe finds it. + // The test bin never contains a real helper, so the stub is always removed afterwards. + var candidate = Path.Combine(AppContext.BaseDirectory, PrivilegedFileOperations.HelperExecutableName); + File.WriteAllText(candidate, "stub"); + + try + { + PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations(); + PrivilegedFileOperations.UseElevationHelper(); + PrivilegedFileOperations.Current.ShouldBeOfType(); + } + finally + { + File.Delete(candidate); + } + } + + private static IEnumerable HelperCandidatePaths() => + [ + Path.Combine(AppContext.BaseDirectory, PrivilegedFileOperations.HelperSubdirectory, PrivilegedFileOperations.HelperExecutableName), + Path.Combine(AppContext.BaseDirectory, PrivilegedFileOperations.HelperExecutableName), + ]; +} diff --git a/HostsFileEditor.Core.Tests/HostsArchiveGapTests.cs b/HostsFileEditor.Core.Tests/HostsArchiveGapTests.cs new file mode 100644 index 0000000..d5aacc3 --- /dev/null +++ b/HostsFileEditor.Core.Tests/HostsArchiveGapTests.cs @@ -0,0 +1,48 @@ +namespace HostsFileEditor.Core.Tests; + +[TestClass] +public sealed class HostsArchiveGapTests +{ + [TestMethod] + public void Validate_InvalidPath_ReturnsFalseWithMessage() + { + // An embedded null makes new FileInfo(...) throw, exercising Validate's catch path. + HostsArchive.Validate("bad\0name", out var error).ShouldBeFalse(); + error.ShouldNotBeNullOrEmpty(); + } + + [TestMethod] + public void Constructor_WithName_UsesEffectiveArchiveDirectory() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(dir); + try + { + HostsArchiveList.TestArchiveDirectoryOverride = dir; + var archive = new HostsArchive("preset.txt"); + archive.FilePath.ShouldBe(Path.Combine(dir, "preset.txt")); + archive.FileName.ShouldBe("preset.txt"); + } + finally + { + HostsArchiveList.TestArchiveDirectoryOverride = null; + } + } + + [TestMethod] + public void Constructor_NullName_Throws() => + Should.Throw(() => new HostsArchive(null!)); + + [TestMethod] + public void FilePath_NullAssignment_Throws() => + Should.Throw(() => new HostsArchive { FilePath = null! }); + + [TestMethod] + public void FileNameComparer_OrdersCaseInsensitively() + { + var a = new HostsArchive { FilePath = @"x\alpha.txt" }; + var b = new HostsArchive { FilePath = @"x\BETA.txt" }; + HostsArchive.FileNameComparer.Compare(a, b).ShouldBeLessThan(0); + HostsArchive.FileNameComparer.Compare(b, a).ShouldBeGreaterThan(0); + } +} diff --git a/HostsFileEditor.Core.Tests/HostsCliExecuteTests.cs b/HostsFileEditor.Core.Tests/HostsCliExecuteTests.cs new file mode 100644 index 0000000..b433e56 --- /dev/null +++ b/HostsFileEditor.Core.Tests/HostsCliExecuteTests.cs @@ -0,0 +1,294 @@ +using HostsFileEditor.CommandLine; +using HostsFileEditor.Elevation; + +namespace HostsFileEditor.Core.Tests; + +/// +/// End-to-end coverage of 's execute paths and top-level error handling. +/// These run against the temp-file-bound established by +/// , so they exercise the real list/apply/enable/disable/import/merge +/// flow without touching the machine hosts file. +/// +[TestClass] +public sealed class HostsCliExecuteTests +{ + private string _archiveDir = null!; + + [TestInitialize] + public void Init() + { + // A prior test may have swapped in a throwing elevation fake; restore the default first so the + // re-enable below (which moves the file) can succeed. + PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations(); + + // A prior test may have left the file disabled (renamed aside). Put it back before reseeding. + if (!HostsFile.IsEnabled) + { + HostsFile.Instance.EnableHostsFile(); + } + + File.WriteAllLines(HostsFile.DefaultHostFilePath, TestAssemblyInit.SeedLines); + HostsFile.Instance.Refresh(); + + _archiveDir = Path.Combine(Path.GetTempPath(), "HfeCliArchives_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_archiveDir); + HostsArchiveList.TestArchiveDirectoryOverride = _archiveDir; + HostsArchiveList.Instance.Refresh(); + } + + [TestCleanup] + public void Cleanup() + { + HostsArchiveList.TestArchiveDirectoryOverride = null; + HostsArchiveList.Instance.Refresh(); + PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations(); + + // Leave the file enabled for the next test / class. + if (!HostsFile.IsEnabled) + { + HostsFile.Instance.EnableHostsFile(); + } + + if (Directory.Exists(_archiveDir)) + { + Directory.Delete(_archiveDir, true); + } + } + + private static (int code, string outText, string errText) RunCli(params string[] args) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var code = HostsCli.Run(args, output, error); + return (code, output.ToString(), error.ToString()); + } + + [TestMethod] + public void List_NoPresets_ReportsNone() + { + var (code, outText, _) = RunCli("list"); + + code.ShouldBe(HostsCli.ExitSuccess); + outText.ShouldContain("No presets found."); + } + + [TestMethod] + public void List_WithPresets_ListsThemSorted() + { + File.WriteAllText(Path.Combine(_archiveDir, "Zeta.txt"), "10.0.0.9 z"); + File.WriteAllText(Path.Combine(_archiveDir, "Alpha.txt"), "10.0.0.8 a"); + HostsArchiveList.Instance.Refresh(); + + var (code, outText, _) = RunCli("list"); + + code.ShouldBe(HostsCli.ExitSuccess); + outText.ShouldContain("Presets:"); + outText.IndexOf("Alpha.txt", StringComparison.Ordinal) + .ShouldBeLessThan(outText.IndexOf("Zeta.txt", StringComparison.Ordinal)); + } + + [TestMethod] + public void Apply_ExistingPreset_ReplacesHostsAndReports() + { + File.WriteAllLines(Path.Combine(_archiveDir, "Custom.txt"), ["192.168.1.1 router.test"]); + HostsArchiveList.Instance.Refresh(); + + var (code, outText, _) = RunCli("apply", "Custom.txt"); + + code.ShouldBe(HostsCli.ExitSuccess); + outText.ShouldContain("Applied preset 'Custom.txt'"); + File.ReadAllText(HostsFile.DefaultHostFilePath).ShouldContain("router.test"); + HostsFile.Instance.Entries.ShouldContain(e => e.HostNames == "router.test"); + } + + [TestMethod] + public void Apply_StemResolvesPreset() + { + File.WriteAllLines(Path.Combine(_archiveDir, "MyHosts1.txt"), ["192.168.1.2 stem.test"]); + HostsArchiveList.Instance.Refresh(); + + var (code, _, _) = RunCli("-s", "MyHosts1"); + + code.ShouldBe(HostsCli.ExitSuccess); + File.ReadAllText(HostsFile.DefaultHostFilePath).ShouldContain("stem.test"); + } + + [TestMethod] + public void Apply_MissingPreset_ReturnsError() + { + var (code, _, errText) = RunCli("apply", "does-not-exist"); + + code.ShouldBe(HostsCli.ExitError); + errText.ShouldContain("not found"); + } + + [TestMethod] + public void Apply_AmbiguousStem_ReturnsError() + { + File.WriteAllText(Path.Combine(_archiveDir, "Dup.txt"), "a"); + File.WriteAllText(Path.Combine(_archiveDir, "Dup.bak"), "b"); + HostsArchiveList.Instance.Refresh(); + + var (code, _, errText) = RunCli("apply", "Dup"); + + code.ShouldBe(HostsCli.ExitError); + errText.ShouldContain("ambiguous"); + } + + [TestMethod] + public void Enable_AlreadyEnabled_ReportsNoOp() + { + var (code, outText, _) = RunCli("enable"); + + code.ShouldBe(HostsCli.ExitSuccess); + outText.ShouldContain("already enabled"); + } + + [TestMethod] + public void Disable_ThenEnable_TogglesFile() + { + var (disableCode, disableOut, _) = RunCli("disable"); + disableCode.ShouldBe(HostsCli.ExitSuccess); + disableOut.ShouldContain("Disabled the hosts file."); + HostsFile.IsEnabled.ShouldBeFalse(); + + // A second disable is a no-op. + var (code2, out2, _) = RunCli("disable"); + code2.ShouldBe(HostsCli.ExitSuccess); + out2.ShouldContain("already disabled"); + + var (enableCode, enableOut, _) = RunCli("enable"); + enableCode.ShouldBe(HostsCli.ExitSuccess); + enableOut.ShouldContain("Enabled the hosts file."); + HostsFile.IsEnabled.ShouldBeTrue(); + } + + [TestMethod] + public void Import_ExistingFile_ReplacesAndSaves() + { + var importFile = Path.Combine(_archiveDir, "import.txt"); + File.WriteAllLines(importFile, ["8.8.8.8 dns.test"]); + + var (code, outText, _) = RunCli("import", importFile); + + code.ShouldBe(HostsCli.ExitSuccess); + outText.ShouldContain("Imported"); + File.ReadAllText(HostsFile.DefaultHostFilePath).ShouldContain("dns.test"); + } + + [TestMethod] + public void Import_MissingFile_ReturnsError() + { + var (code, _, errText) = RunCli("import", Path.Combine(_archiveDir, "nope.txt")); + + code.ShouldBe(HostsCli.ExitError); + errText.ShouldContain("File not found"); + } + + [TestMethod] + public void Import_WhenDisabled_NotesDisabledState() + { + RunCli("disable"); + var importFile = Path.Combine(_archiveDir, "import.txt"); + File.WriteAllLines(importFile, ["8.8.4.4 dns2.test"]); + + var (code, outText, _) = RunCli("import", importFile); + + code.ShouldBe(HostsCli.ExitSuccess); + outText.ShouldContain("currently DISABLED"); + } + + [TestMethod] + public void Merge_AddsNewEntries() + { + var mergeFile = Path.Combine(_archiveDir, "merge.txt"); + File.WriteAllLines(mergeFile, ["4.4.4.4 brandnew.test"]); + + var (code, outText, _) = RunCli("merge", mergeFile); + + code.ShouldBe(HostsCli.ExitSuccess); + outText.ShouldContain("Merged 1 new entry"); + File.ReadAllText(HostsFile.DefaultHostFilePath).ShouldContain("brandnew.test"); + } + + [TestMethod] + public void Merge_AllDuplicates_ReportsNothingAdded() + { + var mergeFile = Path.Combine(_archiveDir, "merge.txt"); + // Same entries as the seed → nothing new to add. + File.WriteAllLines(mergeFile, TestAssemblyInit.SeedLines); + + var (code, outText, _) = RunCli("merge", mergeFile); + + code.ShouldBe(HostsCli.ExitSuccess); + outText.ShouldContain("No new entries"); + } + + [TestMethod] + public void Merge_MultipleEntries_ReportsPluralCount() + { + var mergeFile = Path.Combine(_archiveDir, "merge.txt"); + File.WriteAllLines(mergeFile, ["4.4.4.4 one.test", "5.5.5.5 two.test"]); + + var (code, outText, _) = RunCli("merge", mergeFile); + + code.ShouldBe(HostsCli.ExitSuccess); + outText.ShouldContain("Merged 2 new entries"); + } + + [TestMethod] + public void Merge_MissingFile_ReturnsError() + { + var (code, _, errText) = RunCli("merge", Path.Combine(_archiveDir, "nope.txt")); + + code.ShouldBe(HostsCli.ExitError); + errText.ShouldContain("File not found"); + } + + [TestMethod] + public void Run_ElevationDeclined_ReportsCancelled() + { + PrivilegedFileOperations.Current = new ThrowingPrivilegedFileOperations(new ElevationCancelledException()); + var importFile = Path.Combine(_archiveDir, "import.txt"); + File.WriteAllLines(importFile, ["8.8.8.8 dns.test"]); + + var (code, _, errText) = RunCli("import", importFile); + + code.ShouldBe(HostsCli.ExitError); + errText.ShouldContain("administrator permission is required"); + } + + [TestMethod] + public void Run_AccessDenied_ReportsFriendlyMessage() + { + PrivilegedFileOperations.Current = new ThrowingPrivilegedFileOperations(new UnauthorizedAccessException()); + var importFile = Path.Combine(_archiveDir, "import.txt"); + File.WriteAllLines(importFile, ["8.8.8.8 dns.test"]); + + var (code, _, errText) = RunCli("import", importFile); + + code.ShouldBe(HostsCli.ExitError); + errText.ShouldContain("Access denied"); + } + + [TestMethod] + public void Run_UnexpectedError_ReportsGenericMessage() + { + PrivilegedFileOperations.Current = new ThrowingPrivilegedFileOperations(new InvalidOperationException("boom")); + var importFile = Path.Combine(_archiveDir, "import.txt"); + File.WriteAllLines(importFile, ["8.8.8.8 dns.test"]); + + var (code, _, errText) = RunCli("import", importFile); + + code.ShouldBe(HostsCli.ExitError); + errText.ShouldContain("Error: boom"); + } + + /// An elevation stub whose privileged ops always throw a chosen exception. + private sealed class ThrowingPrivilegedFileOperations(Exception toThrow) : IPrivilegedFileOperations + { + public void WriteAllLines(string path, IEnumerable lines) => throw toThrow; + + public void Move(string sourcePath, string destinationPath) => throw toThrow; + } +} diff --git a/HostsFileEditor.Core.Tests/HostsEntryGapTests.cs b/HostsFileEditor.Core.Tests/HostsEntryGapTests.cs new file mode 100644 index 0000000..295dd9f --- /dev/null +++ b/HostsFileEditor.Core.Tests/HostsEntryGapTests.cs @@ -0,0 +1,393 @@ +using HostsFileEditor.Properties; +using HostsFileEditor.Utilities; +using System.Reflection; + +namespace HostsFileEditor.Core.Tests; + +/// Targeted coverage for branches not reached by the existing suite. +[TestClass] +public sealed class HostsEntryGapTests +{ + [TestInitialize] + public void Init() + { + UndoManager.Instance.ClearHistory(); + HostsEntry.AutoPingIPAddress = false; + HostsEntry.UiSynchronizationContext = null; + } + + [TestCleanup] + public void Cleanup() + { + // Stop new auto-pings, then wait for any fire-and-forget ping this test started to finish + // before yielding to the next test. HostsEntry.Ping() is fire-and-forget and mutates the + // process-global ping counter (BeginPing/EndPing) plus raises the static PingActivityChanged + // on a thread-pool continuation; leaving one in flight would flake the ping-activity tests + // here and the pre-existing PingActivity_RaisesOnZeroBoundaryOnly. + HostsEntry.AutoPingIPAddress = false; + SpinUntil(() => !HostsEntry.IsPingInProgress, TimeSpan.FromSeconds(10)); + HostsEntry.UiSynchronizationContext = null; + } + + // ---- GetComparer: the remaining columns and the descending string wrapper ---- + + [TestMethod] + public void GetComparer_HostNames_Descending() + { + var list = new List { new("1.1.1.1 a"), new("2.2.2.2 c"), new("3.3.3.3 b") }; + list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.HostNames, descending: true)); + list.Select(e => e.HostNames).ShouldBe(["c", "b", "a"]); + } + + [TestMethod] + public void GetComparer_Comment_BothDirections() + { + var list = new List + { + new("1.1.1.1 h # bbb"), + new("2.2.2.2 h # aaa"), + new("3.3.3.3 h # ccc"), + }; + + list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Comment, descending: false)); + list.Select(e => e.Comment).ShouldBe(["aaa", "bbb", "ccc"]); + + list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Comment, descending: true)); + list.Select(e => e.Comment).ShouldBe(["ccc", "bbb", "aaa"]); + } + + [TestMethod] + public void GetComparer_Enabled_Sorts() + { + var enabled = new HostsEntry("1.1.1.1 on"); + var disabled = new HostsEntry("# 2.2.2.2 off"); + var list = new List { enabled, disabled }; + + list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Enabled, descending: false)); + list[0].Enabled.ShouldBeFalse(); // false sorts before true ascending + list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Enabled, descending: true)); + list[0].Enabled.ShouldBeTrue(); + } + + [TestMethod] + public void GetComparer_Valid_Sorts() + { + var valid = new HostsEntry("1.1.1.1 host"); + var invalid = new HostsEntry("# just a comment"); + var list = new List { valid, invalid }; + + list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Valid, descending: false)); + list[0].Valid.ShouldBeFalse(); + list.Sort(HostsEntry.GetComparer(HostsEntry.SortColumn.Valid, descending: true)); + list[0].Valid.ShouldBeTrue(); + } + + [TestMethod] + public void GetComparer_UnknownColumn_Throws() => + Should.Throw(() => HostsEntry.GetComparer((HostsEntry.SortColumn)999, false)); + + [TestMethod] + public void IpSortKey_CompareTo_OrdersByRankThenValue() + { + var v4Low = new HostsEntry("1.1.1.1 a").GetIpSortKey(); + var v4High = new HostsEntry("2.2.2.2 b").GetIpSortKey(); + var v6 = new HostsEntry("::1 c").GetIpSortKey(); + var noIp = new HostsEntry("# note").GetIpSortKey(); + + v4Low.CompareTo(v4High).ShouldBeLessThan(0); + v4High.CompareTo(v4Low).ShouldBeGreaterThan(0); + v4High.CompareTo(v6).ShouldBeLessThan(0); // IPv4 rank before IPv6 + v6.CompareTo(noIp).ShouldBeLessThan(0); // real IP before no-IP + v4Low.CompareTo(v4Low).ShouldBe(0); + } + + // ---- Property setters / undo ---- + + [TestMethod] + public void Comment_Change_RecordsUndo() + { + var entry = new HostsEntry("1.1.1.1 host # original"); + UndoManager.Instance.ClearHistory(); + + entry.Comment = "changed"; + entry.Comment.ShouldBe("changed"); + UndoManager.Instance.CanUndo.ShouldBeTrue(); + + UndoManager.Instance.Undo(); + entry.Comment.ShouldBe("original"); + } + + [TestMethod] + public void HostNames_Change_RecordsUndo_AndTrims() + { + var entry = new HostsEntry("1.1.1.1 host"); + UndoManager.Instance.ClearHistory(); + + entry.HostNames = " newhost "; + entry.HostNames.ShouldBe("newhost"); + UndoManager.Instance.CanUndo.ShouldBeTrue(); + + UndoManager.Instance.Undo(); + entry.HostNames.ShouldBe("host"); + } + + [TestMethod] + public void Comment_NullValue_Throws() => + Should.Throw(() => new HostsEntry("1.1.1.1 h").Comment = null!); + + [TestMethod] + public void HostNames_NullValue_Throws() => + Should.Throw(() => new HostsEntry("1.1.1.1 h").HostNames = null!); + + [TestMethod] + public void IpAddress_NullValue_Throws() => + Should.Throw(() => new HostsEntry("1.1.1.1 h").IpAddress = null!); + + [TestMethod] + public void UnparsedText_NullValue_Throws() => + Should.Throw(() => new HostsEntry("1.1.1.1 h").UnparsedText = null!); + + // ---- UnparsedText re-serialization ---- + + [TestMethod] + public void UnparsedText_InvalidEntry_PrefixedWithHash() + { + var entry = new HostsEntry("127.0.0.1 host"); + entry.HostNames = "bad host!!"; // invalid hostname -> entry becomes invalid + + entry.Valid.ShouldBeFalse(); + entry.UnparsedText.ShouldStartWith("#"); + } + + [TestMethod] + public void UnparsedText_CommentOnly_ReserializesFromComment() + { + var entry = new HostsEntry("# hello"); + entry.Comment = "world"; + + // Re-serialization takes the HasCommentOnly branch; an invalid (comment) line is hash-prefixed. + var text = entry.UnparsedText; + text.ShouldStartWith("#"); + text.ShouldContain("world"); + } + + [TestMethod] + public void UnparsedText_Setter_InvalidatesAndRebuildsFromFields() + { + var entry = new HostsEntry("127.0.0.1 host"); + + // The setter marks the serialized text stale, so the next read rebuilds it from the fields + // rather than returning the assigned string verbatim. + entry.UnparsedText = "literally anything"; + + entry.UnparsedText.ShouldBe("127.0.0.1 host"); + } + + [TestMethod] + public void Valid_Setter_UpdatesProperty() + { + var entry = new HostsEntry("# comment"); + entry.Valid.ShouldBeFalse(); + entry.Valid = true; + entry.Valid.ShouldBeTrue(); + } + + // ---- IDataErrorInfo indexer ---- + + [TestMethod] + public void Indexer_ReturnsErrorForInvalidIp_EmptyOtherwise() + { + var entry = new HostsEntry("127.0.0.1 host"); + entry.IpAddress = "not-an-ip"; + + entry["IpAddress"].ShouldBe(Resources.InvalidIPAddress); + entry["SomethingElse"].ShouldBeEmpty(); + } + + // ---- Validation edge branches ---- + + [TestMethod] + public void ValidateHostnames_Invalid_SurfacesNonEmptyErrorMessage() + { + // Regression: Resources.InvalidHostnames previously had no matching .resx key and resolved to + // null, so an invalid hostname produced a blank IDataErrorInfo message in both editions. + var entry = new HostsEntry("127.0.0.1 host"); + entry.HostNames = "bad host!!"; // spaces/'!' are not valid hostname characters + + entry.Valid.ShouldBeFalse(); + entry["HostNames"].ShouldBe(Resources.InvalidHostnames); + entry["HostNames"].ShouldNotBeNullOrEmpty(); + } + + [TestMethod] + public void ValidateHostnames_BlankOnDisabledEntry_IsNotAnError() + { + var entry = new HostsEntry("# 1.2.3.4 host"); // disabled entry + entry.Enabled.ShouldBeFalse(); + + entry.HostNames = string.Empty; // blank host on a disabled row is allowed + entry["HostNames"].ShouldBeEmpty(); + } + + [TestMethod] + public void ValidateIpAddress_Invalid_SetsErrorAndClearsWhenFixed() + { + var entry = new HostsEntry("127.0.0.1 host"); + + entry.IpAddress = "999.999.999.999"; + entry.Valid.ShouldBeFalse(); + entry["IpAddress"].ShouldBe(Resources.InvalidIPAddress); + + // Fixing the address must clear the error (SetError removal path). + entry.IpAddress = "10.0.0.1"; + entry["IpAddress"].ShouldBeEmpty(); + entry.Valid.ShouldBeTrue(); + } + + [TestMethod] + public void ValidateIpAddress_BlankOnDisabledEntry_IsNotAnError() + { + var entry = new HostsEntry("# 1.2.3.4 host"); + entry.IpAddress = string.Empty; + entry["IpAddress"].ShouldBeEmpty(); + } + + // ---- Auto-ping at parse time ---- + + [TestMethod] + public void Construction_WithAutoPingOn_DoesNotThrow() + { + HostsEntry.AutoPingIPAddress = true; + var entry = new HostsEntry("127.0.0.1 localhost"); // triggers the parse-time ping branch + entry.IpAddress.ShouldBe("127.0.0.1"); + } + + [TestMethod] + public void SuspendAutoPing_SuppressesParseTimePing_AndDisposeIsIdempotent() + { + HostsEntry.AutoPingIPAddress = true; + var scope = HostsEntry.SuspendAutoPing(); + try + { + var entry = new HostsEntry("127.0.0.1 localhost"); + entry.IpAddress.ShouldBe("127.0.0.1"); + } + finally + { + scope.Dispose(); + scope.Dispose(); // idempotent + } + } + + // ---- Ping ---- + + [TestMethod] + public void Ping_InvalidIp_IsNoOp() + { + var entry = new HostsEntry("# just a comment"); // no parseable IP + Should.NotThrow(entry.Ping); + entry.PingFailed.ShouldBeFalse(); + } + + [TestMethod] + public void Ping_ValidLoopback_DoesNotThrow() + { + var entry = new HostsEntry("127.0.0.1 localhost"); + Should.NotThrow(entry.Ping); + } + + [TestMethod] + public void SetIpAddress_WithAutoPingOn_Pings() + { + HostsEntry.AutoPingIPAddress = true; + var entry = new HostsEntry("# comment"); + entry.IpAddress = "127.0.0.1"; // valid -> triggers auto-ping branch in ValidateIpAddress + entry.IpAddress.ShouldBe("127.0.0.1"); + } + + // Opportunistic coverage of the ping-FAILURE reporting path. Pings a non-routable RFC5737 + // TEST-NET address (no external host is contacted). When ICMP is available the ping fails and the + // failure state is asserted; where the environment blocks ICMP entirely the ping throws internally + // and is swallowed, so we don't hard-fail — the deterministic paths above still cover the rest. + [TestMethod] + public void Ping_UnreachableAddress_MarksPingFailed_WhenIcmpAvailable() + { + var entry = new HostsEntry("192.0.2.1 unreachable.test"); + entry.Ping(); + + var flipped = SpinUntil(() => entry.PingFailed, TimeSpan.FromSeconds(12)); + if (!flipped) + { + return; // ICMP unavailable in this environment; nothing to assert. + } + + entry.PingFailed.ShouldBeTrue(); + entry["IpAddress"].ShouldNotBeEmpty(); + + // Editing the IP clears the stale ping-failure (SetPingFailed false-direction change). + entry.IpAddress = "127.0.0.1"; + entry.PingFailed.ShouldBeFalse(); + } + + // ---- Ping-activity marshalling to a UI SynchronizationContext ---- + + [TestMethod] + public void PingActivity_MarshalsThroughUiSynchronizationContext() + { + var context = new RecordingSynchronizationContext(); + HostsEntry.UiSynchronizationContext = context; + var fired = 0; + void Handler(object? s, EventArgs e) => fired++; + HostsEntry.PingActivityChanged += Handler; + try + { + HostsEntry.BeginPing(); // 0 -> 1 posts "started" through the context + HostsEntry.EndPing(); // 1 -> 0 posts "stopped" through the context + context.PostCount.ShouldBe(2); + fired.ShouldBe(2); + } + finally + { + HostsEntry.PingActivityChanged -= Handler; + HostsEntry.UiSynchronizationContext = null; + } + } + + [TestMethod] + public void PingActivity_NoSubscriber_IsNoOp() + { + // No PingActivityChanged handler attached: RaisePingActivityChanged returns early. + HostsEntry.UiSynchronizationContext = null; + HostsEntry.BeginPing(); + HostsEntry.EndPing(); + HostsEntry.IsPingInProgress.ShouldBeFalse(); + } + + private static bool SpinUntil(Func condition, TimeSpan timeout) + { + var deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds; + while (Environment.TickCount64 < deadline) + { + if (condition()) + { + return true; + } + + Thread.Sleep(50); + } + + return condition(); + } + + /// A synchronization context that runs posted callbacks inline and counts them. + private sealed class RecordingSynchronizationContext : SynchronizationContext + { + public int PostCount { get; private set; } + + public override void Post(SendOrPostCallback d, object? state) + { + PostCount++; + d(state); + } + } +} diff --git a/HostsFileEditor.Core.Tests/HostsEntryListGapTests.cs b/HostsFileEditor.Core.Tests/HostsEntryListGapTests.cs new file mode 100644 index 0000000..bf07f58 --- /dev/null +++ b/HostsFileEditor.Core.Tests/HostsEntryListGapTests.cs @@ -0,0 +1,138 @@ +using HostsFileEditor.Properties; +using HostsFileEditor.Utilities; +using System.ComponentModel; + +namespace HostsFileEditor.Core.Tests; + +/// Targeted coverage for paths not exercised elsewhere. +[TestClass] +public sealed class HostsEntryListGapTests +{ + [TestInitialize] + public void Init() => UndoManager.Instance.ClearHistory(); + + [TestCleanup] + public void Cleanup() + { + // PingAll / MergeLines(auto-ping) start fire-and-forget pings that mutate the process-global + // ping counter on a thread-pool continuation. Drain them before yielding so they can't leak + // into (and flake) the ping-activity tests in other classes. Bounded so a stuck ping can't hang. + var deadline = Environment.TickCount64 + 10_000; + while (HostsEntry.IsPingInProgress && Environment.TickCount64 < deadline) + { + Thread.Sleep(50); + } + } + + [TestMethod] + public void Error_ReportsInvalidEntries_WhenAnyEntryInvalid() + { + var list = new HostsEntryList(["127.0.0.1 localhost", "999.999.999.999 bogus"], filterDefault: false); + list.Error.ShouldBe(Resources.InvalidHostEntries); + } + + [TestMethod] + public void Error_Empty_WhenAllValid() + { + var list = new HostsEntryList(["127.0.0.1 localhost", "10.0.0.1 host.test"], filterDefault: false); + list.Error.ShouldBeEmpty(); + } + + [TestMethod] + public void PingAll_DoesNotThrow() + { + var list = new HostsEntryList(["127.0.0.1 localhost", "# comment"], filterDefault: false); + Should.NotThrow(list.PingAll); + } + + [TestMethod] + public void MergeLines_WithAutoPingOn_PingsAddedEntries() + { + var original = HostsEntry.AutoPingIPAddress; + HostsEntry.AutoPingIPAddress = true; + try + { + var list = new HostsEntryList(["127.0.0.1 localhost"], filterDefault: false); + var added = list.MergeLines(["127.0.0.2 added.test"]); + added.ShouldBe(1); + } + finally + { + HostsEntry.AutoPingIPAddress = original; + } + } + + [TestMethod] + public void MoveBefore_AnchorNotInList_IsNoOp() + { + var list = new HostsEntryList(["127.0.0.1 a", "10.0.0.1 b"], filterDefault: false); + var stranger = new HostsEntry("8.8.8.8 stranger"); + + list.MoveBefore([list[0]], stranger); + + list[0].HostNames.ShouldBe("a"); // unchanged + list[1].HostNames.ShouldBe("b"); + } + + [TestMethod] + public void MoveBefore_AnchorInsideMovingSet_IsNoOp() + { + var list = new HostsEntryList(["127.0.0.1 a", "10.0.0.1 b"], filterDefault: false); + + list.MoveBefore([list[0], list[1]], list[0]); + + list[0].HostNames.ShouldBe("a"); + list[1].HostNames.ShouldBe("b"); + } + + [TestMethod] + public void Duplicate_EmptyCollection_IsNoOp() + { + var list = new HostsEntryList(["127.0.0.1 a"], filterDefault: false); + list.Duplicate([]); + list.Count.ShouldBe(1); + } + + [TestMethod] + public void Duplicate_EntriesNotInList_IsNoOp() + { + var list = new HostsEntryList(["127.0.0.1 a"], filterDefault: false); + var stranger = new HostsEntry("8.8.8.8 stranger"); + + list.Duplicate([stranger]); + + list.Count.ShouldBe(1); + } + + [TestMethod] + public void AddNew_UsesAddNewCore() + { + var list = new HostsEntryList(["127.0.0.1 a"], filterDefault: false); + var added = ((IBindingList)list).AddNew(); + added.ShouldBeOfType(); + } + + [TestMethod] + public void RemoveAt_RegistersUndo_AndRemoves() + { + var list = new HostsEntryList(["127.0.0.1 a", "10.0.0.1 b"], filterDefault: false); + UndoManager.Instance.ClearHistory(); + + list.RemoveAt(0); + + list.Count.ShouldBe(1); + list[0].HostNames.ShouldBe("b"); + UndoManager.Instance.CanUndo.ShouldBeTrue(); + + UndoManager.Instance.Undo(); + list.Count.ShouldBe(2); + } + + [TestMethod] + public void Add_Parameterless_AppendsEmptyEntry() + { + var list = new HostsEntryList(["127.0.0.1 a"], filterDefault: false); + list.Add(); + list.Count.ShouldBe(2); + } +} diff --git a/HostsFileEditor.Core.Tests/HostsFileInstanceGapTests.cs b/HostsFileEditor.Core.Tests/HostsFileInstanceGapTests.cs new file mode 100644 index 0000000..7c370d7 --- /dev/null +++ b/HostsFileEditor.Core.Tests/HostsFileInstanceGapTests.cs @@ -0,0 +1,146 @@ +using System.Reflection; + +namespace HostsFileEditor.Core.Tests; + +/// +/// Coverage for instance behavior that can be driven through the private +/// constructor (no singleton / no live hosts file involved). +/// +[TestClass] +public sealed class HostsFileInstanceGapTests +{ + private string _tempDir = null!; + + [TestInitialize] + public void Init() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + HostsFile.TestBackupHostFilePathOverride = Path.Combine(_tempDir, "hosts.bak"); + } + + [TestCleanup] + public void Cleanup() + { + HostsFile.TestBackupHostFilePathOverride = null; + if (Directory.Exists(_tempDir)) + { + foreach (var file in Directory.GetFiles(_tempDir, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(_tempDir, true); + } + } + + private static HostsFile Create(string path) => (HostsFile)typeof(HostsFile) + .GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, null, [typeof(string)], null)! + .Invoke([path]); + + [TestMethod] + public void Constructor_MissingFile_StartsWithEmptyEntries() + { + var hf = Create(Path.Combine(_tempDir, "does-not-exist")); + hf.Entries.Count.ShouldBe(0); + hf.LineCount.ShouldBe(0); + hf.EnabledCount.ShouldBe(0); + } + + [TestMethod] + public void SaveAs_DriveRoot_ThrowsArgumentException() + { + var file = Path.Combine(_tempDir, "hosts"); + File.WriteAllLines(file, ["127.0.0.1 localhost"]); + var hf = Create(file); + + var root = Directory.GetDirectoryRoot(_tempDir); // e.g. "C:\" — DirectoryName is null + + Should.Throw(() => hf.SaveAs(root)); + } + + [TestMethod] + public void SaveAs_CreatesMissingDirectory() + { + var file = Path.Combine(_tempDir, "hosts"); + File.WriteAllLines(file, ["127.0.0.1 localhost"]); + var hf = Create(file); + + var nested = Path.Combine(_tempDir, "new", "sub", "out.txt"); + hf.SaveAs(nested); + + File.Exists(nested).ShouldBeTrue(); + } + + [TestMethod] + public void Refresh_RemoveDefaultFalse_KeepsDefaultLines() + { + var file = Path.Combine(_tempDir, "hosts"); + File.WriteAllLines(file, HostsEntryList.DefaultLines); + var hf = Create(file); + + hf.Refresh(removeDefault: false); + + hf.Entries.Count.ShouldBe(HostsEntryList.DefaultLines.Length); + } + + [TestMethod] + public void RestoreDefault_LoadsBundledDefault_AndMarksModified() + { + var file = Path.Combine(_tempDir, "hosts"); + File.WriteAllLines(file, ["127.0.0.1 localhost"]); + var hf = Create(file); + + hf.RestoreDefault(); + + hf.Entries.Count.ShouldBeGreaterThan(0); + hf.IsModified.ShouldBeTrue(); // RestoreDefault deliberately leaves the model "needs save" + } + + [TestMethod] + public void Merge_AddsNonDuplicateEntries() + { + var file = Path.Combine(_tempDir, "hosts"); + File.WriteAllLines(file, ["127.0.0.1 localhost"]); + var hf = Create(file); + + var mergeFile = Path.Combine(_tempDir, "merge.txt"); + File.WriteAllLines(mergeFile, ["127.0.0.1 localhost", "9.9.9.9 quad9.test"]); + + var added = hf.Merge(mergeFile); + + added.ShouldBe(1); + hf.Entries.ShouldContain(e => e.HostNames == "quad9.test"); + } + + [TestMethod] + public void DisableWouldOverwriteDifferentFile_UnreadableFile_TreatedAsConflict() + { + var live = Path.Combine(_tempDir, "hosts"); + var disabled = Path.Combine(_tempDir, "hosts.disabled"); + File.WriteAllText(live, "aaaa"); + File.WriteAllText(disabled, "bbbb"); // same length so the compare opens the stream + + // Lock the live file so the content compare throws IOException, which the guard treats as a + // conflict (err on the side of not destroying data). + using var _ = new FileStream(live, FileMode.Open, FileAccess.Read, FileShare.None); + + HostsFile.DisableWouldOverwriteDifferentFile(live, disabled).ShouldBeTrue(); + } + + [TestMethod] + public void IsModified_FlipsWithEditsAndSave() + { + var file = Path.Combine(_tempDir, "hosts"); + File.WriteAllLines(file, ["127.0.0.1 localhost"]); + var hf = Create(file); + + hf.IsModified.ShouldBeFalse(); + + hf.Entries.Add(new HostsEntry("10.0.0.1 added.test")); + hf.IsModified.ShouldBeTrue(); + + hf.Save(); + hf.IsModified.ShouldBeFalse(); + } +} diff --git a/HostsFileEditor.Core.Tests/HostsFileSingletonGapTests.cs b/HostsFileEditor.Core.Tests/HostsFileSingletonGapTests.cs new file mode 100644 index 0000000..91c108a --- /dev/null +++ b/HostsFileEditor.Core.Tests/HostsFileSingletonGapTests.cs @@ -0,0 +1,93 @@ +using HostsFileEditor.Elevation; + +namespace HostsFileEditor.Core.Tests; + +/// +/// Coverage for statics and behavior that only make sense against the process +/// singleton (bound to a temp file by ): the override plumbing, +/// preloading, and the disable-conflict guard. +/// +[TestClass] +public sealed class HostsFileSingletonGapTests +{ + [TestInitialize] + public void Init() + { + PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations(); + + // Re-enable first (moves the disabled copy back to live), THEN clear any leftover disabled file. + if (!HostsFile.IsEnabled) + { + HostsFile.Instance.EnableHostsFile(); + } + + if (File.Exists(HostsFile.DefaultDisabledHostFilePath)) + { + File.Delete(HostsFile.DefaultDisabledHostFilePath); + } + + File.WriteAllLines(HostsFile.DefaultHostFilePath, TestAssemblyInit.SeedLines); + HostsFile.Instance.Refresh(); + } + + [TestCleanup] + public void Cleanup() + { + // Re-enable first so the move has a source, then remove any stray disabled copy. + if (!HostsFile.IsEnabled) + { + HostsFile.Instance.EnableHostsFile(); + } + + if (File.Exists(HostsFile.DefaultDisabledHostFilePath)) + { + File.Delete(HostsFile.DefaultDisabledHostFilePath); + } + } + + [TestMethod] + public void OverridePath_IsActiveDuringTests() + { + HostsFile.OverridePath.ShouldNotBeNull(); + HostsFile.DefaultHostFilePath.ShouldBe(HostsFile.OverridePath); + HostsFile.DefaultDisabledHostFilePath.ShouldEndWith(".disabled"); + } + + [TestMethod] + public void IsEnabled_TrueWhenLiveFilePresent() + { + HostsFile.IsEnabled.ShouldBeTrue(); + } + + [TestMethod] + public async Task PreloadAsync_Completes() + { + await HostsFile.PreloadAsync(); + HostsFile.Instance.Entries.Count.ShouldBeGreaterThan(0); + } + + [TestMethod] + public void DisableHostsFile_DifferentDisabledCopyExists_ThrowsConflict() + { + // A pre-existing, DIFFERENT hosts.disabled must not be silently overwritten (issue #99). + File.WriteAllLines( + HostsFile.DefaultDisabledHostFilePath, + ["10.9.9.9 curated.test", "10.9.9.10 more.test"]); + + Should.Throw(() => HostsFile.Instance.DisableHostsFile()); + + // The guard fired before moving anything: the live file is intact and still enabled. + HostsFile.IsEnabled.ShouldBeTrue(); + } + + [TestMethod] + public void DisableHostsFile_IdenticalDisabledCopy_Succeeds() + { + // A byte-identical hosts.disabled is safe to overwrite, so disabling proceeds. + File.WriteAllLines(HostsFile.DefaultDisabledHostFilePath, TestAssemblyInit.SeedLines); + + HostsFile.Instance.DisableHostsFile(); + + HostsFile.IsEnabled.ShouldBeFalse(); + } +} diff --git a/HostsFileEditor.Core.Tests/MiscCoverageTests.cs b/HostsFileEditor.Core.Tests/MiscCoverageTests.cs new file mode 100644 index 0000000..1f38852 --- /dev/null +++ b/HostsFileEditor.Core.Tests/MiscCoverageTests.cs @@ -0,0 +1,58 @@ +using HostsFileEditor.CommandLine; +using System.Reflection; + +namespace HostsFileEditor.Core.Tests; + +[TestClass] +public sealed class MiscCoverageTests +{ + [TestMethod] + public void ProgramSingleInstance_WmShowFirstInstance_IsRegistered() + { + // Touches the static initializer, which registers the window message via NativeMethods. + ProgramSingleInstance.WmShowFirstInstance.ShouldBeGreaterThan(0); + } + + [TestMethod] + public void ConsoleAttach_AttachToParentConsole_DoesNotThrow() + { + // Under `dotnet test` stdout is redirected, so this takes the "already have a real stdout" + // early-return path. Guard against the rare runner where stdout is NOT a real handle: there + // the method attaches to the parent console and rebinds Console.Out/Error (a process-global, + // irreversible side effect). Snapshot and restore them so this test can never corrupt another + // test's output/logger capture. It must never throw regardless of console state. + var savedOut = Console.Out; + var savedError = Console.Error; + try + { + Should.NotThrow(ConsoleAttach.AttachToParentConsole); + } + finally + { + Console.SetOut(savedOut); + Console.SetError(savedError); + } + } + + [TestMethod] + public void HostsArchiveList_MigrateLegacyArchives_IsSafeNoOp() + { + // With no test override, the private migration runs its real guard checks. It is best-effort + // and must never throw; on a machine whose archive directory already exists (or has no legacy + // directory) it returns immediately without side effects. + var previousOverride = HostsArchiveList.TestArchiveDirectoryOverride; + HostsArchiveList.TestArchiveDirectoryOverride = null; + try + { + var method = typeof(HostsArchiveList).GetMethod( + "MigrateLegacyArchives", + BindingFlags.NonPublic | BindingFlags.Static)!; + + Should.NotThrow(() => method.Invoke(null, null)); + } + finally + { + HostsArchiveList.TestArchiveDirectoryOverride = previousOverride; + } + } +} diff --git a/HostsFileEditor.Core.Tests/SmallCoreCoverageTests.cs b/HostsFileEditor.Core.Tests/SmallCoreCoverageTests.cs new file mode 100644 index 0000000..cf8f20f --- /dev/null +++ b/HostsFileEditor.Core.Tests/SmallCoreCoverageTests.cs @@ -0,0 +1,60 @@ +using HostsFileEditor.Utilities; +using HostsFileEditor.Win32; +using Microsoft.Win32; +using System.Reflection; + +namespace HostsFileEditor.Core.Tests; + +[TestClass] +public sealed class SmallCoreCoverageTests +{ + [TestMethod] + public void FileOpener_TryGetRegisteredApplication_ResolvesRegisteredHandler() + { + // Register a throwaway file association under HKCU\Software\Classes (no admin needed; visible + // through the merged Registry.ClassesRoot view the resolver reads) so the success path — both + // GetClassesRootKeyDefaultValue lookups plus the command cleanup — runs deterministically. + const string ext = ".hfetest"; + const string progId = "hfetestfile"; + using (var classes = Registry.CurrentUser.CreateSubKey(@"Software\Classes")) + { + using var extKey = classes.CreateSubKey(ext); + extKey.SetValue(null, progId); + using var cmdKey = classes.CreateSubKey($@"{progId}\shell\open\command"); + cmdKey.SetValue(null, "\"C:\\tools\\editor.exe\" \"%1\""); + } + + try + { + var method = typeof(FileOpener).GetMethod("TryGetRegisteredApplication", BindingFlags.NonPublic | BindingFlags.Static)!; + var parameters = new object?[] { ext, null }; + + var result = (bool)method.Invoke(null, parameters)!; + + result.ShouldBeTrue(); + var app = (string?)parameters[1]; + app.ShouldNotBeNullOrWhiteSpace(); + app!.ShouldContain("editor.exe"); + } + finally + { + using var classes = Registry.CurrentUser.OpenSubKey(@"Software\Classes", writable: true); + classes?.DeleteSubKeyTree(ext, throwOnMissingSubKey: false); + classes?.DeleteSubKeyTree(progId, throwOnMissingSubKey: false); + } + } + + [TestMethod] + public void NativeMethods_IsRunningPackaged_ReturnsFalseForTestHost() + { + // The test host is a loose (unpackaged) process, so this must report false and never throw. + NativeMethods.IsRunningPackaged().ShouldBeFalse(); + } + + [TestMethod] + public void NativeMethods_RegisterWindowMessage_PlainString_ReturnsId() + { + var id = NativeMethods.RegisterWindowMessage("HFE_TEST_PLAIN_" + Guid.NewGuid().ToString("N")); + id.ShouldBeGreaterThan(0); + } +} diff --git a/HostsFileEditor.Core.Tests/TestAssemblyInit.cs b/HostsFileEditor.Core.Tests/TestAssemblyInit.cs new file mode 100644 index 0000000..6df6a9f --- /dev/null +++ b/HostsFileEditor.Core.Tests/TestAssemblyInit.cs @@ -0,0 +1,73 @@ +namespace HostsFileEditor.Core.Tests; + +/// +/// Assembly-wide setup that redirects the singleton at a throwaway temp file +/// via the sanctioned HFE_HOSTS_PATH override, so tests can exercise the full headless-CLI and +/// enable/disable/save code paths (which go through ) without ever +/// touching the real system hosts file. +/// +/// +/// +/// The override is captured in a static readonly field the first time anything touches +/// . is the very first code MSTest runs, before +/// any test or its initializers, so setting the environment variable and creating the file here — then +/// forcing the singleton to construct — guarantees the singleton binds to our temp file. +/// +/// +/// No test in this assembly touched before this harness existed, so +/// nothing races the capture. Instance-level tests that build their own copy via +/// the private constructor pass an explicit path and are unaffected by the override. +/// +/// +[TestClass] +public static class TestAssemblyInit +{ + /// Directory holding the throwaway hosts file the singleton is bound to for this run. + public static string HostsDirectory { get; private set; } = null!; + + /// The initial content written to the temp hosts file at assembly start. + public static readonly string[] SeedLines = + [ + "127.0.0.1 localhost", + "10.0.0.1 example.test", + "# a plain comment", + ]; + + [AssemblyInitialize] + public static void AssemblyInitialize(TestContext context) + { + ArgumentNullException.ThrowIfNull(context); + + HostsDirectory = Path.Combine(Path.GetTempPath(), "HfeTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(HostsDirectory); + + var hostsPath = Path.Combine(HostsDirectory, "hosts"); + File.WriteAllLines(hostsPath, SeedLines); + + // Bind the singleton to the temp file (env var is read once, on first HostsFile access below). + Environment.SetEnvironmentVariable("HFE_HOSTS_PATH", hostsPath); + HostsFile.TestBackupHostFilePathOverride = Path.Combine(HostsDirectory, "hosts.bak"); + + // Force construction now, under known conditions, so the binding is deterministic. + _ = HostsFile.Instance.Entries.Count; + } + + [AssemblyCleanup] + public static void AssemblyCleanup() + { + Environment.SetEnvironmentVariable("HFE_HOSTS_PATH", null); + HostsFile.TestBackupHostFilePathOverride = null; + + try + { + if (Directory.Exists(HostsDirectory)) + { + Directory.Delete(HostsDirectory, true); + } + } + catch (IOException) + { + // Best-effort cleanup of a temp directory; a leftover here never fails the run. + } + } +} diff --git a/HostsFileEditor.Core/Properties/Resources.resx b/HostsFileEditor.Core/Properties/Resources.resx index 039c016..d5114da 100644 --- a/HostsFileEditor.Core/Properties/Resources.resx +++ b/HostsFileEditor.Core/Properties/Resources.resx @@ -68,6 +68,9 @@ Invalid Host Entries + + Invalid host names + Hosts File Editor Question diff --git a/HostsFileEditor.WinForm.Tests/HostsFileEditor.WinForm.Tests.csproj b/HostsFileEditor.WinForm.Tests/HostsFileEditor.WinForm.Tests.csproj new file mode 100644 index 0000000..15da4df --- /dev/null +++ b/HostsFileEditor.WinForm.Tests/HostsFileEditor.WinForm.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0-windows + true + false + false + + win-x64 + false + + + + + + + + + + + + diff --git a/HostsFileEditor.WinForm.Tests/HostsFilterTests.cs b/HostsFileEditor.WinForm.Tests/HostsFilterTests.cs new file mode 100644 index 0000000..c27fd0e --- /dev/null +++ b/HostsFileEditor.WinForm.Tests/HostsFilterTests.cs @@ -0,0 +1,74 @@ +using Shouldly; + +namespace HostsFileEditor.WinForm.Tests; + +/// +/// Tests the classic edition's Equin view filter, which must delegate the three filter rules to the +/// single canonical predicate (issue #75) rather than restate +/// them — so its Include result stays identical to the modern edition's for the same inputs. +/// +[TestClass] +public sealed class HostsFilterTests +{ + private static readonly HostsEntry Enabled = new("127.0.0.1 localhost"); + private static readonly HostsEntry CommentOnly = new("# just a comment"); + private static readonly HostsEntry Disabled = new("# 10.0.0.1 disabled.test"); // disabled entry, valid IP + + [TestMethod] + public void Include_NoFilters_KeepsEverything() + { + var filter = new HostsFilter(() => string.Empty); + + filter.Include(Enabled).ShouldBeTrue(); + filter.Include(CommentOnly).ShouldBeTrue(); + filter.Include(Disabled).ShouldBeTrue(); + } + + [TestMethod] + public void Include_HideComments_DropsCommentOnlyRows() + { + var filter = new HostsFilter(() => string.Empty) { Comments = true }; + + filter.Include(CommentOnly).ShouldBeFalse(); + filter.Include(Enabled).ShouldBeTrue(); + filter.Include(Disabled).ShouldBeTrue(); + } + + [TestMethod] + public void Include_HideDisabled_DropsDisabledEntriesButNotComments() + { + var filter = new HostsFilter(() => string.Empty) { Disabled = true }; + + filter.Include(Disabled).ShouldBeFalse(); + filter.Include(CommentOnly).ShouldBeTrue(); + filter.Include(Enabled).ShouldBeTrue(); + } + + [TestMethod] + public void Include_TextFilter_IsReadPerCall_AndMatchesCanonicalPredicate() + { + var text = "localhost"; + var filter = new HostsFilter(() => text); + + filter.Include(Enabled).ShouldBeTrue(); + filter.Include(Disabled).ShouldBeFalse(); + + // The provider is read on each Include call, so a changed filter takes effect without re-wiring. + text = "disabled"; + filter.Include(Enabled).ShouldBeFalse(); + filter.Include(Disabled).ShouldBeTrue(); + } + + [TestMethod] + public void Include_HideCommentsAndDisabled_CombineWithConcreteExpectations() + { + // Both flags on: comment-only and disabled rows drop; only enabled entries matching the text + // survive. Concrete expected values (not a mirror of the predicate) so a wiring bug — e.g. + // swapping the Comments/Disabled flags — is actually caught. + var filter = new HostsFilter(() => "localhost") { Comments = true, Disabled = true }; + + filter.Include(Enabled).ShouldBeTrue(); // enabled + matches "localhost" + filter.Include(CommentOnly).ShouldBeFalse(); // hidden by Comments + filter.Include(Disabled).ShouldBeFalse(); // hidden by Disabled (and wouldn't match text anyway) + } +} diff --git a/HostsFileEditor.WinForm/Properties/AssemblyInfo.cs b/HostsFileEditor.WinForm/Properties/AssemblyInfo.cs index dc7fbb2..7123e74 100644 --- a/HostsFileEditor.WinForm/Properties/AssemblyInfo.cs +++ b/HostsFileEditor.WinForm/Properties/AssemblyInfo.cs @@ -19,6 +19,9 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("HostsFileEditor.WinForm.Tests")] [assembly: AssemblyDescription( "Hosts File Editor makes it easy to change your hosts file as well as " + diff --git a/HostsFileEditor.slnx b/HostsFileEditor.slnx index 71afe19..c05f62c 100644 --- a/HostsFileEditor.slnx +++ b/HostsFileEditor.slnx @@ -22,6 +22,9 @@ + + +