diff --git a/.github/workflows/unit-testing.yml b/.github/workflows/unit-testing.yml
new file mode 100644
index 00000000..5fbbae8e
--- /dev/null
+++ b/.github/workflows/unit-testing.yml
@@ -0,0 +1,41 @@
+name: Unit testing (Windows / MSBuild)
+
+on:
+ workflow_dispatch:
+ push:
+ branches: ["master"]
+ pull_request:
+ branches: ["master"]
+ schedule:
+ - cron: "0 0 * * 0" # weekly, Sunday 00:00 UTC
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: windows-latest
+
+ env:
+ SOLUTION_NAME: TechnitiumLibrary.sln
+ BUILD_CONFIGURATION: Debug
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install .NET 9 SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Add MSBuild to PATH
+ uses: microsoft/setup-msbuild@v2
+
+ - name: Restore
+ run: msbuild ${{ env.SOLUTION_NAME }} /t:Restore
+
+ - name: Build
+ run: msbuild ${{ env.SOLUTION_NAME }} /m /p:Configuration=${{ env.BUILD_CONFIGURATION }}
+
+ - name: Test (msbuild)
+ run: msbuild TechnitiumLibrary.UnitTests\TechnitiumLibrary.UnitTests.csproj /t:Test /p:Configuration=${{ env.BUILD_CONFIGURATION }}
\ No newline at end of file
diff --git a/README.md b/README.md
index b329873a..ad146a6c 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,6 @@
# TechnitiumLibrary
A library for .net based applications.
+
+## Quality Assurance
+
+[](https://github.com/TechnitiumSoftware/TechnitiumLibrary/actions/workflows/unit-testing.yml)
\ No newline at end of file
diff --git a/TechnitiumLibrary.UnitTests/MSTestSettings.cs b/TechnitiumLibrary.UnitTests/MSTestSettings.cs
new file mode 100644
index 00000000..e466aa12
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/MSTestSettings.cs
@@ -0,0 +1,3 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs
new file mode 100644
index 00000000..e5a90545
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs
@@ -0,0 +1,80 @@
+/*
+Technitium Library
+Copyright (C) 2026 Shreyas Zare (shreyas@technitium.com)
+Copyright (C) 2026 Zafer Balkan (zafer@zaferbalkan.com)
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using TechnitiumLibrary.Net.Firewall;
+
+namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Firewall
+{
+ [TestClass]
+ public sealed class WindowsFirewallTests
+ {
+ [TestMethod]
+ [OSCondition(OperatingSystems.Windows)]
+ public void AddPort_ShouldThrow_WhenUnsupportedProtocol()
+ {
+ // Protocol ICMPv4 cannot be added using AddPort
+ Assert.ThrowsExactly(() => WindowsFirewall.AddPort("bad", Protocol.ICMPv4, port: 55, enable: true));
+ }
+
+ [TestMethod]
+ [OSCondition(OperatingSystems.Windows)]
+ public void RemovePort_ShouldThrow_WhenUnsupportedProtocol()
+ {
+ // RemovePort validates only TCP, UDP, ANY
+ Assert.ThrowsExactly(() => WindowsFirewall.RemovePort(Protocol.IGMP, 123));
+ }
+
+ [TestMethod]
+ [OSCondition(OperatingSystems.Windows)]
+ public void PortExists_ShouldThrow_WhenUnsupportedProtocol()
+ {
+ Assert.ThrowsExactly(() => WindowsFirewall.PortExists(Protocol.IGMP, 44));
+ }
+
+ [TestMethod]
+ [OSCondition(OperatingSystems.Windows)]
+ public void RuleExistsVista_ShouldReturnDoesNotExist_WhenInputsClearlyNotMatchingAnything()
+ {
+ // Since firewall is not guaranteed to have this rule,
+ // safest expected response is DoesNotExists.
+ RuleStatus result = WindowsFirewall.RuleExistsVista(
+ name: "__Definitely_Not_A_Real_Rule__",
+ applicationPath: "__Fake__");
+
+ Assert.AreEqual(RuleStatus.DoesNotExists, result);
+ }
+
+ [TestMethod]
+ [OSCondition(OperatingSystems.Windows)]
+ public void ApplicationExists_ShouldReturnDoesNotExist_WhenApplicationIsNotRegistered()
+ {
+ // Public observable guarantee:
+ // if the system has no such application entry → DoesNotExists
+
+ const string fakePath = "C:\\DefinitelyNotExisting\\app.exe";
+
+ RuleStatus status = WindowsFirewall.ApplicationExists(fakePath);
+
+ Assert.AreEqual(RuleStatus.DoesNotExists, status);
+ }
+ }
+}
diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj
new file mode 100644
index 00000000..744a928c
--- /dev/null
+++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net9.0
+ latest
+ disable
+ enable
+ true
+
+
+
+
+
+
+
diff --git a/TechnitiumLibrary.sln b/TechnitiumLibrary.sln
index 9cbcda3e..bfc3298a 100644
--- a/TechnitiumLibrary.sln
+++ b/TechnitiumLibrary.sln
@@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary", "Techni
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary.Security.OTP", "TechnitiumLibrary.Security.OTP\TechnitiumLibrary.Security.OTP.csproj", "{72AF4EB6-EB81-4655-9998-8BF24B304614}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary.UnitTests", "TechnitiumLibrary.UnitTests\TechnitiumLibrary.UnitTests.csproj", "{D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -75,6 +77,10 @@ Global
{72AF4EB6-EB81-4655-9998-8BF24B304614}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72AF4EB6-EB81-4655-9998-8BF24B304614}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72AF4EB6-EB81-4655-9998-8BF24B304614}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D0CD41D8-E5F0-4EEF-81E3-587A2A877C49}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/TechnitiumLibrary/Base32.cs b/TechnitiumLibrary/Base32.cs
index e5d6b8b2..464590e3 100644
--- a/TechnitiumLibrary/Base32.cs
+++ b/TechnitiumLibrary/Base32.cs
@@ -317,4 +317,4 @@ public static byte[] FromBase32HexString(string data)
#endregion
}
-}
+}
\ No newline at end of file
diff --git a/TechnitiumLibrary/CollectionExtensions.cs b/TechnitiumLibrary/CollectionExtensions.cs
index 91a04cd1..539f8dc4 100644
--- a/TechnitiumLibrary/CollectionExtensions.cs
+++ b/TechnitiumLibrary/CollectionExtensions.cs
@@ -112,4 +112,4 @@ public static int GetArrayHashCode(this IReadOnlyCollection value)
return hashCode;
}
}
-}
+}
\ No newline at end of file