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 + +[![Unit testing (Windows / MSBuild)](https://github.com/TechnitiumSoftware/TechnitiumLibrary/actions/workflows/unit-testing.yml/badge.svg)](https://github.com/TechnitiumSoftware/TechnitiumLibrary/actions/workflows/unit-testing.yml) \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/CanonicallySerializedResourceRecordTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/CanonicallySerializedResourceRecordTests.cs new file mode 100644 index 00000000..1ce0619b --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/CanonicallySerializedResourceRecordTests.cs @@ -0,0 +1,86 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Net; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class CanonicallySerializedResourceRecordTests + { + [TestMethod] + public void CompareTo_UsesCanonicalRdataOrdering() + { + using MemoryStream buffer = new(); + + var low = CanonicallySerializedResourceRecord.Create( + "example.com", + DnsResourceRecordType.A, + DnsClass.IN, + 60, + new DnsARecordData(IPAddress.Parse("192.0.2.1")), + buffer); + + var high = CanonicallySerializedResourceRecord.Create( + "example.com", + DnsResourceRecordType.A, + DnsClass.IN, + 60, + new DnsARecordData(IPAddress.Parse("192.0.2.200")), + buffer); + + Assert.IsTrue(low.CompareTo(high) < 0); + Assert.IsTrue(high.CompareTo(low) > 0); + } + + [TestMethod] + public void Create_CanonicalizesOwnerName_ToLowercase() + { + using MemoryStream buffer = new(); + + var record = CanonicallySerializedResourceRecord.Create( + name: "Example.COM", + type: DnsResourceRecordType.A, + @class: DnsClass.IN, + originalTtl: 3600, + rData: new DnsARecordData(IPAddress.Parse("192.0.2.1")), + buffer: buffer); + + using MemoryStream ms = new(); + record.WriteTo(ms); + + string wire = System.Text.Encoding.ASCII.GetString(ms.ToArray()); + StringAssert.Contains(wire, "example"); + } + + [TestMethod] + public void WriteTo_IsDeterministic_ForSameInput() + { + using MemoryStream buffer = new(); + + var a = CanonicallySerializedResourceRecord.Create( + "example.com", + DnsResourceRecordType.A, + DnsClass.IN, + 60, + new DnsARecordData(IPAddress.Parse("192.0.2.1")), + buffer); + + var b = CanonicallySerializedResourceRecord.Create( + "example.com", + DnsResourceRecordType.A, + DnsClass.IN, + 60, + new DnsARecordData(IPAddress.Parse("192.0.2.1")), + buffer); + + using MemoryStream m1 = new(); + using MemoryStream m2 = new(); + + a.WriteTo(m1); + b.WriteTo(m2); + + CollectionAssert.AreEqual(m1.ToArray(), m2.ToArray()); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAAAARecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAAAARecordDataTests.cs new file mode 100644 index 00000000..296d4668 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAAAARecordDataTests.cs @@ -0,0 +1,106 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Net; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsAAAARecordDataTests + { + [TestMethod] + public void Constructor_IPv4Address_Throws() + { + IPAddress ipv4 = IPAddress.Parse("192.0.2.1"); + + Assert.ThrowsExactly(() => + new DnsAAAARecordData(ipv4)); + } + + [TestMethod] + public void Constructor_ValidIPv6Address_Succeeds() + { + IPAddress address = IPAddress.Parse("2001:db8::1"); + + var rdata = new DnsAAAARecordData(address); + + Assert.AreEqual(address, rdata.Address); + Assert.AreEqual(16, rdata.UncompressedLength); + } + + [TestMethod] + public void Equals_DifferentAddress_IsFalse() + { + var a = new DnsAAAARecordData(IPAddress.Parse("2001:db8::1")); + var b = new DnsAAAARecordData(IPAddress.Parse("2001:db8::2")); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameAddress_IsTrue() + { + IPAddress address = IPAddress.Parse("2001:db8::1"); + + var a = new DnsAAAARecordData(address); + var b = new DnsAAAARecordData(IPAddress.Parse("2001:db8::1")); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + IPAddress address = IPAddress.Parse("2001:db8::dead:beef"); + + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.AAAA, + DnsClass.IN, + 300, + new DnsAAAARecordData(address)); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + var rdata = new DnsAAAARecordData(IPAddress.Parse("2001:db8::1")); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "IPAddress"); + StringAssert.Contains(json, "2001:db8::1"); + } + + [TestMethod] + public void UncompressedLength_IsAlways16() + { + var rdata = new DnsAAAARecordData(IPAddress.Parse("2001:db8::abcd")); + + Assert.AreEqual(16, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsALIASRecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsALIASRecordDataTests.cs new file mode 100644 index 00000000..4dd28098 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsALIASRecordDataTests.cs @@ -0,0 +1,114 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsALIASRecordDataTests + { + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + var rdata = new DnsALIASRecordData( + DnsResourceRecordType.A, + "example.net."); + + Assert.AreEqual(DnsResourceRecordType.A, rdata.Type); + Assert.AreEqual("example.net", rdata.Domain); // It is expected to remove explicit root dot + } + + [TestMethod] + public void Equals_DifferentType_IsFalse() + { + var a = new DnsALIASRecordData( + DnsResourceRecordType.A, + "example.com."); + + var b = new DnsALIASRecordData( + DnsResourceRecordType.AAAA, + "example.com."); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameTypeAndDomain_IgnoresCase() + { + var a = new DnsALIASRecordData( + DnsResourceRecordType.AAAA, + "Example.COM."); + + var b = new DnsALIASRecordData( + DnsResourceRecordType.AAAA, + "example.com."); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.ALIAS, + DnsClass.IN, + 300, + new DnsALIASRecordData( + DnsResourceRecordType.A, + "target.example.")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + var rdata = new DnsALIASRecordData( + DnsResourceRecordType.AAAA, + "example.net."); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "Type"); + StringAssert.Contains(json, "AAAA"); + StringAssert.Contains(json, "Domain"); + StringAssert.Contains(json, "example.net"); + } + + [TestMethod] + public void UncompressedLength_IncludesTypePrefix() + { + var rdata = new DnsALIASRecordData( + DnsResourceRecordType.A, + "example.com."); + + int baseLength = rdata.Domain.Length; // not exact, but sanity check + + Assert.IsTrue(rdata.UncompressedLength > baseLength); + Assert.IsTrue(rdata.UncompressedLength >= 2); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAPLRecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAPLRecordDataTests.cs new file mode 100644 index 00000000..b5a1d55e --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAPLRecordDataTests.cs @@ -0,0 +1,141 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsAPLRecordDataTests + { + [TestMethod] + public void Constructor_SingleNetworkAddress_Succeeds() + { + var network = new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24); + + var rdata = new DnsAPLRecordData(network, negation: false); + + Assert.HasCount(1, rdata.APItems); + Assert.AreEqual(IanaAddressFamily.IPv4, rdata.APItems.First().AddressFamily); + Assert.AreEqual(24, rdata.APItems.First().Prefix); + Assert.IsFalse(rdata.APItems.First().Negation); + } + + [TestMethod] + public void APItem_NegationFlag_Preserved() + { + var network = new NetworkAddress(IPAddress.Parse("2001:db8::"), 32); + + var item = new DnsAPLRecordData.APItem(network, negation: true); + + Assert.IsTrue(item.Negation); + Assert.AreEqual(IanaAddressFamily.IPv6, item.AddressFamily); + } + + [TestMethod] + public void Equals_SameItems_AreEqual() + { + var list1 = new List + { + new(new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false), + new(new NetworkAddress(IPAddress.Parse("198.51.100.0"), 24), true) + }; + + var list2 = new List + { + new(new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false), + new(new NetworkAddress(IPAddress.Parse("198.51.100.0"), 24), true) + }; + + var a = new DnsAPLRecordData(list1); + var b = new DnsAPLRecordData(list2); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentItems_AreNotEqual() + { + var a = new DnsAPLRecordData( + new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false); + + var b = new DnsAPLRecordData( + new NetworkAddress(IPAddress.Parse("192.0.2.0"), 25), false); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var rdata = new DnsAPLRecordData(new List + { + new(new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false), + new(new NetworkAddress(IPAddress.Parse("2001:db8::"), 32), true) + }); + + var rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.APL, + DnsClass.IN, + 300, + rdata); + + byte[] wire = Serialize(rr); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(rr, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesJsonArray() + { + var rdata = new DnsAPLRecordData( + new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.StartsWith(json, "["); + StringAssert.Contains(json, "IPv4"); + StringAssert.Contains(json, "Prefix"); + } + + [TestMethod] + public void UncompressedLength_MatchesSumOfItems() + { + var items = new List + { + new(new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false), + new(new NetworkAddress(IPAddress.Parse("198.51.100.0"), 25), true) + }; + + var rdata = new DnsAPLRecordData(items); + + int expected = 0; + foreach (var item in items) + expected += item.UncompressedLength; + + Assert.AreEqual(expected, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsARecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsARecordDataTests.cs new file mode 100644 index 00000000..d023a898 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsARecordDataTests.cs @@ -0,0 +1,110 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Net; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsARecordDataTests + { + [TestMethod] + public void Constructor_NonIPv4_Throws() + { + var ipv6 = IPAddress.Parse("2001:db8::1"); + + Assert.ThrowsExactly(() => + new DnsARecordData(ipv6)); + } + + [TestMethod] + public void Constructor_ValidIPv4_Succeeds() + { + var ip = IPAddress.Parse("192.0.2.1"); + + var rdata = new DnsARecordData(ip); + + Assert.AreEqual(ip, rdata.Address); + Assert.AreEqual(4, rdata.UncompressedLength); + } + [TestMethod] + public void Equals_DifferentAddress_AreNotEqual() + { + var a = new DnsARecordData(IPAddress.Parse("203.0.113.1")); + var b = new DnsARecordData(IPAddress.Parse("203.0.113.2")); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameAddress_AreEqual() + { + var a = new DnsARecordData(IPAddress.Parse("203.0.113.10")); + var b = new DnsARecordData(IPAddress.Parse("203.0.113.10")); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.A, + DnsClass.IN, + 300, + new DnsARecordData(IPAddress.Parse("192.0.2.55"))); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + var rdata = new DnsARecordData(IPAddress.Parse("198.51.100.42")); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "IPAddress"); + StringAssert.Contains(json, "198.51.100.42"); + } + + [TestMethod] + public void UncompressedLength_MatchesWireRdataLength() + { + var rdata = new DnsARecordData(IPAddress.Parse("192.0.2.9")); + + var rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.A, + DnsClass.IN, + 60, + rdata); + + byte[] wire = Serialize(rr); + + Assert.IsTrue(rdata.UncompressedLength == 4); + Assert.IsTrue(wire.Length >= rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsApplicationRecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsApplicationRecordDataTests.cs new file mode 100644 index 00000000..2426e478 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsApplicationRecordDataTests.cs @@ -0,0 +1,125 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsApplicationRecordDataTests + { + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + var rdata = new DnsApplicationRecordData( + "myApp", + "com.example.MyClass", + "{\"key\":\"value\"}"); + + Assert.AreEqual("myApp", rdata.AppName); + Assert.AreEqual("com.example.MyClass", rdata.ClassPath); + Assert.AreEqual("{\"key\":\"value\"}", rdata.Data); + } + + [TestMethod] + public void Constructor_InvalidJson_Throws() + { + Assert.ThrowsExactly(() => + new DnsApplicationRecordData( + "app", + "path", + "{invalid-json")); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + var a = new DnsApplicationRecordData("a", "b", "c"); + var b = new DnsApplicationRecordData("a", "b", "c"); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentData_AreNotEqual() + { + var a = new DnsApplicationRecordData("a", "b", "c"); + var b = new DnsApplicationRecordData("a", "b", "d"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_ParsesAsUnknownRecord() + { + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.NULL, + DnsClass.IN, + 60, + new DnsApplicationRecordData( + "app", + "class.path", + "payload")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(DnsResourceRecordType.NULL, parsed.Type); + Assert.IsInstanceOfType(parsed.RDATA, typeof(DnsUnknownRecordData)); + } + + [TestMethod] + public void SerializeTo_ProducesValidJson() + { + var rdata = new DnsApplicationRecordData( + "app", + "path", + "data"); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "AppName"); + StringAssert.Contains(json, "ClassPath"); + StringAssert.Contains(json, "Data"); + } + + [TestMethod] + public void UncompressedLength_MatchesWireLength() + { + var rdata = new DnsApplicationRecordData( + "a", + "b", + "c"); + + var rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.NULL, + DnsClass.IN, + 60, + rdata); + + byte[] wire = Serialize(rr); + + Assert.IsTrue(rdata.UncompressedLength > 0); + Assert.IsTrue(wire.Length >= rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCAARecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCAARecordDataTests.cs new file mode 100644 index 00000000..ec56f1af --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCAARecordDataTests.cs @@ -0,0 +1,142 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsCAARecordDataTests + { + [TestMethod] + public void Constructor_EmptyTag_Throws() + { + Assert.ThrowsExactly(() => + new DnsCAARecordData( + 0, + "", + "value")); + } + + [TestMethod] + public void Constructor_TagIsLowercased() + { + var rdata = new DnsCAARecordData( + 0, + "ISSUE", + "ca.example"); + + Assert.AreEqual("issue", rdata.Tag); + } + + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + var rdata = new DnsCAARecordData( + flags: 0, + tag: "issue", + value: "letsencrypt.org"); + + Assert.AreEqual((byte)0, rdata.Flags); + Assert.AreEqual("issue", rdata.Tag); + Assert.AreEqual("letsencrypt.org", rdata.Value); + } + + [TestMethod] + public void Equals_DifferentFlags_AreNotEqual() + { + var a = new DnsCAARecordData(0, "issue", "ca.example"); + var b = new DnsCAARecordData(128, "issue", "ca.example"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_DifferentValue_AreNotEqual() + { + var a = new DnsCAARecordData(0, "issue", "ca.example"); + var b = new DnsCAARecordData(0, "issue", "other.example"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + var a = new DnsCAARecordData(0, "issue", "ca.example"); + var b = new DnsCAARecordData(0, "ISSUE", "ca.example"); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.CAA, + DnsClass.IN, + 300, + new DnsCAARecordData( + 0, + "issue", + "letsencrypt.org")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + var rdata = new DnsCAARecordData( + 128, + "iodef", + "mailto:security@example.com"); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "Flags"); + StringAssert.Contains(json, "128"); + StringAssert.Contains(json, "Tag"); + StringAssert.Contains(json, "iodef"); + StringAssert.Contains(json, "Value"); + StringAssert.Contains(json, "mailto:security@example.com"); + } + + [TestMethod] + public void UncompressedLength_MatchesExpectedSize() + { + var rdata = new DnsCAARecordData( + 0, + "issue", + "ca.example"); + + int expected = + 1 + // flags + 1 + // tag length + "issue".Length + + "ca.example".Length; + + Assert.AreEqual(expected, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCNAMERecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCNAMERecordDataTests.cs new file mode 100644 index 00000000..1ec1682e --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCNAMERecordDataTests.cs @@ -0,0 +1,115 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsCNAMERecordDataTests + { + [TestMethod] + public void Constructor_IDNDomain_IsConvertedToAscii() + { + var rdata = new DnsCNAMERecordData("bücher.example"); + + Assert.AreEqual("xn--bcher-kva.example", rdata.Domain); + } + + [TestMethod] + public void Constructor_InvalidDomain_Throws() + { + Assert.ThrowsExactly(() => + new DnsCNAMERecordData("invalid..domain")); + } + + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + var rdata = new DnsCNAMERecordData("example.net."); + + Assert.AreEqual("example.net", rdata.Domain); + } + + [TestMethod] + public void Equals_DifferentDomain_IsFalse() + { + var a = new DnsCNAMERecordData("a.example."); + var b = new DnsCNAMERecordData("b.example."); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameDomain_IgnoresCase() + { + var a = new DnsCNAMERecordData("Example.COM."); + var b = new DnsCNAMERecordData("example.com."); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var original = new DnsResourceRecord( + "www", + DnsResourceRecordType.CNAME, + DnsClass.IN, + 300, + new DnsCNAMERecordData("target.example.")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + var rdata = new DnsCNAMERecordData("example.net."); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "Domain"); + StringAssert.Contains(json, "example.net"); + } + + [TestMethod] + public void UncompressedLength_IsPositiveAndConsistent() + { + var rdata = new DnsCNAMERecordData("example.org."); + + Assert.IsTrue(rdata.UncompressedLength > 0); + + var rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.CNAME, + DnsClass.IN, + 60, + rdata); + + byte[] wire = Serialize(rr); + + Assert.IsTrue(wire.Length >= rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNAMERecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNAMERecordDataTests.cs new file mode 100644 index 00000000..bd794ed3 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNAMERecordDataTests.cs @@ -0,0 +1,121 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsDNAMERecordDataTests + { + [TestMethod] + public void Constructor_ValidInput_NormalizesDomain() + { + var rdata = new DnsDNAMERecordData("Example.COM."); + + Assert.AreEqual("example.com", rdata.Domain); + } + + [TestMethod] + public void Equals_DifferentDomain_IsFalse() + { + var a = new DnsDNAMERecordData("example.com."); + var b = new DnsDNAMERecordData("example.net."); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameDomain_IgnoresCase() + { + var a = new DnsDNAMERecordData("Example.COM."); + var b = new DnsDNAMERecordData("example.com."); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.DNAME, + DnsClass.IN, + 300, + new DnsDNAMERecordData("target.example.")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + var rdata = new DnsDNAMERecordData("example.net."); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "Domain"); + StringAssert.Contains(json, "example.net"); + } + + [TestMethod] + public void Substitute_QnameNotInOwnerSubtree_Throws() + { + var rdata = new DnsDNAMERecordData("target.example."); + + Assert.ThrowsExactly(() => + rdata.Substitute( + qname: "www.other.com", + owner: "example.com")); + } + + [TestMethod] + public void Substitute_ReplacesOwnerSuffix_PerRFC6672() + { + var rdata = new DnsDNAMERecordData("target.example."); + + string result = rdata.Substitute( + qname: "www.sub.example.com", + owner: "example.com"); + + Assert.AreEqual("www.sub.target.example", result); + } + + [TestMethod] + public void Substitute_ToRoot_RemovesOwnerSuffix() + { + var rdata = new DnsDNAMERecordData(""); + + string result = rdata.Substitute( + qname: "www.example.com", + owner: "example.com"); + + Assert.AreEqual("www", result); + } + [TestMethod] + public void UncompressedLength_IsNonZero() + { + var rdata = new DnsDNAMERecordData("example.com."); + + Assert.IsTrue(rdata.UncompressedLength > 0); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNSKEYRecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNSKEYRecordDataTests.cs new file mode 100644 index 00000000..3cefbf1d --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNSKEYRecordDataTests.cs @@ -0,0 +1,187 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.Dnssec; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsDNSKEYRecordDataTests + { + private static DnssecPublicKey CreateTestRsaKey() + { + // Minimal RSA public key material for deterministic testing + // (exponent + modulus per DNSSEC wire format) + byte[] rawKey = + { + 0x01, 0x00, 0x01, // exponent 65537 + 0xAA, 0xBB, 0xCC, 0xDD, 0xEE // dummy modulus bytes + }; + + return DnssecPublicKey.Parse( + DnssecAlgorithm.RSASHA256, + rawKey); + } + + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + var key = CreateTestRsaKey(); + + var rdata = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key); + + Assert.AreEqual(DnsDnsKeyFlag.ZoneKey, rdata.Flags); + Assert.AreEqual((byte)3, rdata.Protocol); + Assert.AreEqual(DnssecAlgorithm.RSASHA256, rdata.Algorithm); + Assert.HasCount(key.RawPublicKey.Length, rdata.PublicKey.RawPublicKey); + Assert.IsGreaterThan(0, rdata.ComputedKeyTag); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + var key = CreateTestRsaKey(); + + var a = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey | DnsDnsKeyFlag.SecureEntryPoint, + 3, + DnssecAlgorithm.RSASHA256, + key); + + var b = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey | DnsDnsKeyFlag.SecureEntryPoint, + 3, + DnssecAlgorithm.RSASHA256, + key); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentAlgorithm_IsFalse() + { + var key = CreateTestRsaKey(); + + var a = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key); + + var b = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA1, + key); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var key = CreateTestRsaKey(); + + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.DNSKEY, + DnsClass.IN, + 3600, + new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key)); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void CreateDS_And_IsDnsKeyValid_WorkTogether() + { + var key = CreateTestRsaKey(); + + var dnskey = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key); + + var ds = dnskey.CreateDS( + "Example.COM.", + DnssecDigestType.SHA256); + + Assert.IsTrue( + dnskey.IsDnsKeyValid("example.com.", ds), + "DNSKEY must validate its own DS regardless of case"); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + var key = CreateTestRsaKey(); + + var rdata = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "Flags"); + StringAssert.Contains(json, "Protocol"); + StringAssert.Contains(json, "Algorithm"); + StringAssert.Contains(json, "PublicKey"); + StringAssert.Contains(json, "ComputedKeyTag"); + } + + [TestMethod] + public void UncompressedLength_MatchesWireRdataLength() + { + var key = CreateTestRsaKey(); + + var rdata = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key); + + var rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.DNSKEY, + DnsClass.IN, + 3600, + rdata); + + byte[] wire = Serialize(rr); + + Assert.IsGreaterThan(0, rdata.UncompressedLength); + Assert.IsGreaterThanOrEqualTo(rdata.UncompressedLength, wire.Length); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDSRecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDSRecordDataTests.cs new file mode 100644 index 00000000..6411ffc5 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDSRecordDataTests.cs @@ -0,0 +1,177 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsDSRecordDataTests + { + [TestMethod] + public void Constructor_InvalidDigestLength_Throws() + { + byte[] invalidDigest = new byte[10]; + + Assert.ThrowsExactly(() => + new DnsDSRecordData( + keyTag: 1, + algorithm: DnssecAlgorithm.RSASHA256, + digestType: DnssecDigestType.SHA256, + digest: invalidDigest)); + } + + [TestMethod] + public void Constructor_ValidSHA256_Succeeds() + { + byte[] digest = new byte[32]; + Random.Shared.NextBytes(digest); + + var rdata = new DnsDSRecordData( + keyTag: 12345, + algorithm: DnssecAlgorithm.RSASHA256, + digestType: DnssecDigestType.SHA256, + digest: digest); + + Assert.AreEqual((ushort)12345, rdata.KeyTag); + Assert.AreEqual(DnssecAlgorithm.RSASHA256, rdata.Algorithm); + Assert.AreEqual(DnssecDigestType.SHA256, rdata.DigestType); + CollectionAssert.AreEqual(digest, rdata.Digest); + } + + [TestMethod] + public void Equals_DifferentDigest_IsFalse() + { + byte[] digestA = new byte[20]; + byte[] digestB = new byte[20]; + Random.Shared.NextBytes(digestA); + Random.Shared.NextBytes(digestB); + + var a = new DnsDSRecordData( + 10, + DnssecAlgorithm.RSASHA1, + DnssecDigestType.SHA1, + digestA); + + var b = new DnsDSRecordData( + 10, + DnssecAlgorithm.RSASHA1, + DnssecDigestType.SHA1, + digestB); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + byte[] digest = new byte[20]; + Random.Shared.NextBytes(digest); + + var a = new DnsDSRecordData( + 10, + DnssecAlgorithm.RSASHA1, + DnssecDigestType.SHA1, + digest); + + var b = new DnsDSRecordData( + 10, + DnssecAlgorithm.RSASHA1, + DnssecDigestType.SHA1, + digest); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void IsDigestTypeSupported_WorksAsSpecified() + { + Assert.IsTrue(DnsDSRecordData.IsDigestTypeSupported(DnssecDigestType.SHA1)); + Assert.IsTrue(DnsDSRecordData.IsDigestTypeSupported(DnssecDigestType.SHA256)); + Assert.IsFalse(DnsDSRecordData.IsDigestTypeSupported(DnssecDigestType.GOST_R_34_11_94)); + } + + [TestMethod] + public void IsDnssecAlgorithmSupported_WorksAsSpecified() + { + Assert.IsTrue(DnsDSRecordData.IsDnssecAlgorithmSupported(DnssecAlgorithm.RSASHA256)); + Assert.IsTrue(DnsDSRecordData.IsDnssecAlgorithmSupported(DnssecAlgorithm.ED25519)); + Assert.IsFalse(DnsDSRecordData.IsDnssecAlgorithmSupported(DnssecAlgorithm.RSAMD5)); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + byte[] digest = new byte[32]; + Random.Shared.NextBytes(digest); + + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.DS, + DnsClass.IN, + 3600, + new DnsDSRecordData( + 54321, + DnssecAlgorithm.RSASHA256, + DnssecDigestType.SHA256, + digest)); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + byte[] digest = new byte[32]; + Random.Shared.NextBytes(digest); + + var rdata = new DnsDSRecordData( + 100, + DnssecAlgorithm.RSASHA256, + DnssecDigestType.SHA256, + digest); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "KeyTag"); + StringAssert.Contains(json, "Algorithm"); + StringAssert.Contains(json, "DigestType"); + StringAssert.Contains(json, "Digest"); + } + + [TestMethod] + public void UncompressedLength_MatchesExpected() + { + byte[] digest = new byte[48]; + Random.Shared.NextBytes(digest); + + var rdata = new DnsDSRecordData( + 1, + DnssecAlgorithm.ECDSAP384SHA384, + DnssecDigestType.SHA384, + digest); + + Assert.AreEqual(2 + 1 + 1 + 48, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsForwarderRecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsForwarderRecordDataTests.cs new file mode 100644 index 00000000..b6bb7fe6 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsForwarderRecordDataTests.cs @@ -0,0 +1,212 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsForwarderRecordDataTests + { + [TestMethod] + public void Constructor_MinimalValidInput_Succeeds() + { + var rdata = new DnsForwarderRecordData( + DnsTransportProtocol.Udp, + "8.8.8.8", + dnssecValidation: false, + proxyType: DnsForwarderRecordProxyType.None, + proxyAddress: null, + proxyPort: 0, + proxyUsername: null, + proxyPassword: null, + priority: 10); + + Assert.AreEqual(DnsTransportProtocol.Udp, rdata.Protocol); + Assert.AreEqual("8.8.8.8", rdata.Forwarder); + Assert.AreEqual(10, rdata.Priority); + Assert.IsFalse(rdata.DnssecValidation); + Assert.AreEqual(DnsForwarderRecordProxyType.None, rdata.ProxyType); + } + + [TestMethod] + public void Equals_PartialRecord_IgnoresOptionalFields() + { + var partial = DnsForwarderRecordData.CreatePartialRecordData( + DnsTransportProtocol.Udp, + "9.9.9.9"); + + var full = new DnsForwarderRecordData( + DnsTransportProtocol.Udp, + "9.9.9.9", + dnssecValidation: true, + proxyType: DnsForwarderRecordProxyType.Http, + proxyAddress: "proxy.local", + proxyPort: 8080, + proxyUsername: "user", + proxyPassword: "pass", + priority: 100); + + Assert.IsTrue(partial.Equals(full)); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + var a = new DnsForwarderRecordData( + DnsTransportProtocol.Tcp, + "1.1.1.1", + true, + DnsForwarderRecordProxyType.None, + null, + 0, + null, + null, + 1); + + var b = new DnsForwarderRecordData( + DnsTransportProtocol.Tcp, + "1.1.1.1", + true, + DnsForwarderRecordProxyType.None, + null, + 0, + null, + null, + 1); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void GetProxy_ReturnsConfiguredProxy() + { + var rdata = new DnsForwarderRecordData( + DnsTransportProtocol.Tcp, + "8.8.8.8", + dnssecValidation: false, + proxyType: DnsForwarderRecordProxyType.Socks5, + proxyAddress: "proxy.local", + proxyPort: 1080, + proxyUsername: "u", + proxyPassword: "p", + priority: 0); + + NetProxy proxy = rdata.GetProxy(null); + + Assert.IsNotNull(proxy); + Assert.AreEqual(NetProxyType.Socks5, proxy.Type); + } + + [TestMethod] + public void HttpProxy_IsSerializedAndParsedCorrectly() + { + var rdata = new DnsForwarderRecordData( + DnsTransportProtocol.Tcp, + "1.1.1.1", + dnssecValidation: true, + proxyType: DnsForwarderRecordProxyType.Http, + proxyAddress: "proxy.example", + proxyPort: 3128, + proxyUsername: "user", + proxyPassword: "pass", + priority: 20); + + var rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.FWD, + DnsClass.IN, + 60, + rdata); + + byte[] wire = Serialize(rr); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(rr, parsed); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.FWD, + DnsClass.IN, + 60, + new DnsForwarderRecordData( + DnsTransportProtocol.Tcp, + "8.8.4.4", + dnssecValidation: true, + proxyType: DnsForwarderRecordProxyType.None, + proxyAddress: null, + proxyPort: 0, + proxyUsername: null, + proxyPassword: null, + priority: 5)); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + var rdata = new DnsForwarderRecordData( + DnsTransportProtocol.Udp, + "8.8.8.8", + dnssecValidation: false, + proxyType: DnsForwarderRecordProxyType.None, + proxyAddress: null, + proxyPort: 0, + proxyUsername: null, + proxyPassword: null, + priority: 1); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "Protocol"); + StringAssert.Contains(json, "Forwarder"); + StringAssert.Contains(json, "Priority"); + StringAssert.Contains(json, "DnssecValidation"); + } + + [TestMethod] + public void UncompressedLength_IsNonZero() + { + var rdata = new DnsForwarderRecordData( + DnsTransportProtocol.Udp, + "1.1.1.1", + false, + DnsForwarderRecordProxyType.None, + null, + 0, + null, + null, + 0); + + Assert.IsTrue(rdata.UncompressedLength > 0); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsHINFORecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsHINFORecordDataTests.cs new file mode 100644 index 00000000..e14047ac --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsHINFORecordDataTests.cs @@ -0,0 +1,106 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsHINFORecordDataTests + { + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + var rdata = new DnsHINFORecordData( + cpu: "INTEL", + os: "LINUX"); + + Assert.AreEqual("INTEL", rdata.CPU); + Assert.AreEqual("LINUX", rdata.OS); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + var a = new DnsHINFORecordData("AMD64", "WINDOWS"); + var b = new DnsHINFORecordData("AMD64", "WINDOWS"); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentCpu_IsFalse() + { + var a = new DnsHINFORecordData("INTEL", "LINUX"); + var b = new DnsHINFORecordData("ARM", "LINUX"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_DifferentOs_IsFalse() + { + var a = new DnsHINFORecordData("INTEL", "LINUX"); + var b = new DnsHINFORecordData("INTEL", "BSD"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.HINFO, + DnsClass.IN, + 3600, + new DnsHINFORecordData("INTEL", "LINUX")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + var rdata = new DnsHINFORecordData("ARM", "IOS"); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains(json, "CPU"); + StringAssert.Contains(json, "ARM"); + StringAssert.Contains(json, "OS"); + StringAssert.Contains(json, "IOS"); + } + + [TestMethod] + public void UncompressedLength_MatchesExpectedFormula() + { + var rdata = new DnsHINFORecordData("CPU", "OS"); + + int expected = + 1 + "CPU".Length + + 1 + "OS".Length; + + Assert.AreEqual(expected, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsMXRecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsMXRecordDataTests.cs new file mode 100644 index 00000000..96bcdb3b --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsMXRecordDataTests.cs @@ -0,0 +1,128 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsMXRecordDataTests + { + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + var rdata = new DnsMXRecordData( + preference: 10, + exchange: "mail.example.com."); + + Assert.AreEqual((ushort)10, rdata.Preference); + Assert.AreEqual("mail.example.com.", rdata.Exchange); + } + + [TestMethod] + public void Equals_SameValues_IgnoresCaseOnExchange() + { + var a = new DnsMXRecordData( + 10, + "Mail.EXAMPLE.COM."); + + var b = new DnsMXRecordData( + 10, + "mail.example.com."); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentPreference_IsFalse() + { + var a = new DnsMXRecordData(10, "mail.example.com."); + var b = new DnsMXRecordData(20, "mail.example.com."); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_DifferentExchange_IsFalse() + { + var a = new DnsMXRecordData(10, "mail1.example.com."); + var b = new DnsMXRecordData(10, "mail2.example.com."); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void CompareTo_OrdersByPreference() + { + var low = new DnsMXRecordData(5, "a.example."); + var high = new DnsMXRecordData(20, "b.example."); + + Assert.IsLessThan(0, low.CompareTo(high)); + Assert.IsGreaterThan(0, high.CompareTo(low)); + Assert.AreEqual(0, low.CompareTo(new DnsMXRecordData(5, "other.example."))); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var original = new DnsResourceRecord( + "example", + DnsResourceRecordType.MX, + DnsClass.IN, + 3600, + new DnsMXRecordData( + 10, + "mail.example.")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + var rdata = new DnsMXRecordData( + 10, + "mail.example.com."); + + using MemoryStream ms = new(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("Preference", json); + Assert.Contains("10", json); + Assert.Contains("Exchange", json); + Assert.Contains("mail.example.com.", json); + } + + [TestMethod] + public void UncompressedLength_MatchesFormula() + { + var rdata = new DnsMXRecordData( + 5, + "mail.example."); + + int expected = + 2 + DnsDatagram.GetSerializeDomainNameLength("mail.example."); + + Assert.AreEqual(expected, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsNAPTRRecordDataTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsNAPTRRecordDataTests.cs new file mode 100644 index 00000000..5ee110bc --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsNAPTRRecordDataTests.cs @@ -0,0 +1,188 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Linq; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsNAPTRRecordDataTests + { + private static byte[] SerializeRecord(DnsResourceRecord record) + { + using MemoryStream ms = new(); + record.WriteTo(ms); + return ms.ToArray(); + } + + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + var rdata = new DnsNAPTRRecordData( + order: 100, + preference: 10, + flags: "U", + services: "SIP+D2U", + regexp: "!^.*$!sip:info@example.com!", + replacement: "Example.COM."); + + var rr = new DnsResourceRecord( + "example.com.", + DnsResourceRecordType.NAPTR, + DnsClass.IN, + 60, + rdata); + + Assert.AreEqual("U", rdata.Flags); + Assert.AreEqual("Example.COM", rdata.Replacement); + Assert.IsNotNull(rr); + } + + [TestMethod] + public void Constructor_CharacterStringTooLong_Throws() + { + string longValue = new string('a', 256); + + Assert.ThrowsExactly(() => + new DnsNAPTRRecordData( + 0, 0, + longValue, + "", + "", + ".")); + } + + [TestMethod] + public void Constructor_NonAsciiCharacter_Throws() + { + Assert.ThrowsExactly(() => + new DnsNAPTRRecordData( + 0, 0, + "Ü", + "", + "", + ".")); + } + + + [TestMethod] + public void WriteTo_PreservesOriginalCaseOnWire() + { + var rdata = new DnsNAPTRRecordData( + 1, 1, "U", "SIP+D2U", "", "Example.COM."); + + var rr = new DnsResourceRecord( + "Example.COM.", + DnsResourceRecordType.NAPTR, + DnsClass.IN, + 60, + rdata); + + byte[] bytes = SerializeRecord(rr); + + // Ensure uppercase bytes are present + Assert.IsTrue(bytes.Contains((byte)'E')); + Assert.IsTrue(bytes.Contains((byte)'C')); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + var originalRdata = new DnsNAPTRRecordData( + 50, + 20, + "U", + "SIP+D2T", + "!^.*$!sip:test@example.net!", + "example.net."); // replacement MAY be absolute + + var original = new DnsResourceRecord( + "example.net", // owner MUST be relative + DnsResourceRecordType.NAPTR, + DnsClass.IN, + 120, + originalRdata); + + byte[] wire = SerializeRecord(original); + + using MemoryStream ms = new(wire); + var parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void Equals_IsCaseInsensitivePerDnsRules() + { + var a = new DnsNAPTRRecordData( + 10, 10, + "U", + "SIP+D2U", + "", + "example.org."); + + var b = new DnsNAPTRRecordData( + 10, 10, + "u", + "sip+d2u", + "", + "EXAMPLE.ORG."); + + Assert.IsTrue(a.Equals(b)); + } + + [TestMethod] + public void UncompressedLength_MatchesWireRdataLength() + { + var rdata = new DnsNAPTRRecordData( + 1, 1, + "U", + "SIP+D2U", + "", + "example.org."); + + var rr = new DnsResourceRecord( + "example.org.", + DnsResourceRecordType.NAPTR, + DnsClass.IN, + 60, + rdata); + + byte[] wire = SerializeRecord(rr); + + // Strip NAME + TYPE + CLASS + TTL + RDLENGTH (minimum DNS RR header) + int rdataOffset = wire.Length - rdata.UncompressedLength; + + Assert.AreEqual( + rdata.UncompressedLength, + wire.Length - rdataOffset); + } + + [TestMethod] + public void ToString_ProducesZoneFileCompatibleOutput() + { + var rdata = new DnsNAPTRRecordData( + 100, 10, + "U", + "SIP+D2U", + "!^.*$!sip:info@example.com!", + "example.com."); + + var rr = new DnsResourceRecord( + "example.com.", + DnsResourceRecordType.NAPTR, + DnsClass.IN, + 60, + rdata); + + string text = rr.ToString(); + + Assert.Contains("NAPTR", text); + Assert.Contains("SIP+D2U", text); + Assert.Contains("example.com.", text); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs new file mode 100644 index 00000000..4510f1d9 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs @@ -0,0 +1,317 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net.Sockets; +using System.Text; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Dns; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class DomainEndPointTests + { + // ================================================================ + // CONSTRUCTOR – SUCCESS CASES + // ================================================================ + + [TestMethod] + public void Constructor_ShouldAcceptAsciiDomain_AndStorePort() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 853); + + Assert.AreEqual("example.com", ep.Address, + "Constructor must preserve ASCII domain without alteration."); + Assert.AreEqual(853, ep.Port, + "Constructor must store provided port value exactly."); + Assert.AreEqual(AddressFamily.Unspecified, ep.AddressFamily, + "Domain endpoints must remain AddressFamily.Unspecified for defensive correctness."); + } + + [TestMethod] + public void Constructor_ShouldNormalizeUnicodeToAscii() + { + DomainEndPoint ep = new DomainEndPoint("münich.de", 443); + + Assert.AreEqual("xn--mnich-kva.de", ep.Address, + "Constructor must normalize Unicode domain into IDN ASCII equivalent."); + Assert.AreEqual(443, ep.Port, + "Port must remain exactly as provided."); + } + + + // ================================================================ + // CONSTRUCTOR – FAILURE CASES + // ================================================================ + + [TestMethod] + public void Constructor_ShouldFailFast_WhenAddressIsNull() + { + ArgumentNullException ex = Assert.ThrowsExactly( + () => _ = new DomainEndPoint(null!, 53), + "Null address must be rejected to prevent partially invalid instance."); + + Assert.AreEqual("address", ex.ParamName, + "Thrown exception must identify the faulty parameter."); + } + + [TestMethod] + public void Constructor_ShouldRejectIPv4Literal() + { + Assert.ThrowsExactly( + () => _ = new DomainEndPoint("192.168.1.1", 80), + "Constructor must reject IP literals to preserve domain-only invariant."); + } + + [TestMethod] + public void Constructor_ShouldRejectObviouslyMalformedDomain() + { + DnsClientException ex = Assert.ThrowsExactly( + () => _ = new DomainEndPoint("exa mple.com", 853), + "Constructor must reject syntactically invalid domain by failing fast through validation-layer exception."); + + Assert.Contains("exa mple.com", ex.Message, "Thrown validation exception must include original input for caller diagnostic correctness."); + } + + + // ================================================================ + // TRY PARSE – SUCCESS CASES + // ================================================================ + + [TestMethod] + public void TryParse_ShouldParseDomainWithoutPort_DefaultPortZero() + { + bool ok = DomainEndPoint.TryParse("example.com", out DomainEndPoint? ep); + + Assert.IsTrue(ok, "TryParse must succeed for valid domain without port."); + Assert.IsNotNull(ep, "Successful TryParse must produce a concrete instance."); + Assert.AreEqual("example.com", ep.Address, + "Domain segment must remain unchanged."); + Assert.AreEqual(0, ep.Port, + "No explicit port must result in Port=0."); + } + + [TestMethod] + public void TryParse_ShouldParseDomainWithPort() + { + bool ok = DomainEndPoint.TryParse("example.com:445", out DomainEndPoint? ep); + + Assert.IsTrue(ok, + "TryParse must succeed for expected domain:port format."); + Assert.AreEqual("example.com", ep!.Address); + Assert.AreEqual(445, ep.Port); + } + + [TestMethod] + public void TryParse_ShouldNormalizeUnicodeDomain() + { + bool ok = DomainEndPoint.TryParse("münich.de:80", out DomainEndPoint? ep); + + Assert.IsTrue(ok, "Valid Unicode domain must be accepted."); + Assert.AreEqual("xn--mnich-kva.de", ep!.Address, + "Unicode must normalize predictably to ASCII."); + Assert.AreEqual(80, ep.Port, + "Port must reflect provided integer value."); + } + + [TestMethod] + public void TryParse_ShouldRoundtripSuccessfully() + { + const string original = "example.com:853"; + + Assert.IsTrue(DomainEndPoint.TryParse(original, out DomainEndPoint? ep1), + "TryParse must succeed on valid input."); + + string serialized = ep1!.ToString(); + Assert.IsTrue(DomainEndPoint.TryParse(serialized, out DomainEndPoint? ep2), + "Re-parsing output must succeed."); + + Assert.AreEqual(ep1.Address, ep2!.Address, + "Roundtrip must preserve domain identity exactly."); + Assert.AreEqual(ep1.Port, ep2.Port, + "Roundtrip must preserve port identity exactly."); + } + + + // ================================================================ + // TRY PARSE – FAILURE CASES + // ================================================================ + + [TestMethod] + public void TryParse_ShouldFail_WhenInputIsNull() + { + bool ok = DomainEndPoint.TryParse(null, out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Null value cannot represent valid domain endpoint."); + Assert.IsNull(ep, "Endpoint must remain null when parsing fails."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenEmptyString() + { + bool ok = DomainEndPoint.TryParse("", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Empty string cannot represent valid domain endpoint."); + Assert.IsNull(ep, "Endpoint must remain null when parsing fails."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenWhitespaceOnly() + { + bool ok = DomainEndPoint.TryParse(" ", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Whitespace-only input cannot represent valid domain endpoint."); + Assert.IsNull(ep, "Result object must remain null on failure."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenTooManyColons() + { + bool ok = DomainEndPoint.TryParse("a:b:c", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Multiple separators violate predictable domain:port format."); + Assert.IsNull(ep, "Endpoint must remain null to avoid partially valid identity."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenDomainIsIPAddress() + { + bool ok = DomainEndPoint.TryParse("127.0.0.1:81", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "IP literal parsing must be rejected consistently."); + Assert.IsNull(ep, "Null endpoint is required defensive failure output."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenNonNumericPort() + { + bool ok = DomainEndPoint.TryParse("example.com:abc", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Port must parse strictly as numeric."); + Assert.IsNull(ep, "Failure scenario must not yield partially created endpoint."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenPortOutOfRange() + { + bool ok = DomainEndPoint.TryParse("example.com:70000", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Ports exceeding UInt16 range cannot be treated as valid."); + Assert.IsNull(ep, "No endpoint must be generated."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenDomainContainsSpaces() + { + bool ok = DomainEndPoint.TryParse("exa mple.com:53", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Invalid domain format must not succeed."); + Assert.IsNull(ep, "Endpoint must remain null upon failure."); + } + + + // ================================================================ + // ADDRESS BYTES + // ================================================================ + + [TestMethod] + public void GetAddressBytes_MustReturnLengthPrefixedAsciiBytes() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 80); + byte[] result = ep.GetAddressBytes(); + + byte[] ascii = Encoding.ASCII.GetBytes("example.com"); + + Assert.AreEqual(ascii.Length, result[0], + "Length prefix must exactly match ASCII length of the address."); + for (int i = 0; i < ascii.Length; i++) + { + Assert.AreEqual(ascii[i], result[i + 1], + $"Byte index {i} must reflect ASCII domain payload."); + } + } + + [TestMethod] + public void GetAddressBytes_MustReturnIndependentBuffers() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 80); + + byte[] a = ep.GetAddressBytes(); + a[1] ^= 0xFF; + + byte[] b = ep.GetAddressBytes(); + + Assert.AreNotEqual(a[1], b[1], + "Returned byte arrays must not expose internal mutable buffers."); + } + + + // ================================================================ + // EQUALITY & HASH + // ================================================================ + + [TestMethod] + public void Equals_MustBeCaseInsensitiveForDomain_AndStrictOnPort() + { + DomainEndPoint ep1 = new DomainEndPoint("Example.com", 443); + DomainEndPoint ep2 = new DomainEndPoint("example.com", 443); + DomainEndPoint ep3 = new DomainEndPoint("example.com", 853); + + Assert.IsTrue(ep1.Equals(ep2), + "Domain equality must ignore case differences."); + Assert.IsFalse(ep1.Equals(ep3), + "Different ports must break equality even when domain matches."); + } + + [TestMethod] + public void GetHashCode_MustBeStableAcrossRepeatedCalls() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 443); + + int h1 = ep.GetHashCode(); + int h2 = ep.GetHashCode(); + + Assert.AreEqual(h1, h2, + "Hash code must remain stable to support predictable dictionary usage."); + } + + [TestMethod] + public void Equals_MustReturnFalse_ForDifferentTypeAndNull() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 80); + + Assert.IsFalse(ep.Equals(null), + "Comparing against null must never produce equality."); + Assert.IsFalse(ep.Equals("example.com:80"), + "Comparing against non-endpoint type must not succeed."); + } + + + // ================================================================ + // PROPERTY SETTERS + // ================================================================ + + [TestMethod] + public void Address_Setter_MustNotCorruptUnrelatedState() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 53); + + ep.Address = "192.168.9.10"; + + Assert.AreEqual("192.168.9.10", ep.Address, + "Setter does not re-validate by design; caller assumes responsibility."); + Assert.AreEqual(53, ep.Port, + "Setter mutation must not affect unrelated fields."); + } + + [TestMethod] + public void Port_Setter_MustAllowCallerProvidedValueAsIs() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 53); + + ep.Port = -1; + + Assert.AreEqual(-1, ep.Port, + "Setter must store raw caller intent; constraints belong outside endpoint abstraction."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/EndPointExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/EndPointExtensionsTests.cs new file mode 100644 index 00000000..34551af1 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/EndPointExtensionsTests.cs @@ -0,0 +1,252 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class EndPointExtensionsTests + { + [TestMethod] + public void WriteRead_RoundTrip_IPv4() + { + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("192.168.10.25"), 853); + + using MemoryStream ms = new MemoryStream(); + using BinaryWriter bw = new BinaryWriter(ms); + + ep.WriteTo(bw); + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + EndPoint reloaded = EndPointExtensions.ReadFrom(br); + + Assert.AreEqual(ep.Address.ToString(), reloaded.GetAddress(), + "Round-trip must preserve IPv4 address."); + Assert.AreEqual(ep.Port, reloaded.GetPort(), + "Round-trip must preserve port."); + } + + [TestMethod] + public void WriteRead_RoundTrip_IPv6() + { + IPEndPoint ep = new IPEndPoint(IPAddress.IPv6Loopback, 853); + + using MemoryStream ms = new MemoryStream(); + using BinaryWriter bw = new BinaryWriter(ms); + + ep.WriteTo(bw); + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + EndPoint reloaded = EndPointExtensions.ReadFrom(br); + + Assert.AreEqual("::1", reloaded.GetAddress(), + "Round-trip must preserve IPv6 loopback."); + Assert.AreEqual(853, reloaded.GetPort(), + "Round-trip must preserve port."); + } + + [TestMethod] + public void WriteRead_RoundTrip_Domain() + { + DomainEndPoint dep = new DomainEndPoint("example.org", 853); + + using MemoryStream ms = new MemoryStream(); + using BinaryWriter bw = new BinaryWriter(ms); + + dep.WriteTo(bw); + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + EndPoint reloaded = EndPointExtensions.ReadFrom(br); + + Assert.AreEqual("example.org", reloaded.GetAddress(), + "Domain must survive round-trip serialization."); + Assert.AreEqual(853, reloaded.GetPort(), + "Port must survive round-trip serialization."); + } + + [TestMethod] + public void ReadFrom_ShouldFail_OnUnsupportedDiscriminator() + { + using MemoryStream ms = new MemoryStream(); + using BinaryWriter bw = new BinaryWriter(ms); + + bw.Write((byte)99); // invalid discriminator + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + Assert.ThrowsExactly( + () => _ = EndPointExtensions.ReadFrom(br), + "Unsupported prefix must trigger deterministic failure."); + } + + [TestMethod] + public void GetAddress_ShouldReturn_IPString() + { + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("1.2.3.4"), 1234); + Assert.AreEqual("1.2.3.4", ep.GetAddress(), + "Address must be returned as textual IPv4."); + } + + [TestMethod] + public void GetAddress_ShouldReturn_DomainString() + { + DomainEndPoint ep = new DomainEndPoint("dns.google", 53); + Assert.AreEqual("dns.google", ep.GetAddress(), + "Domain must be returned as raw host label."); + } + + [TestMethod] + public void GetPort_ShouldReturn_Port() + { + IPEndPoint ep = new IPEndPoint(IPAddress.Loopback, 1111); + Assert.AreEqual(1111, ep.GetPort(), "Port must be returned unchanged."); + } + + [TestMethod] + public void SetPort_ShouldMutate_IPPort() + { + IPEndPoint ep = new IPEndPoint(IPAddress.Loopback, 53); + ep.SetPort(443); + + Assert.AreEqual(443, ep.Port, "Mutated port must be observable."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldReturn_IP_WhenAlreadyIPEndPoint() + { + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9000); + + IPEndPoint result = await ep.GetIPEndPointAsync(cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual(ep.Address, result.Address, + "Resolved IP must match source."); + Assert.AreEqual(ep.Port, result.Port, + "Resolved port must match source."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldResolve_Localhost_Predictably() + { + DomainEndPoint dep = new DomainEndPoint("localhost", 443); + + IPEndPoint resolved = await dep.GetIPEndPointAsync(AddressFamily.InterNetwork, cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual(443, resolved.Port, "Resolved port must match declared port."); + Assert.AreEqual(AddressFamily.InterNetwork, resolved.Address.AddressFamily, + "Requested AF must be honored when at least one matching address exists."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldFail_WhenDNSReturnsEmpty() + { + DomainEndPoint dep = new DomainEndPoint("test-invalid-unresolvable-domain.local", 5000); + + await Assert.ThrowsExactlyAsync( + async () => await dep.GetIPEndPointAsync(cancellationToken: TestContext.CancellationToken), + "Unresolvable name must trigger HostNotFound."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldFallback_WhenRequestedFamilyUnsupported() + { + DomainEndPoint dep = new DomainEndPoint("localhost", 853); + + IPEndPoint ep = await dep.GetIPEndPointAsync(AddressFamily.AppleTalk, cancellationToken: TestContext.CancellationToken); + + Assert.IsNotNull(ep); + Assert.AreEqual(853, ep.Port, "Port must be preserved."); + Assert.IsInstanceOfType(ep, typeof(IPEndPoint), "Returned endpoint must still be resolved."); + } + + [TestMethod] + public void GetEndPoint_ShouldReturn_IPEndpoint_OnLiteralIP() + { + EndPoint ep = EndPointExtensions.GetEndPoint("10.20.30.40", 8080); + + Assert.IsInstanceOfType(ep, typeof(IPEndPoint), + "Literal IP input must produce IPEndPoint."); + } + + [TestMethod] + public void GetEndPoint_ShouldReturn_DomainEndPoint_OnHostName() + { + EndPoint ep = EndPointExtensions.GetEndPoint("dns.google", 53); + + Assert.IsInstanceOfType(ep, typeof(DomainEndPoint), + "Non-IP literal must produce domain endpoint."); + } + + [TestMethod] + public void TryParse_ShouldReturnTrue_ForIPEndPointSyntax() + { + Assert.IsTrue(EndPointExtensions.TryParse("5.6.7.8:22", out EndPoint? ep), + "Valid IP must be parsed."); + Assert.IsInstanceOfType(ep, typeof(IPEndPoint)); + } + + [TestMethod] + public void TryParse_ShouldReturnTrue_ForDomainSyntax() + { + Assert.IsTrue(EndPointExtensions.TryParse("example.com:25", out EndPoint? ep), + "Valid domain:port must be parsed."); + Assert.IsInstanceOfType(ep, typeof(DomainEndPoint)); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenMissingPort() + { + Assert.IsFalse(EndPointExtensions.TryParse("example.com", out EndPoint? ep), + "Missing port must not parse successfully."); + Assert.IsNull(ep, "Return must be null on parse failure."); + } + + [TestMethod] + public void IsEquals_ShouldCompare_IPCorrectly() + { + IPEndPoint a = new IPEndPoint(IPAddress.Parse("1.1.1.1"), 853); + IPEndPoint b = new IPEndPoint(IPAddress.Parse("1.1.1.1"), 853); + + Assert.IsTrue(a.IsEquals(b), + "IPEndPoint equality must fully honor IP + port."); + } + + [TestMethod] + public void IsEquals_ShouldCompare_DomainCorrectly() + { + DomainEndPoint a = new DomainEndPoint("example.org", 443); + DomainEndPoint b = new DomainEndPoint("example.org", 443); + + Assert.IsTrue(a.IsEquals(b), + "Domain endpoints must compare by semantic equality."); + } + + [TestMethod] + public void IsEquals_MustReturnFalse_OnDifferentAddresses() + { + DomainEndPoint a = new DomainEndPoint("example.org", 443); + DomainEndPoint b = new DomainEndPoint("example.net", 443); + + Assert.IsFalse(a.IsEquals(b), + "Different hostnames must not compare equal."); + } + + [TestMethod] + public void IsEquals_MustReturnFalse_OnDifferentPorts() + { + IPEndPoint a = new IPEndPoint(IPAddress.Parse("8.8.8.8"), 53); + IPEndPoint b = new IPEndPoint(IPAddress.Parse("8.8.8.8"), 853); + + Assert.IsFalse(a.IsEquals(b), + "Same address but different port must not compare equal."); + } + + public TestContext TestContext { get; set; } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Http/Client/HttpClientNetworkHandlerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Http/Client/HttpClientNetworkHandlerTests.cs new file mode 100644 index 00000000..7087d59d --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Http/Client/HttpClientNetworkHandlerTests.cs @@ -0,0 +1,119 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Http.Client; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Http.Client +{ + [TestClass] + public class HttpClientNetworkHandlerTests + { + [TestMethod] + public void Constructor_InitializesSocketsHttpHandlerCorrectly() + { + using HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + + Assert.IsNotNull( + handler.InnerHandler, + "InnerHandler must be initialized."); + + Assert.IsTrue( + handler.InnerHandler.EnableMultipleHttp2Connections, + "Handler must enable multiple HTTP/2 connections."); + } + + [TestMethod] + public void NetworkType_Property_RoundTrips() + { + using HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + + handler.NetworkType = HttpClientNetworkType.IPv6Only; + + Assert.AreEqual( + HttpClientNetworkType.IPv6Only, + handler.NetworkType); + } + + [TestMethod] + public void Send_WhenHttpVersion30_IsDowngradedToHttp2() + { + using HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + using HttpMessageInvoker invoker = new HttpMessageInvoker(handler); + + HttpRequestMessage request = new HttpRequestMessage( + HttpMethod.Get, + "http://example.com") + { + Version = HttpVersion.Version30 + }; + + Assert.AreEqual( + HttpVersion.Version30, + request.Version, + "Precondition: request must start as HTTP/3."); + + Assert.ThrowsExactly(() => + { + invoker.Send(request, CancellationToken.None); + }); + + Assert.AreEqual( + HttpVersion.Version20, + request.Version, + "Handler must downgrade HTTP/3 to HTTP/2 even when the send fails."); + } + + [TestMethod] + public void Send_WhenSocketsHttpHandlerProxyIsUsed_ThrowsHttpRequestException() + { + using HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + + handler.InnerHandler.UseProxy = true; + handler.InnerHandler.Proxy = new WebProxy("http://127.0.0.1:8080"); + + using HttpMessageInvoker invoker = new HttpMessageInvoker(handler); + + HttpRequestMessage request = new HttpRequestMessage( + HttpMethod.Get, + "http://example.com"); + + Assert.ThrowsExactly(() => + { + invoker.Send(request, CancellationToken.None); + }); + } + + [TestMethod] + public async Task SendAsync_WhenHttpVersion30_IsDowngradedToHttp2() + { + using HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + using HttpMessageInvoker invoker = new HttpMessageInvoker(handler); + + HttpRequestMessage request = new HttpRequestMessage( + HttpMethod.Get, + "http://example.com") + { + Version = HttpVersion.Version30 + }; + + // We do NOT assert on success or failure of the send itself. + // The contract we enforce here is the version downgrade. + try + { + await invoker.SendAsync(request, CancellationToken.None); + } + catch + { + // Outcome of the send is environment-dependent and not part of the contract. + } + + Assert.AreEqual( + HttpVersion.Version20, + request.Version, + "Async path must downgrade HTTP/3 to HTTP/2."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Http/HttpRequestTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Http/HttpRequestTests.cs new file mode 100644 index 00000000..0dbd6642 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Http/HttpRequestTests.cs @@ -0,0 +1,321 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Http; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Http +{ + [TestClass] + public class HttpRequestTests + { + public TestContext TestContext { get; set; } + + [TestMethod] + public async Task ReadRequestAsync_ParsesQueryStringCorrectly() + { + string raw = + "GET /search?q=test&flag HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual("/search", req.RequestPath); + Assert.AreEqual("test", req.QueryString["q"]); + Assert.AreEqual(null, req.QueryString["flag"]); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenBodyIsTruncated_ReturnsEOFWithoutThrowing() + { + string raw = + "POST /data HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Content-Length: 10\r\n" + + "\r\n" + + "short"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + byte[] buffer = new byte[16]; + + int totalRead = 0; + int r; + + while ((r = await req.InputStream.ReadAsync( + buffer, 0, buffer.Length, TestContext.CancellationToken)) > 0) + { + totalRead += r; + } + + Assert.AreEqual( + 5, + totalRead, + "InputStream must expose only the bytes actually available."); + + Assert.AreEqual( + 0, + r, + "InputStream must signal truncation via EOF, not via exception."); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenChunkedBodyExceedsMaxContentLength_ThrowsHttpRequestException() + { + string raw = + "POST /x HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "4\r\nWiki\r\n" + + "0\r\n\r\n"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + maxContentLength: 3, + cancellationToken: TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await ReadAllAsciiAsync(req.InputStream, TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenChunkedEndsImmediately_ReturnsEmptyBody() + { + string raw = + "POST /x HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "0\r\n\r\n"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + string body = await ReadAllAsciiAsync(req.InputStream, TestContext.CancellationToken); + Assert.AreEqual(string.Empty, body); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenChunkedTruncated_ThrowsEndOfStreamOnBodyRead() + { + string raw = + "POST /x HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "5\r\nabc"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await ReadAllAsciiAsync(req.InputStream, TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenConnectionClosedBeforeRequest_ReturnsNull() + { + using MemoryStream stream = new MemoryStream(Array.Empty()); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + Assert.IsNull(req, "Graceful close before request must return null."); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenContentLengthExceedsMax_ThrowsHttpRequestException() + { + string raw = + "POST /data HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Content-Length: 100\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpRequest.ReadRequestAsync( + stream, + maxContentLength: 10, + cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenHeaderIsTruncated_ThrowsEndOfStream() + { + string raw = + "GET / HTTP/1.1\r\n" + + "Host: example.com\r\n"; // missing terminating CRLF + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenHeaderLineIsInvalid_ThrowsInvalidData() + { + string raw = + "GET / HTTP/1.1\r\n" + + "Host example.com\r\n" + // missing colon + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenRequestLineIsInvalid_ThrowsInvalidData() + { + string raw = + "GET /only-two-parts\r\n" + + "Host: example.com\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenTransferEncodingChunked_ExposesDecodedBody() + { + string raw = + "POST /submit HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "4\r\nWiki\r\n" + + "5\r\npedia\r\n" + + "0\r\n\r\n"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual("POST", req.HttpMethod); + Assert.AreEqual("/submit", req.RequestPath); + + string body = await ReadAllAsciiAsync(req.InputStream, TestContext.CancellationToken); + Assert.AreEqual("Wikipedia", body); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenTransferEncodingUnsupported_ThrowsHttpRequestException() + { + string raw = + "POST /x HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Transfer-Encoding: gzip\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WithContentLength_FirstBytesMatchDeclaredLength() + { + string raw = + "POST /data HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Content-Length: 5\r\n" + + "\r\n" + + "HelloEXTRA"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + byte[] buffer = new byte[16]; + + int r = await req.InputStream.ReadAsync( + buffer, 0, buffer.Length, TestContext.CancellationToken); + + Assert.IsTrue( + r >= 5, + "InputStream must expose at least Content-Length bytes."); + + Assert.AreEqual( + "Hello", + Encoding.ASCII.GetString(buffer, 0, 5), + "The first Content-Length bytes must match the declared body."); + + // Drain the stream to ensure safe termination + while (r > 0) + { + r = await req.InputStream.ReadAsync( + buffer, 0, buffer.Length, TestContext.CancellationToken); + } + + Assert.AreEqual( + 0, + r, + "InputStream must eventually terminate with EOF."); + } + + private static MemoryStream MakeStream(string ascii) => new MemoryStream(Encoding.ASCII.GetBytes(ascii)); + + private static async Task ReadAllAsciiAsync(Stream s, CancellationToken ct) + { + using MemoryStream ms = new MemoryStream(); + await s.CopyToAsync(ms, 8192, ct); + return Encoding.ASCII.GetString(ms.ToArray()); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Http/HttpResponseTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Http/HttpResponseTests.cs new file mode 100644 index 00000000..f1d54623 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Http/HttpResponseTests.cs @@ -0,0 +1,197 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Http; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Http +{ + [TestClass] + public class HttpResponseTests + { + public TestContext TestContext { get; set; } + + [TestMethod] + public async Task ReadResponseAsync_WhenChunkedTruncated_ThrowsEndOfStreamOnBodyRead() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "5\r\nabc"; + + using MemoryStream stream = MakeStream(raw); + + HttpResponse resp = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await ReadAllAsciiAsync(resp.OutputStream, TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenHeaderLineIsInvalid_ThrowsInvalidData() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Content-Length 10\r\n" + // missing colon + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenHeadersAreTruncated_ThrowsEndOfStream() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n"; // missing terminating CRLF + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenStatusCodeIsNonNumeric_ThrowsFormatException() + { + string raw = + "HTTP/1.1 OK OK\r\n" + + "Content-Length: 0\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenStatusLineIsInvalid_ThrowsInvalidData() + { + string raw = + "HTTP/1.1 200\r\n" + // missing reason phrase + "Content-Length: 0\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenTransferEncodingChunked_ExposesDecodedBody() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "3\r\nfoo\r\n" + + "3\r\nbar\r\n" + + "0\r\n\r\n"; + + using MemoryStream stream = MakeStream(raw); + + HttpResponse resp = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + + Assert.AreEqual("HTTP/1.1", resp.Protocol); + Assert.AreEqual(200, resp.StatusCode); + + string body = await ReadAllAsciiAsync(resp.OutputStream, TestContext.CancellationToken); + Assert.AreEqual("foobar", body); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenTransferEncodingUnsupported_ThrowsHttpRequestException() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: br\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WithContentLength_ExposesExactlyContentLengthBytesInTotal() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Content-Length: 4\r\n" + + "\r\n" + + "TestEXTRA"; + + using MemoryStream stream = MakeStream(raw); + + HttpResponse resp = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + + byte[] buffer = new byte[8]; + + int totalRead = 0; + int r; + + while ((r = await resp.OutputStream.ReadAsync( + buffer, 0, buffer.Length, TestContext.CancellationToken)) > 0) + { + totalRead += r; + } + + Assert.AreEqual( + 4, + totalRead, + "OutputStream must expose exactly Content-Length bytes (RFC 9112)."); + + Assert.AreEqual( + "Test", + Encoding.ASCII.GetString(buffer, 0, totalRead)); + } + + private static MemoryStream MakeStream(string ascii) + => new MemoryStream(Encoding.ASCII.GetBytes(ascii)); + + private static async Task ReadAllAsciiAsync(Stream s, CancellationToken ct) + { + using MemoryStream ms = new MemoryStream(); + await s.CopyToAsync(ms, 8192, ct); + return Encoding.ASCII.GetString(ms.ToArray()); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs new file mode 100644 index 00000000..aa063681 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs @@ -0,0 +1,445 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class IPAddressExtensionsTests + { + private static MemoryStream NewStream(byte[]? initial = null) => + initial is null ? new MemoryStream() : new MemoryStream(initial, writable: true); + + // ------------------------------------------------------ + // WRITE & READ (BINARY FORMAT) + // ------------------------------------------------------ + + [TestMethod] + public void WriteTo_ThenReadFrom_ShouldRoundtrip_IPv4() + { + // GIVEN + IPAddress ip = IPAddress.Parse("1.2.3.4"); + using MemoryStream ms = NewStream(); + + // WHEN + ip.WriteTo(ms); + ms.Position = 0; + IPAddress read = IPAddressExtensions.ReadFrom(ms); + + // THEN + Assert.AreEqual(ip, read, "WriteTo/ReadFrom must preserve IPv4 address bits exactly."); + Assert.AreEqual(ms.Length, ms.Position, + "ReadFrom must consume exactly one encoded address and no more bytes."); + } + + [TestMethod] + public void WriteTo_ThenReadFrom_ShouldRoundtrip_IPv6() + { + // GIVEN + IPAddress ip = IPAddress.Parse("2001:db8::1"); + using MemoryStream ms = NewStream(); + + // WHEN + ip.WriteTo(ms); + ms.Position = 0; + IPAddress read = IPAddressExtensions.ReadFrom(ms); + + // THEN + Assert.AreEqual(ip, read, "WriteTo/ReadFrom must preserve IPv6 address bits exactly."); + Assert.AreEqual(ms.Length, ms.Position, + "ReadFrom must consume exactly one encoded IPv6 address and no extra bytes."); + } + + [TestMethod] + public void WriteTo_WithBinaryWriter_ShouldProduceSameFormat() + { + // GIVEN + IPAddress ip = IPAddress.Parse("10.20.30.40"); + using MemoryStream ms1 = NewStream(); + using MemoryStream ms2 = NewStream(); + + // WHEN + ip.WriteTo(ms1); // direct Stream overload + + using (BinaryWriter writer = new BinaryWriter(ms2, System.Text.Encoding.UTF8, leaveOpen: true)) + { + ip.WriteTo(writer); + } + + // THEN + CollectionAssert.AreEqual(ms1.ToArray(), ms2.ToArray(), + "WriteTo(BinaryWriter) must delegate to identical wire format as WriteTo(Stream)."); + } + + [TestMethod] + public void ReadFrom_ShouldThrowEndOfStream_WhenNoFamilyMarkerAvailable() + { + // GIVEN + using MemoryStream ms = NewStream(Array.Empty()); + long startPos = ms.Position; + + // WHEN - THEN + Assert.ThrowsExactly( + () => IPAddressExtensions.ReadFrom(ms), + "ReadFrom must fail fast when stream ends before family marker."); + + Assert.AreEqual(startPos, ms.Position, + "On EOS, ReadFrom must not advance stream position."); + } + + [TestMethod] + public void ReadFrom_ShouldThrowNotSupported_WhenFamilyMarkerUnknown() + { + // GIVEN: marker 3 (unsupported) + one extra byte (must remain unread) + using MemoryStream ms = NewStream(new byte[] { 3, 0xFF }); + + // WHEN + Assert.ThrowsExactly( + () => IPAddressExtensions.ReadFrom(ms), + "ReadFrom must reject unsupported address family markers deterministically."); + + // THEN + Assert.AreEqual(1L, ms.Position, + "On unsupported family marker, ReadFrom must consume only the marker byte and leave payload intact."); + Assert.AreEqual(2L, ms.Length); + } + + // ------------------------------------------------------ + // IPv4 <-> NUMBER CONVERSION + // ------------------------------------------------------ + + [TestMethod] + public void ConvertIpToNumber_ThenBack_ShouldRoundtrip_IPv4() + { + // GIVEN + IPAddress ip = IPAddress.Parse("1.2.3.4"); + + // WHEN + uint number = ip.ConvertIpToNumber(); + IPAddress roundtrip = IPAddressExtensions.ConvertNumberToIp(number); + + // THEN + Assert.AreEqual("1.2.3.4", roundtrip.ToString(), + "ConvertNumberToIp(ConvertIpToNumber(ip)) must yield the original IPv4 address."); + } + + [TestMethod] + public void ConvertIpToNumber_ShouldThrow_WhenAddressIsIPv6() + { + // GIVEN + IPAddress ip = IPAddress.Parse("::1"); + + // WHEN - THEN + Assert.ThrowsExactly( + () => ip.ConvertIpToNumber(), + "ConvertIpToNumber must reject non-IPv4 addresses with ArgumentException."); + } + + // ------------------------------------------------------ + // SUBNET MASK HELPERS + // ------------------------------------------------------ + + [TestMethod] + public void GetSubnetMask_ShouldReturnCorrectMasks_ForBoundaryPrefixLengths() + { + // WHEN + IPAddress mask0 = IPAddressExtensions.GetSubnetMask(0); + IPAddress mask24 = IPAddressExtensions.GetSubnetMask(24); + IPAddress mask32 = IPAddressExtensions.GetSubnetMask(32); + + // THEN + Assert.AreEqual("0.0.0.0", mask0.ToString(), + "Prefix length 0 must map to all-zero IPv4 mask."); + Assert.AreEqual("255.255.255.0", mask24.ToString(), + "Prefix length 24 must map to 255.255.255.0."); + Assert.AreEqual("255.255.255.255", mask32.ToString(), + "Prefix length 32 must map to 255.255.255.255."); + } + + [TestMethod] + public void GetSubnetMask_ShouldThrow_WhenPrefixExceedsIPv4Width() + { + Assert.ThrowsExactly( + () => IPAddressExtensions.GetSubnetMask(33), + "GetSubnetMask must reject prefix lengths greater than 32."); + } + + [TestMethod] + public void GetSubnetMaskWidth_ShouldReturnCorrectWidth_ForValidMasks() + { + // GIVEN + IPAddress mask0 = IPAddress.Parse("0.0.0.0"); + IPAddress mask8 = IPAddress.Parse("255.0.0.0"); + IPAddress mask24 = IPAddress.Parse("255.255.255.0"); + + // WHEN + int width0 = mask0.GetSubnetMaskWidth(); + int width8 = mask8.GetSubnetMaskWidth(); + int width24 = mask24.GetSubnetMaskWidth(); + + // THEN + Assert.AreEqual(0, width0, "Mask 0.0.0.0 must have width 0."); + Assert.AreEqual(8, width8, "Mask 255.0.0.0 must have width 8."); + Assert.AreEqual(24, width24, "Mask 255.255.255.0 must have width 24."); + } + + [TestMethod] + public void GetSubnetMaskWidth_ShouldThrow_WhenMaskIsNotIPv4() + { + // GIVEN + IPAddress ipv6Mask = IPAddress.Parse("ffff::"); + + // WHEN - THEN + Assert.ThrowsExactly( + () => ipv6Mask.GetSubnetMaskWidth(), + "GetSubnetMaskWidth must reject non-IPv4 subnet masks."); + } + + // ------------------------------------------------------ + // GET NETWORK ADDRESS + // ------------------------------------------------------ + + [TestMethod] + public void GetNetworkAddress_ShouldZeroOutHostBits_ForIPv4() + { + // GIVEN + IPAddress ip = IPAddress.Parse("192.168.10.123"); + + // WHEN + IPAddress network24 = ip.GetNetworkAddress(24); + IPAddress network16 = ip.GetNetworkAddress(16); + IPAddress network0 = ip.GetNetworkAddress(0); + + // THEN + Assert.AreEqual("192.168.10.0", network24.ToString(), + "Prefix 24 must zero out last octet."); + Assert.AreEqual("192.168.0.0", network16.ToString(), + "Prefix 16 must zero out last two octets."); + Assert.AreEqual("0.0.0.0", network0.ToString(), + "Prefix 0 must zero out all IPv4 bits."); + } + + [TestMethod] + public void GetNetworkAddress_ShouldReturnSameAddress_ForFullPrefixLength() + { + // GIVEN + IPAddress ip4 = IPAddress.Parse("10.0.0.42"); + IPAddress ip6 = IPAddress.Parse("2001:db8::dead:beef"); + + // WHEN + IPAddress net4 = ip4.GetNetworkAddress(32); + IPAddress net6 = ip6.GetNetworkAddress(128); + + // THEN + Assert.AreEqual(ip4, net4, + "IPv4 prefix 32 must leave the address unchanged."); + Assert.AreEqual(ip6, net6, + "IPv6 prefix 128 must leave the address unchanged."); + } + + [TestMethod] + public void GetNetworkAddress_ShouldThrow_WhenPrefixTooLargeForFamily() + { + // GIVEN + IPAddress ip4 = IPAddress.Parse("192.168.1.1"); + IPAddress ip6 = IPAddress.Parse("2001:db8::1"); + + // WHEN - THEN + Assert.ThrowsExactly( + () => ip4.GetNetworkAddress(33), + "IPv4 network prefix > 32 must be rejected."); + Assert.ThrowsExactly( + () => ip6.GetNetworkAddress(129), + "IPv6 network prefix > 128 must be rejected."); + } + + // ------------------------------------------------------ + // REVERSE DOMAIN GENERATION + // ------------------------------------------------------ + + [TestMethod] + public void GetReverseDomain_ShouldReturnCorrectIPv4PtrName() + { + // GIVEN + IPAddress ip = IPAddress.Parse("192.168.10.1"); + + // WHEN + string ptr = ip.GetReverseDomain(); + + // THEN + Assert.AreEqual("1.10.168.192.in-addr.arpa", ptr, + "IPv4 reverse domain must list octets in reverse order followed by in-addr.arpa."); + } + + [TestMethod] + public void GetReverseDomain_ThenParseReverseDomain_ShouldRoundtrip_IPv4() + { + // GIVEN + IPAddress ip = IPAddress.Parse("10.20.30.40"); + + // WHEN + string ptr = ip.GetReverseDomain(); + IPAddress parsed = IPAddressExtensions.ParseReverseDomain(ptr); + + // THEN + Assert.AreEqual(ip, parsed, + "ParseReverseDomain(GetReverseDomain(ip)) must roundtrip IPv4 address exactly."); + } + + [TestMethod] + public void GetReverseDomain_ThenParseReverseDomain_ShouldRoundtrip_IPv6() + { + // GIVEN + IPAddress ip = IPAddress.Parse("2001:db8::8b3b:3eb"); + + // WHEN + string ptr = ip.GetReverseDomain(); + IPAddress parsed = IPAddressExtensions.ParseReverseDomain(ptr); + + // THEN + Assert.AreEqual(ip, parsed, + "ParseReverseDomain(GetReverseDomain(ip)) must roundtrip IPv6 address exactly, including all nibbles."); + } + + // ------------------------------------------------------ + // TRY PARSE REVERSE DOMAIN – FAILURE HYGIENE + // ------------------------------------------------------ + + [TestMethod] + public void TryParseReverseDomain_ShouldReturnFalseAndNull_ForUnknownSuffix() + { + // GIVEN + IPAddress original = IPAddress.Loopback; // must be overwritten on failure + + // WHEN + bool ok = IPAddressExtensions.TryParseReverseDomain("example.com", out IPAddress? parsed); + + // THEN + Assert.IsFalse(ok, "TryParseReverseDomain must return false for non-PTR domains."); + Assert.IsNull(parsed, + "On failure, TryParseReverseDomain must set out address to null to avoid stale references."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldReturnFalseAndNull_WhenIPv4LabelsAreNotNumeric() + { + // GIVEN + const string invalidPtr = "x.10.168.192.in-addr.arpa"; + + // WHEN + bool ok = IPAddressExtensions.TryParseReverseDomain(invalidPtr, out IPAddress? parsed); + + // THEN + Assert.IsFalse(ok, "Non-numeric IPv4 labels must cause TryParseReverseDomain to fail cleanly."); + Assert.IsNull(parsed, + "On invalid IPv4 PTR, out address must be null to avoid partial parsing."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldRejectShortIPv4Ptr() + { + const string ptr = "3.2.1.in-addr.arpa"; + + bool ok = IPAddressExtensions.TryParseReverseDomain(ptr, out IPAddress? parsed); + + Assert.IsFalse(ok, "Short IPv4 PTR is not RFC-compliant and must not be accepted."); + Assert.IsNull(parsed, "No mapping exists for truncated PTR names."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldReturnFalseAndNull_WhenIPv6NibbleInvalid() + { + // GIVEN: invalid hex nibble "Z" + const string ptr = "Z.0.0.0.ip6.arpa"; + + // WHEN + bool ok = IPAddressExtensions.TryParseReverseDomain(ptr, out IPAddress? parsed); + + // THEN + Assert.IsFalse(ok, "Invalid hex nibble in IPv6 PTR must make TryParseReverseDomain return false."); + Assert.IsNull(parsed, + "Out address must be null when IPv6 PTR parsing fails."); + } + + [TestMethod] + public void ParseReverseDomain_ShouldThrowNotSupported_WhenTryParseWouldFail() + { + // GIVEN + const string ptr = "not-a-valid.ptr.domain"; + + // WHEN - THEN + Assert.ThrowsExactly( + () => IPAddressExtensions.ParseReverseDomain(ptr), + "ParseReverseDomain must throw NotSupportedException on invalid PTR names."); + } + + [TestMethod] + public void WriteTo_ShouldWriteIPv4Correctly() + { + IPAddress ipv4 = IPAddress.Parse("1.2.3.4"); + using MemoryStream ms = new MemoryStream(); + + ipv4.WriteTo(ms); + + byte[] data = ms.ToArray(); + Assert.AreEqual(1, data[0], "First byte encodes IPv4 family discriminator."); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3, 4 }, data[1..5], "IPv4 bytes must be written exactly."); + } + + [TestMethod] + public void WriteTo_ShouldWriteIPv6Correctly() + { + IPAddress ipv6 = IPAddress.Parse("2001:db8::1"); + using MemoryStream ms = new MemoryStream(); + + ipv6.WriteTo(ms); + + byte[] data = ms.ToArray(); + Assert.AreEqual(2, data[0], "First byte encodes IPv6 family discriminator."); + Assert.AreEqual(16, data.Length - 1, "IPv6 must write exactly 16 bytes."); + } + + + [TestMethod] + public void GetSubnetMaskWidth_ShouldNotSilentlyAcceptNonContiguousMasks() + { + IPAddress mask = IPAddress.Parse("255.0.255.0"); + + // current behavior + int width = mask.GetSubnetMaskWidth(); + + Assert.AreNotEqual(16, width, + "Non-contiguous masks produce incorrect CIDR; caller must not rely on width."); + } + [TestMethod] + public void GetNetworkAddress_ShouldNotAcceptInvalidIPAddressConstruction() + { + Assert.ThrowsExactly(() => _ = new IPAddress(Array.Empty()), + "IPAddress itself must reject invalid byte arrays at construction time."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldRejectTooManyIPv4Labels() + { + bool ok = IPAddressExtensions.TryParseReverseDomain( + "1.2.3.4.5.in-addr.arpa", out IPAddress? ip); + + Assert.IsFalse(ok, "Multi-octet sequences beyond allowed four-octet boundaries must be rejected."); + Assert.IsNull(ip, "Returned value must remain null on malformed reverse domain."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldMapShortNibblesIntoLeadingBytes() + { + bool ok = IPAddressExtensions.TryParseReverseDomain("A.B.C.ip6.arpa", out IPAddress? ip); + + Assert.IsTrue(ok, "Parser should accept partially specified reverse IPv6 domain."); + + Assert.IsNotNull(ip); + Assert.AreEqual(IPAddress.Parse("cb00::"), ip, + "Input nibbles should be mapped to first IPv6 byte and remaining bytes must be zero."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetUtilitiesTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetUtilitiesTests.cs new file mode 100644 index 00000000..2527177b --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetUtilitiesTests.cs @@ -0,0 +1,198 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class NetUtilitiesTests + { + [TestMethod] + public void IsPrivateIPv4_ShouldClassify_RFC1918_Correctly() + { + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("10.0.1.2")), + "10.x must be private."); + + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("192.168.1.55")), + "192.168.x must be private."); + + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("172.16.5.8")), + "172.16/12 must be private."); + + Assert.IsFalse(NetUtilities.IsPrivateIPv4(IPAddress.Parse("11.1.1.1")), + "Non-reserved space must not be treated private."); + } + + [TestMethod] + public void IsPrivateIPv4_ShouldRecognize_CarrierGradeNat() + { + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("100.64.10.10")), + "100.64/10 must be private."); + + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("100.127.20.30")), + "Upper CGNAT boundary must remain private."); + + Assert.IsFalse(NetUtilities.IsPrivateIPv4(IPAddress.Parse("100.128.10.10")), + "Outside CGNAT must be classified public."); + } + + [TestMethod] + public void IsPrivateIPv4_ShouldReject_NonIPv4() + { + Assert.ThrowsExactly( + () => NetUtilities.IsPrivateIPv4(IPAddress.IPv6Loopback), + "Method must reject IPv6 input explicitly."); + } + + [TestMethod] + public void IsPrivateIP_ShouldMap_MappedIPv6_ToIPv4() + { + IPAddress mapped = IPAddress.Parse("::ffff:192.168.1.10"); + + Assert.IsTrue(NetUtilities.IsPrivateIP(mapped), + "Mapped IPv6 pointing to private IPv4 must classify private."); + } + + [TestMethod] + public void IsPrivateIP_ShouldTreat_NonGlobalIPv6_AsPrivate() + { + // fd00::/8 → Unique local + IPAddress ula = IPAddress.Parse("fd00::1"); + + Assert.IsTrue(NetUtilities.IsPrivateIP(ula), + "Unique local must be private."); + } + + [TestMethod] + public void IsPrivateIP_ShouldThrow_WhenNullInput() + { + Assert.ThrowsExactly(() => + NetUtilities.IsPrivateIP(null!), + "Null input must be rejected immediately."); + } + + [TestMethod] + public void IsPrivateIP_ShouldNotThrow_ForIPv4() + { + IPAddress ip = IPAddress.Parse("192.168.1.10"); + Assert.IsTrue(NetUtilities.IsPrivateIP(ip)); + } + + [TestMethod] + public void IsPrivateIP_ShouldNotThrow_ForIPv6() + { + IPAddress ip = IPAddress.Parse("2001:db8::1"); + Assert.IsFalse(NetUtilities.IsPrivateIP(ip)); + } + + [TestMethod] + public void IsPublicIPv6_ShouldBeTrue_For2000Prefix() + { + IPAddress ip = IPAddress.Parse("2001:db8::1"); + + Assert.IsTrue(NetUtilities.IsPublicIPv6(ip), + "2000::/3 must be classified public."); + } + + [TestMethod] + public void IsPublicIPv6_ShouldBeFalse_WhenNotUnderGlobalRange() + { + IPAddress ip = IPAddress.Parse("fd00::1"); + + Assert.IsFalse(NetUtilities.IsPublicIPv6(ip), + "fd00:: is ULA and must not be public."); + } + + [TestMethod] + public void IsPublicIPv6_ShouldReject_IPv4() + { + Assert.ThrowsExactly(() => + NetUtilities.IsPublicIPv6(IPAddress.Parse("10.0.0.1")), + "IPv6-only API must reject IPv4 explicitly."); + } + + [TestMethod] + public void NetworkInfoIPv4_ShouldComputeBroadcastCorrectly() + { + System.Net.NetworkInformation.NetworkInterface nic = FakeInterface.GetDummy(); + IPAddress local = IPAddress.Parse("192.168.5.10"); + IPAddress mask = IPAddress.Parse("255.255.255.0"); + + NetworkInfo info = new NetworkInfo(nic, local, mask); + + Assert.AreEqual(IPAddress.Parse("192.168.5.255"), info.BroadcastIP, + "Broadcast must OR mask inverse properly."); + } + + [TestMethod] + public void NetworkInfoIPv6_ShouldRejectIPv4() + { + System.Net.NetworkInformation.NetworkInterface nic = FakeInterface.GetDummy(); + + Assert.ThrowsExactly(() => + new NetworkInfo(nic, IPAddress.Parse("10.0.0.10")), + "Constructor must reject non-IPv6 selectively."); + } + + [TestMethod] + public void NetworkInfoIPv4_ShouldRejectIPv6() + { + System.Net.NetworkInformation.NetworkInterface nic = FakeInterface.GetDummy(); + IPAddress local = IPAddress.Parse("fd00::1"); + IPAddress mask = IPAddress.Parse("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"); + + Assert.ThrowsExactly(() => + new NetworkInfo(nic, local, mask), + "IPv4 constructor must reject IPv6 local address."); + } + + [TestMethod] + public void NetworkInfoEquality_ShouldBeTrue_WhenIPAndInterfaceMatch() + { + System.Net.NetworkInformation.NetworkInterface nic = FakeInterface.GetDummy(); + + NetworkInfo a = new NetworkInfo(nic, IPAddress.IPv6Loopback); + NetworkInfo b = new NetworkInfo(nic, IPAddress.IPv6Loopback); + + Assert.IsTrue(a.Equals(b), + "Equality must hold across semantically identical instances."); + } + + [TestMethod] + public void NetworkInfoEquality_ShouldFail_OnDifferentIPs() + { + System.Net.NetworkInformation.NetworkInterface nic = FakeInterface.GetDummy(); + + NetworkInfo a = new NetworkInfo(nic, IPAddress.IPv6Loopback); + NetworkInfo b = new NetworkInfo(nic, IPAddress.Parse("2001:db8::1")); + + Assert.IsFalse(a.Equals(b), + "Different addresses cannot compare equal."); + } + } + + static class FakeInterface + { + public static System.Net.NetworkInformation.NetworkInterface GetDummy() + { + // Fully stubbed mock via nested fake + return new DummyNic(); + } + + private sealed class DummyNic : System.Net.NetworkInformation.NetworkInterface + { + public override string Description => "dummy"; + public override string Id => "dummy"; + public override bool IsReceiveOnly => false; + public override string Name => "dummy0"; + public override System.Net.NetworkInformation.NetworkInterfaceType NetworkInterfaceType => + System.Net.NetworkInformation.NetworkInterfaceType.Loopback; + public override System.Net.NetworkInformation.OperationalStatus OperationalStatus => + System.Net.NetworkInformation.OperationalStatus.Up; + public override long Speed => 1; + public override System.Net.NetworkInformation.IPInterfaceProperties GetIPProperties() => + throw new NotSupportedException(); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAccessControlTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAccessControlTests.cs new file mode 100644 index 00000000..fc7cdeee --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAccessControlTests.cs @@ -0,0 +1,165 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class NetworkAccessControlTests + { + [TestMethod] + public void Parse_ShouldParseAllowRule() + { + NetworkAccessControl nac = NetworkAccessControl.Parse("192.168.1.0/24"); + + Assert.IsFalse(nac.Deny); + Assert.AreEqual("192.168.1.0/24", nac.ToString()); + } + + [TestMethod] + public void Parse_ShouldParseDenyRule() + { + NetworkAccessControl nac = NetworkAccessControl.Parse("!10.0.0.0/8"); + + Assert.IsTrue(nac.Deny); + Assert.AreEqual("!10.0.0.0/8", nac.ToString()); + } + + [TestMethod] + public void Parse_ShouldThrow_OnInvalidAddress() + { + Assert.ThrowsExactly( + () => NetworkAccessControl.Parse("!!bad"), + "Invalid rules must trigger FormatException."); + } + + [TestMethod] + public void TryParse_ShouldReturnFalse_OnMalformed() + { + bool ok = NetworkAccessControl.TryParse("invalid", out NetworkAccessControl? nac); + + Assert.IsFalse(ok); + Assert.IsNull(nac); + } + + [TestMethod] + public void TryMatch_ShouldReturnTrueOnMatch() + { + NetworkAccessControl nac = new NetworkAccessControl(IPAddress.Parse("192.168.1.0"), 24); + + bool matched = nac.TryMatch(IPAddress.Parse("192.168.1.42"), out bool allowed); + + Assert.IsTrue(matched, "Prefix match expected."); + Assert.IsTrue(allowed, "Positive rule must allow."); + } + + [TestMethod] + public void TryMatch_ShouldReturnFalseWhenNotInNetwork() + { + NetworkAccessControl nac = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8); + + bool matched = nac.TryMatch(IPAddress.Parse("11.0.0.1"), out bool allowed); + + Assert.IsFalse(matched); + Assert.IsFalse(allowed); + } + + [TestMethod] + public void TryMatch_ShouldHonorNegation() + { + NetworkAccessControl nac = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8, deny: true); + + bool matched = nac.TryMatch(IPAddress.Parse("10.0.55.77"), out bool allowed); + + Assert.IsTrue(matched); + Assert.IsFalse(allowed, "Deny rule must return allowed=false."); + } + + [TestMethod] + public void IsAddressAllowed_ShouldReturnFirstMatchingResult() + { + NetworkAccessControl[] acl = new[] + { + new NetworkAccessControl(IPAddress.Parse("10.0.1.0"), 24, deny:true), // deny first + new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8), // allow + }; + + bool allowed = NetworkAccessControl.IsAddressAllowed(IPAddress.Parse("10.0.1.42"), acl); + + Assert.IsFalse(allowed, "First matching entry (deny) must determine result."); + } + + + [TestMethod] + public void IsAddressAllowed_ShouldReturnLoopbackWhenNoMatch() + { + bool allowed = NetworkAccessControl.IsAddressAllowed( + IPAddress.Loopback, + acl: null, + allowLoopbackWhenNoMatch: true); + + Assert.IsTrue(allowed); + } + + [TestMethod] + public void IsAddressAllowed_ShouldReturnFalseWithoutMatchAndNoLoopbackMode() + { + bool allowed = NetworkAccessControl.IsAddressAllowed( + IPAddress.Parse("5.5.5.5"), + new NetworkAccessControl[0], + allowLoopbackWhenNoMatch: false); + + Assert.IsFalse(allowed); + } + + [TestMethod] + public void WriteTo_ShouldRoundtrip() + { + NetworkAccessControl original = new NetworkAccessControl(IPAddress.Parse("10.2.3.0"), 24, deny: true); + + using MemoryStream ms = new MemoryStream(); + using BinaryWriter bw = new BinaryWriter(ms); + + original.WriteTo(bw); + bw.Flush(); + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + NetworkAccessControl read = NetworkAccessControl.ReadFrom(br); + + Assert.IsTrue(original.Equals(read), "Binary round trip must preserve rule."); + Assert.AreEqual(original.ToString(), read.ToString()); + } + + [TestMethod] + public void Equals_ShouldReturnTrue_WhenEquivalent() + { + NetworkAccessControl a = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8, deny: true); + NetworkAccessControl b = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8, deny: true); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_ShouldReturnFalse_WhenDifferentAddress() + { + NetworkAccessControl a = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8); + NetworkAccessControl b = new NetworkAccessControl(IPAddress.Parse("10.1.0.0"), 16); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void ToString_ShouldRenderCorrectly() + { + NetworkAccessControl allow = new NetworkAccessControl(IPAddress.Parse("192.168.0.0"), 16); + NetworkAccessControl deny = new NetworkAccessControl(IPAddress.Parse("100.64.0.0"), 10, deny: true); + + Assert.AreEqual("192.168.0.0/16", allow.ToString()); + Assert.AreEqual("!100.64.0.0/10", deny.ToString()); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAddressTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAddressTests.cs new file mode 100644 index 00000000..beb2b450 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAddressTests.cs @@ -0,0 +1,208 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public class NetworkAddressTests + { + [TestMethod] + public void Constructor_ShouldNormalizeToNetworkBoundary_IPv4() + { + NetworkAddress addr = new NetworkAddress(IPAddress.Parse("10.1.2.99"), 24); + + Assert.AreEqual("10.1.2.0", addr.Address.ToString(), + "NetworkAddress constructor must mask host bits."); + Assert.AreEqual((byte)24, addr.PrefixLength); + } + + [TestMethod] + public void Constructor_ShouldNormalizeToNetworkBoundary_IPv6() + { + NetworkAddress addr = new NetworkAddress(IPAddress.Parse("2001:db8::1234"), 64); + + Assert.AreEqual("2001:db8::", addr.Address.ToString(), + "NetworkAddress must enforce network mask."); + Assert.AreEqual((byte)64, addr.PrefixLength); + } + + [TestMethod] + public void Constructor_ShouldReject_InvalidPrefix_IPv4() + { + Assert.ThrowsExactly( + () => new NetworkAddress(IPAddress.Parse("1.2.3.4"), 33), + "IPv4 prefix >32 must be rejected."); + } + + [TestMethod] + public void Constructor_ShouldReject_InvalidPrefix_IPv6() + { + Assert.ThrowsExactly( + () => new NetworkAddress(IPAddress.Parse("2001::1"), 129), + "IPv6 prefix >128 must be rejected."); + } + + [TestMethod] + public void Parse_ShouldSupportNoPrefix_IPv4_DefaultsTo32Bits() + { + NetworkAddress n = NetworkAddress.Parse("8.8.8.8"); + + Assert.AreEqual("8.8.8.8", n.Address.ToString()); + Assert.AreEqual((byte)32, n.PrefixLength); + Assert.IsTrue(n.IsHostAddress); + } + + [TestMethod] + public void Parse_ShouldSupportPrefix_IPv4() + { + NetworkAddress n = NetworkAddress.Parse("10.0.0.123/8"); + + Assert.AreEqual("10.0.0.0", n.Address.ToString()); + Assert.AreEqual((byte)8, n.PrefixLength); + } + + [TestMethod] + public void Parse_ShouldFail_IfBaseAddressInvalid() + { + Assert.ThrowsExactly( + () => NetworkAddress.Parse("notAnIP/16"), + "Invalid IP should fail parsing."); + } + + [TestMethod] + public void Parse_ShouldFail_IfPrefixInvalid() + { + Assert.ThrowsExactly( + () => NetworkAddress.Parse("10.0.0.1/notanumber"), + "Prefix must be numeric."); + } + + [TestMethod] + public void TryParse_ShouldReturnFalse_OnMalformedInput() + { + bool ok = NetworkAddress.TryParse("hello", out NetworkAddress? result); + + Assert.IsFalse(ok); + Assert.IsNull(result); + } + + [TestMethod] + public void Contains_ShouldReturnTrue_ForMatchingAddress() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("192.168.10.0"), 24); + + Assert.IsTrue(net.Contains(IPAddress.Parse("192.168.10.55"))); + } + + [TestMethod] + public void Contains_ShouldReturnFalse_ForDifferentNetwork() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("192.168.10.0"), 24); + + Assert.IsFalse(net.Contains(IPAddress.Parse("192.168.11.1"))); + } + + [TestMethod] + public void Contains_ShouldReturnFalse_WhenAddressFamilyDiffers() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + + Assert.IsFalse(net.Contains(IPAddress.IPv6Loopback)); + } + + [TestMethod] + public void GetLastAddress_ShouldReturnBroadcastIPv4() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("192.168.50.0"), 24); + + IPAddress last = net.GetLastAddress(); + + Assert.AreEqual("192.168.50.255", last.ToString()); + } + [TestMethod] + public void GetLastAddress_ShouldReturnBroadcastIPv6() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("2001:db8::"), 64); + + IPAddress last = net.GetLastAddress(); + + IPAddress expected = IPAddress.Parse("2001:db8:0:0:ffff:ffff:ffff:ffff"); + + Assert.AreEqual(expected, last, + "Last IPv6 address must have all host bits set."); + } + + [TestMethod] + public void ToString_ShouldOmitPrefix_WhenHostAddressIPv4() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("9.9.9.9"), 32); + + Assert.AreEqual("9.9.9.9", net.ToString(), + "Full host prefix must not show /32"); + } + + [TestMethod] + public void ToString_ShouldIncludePrefix_WhenNotHostIPv4() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("9.9.9.0"), 24); + + Assert.AreEqual("9.9.9.0/24", net.ToString()); + } + + [TestMethod] + public void ToString_ShouldOmitPrefix_WhenHostAddressIPv6() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("2001::1"), 128); + + Assert.AreEqual("2001::1", net.ToString()); + } + + [TestMethod] + public void Roundtrip_BinarySerialization_Works() + { + NetworkAddress original = new NetworkAddress(IPAddress.Parse("10.20.30.40"), 20); + + using MemoryStream ms = new MemoryStream(); + using (BinaryWriter bw = new BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true)) + original.WriteTo(bw); + + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + NetworkAddress roundtrip = NetworkAddress.ReadFrom(br); + + Assert.AreEqual(original, roundtrip); + } + + [TestMethod] + public void Equals_ShouldReturnTrue_ForSameValue() + { + NetworkAddress a = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + NetworkAddress b = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_ShouldReturnFalse_WhenPrefixDiffers() + { + NetworkAddress a = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + NetworkAddress b = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 16); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_ShouldReturnFalse_WhenAddressDiffers() + { + NetworkAddress a = new NetworkAddress(IPAddress.Parse("192.168.0.0"), 24); + NetworkAddress b = new NetworkAddress(IPAddress.Parse("192.168.1.0"), 24); + + Assert.IsFalse(a.Equals(b)); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkMapTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkMapTests.cs new file mode 100644 index 00000000..e0f5e0b5 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkMapTests.cs @@ -0,0 +1,203 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class NetworkMapTests + { + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenMapIsEmpty() + { + NetworkMap map = new NetworkMap(); + + bool ok = map.TryGetValue("10.1.2.3", out string? value); + + Assert.IsFalse(ok, "Empty map must not resolve any address."); + Assert.IsNull(value, "Value must be null when lookup fails."); + } + + [TestMethod] + public void TryGetValue_ShouldReturnAssignedValue_ForExactSingleHost() + { + NetworkMap map = new NetworkMap(); + map.Add("192.168.1.10/32", "local"); + + Assert.IsTrue(map.TryGetValue("192.168.1.10", out string? value), + "Exact host entry must be resolved."); + + Assert.AreEqual("local", value, + "Resolved value must match inserted value."); + } + + [TestMethod] + public void TryGetValue_ShouldMatchWithinRange_ForIPv4Subnet() + { + NetworkMap map = new NetworkMap(); + map.Add("10.0.0.0/24", 42); + map.Add("10.0.1.0/24", 43); + + Assert.IsTrue(map.TryGetValue("10.0.0.255", out int v1), + "Boundary address belongs to first range."); + Assert.AreEqual(42, v1); + + Assert.IsTrue(map.TryGetValue("10.0.1.0", out int v2), + "Exact lower bound of second range should match."); + Assert.AreEqual(43, v2); + + Assert.IsTrue(map.TryGetValue("10.0.1.255", out int v3), + "Upper bound of second range should match."); + Assert.AreEqual(43, v3); + + Assert.IsFalse(map.TryGetValue("10.0.1.1", out _), + "Interior values cannot match because floor and ceiling belong to different ranges."); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenAddressOutsideRange() + { + NetworkMap map = new NetworkMap(); + map.Add("10.0.0.0/24", 11); + + bool ok = map.TryGetValue("10.0.1.1", out int value); + + Assert.IsFalse(ok, "Address outside stored range must not match."); + Assert.AreEqual(default, value, "Value must reset on failure."); + } + + [TestMethod] + public void TryGetValue_ShouldPreferNearestMatchingRange_OnSortedInsertionOrder() + { + NetworkMap map = new NetworkMap(); + + // Notice insertion bias: bigger range, then narrower override + map.Add("192.168.0.0/16", "WIDE"); + map.Add("192.168.100.0/24", "TIGHT"); + + Assert.IsTrue(map.TryGetValue("192.168.100.10", out string? value), + "Lookup must still resolve correct nearest boundary."); + + Assert.AreEqual("TIGHT", value, + "More specific entry must apply implicitly via boundary comparison."); + } + + [TestMethod] + public void Remove_ShouldReturnTrue_WhenEntryExists() + { + NetworkMap map = new NetworkMap(); + map.Add("10.10.10.0/24", "x"); + + bool removed = map.Remove("10.10.10.0/24"); + + Assert.IsTrue(removed, "Remove must return true when both start and last entries are removed."); + } + + [TestMethod] + public void Remove_ShouldReturnFalse_WhenEntryDoesNotExist() + { + NetworkMap map = new NetworkMap(); + map.Add("192.168.1.0/24", 1); + + bool removed = map.Remove("192.168.2.0/24"); + + Assert.IsFalse(removed, "Remove must fail if ranges never existed."); + } + + [TestMethod] + public void AfterRemove_ShouldNotResolve() + { + NetworkMap map = new NetworkMap(); + map.Add("10.0.0.0/8", "meta"); + + Assert.IsTrue(map.TryGetValue("10.20.30.40", out _), + "Initial resolution must work."); + + map.Remove("10.0.0.0/8"); + + Assert.IsFalse(map.TryGetValue("10.20.30.40", out string? now), + "After removal no resolution must survive."); + + Assert.IsNull(now, "Value must reset on failure."); + } + + [TestMethod] + public void TryGetValue_ShouldResolveIPv6Range() + { + NetworkMap map = new NetworkMap(); + map.Add("2001:db8::/64", "v6"); + + Assert.IsTrue(map.TryGetValue(IPAddress.Parse("2001:db8::abcd"), out string? value), + "IPv6 inside range must resolve correctly."); + + Assert.AreEqual("v6", value); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenIPv4QueryAgainstIPv6Range() + { + NetworkMap map = new NetworkMap(); + map.Add("2001:db8::/64", 99); + + bool ok = map.TryGetValue("10.0.0.1", out int val); + + Assert.IsFalse(ok, "Mismatched families must not resolve."); + Assert.AreEqual(default, val); + } + + [TestMethod] + public void AddingMultipleRanges_ShouldNotRequireManualSorting() + { + NetworkMap map = new NetworkMap(); + + map.Add("10.0.0.0/24", "A"); + map.Add("10.0.1.0/24", "B"); + map.Add("10.0.2.0/24", "C"); + + // The absence of prior TryGetValue calls guarantees lazy sorting is triggered here. + Assert.IsTrue(map.TryGetValue("10.0.2.9", out string? value), + "Lookup must not depend on explicit sorting."); + + Assert.AreEqual("C", value); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenFloorIsNull() + { + NetworkMap map = new NetworkMap(); + + map.Add("100.0.0.0/8", "x"); + + bool ok = map.TryGetValue(IPAddress.Parse("1.1.1.1"), out string? result); + + Assert.IsFalse(ok, "When requested IP precedes first boundary, match must fail."); + Assert.IsNull(result); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenCeilingIsNull() + { + NetworkMap map = new NetworkMap(); + + map.Add("10.0.0.0/8", "x"); + + bool ok = map.TryGetValue(IPAddress.Parse("200.200.200.200"), out string? result); + + Assert.IsFalse(ok, "When requested IP exceeds last boundary, match must fail."); + Assert.IsNull(result); + } + + [TestMethod] + public void ValuesMustBeMatchedByReference_WhenBothBoundsHoldSameInstance() + { + object payload = new object(); + NetworkMap map = new NetworkMap(); + + map.Add("10.20.30.0/24", payload); + + Assert.IsTrue(map.TryGetValue("10.20.30.50", out object? resolved)); + Assert.AreSame(payload, resolved, + "When value instance is identical, resolution must return exact object reference."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/DefaultProxyServerConnectionManagerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/DefaultProxyServerConnectionManagerTests.cs new file mode 100644 index 00000000..ebfffc45 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/DefaultProxyServerConnectionManagerTests.cs @@ -0,0 +1,142 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class DefaultProxyServerConnectionManagerTests + { + public TestContext TestContext { get; set; } + + private static TcpListener StartLoopbackListener(AddressFamily family, out IPEndPoint ep) + { + IPAddress addr = family == AddressFamily.InterNetwork ? + IPAddress.Loopback : + IPAddress.IPv6Loopback; + + var listener = new TcpListener(addr, 0); + listener.Start(); + ep = (IPEndPoint)listener.LocalEndpoint; + return listener; + } + + [TestMethod] + public async Task ConnectAsync_WithIPEndPoint_ConnectsSuccessfully() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEp); + + var manager = new DefaultProxyServerConnectionManager(); + + using Socket client = await manager.ConnectAsync(serverEp, TestContext.CancellationToken); + + Assert.IsTrue(client.Connected, "Socket must connect successfully to loopback listener."); + + using Socket server = await listener.AcceptSocketAsync(TestContext.CancellationToken); + Assert.IsTrue(server.Connected, "Listener must accept connection."); + + Assert.IsTrue(client.NoDelay, "ConnectAsync must set NoDelay=true."); + + client.Dispose(); + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_WithDnsEndPoint_ExplicitIPv4_ResolvesAndConnects() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEp); + + var manager = new DefaultProxyServerConnectionManager(); + + // DnsEndPoint → IPv4 resolution is supported when family is explicitly InterNetwork. + var dns = new DnsEndPoint("localhost", serverEp.Port, AddressFamily.InterNetwork); + + using Socket client = await manager.ConnectAsync(dns, TestContext.CancellationToken); + + Assert.IsTrue(client.Connected); + + using Socket server = await listener.AcceptSocketAsync(TestContext.CancellationToken); + Assert.IsTrue(server.Connected); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_WithDnsEndPoint_ExplicitIPv6_ResolvesAndConnects_IfIPv6Available() + { + // Skip test on machines without IPv6 enabled. + if (!Socket.OSSupportsIPv6) + { + Assert.Inconclusive("IPv6 not supported on this system."); + return; + } + + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetworkV6, out IPEndPoint serverEp); + + var manager = new DefaultProxyServerConnectionManager(); + + var dns = new DnsEndPoint("localhost", serverEp.Port, AddressFamily.InterNetworkV6); + + using Socket client = await manager.ConnectAsync(dns, TestContext.CancellationToken); + + Assert.IsTrue(client.Connected); + + using Socket server = await listener.AcceptSocketAsync(TestContext.CancellationToken); + Assert.IsTrue(server.Connected); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_WithDnsEndPoint_AddressFamilyMismatch_ThrowsSocketException() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEp); + + var manager = new DefaultProxyServerConnectionManager(); + + // Force IPv6 resolution against an IPv4 listener → mismatch. + var dns = new DnsEndPoint("localhost", serverEp.Port, AddressFamily.InterNetworkV6); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(dns, TestContext.CancellationToken)); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_UnspecifiedAddressFamilyDns_ThrowsNotSupportedException() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEp); + + var manager = new DefaultProxyServerConnectionManager(); + + var dns = new DnsEndPoint("localhost", serverEp.Port, AddressFamily.Unspecified); + + // Implementation explicitly throws NotSupportedException through GetIPEndPointAsync + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(dns, TestContext.CancellationToken)); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_AddressFamilyMismatchWithIPEndPoint_ThrowsSocketException() + { + // Listener is IPv4 + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEp); + + var manager = new DefaultProxyServerConnectionManager(); + + // Try to connect using IPv6 to IPv4 listener + var ipv6Target = new IPEndPoint(IPAddress.IPv6Loopback, serverEp.Port); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(ipv6Target, TestContext.CancellationToken)); + + listener.Stop(); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyAuthenticationFailedExceptionTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyAuthenticationFailedExceptionTests.cs new file mode 100644 index 00000000..c0f50a60 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyAuthenticationFailedExceptionTests.cs @@ -0,0 +1,69 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class HttpProxyAuthenticationFailedExceptionTests + { + [TestMethod] + public void DefaultConstructor_SetsDefaultMessage() + { + HttpProxyAuthenticationFailedException ex = new HttpProxyAuthenticationFailedException(); + + Assert.AreEqual( + expected: new HttpProxyAuthenticationFailedException().Message, + actual: ex.Message, + message: "Default constructor must provide the base exception message." + ); + + Assert.IsNull(ex.InnerException, "Default constructor must not assign an inner exception."); + } + + [TestMethod] + public void MessageConstructor_PreservesMessage() + { + string msg = "Proxy auth failed."; + HttpProxyAuthenticationFailedException ex = new HttpProxyAuthenticationFailedException(msg); + + Assert.AreEqual( + expected: msg, + actual: ex.Message, + message: "Message constructor must preserve the supplied message verbatim." + ); + } + + [TestMethod] + public void MessageAndInnerConstructor_PreservesBoth() + { + string msg = "Proxy authentication failed."; + InvalidOperationException inner = new InvalidOperationException("inner"); + HttpProxyAuthenticationFailedException ex = new HttpProxyAuthenticationFailedException(msg, inner); + + Assert.AreEqual( + expected: msg, + actual: ex.Message, + message: "Constructor must store the message." + ); + + Assert.AreSame( + expected: inner, + actual: ex.InnerException, + message: "Constructor must attach the inner exception." + ); + } + + [TestMethod] + public void ExceptionType_IsCorrect() + { + HttpProxyAuthenticationFailedException ex = new HttpProxyAuthenticationFailedException(); + + Assert.AreEqual( + expected: typeof(HttpProxyAuthenticationFailedException), + actual: ex.GetType(), + message: "Exception type must remain stable for consumer type checks." + ); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyExceptionTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyExceptionTests.cs new file mode 100644 index 00000000..e2d710aa --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyExceptionTests.cs @@ -0,0 +1,78 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class HttpProxyExceptionTests + { + [TestMethod] + public void DefaultConstructor_ProvidesNonNullMessage() + { + HttpProxyException ex = new HttpProxyException(); + + Assert.IsFalse( + string.IsNullOrWhiteSpace(ex.Message), + "Default constructor must provide a non-empty diagnostic message." + ); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not assign an inner exception." + ); + + Assert.AreEqual( + expected: typeof(HttpProxyException), + actual: ex.GetType(), + message: "Exception type must remain stable for typed exception handling." + ); + } + + [TestMethod] + public void MessageConstructor_PreservesMessage() + { + string msg = "HTTP proxy operation failed."; + HttpProxyException ex = new HttpProxyException(msg); + + Assert.AreEqual( + expected: msg, + actual: ex.Message, + message: "Message constructor must preserve supplied message verbatim." + ); + } + + [TestMethod] + public void MessageAndInnerExceptionConstructor_PreservesBoth() + { + string msg = "Proxy protocol error."; + InvalidOperationException inner = new InvalidOperationException("inner"); + + HttpProxyException ex = new HttpProxyException(msg, inner); + + Assert.AreEqual( + expected: msg, + actual: ex.Message, + message: "Exception must preserve its message." + ); + + Assert.AreSame( + expected: inner, + actual: ex.InnerException, + message: "Exception must preserve the supplied inner exception." + ); + } + + [TestMethod] + public void TypeIdentity_RemainsStable() + { + HttpProxyException ex = new HttpProxyException(); + + Assert.AreEqual( + expected: typeof(HttpProxyException), + actual: ex.GetType(), + message: "Typed exceptions must preserve exact runtime type identity." + ); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyServerExceptionTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyServerExceptionTests.cs new file mode 100644 index 00000000..6ce2ae6b --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyServerExceptionTests.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class HttpProxyServerExceptionTests + { + [TestMethod] + public void DefaultConstructor_SetsDefaultMessage_AndNullInnerException() + { + var ex = new HttpProxyServerException(); + + // .NET default message for exceptions with no message explicitly passed + // always includes the fully-qualified type name. + string expectedTypeName = typeof(HttpProxyServerException).FullName!; + + Assert.Contains( + expectedTypeName, + ex.Message, + "Default constructor must include the exception type name." + ); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not provide an inner exception." + ); + } + + [TestMethod] + public void MessageConstructor_SetsMessage_AndNullInnerException() + { + const string msg = "Server failure"; + + var ex = new HttpProxyServerException(msg); + + Assert.AreEqual( + msg, + ex.Message, + "Message constructor must store the provided message." + ); + + Assert.IsNull( + ex.InnerException, + "Message constructor must not set an inner exception." + ); + } + + [TestMethod] + public void MessageAndInnerConstructor_SetsMessage_AndInnerException() + { + const string msg = "Server failure"; + var inner = new InvalidOperationException("inner"); + + var ex = new HttpProxyServerException(msg, inner); + + Assert.AreEqual( + msg, + ex.Message, + "Message+Inner constructor must store the provided message." + ); + + Assert.AreSame( + inner, + ex.InnerException, + "The provided inner exception must be preserved." + ); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyServerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyServerTests.cs new file mode 100644 index 00000000..0349ae38 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyServerTests.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class HttpProxyServerTests + { + public TestContext TestContext { get; set; } + + #region helpers + + /// + /// Connects a TcpClient to the proxy server's listening endpoint. + /// + private async Task ConnectClientAsync(HttpProxyServer server) + { + TcpClient client = new TcpClient(); + IPEndPoint ep = server.LocalEndPoint; + + Assert.IsNotNull(ep, "LocalEndPoint must be initialized before accepting connections."); + + await client.ConnectAsync( + ep.Address.ToString(), + ep.Port, + TestContext.CancellationToken); + + return client; + } + + /// + /// Reads a single response frame from the server into a string. + /// Used for small HTTP status responses. + /// + private static async Task ReadResponseAsync(NetworkStream stream, CancellationToken cancellationToken) + { + byte[] buffer = new byte[4096]; + int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + + return Encoding.ASCII.GetString(buffer, 0, bytesRead); + } + + /// + /// Reads everything the server has written to the given socket until it closes + /// or no more data arrives. Intended for capturing forwarded HTTP requests. + /// + private static async Task ReadFromSocketAsync(Socket socket, CancellationToken cancellationToken) + { + await using MemoryStream ms = new MemoryStream(); + using NetworkStream networkStream = new NetworkStream(socket, ownsSocket: false); + + byte[] buffer = new byte[4096]; + + while (!cancellationToken.IsCancellationRequested) + { + if (!networkStream.CanRead) + break; + + if (!socket.Connected) + break; + + int read; + try + { + read = await networkStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + } + catch (IOException) + { + break; + } + + if (read <= 0) + break; + + await ms.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + + // For our tests, a single HTTP request is enough; break if end-of-headers reached. + if (ms.Length > 4) + { + byte[] data = ms.ToArray(); + string text = Encoding.ASCII.GetString(data); + if (text.Contains("\r\n\r\n", StringComparison.Ordinal)) + break; + } + } + + return Encoding.ASCII.GetString(ms.ToArray()); + } + + #endregion + + #region tests + + [TestMethod] + public void Constructor_UsesLoopbackAndEphemeralPort() + { + using HttpProxyServer server = new HttpProxyServer(); + + IPEndPoint ep = server.LocalEndPoint; + + Assert.IsNotNull(ep, "LocalEndPoint must be non-null after construction."); + Assert.IsTrue(IPAddress.IsLoopback(ep.Address), "HttpProxyServer must bind only to loopback by default to avoid exposing an open proxy."); + Assert.IsGreaterThan(0, ep.Port, "HttpProxyServer must bind to an ephemeral port when 0 is specified."); + } + + [TestMethod] + public async Task ConnectMethod_ValidConnectRequest_RespondsWith200AndUsesConnectionManager() + { + using RecordingConnectionManager connectionManager = new RecordingConnectionManager(); + using HttpProxyServer server = new HttpProxyServer(connectionManager); + + using TcpClient client = await ConnectClientAsync(server); + using NetworkStream clientStream = client.GetStream(); + + const string host = "198.51.100.10"; + const int port = 443; + + string request = + $"CONNECT {host}:{port} HTTP/1.1\r\n" + + $"Host: {host}:{port}\r\n" + + "\r\n"; + + byte[] requestBytes = Encoding.ASCII.GetBytes(request); + await clientStream.WriteAsync(requestBytes.AsMemory(0, requestBytes.Length), TestContext.CancellationToken); + await clientStream.FlushAsync(TestContext.CancellationToken); + + string response = await ReadResponseAsync(clientStream, TestContext.CancellationToken); + + StringAssert.StartsWith( + response, + "HTTP/1.1 200 OK", + "CONNECT must be acknowledged with 200 OK when the connection manager succeeds."); + + Assert.HasCount( + 1, + connectionManager.ConnectedEndpoints, + "Proxy server must delegate exactly one CONNECT to the connection manager."); + + Assert.IsInstanceOfType( + connectionManager.ConnectedEndpoints[0], + typeof(IPEndPoint), + "CONNECT target must be resolved to an IPEndPoint."); + + IPEndPoint ep = (IPEndPoint)connectionManager.ConnectedEndpoints[0]; + + Assert.AreEqual( + IPAddress.Parse(host), + ep.Address, + "CONNECT must target the exact IP address parsed from the request path."); + + Assert.AreEqual( + port, + ep.Port, + "CONNECT must target the exact TCP port parsed from the request path."); + } + + [TestMethod] + public async Task ConnectMethod_ConnectWithoutPort_Returns500InternalServerError() + { + using RecordingConnectionManager connectionManager = new RecordingConnectionManager(); + using HttpProxyServer server = new HttpProxyServer(connectionManager); + + using TcpClient client = await ConnectClientAsync(server); + using NetworkStream clientStream = client.GetStream(); + + const string host = "example.com"; + + string request = + $"CONNECT {host} HTTP/1.1\r\n" + + $"Host: {host}\r\n" + + "\r\n"; + + byte[] requestBytes = Encoding.ASCII.GetBytes(request); + await clientStream.WriteAsync(requestBytes.AsMemory(0, requestBytes.Length), TestContext.CancellationToken); + await clientStream.FlushAsync(TestContext.CancellationToken); + + string response = await ReadResponseAsync(clientStream, TestContext.CancellationToken); + + StringAssert.StartsWith( + response, + "HTTP/1.1 500", + "CONNECT without port is invalid per server contract and must return 500."); + + Assert.IsEmpty( + connectionManager.ConnectedEndpoints, + "Invalid CONNECT target must not trigger downstream connection attempts."); + } + + [TestMethod] + public async Task ConnectMethod_InvalidTarget_Returns500InternalServerError() + { + using RecordingConnectionManager connectionManager = new RecordingConnectionManager(); + using HttpProxyServer server = new HttpProxyServer(connectionManager); + + using TcpClient client = await ConnectClientAsync(server); + using NetworkStream clientStream = client.GetStream(); + + // Request path that EndPointExtensions.TryParse cannot interpret as an endpoint. + string request = + "CONNECT /not-an-endpoint HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n"; + + byte[] requestBytes = Encoding.ASCII.GetBytes(request); + await clientStream.WriteAsync(requestBytes.AsMemory(0, requestBytes.Length), TestContext.CancellationToken); + await clientStream.FlushAsync(TestContext.CancellationToken); + + string response = await ReadResponseAsync(clientStream, TestContext.CancellationToken); + + StringAssert.StartsWith( + response, + "HTTP/1.1 500 500 Internal Server Error", + "Invalid CONNECT request must be surfaced as 500 Internal Server Error according to server behavior."); + + + Assert.IsEmpty( + connectionManager.ConnectedEndpoints, + "Invalid CONNECT target must not trigger any downstream connection attempts."); + } + + [TestMethod] + public async Task Forwarding_NonConnectAbsoluteUri_RewritesPathAndStripsProxyHeaders() + { + using CapturingConnectionManager connectionManager = new CapturingConnectionManager(); + using HttpProxyServer server = new HttpProxyServer(connectionManager); + + using TcpClient client = await ConnectClientAsync(server); + using NetworkStream clientStream = client.GetStream(); + + const string targetUri = "http://example.com/resource/path?q=1"; + + string request = + $"GET {targetUri} HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Proxy-Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==\r\n" + + "Proxy-Connection: keep-alive\r\n" + + "\r\n"; + + byte[] requestBytes = Encoding.ASCII.GetBytes(request); + await clientStream.WriteAsync(requestBytes.AsMemory(0, requestBytes.Length), TestContext.CancellationToken); + await clientStream.FlushAsync(TestContext.CancellationToken); + + // Wait until the proxy has established a remote connection. + Assert.IsTrue( + connectionManager.WaitForAcceptedSocket(TimeSpan.FromSeconds(5)), + "Proxy server must establish a remote connection for non-CONNECT requests with absolute URI."); + + Socket remoteSocket = connectionManager.AcceptedSockets[0]; + + string forwardedRequest = await ReadFromSocketAsync(remoteSocket, TestContext.CancellationToken); + + StringAssert.StartsWith( + forwardedRequest, + "GET /resource/path?q=1 HTTP/1.1", + "Proxy must rewrite the request line to use the origin-form path and query, not the absolute URI."); + + Assert.IsFalse( + forwardedRequest.Contains("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase), + "Proxy-Authorization header must be stripped before forwarding to the origin server to prevent credential leakage."); + + Assert.IsFalse( + forwardedRequest.Contains("Proxy-Connection:", StringComparison.OrdinalIgnoreCase), + "Proxy-specific connection headers must be stripped before forwarding to the origin server."); + } + + [TestMethod] + public void Dispose_MultipleCalls_AreIdempotentAndCloseListener() + { + HttpProxyServer server = new HttpProxyServer(); + + IPEndPoint ep = server.LocalEndPoint; + + Assert.IsNotNull(ep, "LocalEndPoint must be available before disposal."); + + // First dispose should close underlying listener and all sessions. + server.Dispose(); + + // Second dispose must be a no-op (no ObjectDisposedException, no side-effects). + server.Dispose(); + } + + #endregion + + #region fakes + + /// + /// Minimal connection manager that records the endpoints it is asked to connect to, + /// and returns a connected loopback socket for each request. + /// Suitable for CONNECT tests that only care about the handshake and not data relay. + /// + private sealed class RecordingConnectionManager : IProxyServerConnectionManager, IDisposable + { + private readonly List _allocatedSockets = new(); + + public IList ConnectedEndpoints { get; } = new List(); + + public async Task ConnectAsync(EndPoint remoteEP, CancellationToken cancellationToken = default) + { + // Record the requested endpoint without performing any external network calls. + ConnectedEndpoints.Add(remoteEP); + + // Create a loopback-connected socket pair so that the proxy has + // a valid, connected socket to work with. + TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + EndPoint? epObj = listener.Server.LocalEndPoint; + Assert.IsNotNull(epObj, "Listener.LocalEndPoint must not be null after Start()."); + IPEndPoint listenerEp = (IPEndPoint)epObj; + + Socket clientSocket = new Socket(listenerEp.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + + await clientSocket.ConnectAsync(listenerEp, cancellationToken); + Socket serverSocket = await listener.AcceptSocketAsync(cancellationToken); + + listener.Stop(); + + // We only return the client side to the proxy; the server side is discarded. + serverSocket.Dispose(); + + clientSocket.NoDelay = true; + _allocatedSockets.Add(clientSocket); + + return clientSocket; + } + + public Task GetBindHandlerAsync(AddressFamily family) + { + throw new NotSupportedException("Bind is not required for HttpProxyServer unit tests."); + } + + public Task GetUdpAssociateHandlerAsync(EndPoint localEP) + { + throw new NotSupportedException("UDP associate is not required for HttpProxyServer unit tests."); + } + + public void Dispose() + { + foreach (Socket s in _allocatedSockets) + { + try + { + s.Dispose(); + } + catch + { + // Ignore cleanup errors in test fake. + } + } + + _allocatedSockets.Clear(); + } + } + + /// + /// Connection manager that exposes the server-side sockets so tests can + /// inspect the HTTP request bytes forwarded by the proxy. + /// + private sealed class CapturingConnectionManager : IProxyServerConnectionManager, IDisposable + { + private readonly List _listeners = new(); + private readonly List _clientSockets = new(); + + private readonly List _acceptedSockets = new(); + private readonly List _endpoints = new(); + + private readonly AutoResetEvent _hasAccepted = new(false); + + public IList AcceptedSockets => _acceptedSockets; + public IList ConnectedEndpoints => _endpoints; + + public async Task ConnectAsync(EndPoint remoteEP, CancellationToken cancellationToken = default) + { + _endpoints.Add(remoteEP); + + TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + _listeners.Add(listener); + + EndPoint? epObj = listener.Server.LocalEndPoint; + Assert.IsNotNull(epObj, "Listener.LocalEndPoint must not be null after Start()."); + IPEndPoint listenerEp = (IPEndPoint)epObj; + + Socket clientSocket = new Socket(listenerEp.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + _clientSockets.Add(clientSocket); + + await clientSocket.ConnectAsync(listenerEp, cancellationToken); + Socket serverSocket = await listener.AcceptSocketAsync(cancellationToken); + + _acceptedSockets.Add(serverSocket); + _hasAccepted.Set(); + + clientSocket.NoDelay = true; + + return clientSocket; + } + + public Task GetBindHandlerAsync(AddressFamily family) + { + throw new NotSupportedException("Bind is not required for HttpProxyServer forwarding tests."); + } + + public Task GetUdpAssociateHandlerAsync(EndPoint localEP) + { + throw new NotSupportedException("UDP associate is not required for HttpProxyServer forwarding tests."); + } + + public bool WaitForAcceptedSocket(TimeSpan timeout) + { + return _hasAccepted.WaitOne(timeout); + } + + public void Dispose() + { + foreach (Socket s in _clientSockets) + { + try + { + s.Dispose(); + } + catch + { + // Ignore cleanup errors in test fake. + } + } + + foreach (Socket s in _acceptedSockets) + { + try + { + s.Dispose(); + } + catch + { + // Ignore cleanup errors in test fake. + } + } + + foreach (TcpListener l in _listeners) + { + try + { + l.Stop(); + } + catch + { + // Ignore cleanup errors in test fake. + } + } + + _clientSockets.Clear(); + _acceptedSockets.Clear(); + _listeners.Clear(); + } + } + + #endregion + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyTests.cs new file mode 100644 index 00000000..7c838664 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/HttpProxyTests.cs @@ -0,0 +1,243 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class HttpProxyTests + { + public TestContext TestContext { get; set; } + + private static Task<(TcpListener listener, int port)> StartListenerAsync() + { + TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + return Task.FromResult((listener, port)); + } + + /// + /// Reads a complete HTTP request from the given socket until the end-of-headers + /// marker ("\r\n\r\n") is observed or the socket closes. This is robust against + /// TCP fragmentation of the CONNECT and Proxy-Authorization lines. + /// + private static async Task ReadHttpRequestAsync(Socket socket, CancellationToken cancellationToken) + { + byte[] buffer = new byte[2048]; + StringBuilder builder = new StringBuilder(); + + while (!cancellationToken.IsCancellationRequested) + { + int read = await socket.ReceiveAsync(buffer.AsMemory(0, buffer.Length), SocketFlags.None, cancellationToken); + if (read <= 0) + break; + + builder.Append(Encoding.ASCII.GetString(buffer, 0, read)); + + if (builder.ToString().Contains("\r\n\r\n", StringComparison.Ordinal)) + break; + } + + return builder.ToString(); + } + + private static Task RespondAsync(Socket socket, string httpResponse, CancellationToken cancellationToken) + { + byte[] bytes = Encoding.ASCII.GetBytes(httpResponse); + return socket.SendAsync(bytes.AsMemory(0, bytes.Length), SocketFlags.None, cancellationToken).AsTask(); + } + + // ------------------------------------------------------------ + // 200 OK + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_When200_ReturnsConnectedSocket() + { + (TcpListener listener, int port) = await StartListenerAsync(); + + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port)); + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 5555); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + string request = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + Console.WriteLine("REQUEST RAW:"); + Console.WriteLine(request); + + Assert.IsTrue( + request.StartsWith("CONNECT ", StringComparison.Ordinal), + "Proxy must send a CONNECT request line to the upstream proxy." + ); + + Assert.Contains( + value: request, + substring: destination.ToString(), + message: "CONNECT request must contain 'host:port'." + ); + + await RespondAsync(serverSide, "HTTP/1.0 200 Connection Established\r\n\r\n", TestContext.CancellationToken); + + Socket result = await connectTask; + Assert.IsNotNull(result, "ConnectAsync must return a non-null Socket when the proxy responds 200."); + Assert.IsTrue(result.Connected, "Socket must be connected after a 200 OK response from the HTTP proxy."); + + result.Dispose(); + listener.Stop(); + } + + // ------------------------------------------------------------ + // 407 Authentication Required + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_When407_ThrowsAuthenticationFailed() + { + (TcpListener listener, int port) = await StartListenerAsync(); + NetworkCredential creds = new NetworkCredential("alice", "secret"); + + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port), creds); + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 8080); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + + string request = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + string expectedAuth = Convert.ToBase64String( + Encoding.ASCII.GetBytes("alice:secret") + ); + + Assert.Contains( + value: request, + substring: expectedAuth, + message: "CONNECT request must include Proxy-Authorization header with Base64 credentials." + ); + + await RespondAsync(serverSide, "HTTP/1.0 407 Proxy Authentication Required\r\n\r\n", TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(() => connectTask); + + listener.Stop(); + } + + // ------------------------------------------------------------ + // 500 Internal Server Error + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_When500_ThrowsHttpProxyException() + { + (TcpListener listener, int port) = await StartListenerAsync(); + + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port)); + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 9090); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + + string request = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + Assert.IsTrue( + request.StartsWith("CONNECT ", StringComparison.Ordinal), + "Proxy must issue a CONNECT before receiving a 500 response." + ); + + await RespondAsync(serverSide, "HTTP/1.0 500 Internal Server Error\r\n\r\n", TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(() => connectTask); + + listener.Stop(); + } + + // ------------------------------------------------------------ + // Malformed response + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_WhenMalformedResponse_ThrowsHttpProxyException() + { + (TcpListener listener, int port) = await StartListenerAsync(); + + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port)); + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 8081); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + _ = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + await RespondAsync(serverSide, "NOTVALID\r\n\r\n", TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(() => connectTask); + + listener.Stop(); + } + + // ------------------------------------------------------------ + // Zero-byte receive + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_WhenZeroByteResponse_ThrowsHttpProxyException() + { + (TcpListener listener, int port) = await StartListenerAsync(); + + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port)); + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 6060); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + _ = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + serverSide.Shutdown(SocketShutdown.Both); + serverSide.Close(); + + await Assert.ThrowsExactlyAsync(() => connectTask); + + listener.Stop(); + } + + // ------------------------------------------------------------ + // Basic auth header correctness + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_IncludesBasicAuthHeader_WhenCredentialsProvided() + { + (TcpListener listener, int port) = await StartListenerAsync(); + + NetworkCredential creds = new NetworkCredential("userX", "pa$$word"); + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port), creds); + + // Use a non-bypassed address + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 7007); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + string request = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + string expected = Convert.ToBase64String(Encoding.ASCII.GetBytes("userX:pa$$word")); + + Assert.Contains( + value: request, + substring: expected, + message: "CONNECT request must include Proxy-Authorization header with Base64 credentials." + ); + + await RespondAsync(serverSide, "HTTP/1.0 200 OK\r\n\r\n", TestContext.CancellationToken); + + Socket finalSocket = await connectTask; + Assert.IsTrue(finalSocket.Connected, "Socket must remain connected after a successful authenticated CONNECT."); + + finalSocket.Dispose(); + listener.Stop(); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/IProxyServerAuthenticationManagerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/IProxyServerAuthenticationManagerTests.cs new file mode 100644 index 00000000..d82203d6 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/IProxyServerAuthenticationManagerTests.cs @@ -0,0 +1,78 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class IProxyServerAuthenticationManagerTests + { + [TestMethod] + public void Authenticate_ReturnsTrue_AllowsAccess() + { + var auth = new FakeAuthManager(result: true); + + bool ok = auth.Authenticate("alice", "secret"); + + Assert.IsTrue(ok, "Authentication manager should return true when credentials are accepted."); + Assert.AreEqual("alice", auth.LastUser); + Assert.AreEqual("secret", auth.LastPass); + } + + [TestMethod] + public void Authenticate_ReturnsFalse_DeniesAccess() + { + var auth = new FakeAuthManager(result: false); + + bool ok = auth.Authenticate("bob", "wrong"); + + Assert.IsFalse(ok, "Authentication manager should return false when credentials are rejected."); + Assert.AreEqual("bob", auth.LastUser); + Assert.AreEqual("wrong", auth.LastPass); + } + + [TestMethod] + public void Authenticate_HandlesNulls() + { + var auth = new FakeAuthManager(result: false); + + bool ok = auth.Authenticate(null, null); + + Assert.IsFalse(ok, "Null credentials must be treated as failed authentication."); + Assert.IsNull(auth.LastUser); + Assert.IsNull(auth.LastPass); + } + + [TestMethod] + public void Authenticate_CalledExactlyOncePerInvocation() + { + var auth = new FakeAuthManager(result: true); + + _ = auth.Authenticate("u", "p"); + _ = auth.Authenticate("u", "p"); + + Assert.AreEqual(2, auth.Calls, "Authenticate method must be invoked exactly once per request."); + } + + private sealed class FakeAuthManager : IProxyServerAuthenticationManager + { + private readonly bool _result; + + public int Calls { get; private set; } + public string LastUser { get; private set; } + public string LastPass { get; private set; } + + public FakeAuthManager(bool result) + { + _result = result; + } + + public bool Authenticate(string username, string password) + { + Calls++; + LastUser = username; + LastPass = password; + return _result; + } + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/IProxyServerConnectionManagerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/IProxyServerConnectionManagerTests.cs new file mode 100644 index 00000000..3faebe77 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/IProxyServerConnectionManagerTests.cs @@ -0,0 +1,107 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class IProxyServerConnectionManagerTests + { + [TestMethod] + public async Task ConnectAsync_MustHonorCancellation() + { + using CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + + IProxyServerConnectionManager manager = new ContractTestConnectionManager(); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, 1), + cts.Token), + "ConnectAsync must honor pre-cancelled tokens deterministically."); + } + + [TestMethod] + public async Task ConnectAsync_MustNotLeakSocket_OnCancellation() + { + using CancellationTokenSource cts = + new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + IProxyServerConnectionManager manager = new ContractTestConnectionManager(); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync( + new IPEndPoint(IPAddress.Parse("192.0.2.1"), 65000), + cts.Token), + "ConnectAsync must release resources cleanly when cancelled during connection attempt."); + } + + [TestMethod] + public async Task ConnectAsync_MustRejectUnsupportedEndpointTypes() + { + IProxyServerConnectionManager manager = new ContractTestConnectionManager(); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync( + new DnsEndPoint("example.com", 80), + CancellationToken.None), + "Unsupported EndPoint types must be rejected deterministically."); + } + + [TestMethod] + public async Task ConnectAsync_MustReturnConnectedSocket_OnSuccess() + { + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + IPEndPoint target = (IPEndPoint)listener.LocalEndpoint; + + IProxyServerConnectionManager manager = new ContractTestConnectionManager(); + + using Socket client = await manager.ConnectAsync(target); + + Assert.IsTrue(client.Connected, + "ConnectAsync must return a socket that is already connected."); + + using Socket server = await listener.AcceptSocketAsync(); + Assert.IsTrue(server.Connected, + "Returned socket must result in an observable server-side connection."); + } + + private sealed class ContractTestConnectionManager : IProxyServerConnectionManager + { + public async Task ConnectAsync(EndPoint remoteEP, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (remoteEP is not IPEndPoint ip) + throw new NotSupportedException("Only IPEndPoint supported by contract test."); + + Socket socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + + try + { + await socket.ConnectAsync(ip, cancellationToken); + socket.NoDelay = true; + return socket; + } + catch + { + socket.Dispose(); + throw; + } + } + + public Task GetBindHandlerAsync(AddressFamily family) + => throw new NotSupportedException(); + + public Task GetUdpAssociateHandlerAsync(EndPoint localEP) + => throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/InterfaceBoundProxyServerConnectionManagerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/InterfaceBoundProxyServerConnectionManagerTests.cs new file mode 100644 index 00000000..b5710bac --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/InterfaceBoundProxyServerConnectionManagerTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class InterfaceBoundProxyServerConnectionManagerTests + { + public TestContext TestContext { get; set; } + + private static TcpListener StartLoopbackListener(AddressFamily family, out IPEndPoint localEndPoint) + { + IPAddress address = family switch + { + AddressFamily.InterNetwork => IPAddress.Loopback, + AddressFamily.InterNetworkV6 => IPAddress.IPv6Loopback, + _ => throw new NotSupportedException("Only IPv4 and IPv6 are supported in test helper.") + }; + + TcpListener listener = new TcpListener(address, 0); + listener.Start(); + + Assert.IsNotNull(listener.LocalEndpoint, "Listener.LocalEndpoint must be initialized after Start()."); + Assert.IsInstanceOfType( + listener.LocalEndpoint, + "Listener.LocalEndpoint must be an IPEndPoint instance."); + + // Null-forgiving operator to satisfy nullable analysis; we already asserted non-null + type. + localEndPoint = (IPEndPoint)listener.LocalEndpoint!; + return listener; + } + + [TestMethod] + public void Constructor_ExposesBindAddress() + { + IPAddress bindAddress = IPAddress.Loopback; + + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(bindAddress); + + Assert.AreEqual( + bindAddress, + manager.BindAddress, + "BindAddress property must reflect the constructor-provided bind address."); + } + + [TestMethod] + public async Task ConnectAsync_WithMatchingAddressFamily_BindsAndConnectsFromBindAddress() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEndPoint); + + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + + Socket clientSocket = await manager.ConnectAsync(serverEndPoint, TestContext.CancellationToken); + + using Socket serverSocket = await listener.AcceptSocketAsync(TestContext.CancellationToken); + + Assert.IsTrue(clientSocket.Connected, "Client socket must be connected when address families match."); + Assert.IsTrue(serverSocket.Connected, "Server-side accepted socket must be connected."); + + Assert.IsNotNull(clientSocket.LocalEndPoint, "Client LocalEndPoint must be set after a successful connect."); + Assert.IsInstanceOfType( + clientSocket.LocalEndPoint, + "Client LocalEndPoint must be an IPEndPoint."); + + // Null-forgiving: guarded by IsNotNull + IsInstanceOfType above. + IPEndPoint local = (IPEndPoint)clientSocket.LocalEndPoint!; + Assert.AreEqual( + IPAddress.Loopback, + local.Address, + "Client must bind to the configured bind address for outbound connections."); + + clientSocket.Dispose(); + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_WithUnspecifiedDnsEndPoint_ThrowsNotSupported() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEndPoint); + + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + + DnsEndPoint dnsEp = new DnsEndPoint("localhost", serverEndPoint.Port, AddressFamily.Unspecified); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(dnsEp, TestContext.CancellationToken), + "Unspecified DnsEndPoint with ambiguous resolution must fail with NotSupportedException when bound to a specific address family."); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_WithMismatchedFamily_ThrowsNetworkUnreachable() + { + // Bind manager to IPv4 but use IPv6 endpoint. + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + IPEndPoint remote = new IPEndPoint(IPAddress.IPv6Loopback, 443); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(remote, TestContext.CancellationToken), + "ConnectAsync must throw SocketException when the remote endpoint family does not match the bind address family."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "Mismatched family must surface NetworkUnreachable to the caller."); + } + + [TestMethod] + public async Task GetBindHandlerAsync_WithMatchingFamily_ReturnsHandler() + { + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + + IProxyServerBindHandler handler = await manager.GetBindHandlerAsync(AddressFamily.InterNetwork); + + Assert.IsNotNull(handler, "GetBindHandlerAsync must return a non-null handler for matching address family."); + + if (handler is IDisposable disposable) + { + disposable.Dispose(); + } + } + + [TestMethod] + public async Task GetBindHandlerAsync_WithMismatchedFamily_ThrowsNetworkUnreachable() + { + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.GetBindHandlerAsync(AddressFamily.InterNetworkV6), + "GetBindHandlerAsync must fail when the requested family does not match the bind address family."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "Bind handler lookup must surface NetworkUnreachable for mismatched family."); + } + + [TestMethod] + public async Task GetUdpAssociateHandlerAsync_WithMatchingFamily_ReturnsHandler() + { + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + IPEndPoint localEp = new IPEndPoint(IPAddress.Loopback, 0); + + IProxyServerUdpAssociateHandler handler = await manager.GetUdpAssociateHandlerAsync(localEp); + + Assert.IsNotNull(handler, "GetUdpAssociateHandlerAsync must return a non-null handler for matching family."); + + if (handler is IDisposable disposable) + disposable.Dispose(); + } + + [TestMethod] + public async Task GetUdpAssociateHandlerAsync_WithMismatchedFamily_ThrowsNetworkUnreachable() + { + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + IPEndPoint localEp = new IPEndPoint(IPAddress.IPv6Loopback, 0); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.GetUdpAssociateHandlerAsync(localEp), + "UDP handler lookup must fail when the endpoint family does not match the bind address."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "UDP handler lookup must surface NetworkUnreachable for mismatched family."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/LoadBalancingProxyServerConnectionManagerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/LoadBalancingProxyServerConnectionManagerTests.cs new file mode 100644 index 00000000..1964370d --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/LoadBalancingProxyServerConnectionManagerTests.cs @@ -0,0 +1,510 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class LoadBalancingProxyServerConnectionManagerTests + { + public TestContext TestContext { get; set; } + + private static readonly EndPoint DummyConnectivityEndPoint = + new IPEndPoint(IPAddress.Loopback, 80); + + #region tests – ConnectAsync + + [TestMethod] + public async Task ConnectAsync_WithIPv4Endpoint_UsesIPv4ConnectionManager() + { + var ipv4Manager = new FakeConnectionManager(AddressFamily.InterNetwork); + var ipv6Manager = new FakeConnectionManager(AddressFamily.InterNetworkV6); + + using var manager = new LoadBalancingProxyServerConnectionManager( + new[] { ipv4Manager }, + new[] { ipv6Manager }, + new[] { DummyConnectivityEndPoint }); + + var target = new IPEndPoint(IPAddress.Loopback, 12345); + + using Socket socket = await manager.ConnectAsync(target, TestContext.CancellationToken); + + Assert.AreEqual( + 1, + ipv4Manager.ConnectCallCount, + "IPv4 endpoint must be delegated to an IPv4 connection manager."); + + Assert.AreEqual( + 0, + ipv6Manager.ConnectCallCount, + "IPv6 connection manager must not be used for IPv4 endpoints."); + + Assert.AreEqual( + target, + ipv4Manager.LastRemoteEndPoint, + "IPv4 manager must see the exact remote endpoint passed to ConnectAsync."); + + Assert.AreEqual( + AddressFamily.InterNetwork, + socket.AddressFamily, + "Returned socket family must match the selected IPv4 manager."); + } + + [TestMethod] + public async Task ConnectAsync_WithIPv6Endpoint_UsesIPv6ConnectionManager_IfSupported() + { + if (!Socket.OSSupportsIPv6) + { + Assert.Inconclusive("IPv6 is not supported on this system."); + return; + } + + var ipv4Manager = new FakeConnectionManager(AddressFamily.InterNetwork); + var ipv6Manager = new FakeConnectionManager(AddressFamily.InterNetworkV6); + + using var manager = new LoadBalancingProxyServerConnectionManager( + new[] { ipv4Manager }, + new[] { ipv6Manager }, + new[] { DummyConnectivityEndPoint }); + + var target = new IPEndPoint(IPAddress.IPv6Loopback, 12345); + + using Socket socket = await manager.ConnectAsync(target, TestContext.CancellationToken); + + Assert.AreEqual( + 0, + ipv4Manager.ConnectCallCount, + "IPv4 connection manager must not be used for IPv6 endpoints."); + + Assert.AreEqual( + 1, + ipv6Manager.ConnectCallCount, + "IPv6 endpoint must be delegated to an IPv6 connection manager."); + + Assert.AreEqual( + target, + ipv6Manager.LastRemoteEndPoint, + "IPv6 manager must see the exact remote endpoint passed to ConnectAsync."); + + Assert.AreEqual( + AddressFamily.InterNetworkV6, + socket.AddressFamily, + "Returned socket family must match the selected IPv6 manager."); + } + + [TestMethod] + public async Task ConnectAsync_WithUnspecifiedDomain_BothFamiliesAvailable_UsesOneFamilyConsistently() + { + var ipv4Manager = new FakeConnectionManager(AddressFamily.InterNetwork); + var ipv6Manager = new FakeConnectionManager(AddressFamily.InterNetworkV6); + + using var manager = new LoadBalancingProxyServerConnectionManager( + new[] { ipv4Manager }, + new[] { ipv6Manager }, + new[] { DummyConnectivityEndPoint }); + + // DomainEndPoint with AddressFamily.Unspecified – will be resolved by GetIPEndPointAsync. + var domain = new DomainEndPoint("localhost", 443); + + using Socket socket = await manager.ConnectAsync(domain, TestContext.CancellationToken); + + int totalCalls = ipv4Manager.ConnectCallCount + ipv6Manager.ConnectCallCount; + + Assert.AreEqual( + 1, + totalCalls, + "Exactly one underlying connection manager must be used per ConnectAsync call."); + + FakeConnectionManager chosen = + ipv4Manager.ConnectCallCount == 1 ? ipv4Manager : ipv6Manager; + + Assert.IsNotNull( + chosen.LastRemoteEndPoint, + "Chosen manager must receive a resolved IPEndPoint."); + + Assert.IsInstanceOfType( + chosen.LastRemoteEndPoint, + typeof(IPEndPoint), + "Unspecified domain endpoint must be resolved to an IPEndPoint."); + + var resolved = (IPEndPoint)chosen.LastRemoteEndPoint!; + Assert.AreEqual( + chosen.Family, + resolved.AddressFamily, + "Resolved endpoint family must match the chosen manager family."); + + Assert.AreEqual( + chosen.Family, + socket.AddressFamily, + "Returned socket family must match the chosen manager family."); + } + + [TestMethod] + public async Task ConnectAsync_WithUnspecifiedDomain_OnlyIPv4Available_ResolvesToIPv4() + { + var ipv4Manager = new FakeConnectionManager(AddressFamily.InterNetwork); + + using var manager = new LoadBalancingProxyServerConnectionManager( + new[] { ipv4Manager }, + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + var domain = new DomainEndPoint("localhost", 80); + + using Socket socket = await manager.ConnectAsync(domain, TestContext.CancellationToken); + + Assert.AreEqual( + 1, + ipv4Manager.ConnectCallCount, + "With only IPv4 managers available, ConnectAsync must route to IPv4."); + + Assert.IsInstanceOfType( + ipv4Manager.LastRemoteEndPoint, + typeof(IPEndPoint), + "DomainEndPoint must be resolved to an IPv4 IPEndPoint when only IPv4 is available."); + + var resolved = (IPEndPoint)ipv4Manager.LastRemoteEndPoint!; + Assert.AreEqual( + AddressFamily.InterNetwork, + resolved.AddressFamily, + "Resolved endpoint must be IPv4 when only IPv4 managers are available."); + + Assert.AreEqual( + AddressFamily.InterNetwork, + socket.AddressFamily, + "Returned socket family must be IPv4 when only IPv4 managers are available."); + } + + [TestMethod] + public async Task ConnectAsync_WithUnspecifiedDomain_OnlyIPv6Available_ResolvesToIPv6_IfSupported() + { + if (!Socket.OSSupportsIPv6) + { + Assert.Inconclusive("IPv6 is not supported on this system."); + return; + } + + var ipv6Manager = new FakeConnectionManager(AddressFamily.InterNetworkV6); + + using var manager = new LoadBalancingProxyServerConnectionManager( + Array.Empty(), + new[] { ipv6Manager }, + new[] { DummyConnectivityEndPoint }); + + var domain = new DomainEndPoint("localhost", 80); + + using Socket socket = await manager.ConnectAsync(domain, TestContext.CancellationToken); + + Assert.AreEqual( + 1, + ipv6Manager.ConnectCallCount, + "With only IPv6 managers available, ConnectAsync must route to IPv6."); + + Assert.IsInstanceOfType( + ipv6Manager.LastRemoteEndPoint, + typeof(IPEndPoint), + "DomainEndPoint must be resolved to an IPv6 IPEndPoint when only IPv6 is available."); + + var resolved = (IPEndPoint)ipv6Manager.LastRemoteEndPoint!; + Assert.AreEqual( + AddressFamily.InterNetworkV6, + resolved.AddressFamily, + "Resolved endpoint must be IPv6 when only IPv6 managers are available."); + + Assert.AreEqual( + AddressFamily.InterNetworkV6, + socket.AddressFamily, + "Returned socket family must be IPv6 when only IPv6 managers are available."); + } + + [TestMethod] + public async Task ConnectAsync_WithUnspecifiedDomain_NoWorkingManagers_ThrowsNetworkUnreachable() + { + using var manager = new LoadBalancingProxyServerConnectionManager( + Array.Empty(), + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + var domain = new DomainEndPoint("localhost", 443); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(domain, TestContext.CancellationToken), + "When no working managers exist, ConnectAsync must fail with SocketError.NetworkUnreachable."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "ConnectAsync must surface NetworkUnreachable when no family is available."); + } + + [TestMethod] + public async Task ConnectAsync_WithRedundancyOnly_AlwaysUsesFirstWorkingManager() + { + var primary = new FakeConnectionManager(AddressFamily.InterNetwork); + var secondary = new FakeConnectionManager(AddressFamily.InterNetwork); + + using var manager = new LoadBalancingProxyServerConnectionManager( + new[] { primary, secondary }, + Array.Empty(), + new[] { DummyConnectivityEndPoint }, + redundancyOnly: true); + + var target = new IPEndPoint(IPAddress.Loopback, 8080); + + const int attempts = 5; + + for (int i = 0; i < attempts; i++) + { + using Socket socket = await manager.ConnectAsync(target, TestContext.CancellationToken); + } + + Assert.AreEqual( + attempts, + primary.ConnectCallCount, + "In redundancy-only mode, the first working manager must handle all IPv4 connections."); + + Assert.AreEqual( + 0, + secondary.ConnectCallCount, + "In redundancy-only mode, secondary managers must not be used while primary is healthy."); + } + + #endregion + + #region tests – Bind and UDP delegation + + [TestMethod] + public async Task GetBindHandlerAsync_DelegatesToCorrectFamilyManager() + { + var v4Primary = new FakeConnectionManager(AddressFamily.InterNetwork); + var v4Secondary = new FakeConnectionManager(AddressFamily.InterNetwork); + + using var manager = new LoadBalancingProxyServerConnectionManager( + new[] { v4Primary, v4Secondary }, + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + IProxyServerBindHandler handler = await manager.GetBindHandlerAsync(AddressFamily.InterNetwork); + + Assert.IsTrue( + ReferenceEquals(handler, v4Primary.BindHandler) || + ReferenceEquals(handler, v4Secondary.BindHandler), + "Bind handler must be obtained from one of the IPv4 managers."); + + int totalBindCalls = v4Primary.BindCallCount + v4Secondary.BindCallCount; + + Assert.AreEqual( + 1, + totalBindCalls, + "Load balancer must delegate a single bind request to exactly one manager."); + } + + [TestMethod] + public async Task GetBindHandlerAsync_NoManagersForFamily_ThrowsNetworkUnreachable() + { + using var manager = new LoadBalancingProxyServerConnectionManager( + Array.Empty(), + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.GetBindHandlerAsync(AddressFamily.InterNetwork), + "GetBindHandlerAsync must fail with NetworkUnreachable when no managers exist for the family."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "Bind handler lookup must surface NetworkUnreachable when no managers exist."); + } + + [TestMethod] + public async Task GetUdpAssociateHandlerAsync_DelegatesToCorrectFamilyManager() + { + var v4Manager = new FakeConnectionManager(AddressFamily.InterNetwork); + + using var manager = new LoadBalancingProxyServerConnectionManager( + new[] { v4Manager }, + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + var localEp = new IPEndPoint(IPAddress.Loopback, 0); + + IProxyServerUdpAssociateHandler handler = + await manager.GetUdpAssociateHandlerAsync(localEp); + + Assert.IsTrue( + ReferenceEquals(handler, v4Manager.UdpHandler), + "UDP associate handler must be obtained from the matching IPv4 manager."); + + Assert.AreEqual( + 1, + v4Manager.UdpCallCount, + "Exactly one UDP associate request must be delegated to the manager."); + } + + [TestMethod] + public async Task GetUdpAssociateHandlerAsync_NoManagersForFamily_ThrowsNetworkUnreachable() + { + using var manager = new LoadBalancingProxyServerConnectionManager( + Array.Empty(), + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + var localEp = new IPEndPoint(IPAddress.Loopback, 0); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.GetUdpAssociateHandlerAsync(localEp), + "GetUdpAssociateHandlerAsync must fail when no managers exist for the endpoint family."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "UDP associate lookup must surface NetworkUnreachable when no managers exist."); + } + + #endregion + + #region fakes + + private sealed class FakeConnectionManager : IProxyServerConnectionManager, IDisposable + { + public AddressFamily Family { get; } + + public int ConnectCallCount { get; private set; } + + public EndPoint LastRemoteEndPoint { get; private set; } + + public int BindCallCount { get; private set; } + + public int UdpCallCount { get; private set; } + + public bool ShouldThrow { get; } + + public SocketError ThrowError { get; set; } = SocketError.NetworkUnreachable; + + public IProxyServerBindHandler BindHandler { get; set; } + + public IProxyServerUdpAssociateHandler UdpHandler { get; set; } + + public FakeConnectionManager(AddressFamily family) + { + Family = family; + BindHandler = new FakeBindHandler(family); + UdpHandler = new FakeUdpHandler(family); + } + + public Task ConnectAsync(EndPoint remoteEP, CancellationToken cancellationToken = default) + { + ConnectCallCount++; + LastRemoteEndPoint = remoteEP; + + if (ShouldThrow) + throw new SocketException((int)ThrowError); + + var socket = new Socket(Family, SocketType.Stream, ProtocolType.Tcp); + return Task.FromResult(socket); + } + + public Task GetBindHandlerAsync(AddressFamily family) + { + BindCallCount++; + + if (ShouldThrow) + throw new SocketException((int)ThrowError); + + return Task.FromResult(BindHandler); + } + + public Task GetUdpAssociateHandlerAsync(EndPoint localEP) + { + UdpCallCount++; + + if (ShouldThrow) + throw new SocketException((int)ThrowError); + + return Task.FromResult(UdpHandler); + } + + public void Dispose() + { + // Nothing to dispose in this fake; sockets returned to tests are disposed there. + } + } + + private sealed class FakeBindHandler : IProxyServerBindHandler + { + public SocksProxyReplyCode ReplyCode { get; } + + public EndPoint ProxyRemoteEndPoint { get; } + + public EndPoint ProxyLocalEndPoint { get; } + + public FakeBindHandler(AddressFamily family) + { + var address = family == AddressFamily.InterNetwork + ? IPAddress.Loopback + : IPAddress.IPv6Loopback; + + ProxyLocalEndPoint = new IPEndPoint(address, 10000); + ProxyRemoteEndPoint = new IPEndPoint(address, 20000); + ReplyCode = SocksProxyReplyCode.Succeeded; + } + + public Task AcceptAsync(CancellationToken cancellationToken = default) + { + var socket = new Socket( + ((IPEndPoint)ProxyLocalEndPoint).AddressFamily, + SocketType.Stream, + ProtocolType.Tcp); + + return Task.FromResult(socket); + } + + public void Dispose() + { + // No resources allocated by this fake. + } + } + + private sealed class FakeUdpHandler : IProxyServerUdpAssociateHandler + { + private readonly AddressFamily _family; + + public FakeUdpHandler(AddressFamily family) + { + _family = family; + } + + public Task SendToAsync(ArraySegment buffer, EndPoint remoteEP, CancellationToken cancellationToken = default) + { + // Echo back the buffer length to simulate a successful send. + return Task.FromResult(buffer.Count); + } + + public Task ReceiveFromAsync(ArraySegment buffer, CancellationToken cancellationToken = default) + { + var result = new SocketReceiveFromResult + { + ReceivedBytes = 0, + RemoteEndPoint = new IPEndPoint( + _family == AddressFamily.InterNetwork ? IPAddress.Loopback : IPAddress.IPv6Loopback, + 53) + }; + + return Task.FromResult(result); + } + + public void Dispose() + { + // Nothing to dispose in this fake. + } + } + + #endregion + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyAuthenticationFailedExceptionTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyAuthenticationFailedExceptionTests.cs new file mode 100644 index 00000000..81aca152 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyAuthenticationFailedExceptionTests.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class NetProxyAuthenticationFailedExceptionTests + { + [TestMethod] + public void DefaultConstructor_SetsMessage_AndNullInnerException() + { + var ex = new NetProxyAuthenticationFailedException(); + + Assert.IsNotNull( + ex.Message, + "Default constructor must set a non-null message." + ); + + Assert.IsGreaterThan( +0, + ex.Message.Length, "Default constructor must provide a meaningful error message." + ); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not assign an inner exception." + ); + } + + [TestMethod] + public void Constructor_WithMessage_PreservesMessage_AndNullInnerException() + { + const string message = "Authentication failed due to invalid credentials."; + + var ex = new NetProxyAuthenticationFailedException(message); + + Assert.AreEqual( + message, + ex.Message, + "Message-only constructor must preserve the provided message verbatim." + ); + + Assert.IsNull( + ex.InnerException, + "Message-only constructor must not assign an inner exception." + ); + } + + [TestMethod] + public void Constructor_WithMessageAndInnerException_PreservesBoth() + { + const string message = "Authentication failed."; + var inner = new InvalidOperationException("Inner failure"); + + var ex = new NetProxyAuthenticationFailedException(message, inner); + + Assert.AreEqual( + message, + ex.Message, + "Constructor must preserve the provided message verbatim." + ); + + Assert.AreSame( + inner, + ex.InnerException, + "Constructor must preserve the provided inner exception reference." + ); + } + + [TestMethod] + public void Exception_IsNetProxyException() + { + var ex = new NetProxyAuthenticationFailedException(); + + Assert.IsInstanceOfType( + ex, + "NetProxyAuthenticationFailedException must inherit from NetProxyException." + ); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyBypassItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyBypassItemTests.cs new file mode 100644 index 00000000..38dfc8eb --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyBypassItemTests.cs @@ -0,0 +1,166 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Net; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class NetProxyBypassItemTests + { + // ------------------------------------------------------------ + // IPv4 CIDR + // ------------------------------------------------------------ + + [TestMethod] + public void IsMatching_Ipv4Cidr_MatchesAddressInsideRange() + { + var bypass = new NetProxyBypassItem("192.168.1.0/24"); + var ep = new IPEndPoint(IPAddress.Parse("192.168.1.42"), 80); + + Assert.IsTrue( + bypass.IsMatching(ep), + "IPv4 address inside CIDR range must bypass the proxy." + ); + } + + [TestMethod] + public void IsMatching_Ipv4Cidr_DoesNotMatchOutsideRange() + { + var bypass = new NetProxyBypassItem("192.168.1.0/24"); + var ep = new IPEndPoint(IPAddress.Parse("192.168.2.1"), 80); + + Assert.IsFalse( + bypass.IsMatching(ep), + "IPv4 address outside CIDR range must not bypass the proxy." + ); + } + + // ------------------------------------------------------------ + // Exact IP match + // ------------------------------------------------------------ + + [TestMethod] + public void IsMatching_ExactIpv4Address_MatchesOnlySameAddress() + { + var bypass = new NetProxyBypassItem("10.0.0.5"); + + Assert.IsTrue( + bypass.IsMatching(new IPEndPoint(IPAddress.Parse("10.0.0.5"), 1234)), + "Exact IPv4 address must match regardless of port." + ); + + Assert.IsFalse( + bypass.IsMatching(new IPEndPoint(IPAddress.Parse("10.0.0.6"), 1234)), + "Different IPv4 address must not match exact bypass entry." + ); + } + + // ------------------------------------------------------------ + // IPv6 CIDR + // ------------------------------------------------------------ + + [TestMethod] + public void IsMatching_Ipv6Cidr_MatchesAddressInsideRange() + { + var bypass = new NetProxyBypassItem("fe80::/10"); + var ep = new IPEndPoint(IPAddress.Parse("fe80::1"), 443); + + Assert.IsTrue( + bypass.IsMatching(ep), + "IPv6 address inside CIDR range must bypass the proxy." + ); + } + + [TestMethod] + public void IsMatching_Ipv6Cidr_DoesNotMatchOutsideRange() + { + var bypass = new NetProxyBypassItem("fe80::/10"); + var ep = new IPEndPoint(IPAddress.Parse("2001:db8::1"), 443); + + Assert.IsFalse( + bypass.IsMatching(ep), + "IPv6 address outside CIDR range must not bypass the proxy." + ); + } + + // ------------------------------------------------------------ + // Hostname matching + // ------------------------------------------------------------ + + [TestMethod] + public void IsMatching_Localhost_BypassesLoopbackIp() + { + var bypass = new NetProxyBypassItem("localhost"); + + Assert.IsTrue( + bypass.IsMatching(new IPEndPoint(IPAddress.Loopback, 80)), + "Bypass entry 'localhost' must match IPv4 loopback address." + ); + + Assert.IsTrue( + bypass.IsMatching(new IPEndPoint(IPAddress.IPv6Loopback, 80)), + "Bypass entry 'localhost' must match IPv6 loopback address." + ); + } + + [TestMethod] + public void IsMatching_Localhost_DoesNotMatchDnsEndPoint() + { + var bypass = new NetProxyBypassItem("localhost"); + + var ep = new DnsEndPoint("localhost", 80); + + Assert.IsFalse( + bypass.IsMatching(ep), + "Bypass logic must not resolve or match DnsEndPoint hostnames." + ); + } + + + [TestMethod] + public void IsMatching_Hostname_DoesNotMatchDifferentName() + { + var bypass = new NetProxyBypassItem("localhost"); + + var ep = new DnsEndPoint("example.com", 80); + + Assert.IsFalse( + bypass.IsMatching(ep), + "Different hostname must not bypass the proxy." + ); + } + + // ------------------------------------------------------------ + // Safety and stability + // ------------------------------------------------------------ + + [TestMethod] + public void IsMatching_UnsupportedEndpointType_ReturnsFalse() + { + var bypass = new NetProxyBypassItem("127.0.0.1"); + + EndPoint unsupported = new IPEndPoint(IPAddress.IPv6Any, 0); + + Assert.IsFalse( + bypass.IsMatching(unsupported), + "Unsupported or non-matching endpoint types must fail safely." + ); + } + + [TestMethod] + public void IsMatching_RepeatedCalls_AreDeterministic() + { + var bypass = new NetProxyBypassItem("192.168.0.0/16"); + var ep = new IPEndPoint(IPAddress.Parse("192.168.10.10"), 80); + + bool first = bypass.IsMatching(ep); + bool second = bypass.IsMatching(ep); + + Assert.AreEqual( + first, + second, + "Bypass decision must be deterministic across multiple invocations." + ); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyExceptionTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyExceptionTests.cs new file mode 100644 index 00000000..5a3a9c0e --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyExceptionTests.cs @@ -0,0 +1,72 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class NetProxyExceptionTests + { + [TestMethod] + public void DefaultConstructor_MustProvideNonEmptyMessage_AndNullInnerException() + { + NetProxyException ex = new NetProxyException(); + + Assert.IsFalse( + string.IsNullOrWhiteSpace(ex.Message), + "Default constructor must provide a non-empty diagnostic message."); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not assign an inner exception."); + + Assert.AreEqual( + typeof(NetProxyException), + ex.GetType(), + "Runtime exception type must remain exactly NetProxyException."); + } + + [TestMethod] + public void MessageConstructor_MustPreserveMessage() + { + const string message = "Net proxy operation failed."; + + NetProxyException ex = new NetProxyException(message); + + Assert.AreEqual( + message, + ex.Message, + "Message constructor must preserve the supplied message verbatim."); + } + + [TestMethod] + public void MessageAndInnerExceptionConstructor_MustPreserveBoth() + { + const string message = "Proxy tunnel failure."; + InvalidOperationException inner = new InvalidOperationException("inner"); + + NetProxyException ex = new NetProxyException(message, inner); + + Assert.AreEqual( + message, + ex.Message, + "Exception must preserve the supplied message."); + + Assert.AreSame( + inner, + ex.InnerException, + "Exception must preserve the supplied inner exception reference."); + } + + [TestMethod] + public void ExceptionTypeIdentity_MustRemainStable() + { + Exception ex = new NetProxyException(); + + Assert.AreEqual( + typeof(NetProxyException), + ex.GetType(), + "Consumers rely on exact exception type identity for catch filters."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyTests.cs new file mode 100644 index 00000000..985cc7d0 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/NetProxyTests.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class NetProxyTests + { + public TestContext TestContext { get; set; } + + #region helpers + + private static TcpListener StartListener(IPAddress address, out IPEndPoint localEndPoint) + { + var listener = new TcpListener(address, 0); + listener.Start(); + + Assert.IsNotNull(listener.LocalEndpoint, "Listener.LocalEndpoint must be initialized after Start()."); + Assert.IsInstanceOfType( + listener.LocalEndpoint, + typeof(IPEndPoint), + "Listener.LocalEndpoint must be an IPEndPoint instance."); + + localEndPoint = (IPEndPoint)listener.LocalEndpoint!; + return listener; + } + + /// + /// Concrete NetProxy implementation that simply returns the viaSocket, + /// while recording the parameters passed to the protected ConnectAsync. + /// + private sealed class TestNetProxy : NetProxy + { + public int ProtectedConnectCallCount { get; private set; } + + public EndPoint? LastRemoteEndPoint { get; private set; } + + public Socket? LastViaSocket { get; private set; } + + public TestNetProxy(EndPoint proxyEp) + : base(NetProxyType.Http, proxyEp) + { + } + + protected override Task ConnectAsync(EndPoint remoteEP, Socket viaSocket, CancellationToken cancellationToken) + { + ProtectedConnectCallCount++; + LastRemoteEndPoint = remoteEP; + LastViaSocket = viaSocket; + return Task.FromResult(viaSocket); + } + } + + /// + /// Concrete NetProxy used as viaProxy in chaining tests. + /// Records its protected ConnectAsync calls. + /// + private sealed class ChainedNetProxy : NetProxy + { + public int ProtectedConnectCallCount { get; private set; } + + public EndPoint? LastRemoteEndPoint { get; private set; } + + public EndPoint? LastProxyEndPointSeen { get; private set; } + + public ChainedNetProxy(EndPoint proxyEp) + : base(NetProxyType.Http, proxyEp) + { + } + + protected override Task ConnectAsync(EndPoint remoteEP, Socket viaSocket, CancellationToken cancellationToken) + { + ProtectedConnectCallCount++; + LastRemoteEndPoint = remoteEP; + LastProxyEndPointSeen = ProxyEndPoint; + return Task.FromResult(viaSocket); + } + } + + #endregion + + #region tests + + [TestMethod] + public async Task ConnectAsync_BypassedEndpoint_UsesDirectTcpAndSkipsProtectedConnect() + { + // Arrange: loopback is in the default bypass list. + TcpListener listener = StartListener(IPAddress.Loopback, out IPEndPoint remoteEp); + + // proxyEP value is irrelevant for bypassed endpoints; it will not be used. + var proxyEp = new IPEndPoint(IPAddress.Loopback, 65000); + var proxy = new TestNetProxy(proxyEp); + + // Act + using Socket socket = await proxy.ConnectAsync(remoteEp, TestContext.CancellationToken); + + // Accept the incoming connection to complete the TCP handshake. + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + + // Assert + Assert.IsTrue(socket.Connected, "Bypassed endpoint must result in a direct TCP connection to the remote endpoint."); + Assert.AreEqual( + 0, + proxy.ProtectedConnectCallCount, + "Protected ConnectAsync(remote, viaSocket) must not be called for bypassed endpoints."); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_NonBypassedEndpoint_ConnectsToProxyEndpointAndInvokesProtectedConnect() + { + // Arrange: choose an address that is NOT in the default bypass list (203.0.113.77). + var remote = new IPEndPoint(IPAddress.Parse("203.0.113.77"), 9000); + + // NetProxy must first connect to _proxyEP. + TcpListener proxyListener = StartListener(IPAddress.Loopback, out IPEndPoint proxyEp); + + var proxy = new TestNetProxy(proxyEp); + + // Act + using Socket socket = await proxy.ConnectAsync(remote, TestContext.CancellationToken); + + // Accept the TCP connection that GetTcpConnectionAsync opened to proxyEp. + using Socket serverSide = await proxyListener.AcceptSocketAsync(TestContext.CancellationToken); + + // Assert + Assert.AreEqual( + 1, + proxy.ProtectedConnectCallCount, + "Protected ConnectAsync must be called exactly once for non-bypassed endpoints."); + + Assert.AreEqual( + remote, + proxy.LastRemoteEndPoint, + "Protected ConnectAsync must see the original remote endpoint, not the proxy endpoint."); + + Assert.IsNotNull( + proxy.LastViaSocket, + "Protected ConnectAsync must receive a viaSocket representing a TCP connection to the proxy endpoint."); + + Assert.AreSame( + socket, + proxy.LastViaSocket, + "Public ConnectAsync must return exactly the viaSocket passed into the protected overload."); + + Assert.IsTrue( + socket.Connected, + "Socket returned by ConnectAsync must represent a live TCP connection to the proxy endpoint."); + + proxyListener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_ChainOfProxies_UsesViaProxyThenMainProxy() + { + // Arrange: + // viaProxy has its own proxy endpoint where it will open a TCP connection + // when connecting to mainProxy.ProxyEndPoint. + TcpListener viaProxyListener = StartListener(IPAddress.Loopback, out IPEndPoint viaProxyEp); + var viaProxy = new ChainedNetProxy(viaProxyEp) + { + // Ensure that mainProxy.ProxyEndPoint is NOT bypassed for viaProxy. + BypassList = Array.Empty() + }; + + // Main proxy has its own upstream endpoint; this is the remoteEP passed into viaProxy. + var mainProxyEp = new IPEndPoint(IPAddress.Loopback, 60000); + var mainProxy = new TestNetProxy(mainProxyEp) + { + ViaProxy = viaProxy + }; + + // Target endpoint is non-bypassed for mainProxy. + var target = new IPEndPoint(IPAddress.Parse("203.0.113.44"), 443); + + // Act + using Socket finalSocket = await mainProxy.ConnectAsync(target, TestContext.CancellationToken); + + // viaProxy must receive a ConnectAsync call with remoteEP = mainProxy.ProxyEndPoint + Assert.AreEqual( + 1, + viaProxy.ProtectedConnectCallCount, + "Via proxy must have its protected ConnectAsync invoked exactly once."); + + Assert.AreEqual( + mainProxyEp, + viaProxy.LastRemoteEndPoint, + "Via proxy must be asked to connect to the main proxy endpoint."); + + // Accept the TCP connection that viaProxy's GetTcpConnectionAsync opened + // to its own proxy endpoint. + using Socket viaProxyServerSide = await viaProxyListener.AcceptSocketAsync(TestContext.CancellationToken); + + // Then main proxy must be invoked with the final target. + Assert.AreEqual( + 1, + mainProxy.ProtectedConnectCallCount, + "Main proxy must have its protected ConnectAsync invoked exactly once."); + + Assert.AreEqual( + target, + mainProxy.LastRemoteEndPoint, + "Main proxy protected ConnectAsync must see the original target endpoint."); + + Assert.IsTrue( + finalSocket.Connected, + "Final socket must represent the TCP connection established by viaProxy to its own proxy endpoint."); + + viaProxyListener.Stop(); + } + + [TestMethod] + public void BypassList_CanBeReplacedAndAffectsIsBypassed() + { + var proxyEp = new IPEndPoint(IPAddress.Loopback, 8080); + var proxy = new TestNetProxy(proxyEp); + + // Replace default bypass list with a custom one. + proxy.BypassList = new[] + { + new NetProxyBypassItem("192.168.10.0/24") + }; + + var bypassed = new IPEndPoint(IPAddress.Parse("192.168.10.5"), 80); + var notBypassed = new IPEndPoint(IPAddress.Loopback, 80); // not in our custom list + + Assert.IsTrue( + proxy.IsBypassed(bypassed), + "Endpoint inside configured CIDR must be treated as bypassed."); + + Assert.IsFalse( + proxy.IsBypassed(notBypassed), + "Endpoint outside custom bypass list must not be bypassed."); + } + + #endregion + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/SocksProxyAuthenticationFailedExceptionTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/SocksProxyAuthenticationFailedExceptionTests.cs new file mode 100644 index 00000000..47006c53 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/SocksProxyAuthenticationFailedExceptionTests.cs @@ -0,0 +1,74 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class SocksProxyAuthenticationFailedExceptionTests + { + [TestMethod] + public void DefaultConstructor_MustProvideNonEmptyMessage_AndNullInnerException() + { + SocksProxyAuthenticationFailedException ex = new SocksProxyAuthenticationFailedException(); + + Assert.IsFalse( + string.IsNullOrWhiteSpace(ex.Message), + "Default constructor must provide a non-empty diagnostic message."); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not assign an inner exception."); + + Assert.AreEqual( + typeof(SocksProxyAuthenticationFailedException), + ex.GetType(), + "Exception type identity must remain stable."); + } + + [TestMethod] + public void MessageConstructor_MustPreserveMessage() + { + const string message = "SOCKS authentication failed."; + + SocksProxyAuthenticationFailedException ex = + new SocksProxyAuthenticationFailedException(message); + + Assert.AreEqual( + message, + ex.Message, + "Message constructor must preserve the supplied message verbatim."); + } + + [TestMethod] + public void MessageAndInnerExceptionConstructor_MustPreserveBoth() + { + const string message = "SOCKS auth rejected."; + InvalidOperationException inner = new InvalidOperationException("inner"); + + SocksProxyAuthenticationFailedException ex = + new SocksProxyAuthenticationFailedException(message, inner); + + Assert.AreEqual( + message, + ex.Message, + "Exception must preserve the supplied message."); + + Assert.AreSame( + inner, + ex.InnerException, + "Exception must preserve the supplied inner exception reference."); + } + + [TestMethod] + public void ExceptionTypeIdentity_MustRemainExact() + { + Exception ex = new SocksProxyAuthenticationFailedException(); + + Assert.AreEqual( + typeof(SocksProxyAuthenticationFailedException), + ex.GetType(), + "Consumers rely on exact exception type identity for authentication failure handling."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/SocksProxyExceptionTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/SocksProxyExceptionTests.cs new file mode 100644 index 00000000..946fe547 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/SocksProxyExceptionTests.cs @@ -0,0 +1,72 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class SocksProxyExceptionTests + { + [TestMethod] + public void DefaultConstructor_MustProvideNonEmptyMessage_AndNullInnerException() + { + SocksProxyException ex = new SocksProxyException(); + + Assert.IsFalse( + string.IsNullOrWhiteSpace(ex.Message), + "Default constructor must provide a non-empty diagnostic message."); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not assign an inner exception."); + + Assert.AreEqual( + typeof(SocksProxyException), + ex.GetType(), + "Runtime exception type must remain exactly SocksProxyException."); + } + + [TestMethod] + public void MessageConstructor_MustPreserveMessage() + { + const string message = "SOCKS proxy operation failed."; + + SocksProxyException ex = new SocksProxyException(message); + + Assert.AreEqual( + message, + ex.Message, + "Message constructor must preserve the supplied message verbatim."); + } + + [TestMethod] + public void MessageAndInnerExceptionConstructor_MustPreserveBoth() + { + const string message = "SOCKS negotiation error."; + InvalidOperationException inner = new InvalidOperationException("inner"); + + SocksProxyException ex = new SocksProxyException(message, inner); + + Assert.AreEqual( + message, + ex.Message, + "Exception must preserve the supplied message."); + + Assert.AreSame( + inner, + ex.InnerException, + "Exception must preserve the supplied inner exception reference."); + } + + [TestMethod] + public void ExceptionTypeIdentity_MustRemainStable() + { + Exception ex = new SocksProxyException(); + + Assert.AreEqual( + typeof(SocksProxyException), + ex.GetType(), + "Consumers rely on exact exception type identity for SOCKS error handling."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/SocksProxyServerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/SocksProxyServerTests.cs new file mode 100644 index 00000000..df603288 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/SocksProxyServerTests.cs @@ -0,0 +1,86 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class SocksProxyServerTests + { + [TestMethod] + public void Constructor_Default_BindsLoopbackAndEphemeralPort() + { + using SocksProxyServer server = new SocksProxyServer(); + + IPEndPoint ep = server.LocalEndPoint; + + Assert.IsNotNull(ep, "LocalEndPoint must be non-null after construction."); + Assert.IsTrue(IPAddress.IsLoopback(ep.Address), + "Default SocksProxyServer must bind only to loopback to avoid exposing an open proxy."); + Assert.IsTrue(ep.Port > 0, + "Default SocksProxyServer must bind an ephemeral port (port > 0)."); + } + + [TestMethod] + public async Task Constructor_StartsListening_AndAcceptsTcpConnections() + { + using SocksProxyServer server = new SocksProxyServer(); + IPEndPoint ep = server.LocalEndPoint; + + using Socket client = new Socket(ep.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + await client.ConnectAsync(ep); + + Assert.IsTrue(client.Connected, + "Client must be able to connect immediately since SocksProxyServer listens in the constructor."); + } + + [TestMethod] + public async Task Negotiation_InvalidVersion_MustBeRejected_Safely() + { + using SocksProxyServer server = new SocksProxyServer(); + IPEndPoint ep = server.LocalEndPoint; + + using Socket client = new Socket(ep.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + await client.ConnectAsync(ep); + + // Invalid SOCKS greeting (version 0x04) + byte[] invalidGreeting = new byte[] { 0x04, 0x01, 0x00 }; + await client.SendAsync(invalidGreeting, SocketFlags.None); + + byte[] buffer = new byte[2]; + + try + { + int received = await client.ReceiveAsync(buffer, SocketFlags.None); + + // If bytes are received, connection must not proceed further + Assert.IsTrue( + received == 0 || received == 2, + "Server may either close immediately or send a minimal rejection response." + ); + } + catch (SocketException) + { + // Also acceptable: immediate connection reset + } + } + + [TestMethod] + public async Task Dispose_MustStopAcceptingNewConnections_AndBeIdempotent() + { + SocksProxyServer server = new SocksProxyServer(); + IPEndPoint ep = server.LocalEndPoint; + + server.Dispose(); + server.Dispose(); + + using Socket client = new Socket(ep.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + + await Assert.ThrowsExactlyAsync( + () => client.ConnectAsync(ep), + "Disposed SocksProxyServer must not accept new TCP connections."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/TransparentProxyServerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/TransparentProxyServerTests.cs new file mode 100644 index 00000000..3f9b977f --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/TransparentProxyServerTests.cs @@ -0,0 +1,99 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class TransparentProxyServerTests + { + [TestMethod] + public void Constructor_BindsLocalEndPoint_Immediately() + { + using TransparentProxyServer server = + new TransparentProxyServer( + localEP: new IPEndPoint(IPAddress.Loopback, 0), + method: TransparentProxyServerMethod.Tunnel + ); + + IPEndPoint ep = server.LocalEndPoint; + + Assert.IsNotNull(ep, + "LocalEndPoint must be available immediately after construction."); + + Assert.IsTrue(ep.Port > 0, + "TransparentProxyServer must bind to an ephemeral port when port=0 is specified."); + } + + [TestMethod] + public async Task Server_MustAcceptTcpConnections_WhileAlive() + { + using TransparentProxyServer server = + new TransparentProxyServer( + localEP: new IPEndPoint(IPAddress.Loopback, 0), + method: TransparentProxyServerMethod.Tunnel + ); + + using Socket client = + new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + await client.ConnectAsync(server.LocalEndPoint); + + Assert.IsTrue(client.Connected, + "TransparentProxyServer must accept TCP connections while not disposed."); + } + + [TestMethod] + public async Task Dispose_MustStopAcceptingNewConnections() + { + TransparentProxyServer server = + new TransparentProxyServer( + localEP: new IPEndPoint(IPAddress.Loopback, 0), + method: TransparentProxyServerMethod.Tunnel + ); + + IPEndPoint ep = server.LocalEndPoint; + + server.Dispose(); + server.Dispose(); // idempotency + + using Socket client = + new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + await Assert.ThrowsExactlyAsync( + () => client.ConnectAsync(ep), + "Disposed TransparentProxyServer must not accept new TCP connections."); + } + + [TestMethod] + public void Constructor_DNAT_OnNonUnix_MustThrowNotSupportedException() + { + if (Environment.OSVersion.Platform == PlatformID.Unix) + Assert.Inconclusive("DNAT platform restriction applies only on non-Unix systems."); + + Assert.ThrowsExactly( + () => new TransparentProxyServer( + localEP: new IPEndPoint(IPAddress.Loopback, 0), + method: TransparentProxyServerMethod.DNAT + ), + "DNAT mode must throw on non-Unix platforms."); + } + + [TestMethod] + public void Constructor_DNAT_WithIPv6_MustThrowNotSupportedException() + { + if (Environment.OSVersion.Platform != PlatformID.Unix) + return; // explicitly skip, not inconclusive + + Assert.ThrowsExactly( + () => new TransparentProxyServer( + localEP: new IPEndPoint(IPAddress.IPv6Loopback, 0), + method: TransparentProxyServerMethod.DNAT + ), + "DNAT mode must reject non-IPv4 local endpoints."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/TunnelProxyTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/TunnelProxyTests.cs new file mode 100644 index 00000000..17029808 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/TunnelProxyTests.cs @@ -0,0 +1,135 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class TunnelProxyTests + { + [TestMethod] + public async Task Constructor_MustExposeConnectableTunnelEndPoint() + { + using TcpListener remoteListener = new TcpListener(IPAddress.Loopback, 0); + remoteListener.Start(); + + using Socket remoteClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + Task acceptTask = remoteListener.AcceptSocketAsync(); + await remoteClient.ConnectAsync(remoteListener.LocalEndpoint); + using Socket remoteServer = await acceptTask; + + using TunnelProxy tunnel = new TunnelProxy( + remoteServer, + remoteListener.LocalEndpoint, + enableSsl: false, + ignoreCertificateErrors: false); + + using Socket tunnelClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await tunnelClient.ConnectAsync(tunnel.TunnelEndPoint); + + Assert.IsTrue( + tunnelClient.Connected, + "TunnelProxy must expose a connectable tunnel endpoint immediately after construction."); + } + + [TestMethod] + public async Task Tunnel_MustForwardData_FromTunnelClient_ToRemoteSocket() + { + using TcpListener remoteListener = new TcpListener(IPAddress.Loopback, 0); + remoteListener.Start(); + + using Socket remoteClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + Task acceptTask = remoteListener.AcceptSocketAsync(); + await remoteClient.ConnectAsync(remoteListener.LocalEndpoint); + using Socket remoteServer = await acceptTask; + + using TunnelProxy tunnel = new TunnelProxy( + remoteServer, + remoteListener.LocalEndpoint, + enableSsl: false, + ignoreCertificateErrors: false); + + using Socket tunnelClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await tunnelClient.ConnectAsync(tunnel.TunnelEndPoint); + + byte[] payload = Encoding.ASCII.GetBytes("ping"); + await tunnelClient.SendAsync(payload, SocketFlags.None); + + byte[] buffer = new byte[4]; + int received = await remoteClient.ReceiveAsync(buffer, SocketFlags.None); + + CollectionAssert.AreEqual( + payload, + buffer[..received], + "Bytes written to the tunnel endpoint must reach the remote socket without mutation."); + } + + [TestMethod] + public async Task Tunnel_MustForwardData_FromRemoteSocket_ToTunnelClient() + { + using TcpListener remoteListener = new TcpListener(IPAddress.Loopback, 0); + remoteListener.Start(); + + using Socket remoteClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + Task acceptTask = remoteListener.AcceptSocketAsync(); + await remoteClient.ConnectAsync(remoteListener.LocalEndpoint); + using Socket remoteServer = await acceptTask; + + using TunnelProxy tunnel = new TunnelProxy( + remoteServer, + remoteListener.LocalEndpoint, + enableSsl: false, + ignoreCertificateErrors: false); + + using Socket tunnelClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await tunnelClient.ConnectAsync(tunnel.TunnelEndPoint); + + byte[] payload = Encoding.ASCII.GetBytes("pong"); + await remoteClient.SendAsync(payload, SocketFlags.None); + + byte[] buffer = new byte[4]; + int received = await tunnelClient.ReceiveAsync(buffer, SocketFlags.None); + + CollectionAssert.AreEqual( + payload, + buffer[..received], + "Bytes written by the remote socket must be forwarded to the tunnel client without mutation."); + } + + [TestMethod] + public async Task Dispose_MustBreakTunnelAndRejectNewConnections() + { + using TcpListener remoteListener = new TcpListener(IPAddress.Loopback, 0); + remoteListener.Start(); + + using Socket remoteClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + Task acceptTask = remoteListener.AcceptSocketAsync(); + await remoteClient.ConnectAsync(remoteListener.LocalEndpoint); + using Socket remoteServer = await acceptTask; + + TunnelProxy tunnel = new TunnelProxy( + remoteServer, + remoteListener.LocalEndpoint, + enableSsl: false, + ignoreCertificateErrors: false); + + IPEndPoint tunnelEP = tunnel.TunnelEndPoint; + + tunnel.Dispose(); + tunnel.Dispose(); // idempotency + + Assert.IsTrue( + tunnel.IsBroken, + "Dispose must mark TunnelProxy as broken."); + + using Socket tunnelClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + await Assert.ThrowsExactlyAsync( + () => tunnelClient.ConnectAsync(tunnelEP), + "Disposed TunnelProxy must not accept new tunnel connections."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/UdpTunnelProxyTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/UdpTunnelProxyTests.cs new file mode 100644 index 00000000..46c78a8f --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/Proxy/UdpTunnelProxyTests.cs @@ -0,0 +1,91 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class UdpTunnelProxyTests + { + [TestMethod] + public void Constructor_MustExposeTunnelEndPoint() + { + using Socket remoteSocket = + new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + + remoteSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + using UdpTunnelProxy tunnel = + new UdpTunnelProxy(remoteSocket, remoteSocket.LocalEndPoint); + + IPEndPoint tunnelEP = tunnel.TunnelEndPoint; + + Assert.IsNotNull( + tunnelEP, + "UdpTunnelProxy must expose a tunnel endpoint immediately after construction."); + + Assert.IsTrue( + tunnelEP.Port > 0, + "UdpTunnelProxy must bind an ephemeral UDP port."); + } + + [TestMethod] + public void Dispose_MustStopTunnelAndMarkBroken() + { + using Socket remoteSocket = + new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + + remoteSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + UdpTunnelProxy tunnel = + new UdpTunnelProxy(remoteSocket, remoteSocket.LocalEndPoint); + + tunnel.Dispose(); + tunnel.Dispose(); // idempotent + + Assert.IsTrue( + tunnel.IsBroken, + "Dispose must mark UdpTunnelProxy as broken and prevent further relay activity."); + } + + [TestMethod] + public async Task Tunnel_MustForwardDatagram_FromTunnelClient_ToRemoteSocket() + { + using Socket remoteSocket = + new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + + remoteSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + IPEndPoint remoteEP = (IPEndPoint)remoteSocket.LocalEndPoint; + + using UdpTunnelProxy tunnel = + new UdpTunnelProxy(remoteSocket, remoteEP); + + using Socket tunnelClient = + new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + + byte[] payload = Encoding.ASCII.GetBytes("udp-ping"); + + byte[] buffer = new byte[32]; + EndPoint anyEP = new IPEndPoint(IPAddress.Any, 0); + + Task receiveTask = + remoteSocket.ReceiveFromAsync(buffer, SocketFlags.None, anyEP); + + await tunnelClient.SendToAsync( + payload, + SocketFlags.None, + tunnel.TunnelEndPoint); + + SocketReceiveFromResult result = await receiveTask; + + CollectionAssert.AreEqual( + payload, + buffer.AsSpan(0, result.ReceivedBytes).ToArray(), + "Datagram sent to TunnelEndPoint must reach the remote socket unmodified."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/ProxyProtocol/ProxyProtocolStreamTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/ProxyProtocol/ProxyProtocolStreamTests.cs new file mode 100644 index 00000000..143b8267 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/ProxyProtocol/ProxyProtocolStreamTests.cs @@ -0,0 +1,422 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace TechnitiumLibrary.Net.ProxyProtocol.Tests +{ + [TestClass] + public class ProxyProtocolStreamTests + { + [TestMethod] + public async Task CreateAsServerAsync_V1_MemoryStream_ParsesMetadataAndExposesPayload() + { + string line = "PROXY TCP4 192.168.0.1 192.168.0.11 56324 443"; + byte[] header = MakeV1(line); + byte[] payload = Encoding.ASCII.GetBytes("HELLO"); + byte[] source = Join(header, payload); + + using MemoryStream baseStream = new(source); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream); + + Assert.AreEqual(1, proxy.ProtocolVersion, "ProtocolVersion must be 1."); + Assert.AreEqual(AddressFamily.InterNetwork, proxy.AddressFamily, "TCP4 must map to IPv4."); + Assert.AreEqual(SocketType.Stream, proxy.SocketType); + Assert.AreEqual(IPAddress.Parse("192.168.0.1"), proxy.SourceAddress); + Assert.AreEqual(IPAddress.Parse("192.168.0.11"), proxy.DestinationAddress); + Assert.AreEqual(56324, proxy.SourcePort); + Assert.AreEqual(443, proxy.DestinationPort); + Assert.AreEqual(header.Length, proxy.DataOffset, "DataOffset must equal the header length."); + + byte[] buffer = new byte[payload.Length]; + int read = proxy.Read(buffer, 0, buffer.Length); + + Assert.AreEqual(payload.Length, read, "Read must return only payload bytes."); + Assert.AreEqual("HELLO", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task CreateAsServerAsync_V1_FragmentedStream_ReadsHeaderAcrossMultipleChunks() + { + string line = "PROXY TCP4 10.0.0.1 10.0.0.2 1000 2000"; + byte[] header = MakeV1(line); + byte[] payload = Encoding.ASCII.GetBytes("PAYLOAD"); + byte[] source = Join(header, payload); + + using FragmentedReadStream baseStream = + new(source, header.Length); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream); + + Assert.AreEqual(1, proxy.ProtocolVersion); + Assert.AreEqual("10.0.0.1", proxy.SourceAddress.ToString()); + Assert.AreEqual("10.0.0.2", proxy.DestinationAddress.ToString()); + Assert.AreEqual(1000, proxy.SourcePort); + Assert.AreEqual(2000, proxy.DestinationPort); + Assert.AreEqual(header.Length, proxy.DataOffset); + + byte[] buffer = new byte[payload.Length]; + int read = proxy.Read(buffer, 0, buffer.Length); + + Assert.AreEqual(payload.Length, read); + Assert.AreEqual("PAYLOAD", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task CreateAsServerAsync_V2_IPv4_MemoryStream_CorrectMetadataAndPayload() + { + IPAddress src = IPAddress.Parse("192.0.2.1"); + IPAddress dst = IPAddress.Parse("198.51.100.2"); + ushort srcPort = 12345; + ushort dstPort = 443; + + byte[] header = MakeV2v4(src, dst, srcPort, dstPort, local: false, streamProto: true); + byte[] payload = Encoding.ASCII.GetBytes("DATA"); + byte[] source = Join(header, payload); + + using MemoryStream baseStream = new(source); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream); + + Assert.AreEqual(2, proxy.ProtocolVersion); + Assert.IsFalse(proxy.IsLocal); + Assert.AreEqual(AddressFamily.InterNetwork, proxy.AddressFamily); + Assert.AreEqual(SocketType.Stream, proxy.SocketType); + Assert.AreEqual(src, proxy.SourceAddress); + Assert.AreEqual(dst, proxy.DestinationAddress); + Assert.AreEqual(srcPort, proxy.SourcePort); + Assert.AreEqual(dstPort, proxy.DestinationPort); + Assert.AreEqual(28, proxy.DataOffset, "IPv4 PROXY v2 header must be 16 + 12 bytes."); + + byte[] buffer = new byte[payload.Length]; + int read = proxy.Read(buffer, 0, buffer.Length); + + Assert.AreEqual("DATA", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task CreateAsServerAsync_V2_IPv4_Fragmented_StillParsesCorrectly() + { + IPAddress src = IPAddress.Parse("203.0.113.10"); + IPAddress dst = IPAddress.Parse("203.0.113.20"); + ushort srcPort = 8080; + ushort dstPort = 8443; + + byte[] header = MakeV2v4(src, dst, srcPort, dstPort, local: false, streamProto: true); + byte[] payload = Encoding.ASCII.GetBytes("FRAG"); + byte[] source = Join(header, payload); + + using FragmentedReadStream baseStream = + new(source, header.Length); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream); + + Assert.AreEqual(2, proxy.ProtocolVersion); + Assert.AreEqual(src, proxy.SourceAddress); + Assert.AreEqual(dst, proxy.DestinationAddress); + Assert.AreEqual(8080, proxy.SourcePort); + Assert.AreEqual(8443, proxy.DestinationPort); + + byte[] buffer = new byte[payload.Length]; + int read = proxy.Read(buffer, 0, buffer.Length); + + Assert.AreEqual("FRAG", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task CreateAsServerAsync_InvalidPrefix_ThrowsInvalidDataException() + { + // Arrange: must provide enough bytes to exceed detection thresholds (>=16 bytes) + byte[] bad = + { + 0xFF, 0xFF, 0xFF, 0xFF, + 0xAA, 0xAA, 0xAA, 0xAA, + 0xEE, 0xEE, 0xEE, 0xEE, + 0xCC, 0xCC, 0xCC, 0xCC, + 0x00 // ensures not EOF boundary + }; + + using MemoryStream baseStream = new(bad); + + // Act & Assert + InvalidDataException ex = + await Assert.ThrowsExactlyAsync( + () => ProxyProtocolStream.CreateAsServerAsync(baseStream)); + + Assert.IsTrue( + ex.Message.Contains("PROXY", StringComparison.OrdinalIgnoreCase), + "Exception message must indicate invalid PROXY protocol header."); + } + + + [TestMethod] + public async Task CreateAsServerAsync_EndOfStreamBeforeHeader_ThrowsEndOfStreamException() + { + using MemoryStream baseStream = new(Encoding.ASCII.GetBytes("PROX")); + + _ = await Assert.ThrowsExactlyAsync( + () => ProxyProtocolStream.CreateAsServerAsync(baseStream)); + } + + [TestMethod] + public async Task Dispose_IsIdempotent_AndDisposesUnderlyingStream() + { + string line = "PROXY TCP4 127.0.0.1 127.0.0.1 1 2"; + byte[] header = MakeV1(line); + using MemoryStream underlying = new(header); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(underlying); + + proxy.Dispose(); + proxy.Dispose(); // must not throw + + Assert.ThrowsExactly( + () => underlying.ReadByte(), + "Underlying stream must actually be disposed."); + } + + [TestMethod] + public async Task FlushAfterDispose_Throws_ObjectDisposedException() + { + string line = "PROXY TCP4 127.0.0.1 127.0.0.1 10 20"; + byte[] header = MakeV1(line); + using MemoryStream underlying = new(header); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(underlying); + + proxy.Dispose(); + + Assert.ThrowsExactly( + () => proxy.Flush(), + "Flush() after disposal must signal ODE."); + } + + [TestMethod] + public async Task WriteAfterDispose_DoesNotWriteAndThrows() + { + string line = "PROXY TCP4 127.0.0.1 127.0.0.1 30 40"; + byte[] header = MakeV1(line); + using WriteTrackerStream baseStream = new(header); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream); + + proxy.Dispose(); + + byte[] bytes = Encoding.ASCII.GetBytes("NOPE"); + + Assert.ThrowsExactly( + () => proxy.Write(bytes, 0, bytes.Length)); + + Assert.IsFalse(baseStream.WroteAfterDispose, + "Write() must not propagate to underlying after disposal."); + } + + [TestMethod] + public async Task ReadAsyncBufferedData_ReturnsPayloadWithoutTouchingBaseStream() + { + string line = "PROXY TCP4 192.168.1.1 192.168.1.2 100 200"; + byte[] header = MakeV1(line); + byte[] payload = Encoding.ASCII.GetBytes("ASYNC"); + byte[] source = Join(header, payload); + + using MemoryStream baseStream = new(source); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream); + + byte[] buffer = new byte[payload.Length]; + int read = await proxy.ReadAsync(buffer, 0, buffer.Length); + + Assert.AreEqual(payload.Length, read); + Assert.AreEqual("ASYNC", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task CapabilityAndSeekContract_IsCorrect() + { + string line = "PROXY TCP4 127.0.0.1 127.0.0.1 5 6"; + byte[] header = MakeV1(line); + + using MemoryStream baseStream = new(header); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream); + + Assert.IsTrue(proxy.CanRead); + Assert.IsFalse(proxy.CanSeek); + Assert.IsTrue(proxy.CanWrite); + + _ = Assert.ThrowsExactly(() => proxy.Length); + _ = Assert.ThrowsExactly(() => proxy.Seek(0, SeekOrigin.Begin)); + _ = Assert.ThrowsExactly(() => proxy.SetLength(0)); + } + + // -------------------- + // Helper functions + // -------------------- + private static byte[] MakeV1(string headerLineNoCrlf) + { + string full = headerLineNoCrlf + "\r\n"; + return Encoding.ASCII.GetBytes(full); + } + + private static byte[] Join(byte[] left, byte[] right) + { + byte[] result = new byte[left.Length + right.Length]; + Buffer.BlockCopy(left, 0, result, 0, left.Length); + Buffer.BlockCopy(right, 0, result, left.Length, right.Length); + return result; + } + + private static byte[] MakeV2sig() + { + return new byte[] + { + 0x0D,0x0A,0x0D,0x0A, + 0x00,0x0D,0x0A,0x51, + 0x55,0x49,0x54,0x0A + }; + } + + private static byte[] MakeV2v4( + IPAddress src, + IPAddress dst, + ushort srcPort, + ushort dstPort, + bool local, + bool streamProto) + { + byte[] sig = MakeV2sig(); + byte command = (byte)(local ? 0x0 : 0x1); // LOCAL or PROXY + byte versionNibble = 0x2; + byte verCmd = (byte)((versionNibble << 4) | command); + + byte afNibble = 1; + byte protoNibble = streamProto ? (byte)1 : (byte)2; + byte famProto = (byte)((afNibble << 4) | protoNibble); + + ushort len = 12; + byte[] h = new byte[16 + len]; + + Buffer.BlockCopy(sig, 0, h, 0, sig.Length); + h[12] = verCmd; + h[13] = famProto; + h[14] = (byte)(len >> 8); + h[15] = (byte)(len & 0xFF); + + byte[] srcb = src.GetAddressBytes(); + byte[] dstb = dst.GetAddressBytes(); + + Buffer.BlockCopy(srcb, 0, h, 16, 4); + Buffer.BlockCopy(dstb, 0, h, 20, 4); + + h[24] = (byte)(srcPort >> 8); + h[25] = (byte)(srcPort & 0xFF); + h[26] = (byte)(dstPort >> 8); + h[27] = (byte)(dstPort & 0xFF); + + return h; + } + + internal sealed class FragmentedReadStream : Stream + { + private readonly byte[] _data; + private readonly int _chunkSize; + private int _pos; + private bool _disposed; + + public FragmentedReadStream(byte[] data, int chunkSize) + { + _data = data; + _chunkSize = chunkSize; + } + + public override bool CanRead => !_disposed; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_disposed) + throw new ObjectDisposedException(nameof(FragmentedReadStream)); + + if (_pos >= _data.Length) + return 0; + + int cut = Math.Min(count, _chunkSize); + cut = Math.Min(cut, _data.Length - _pos); + + Buffer.BlockCopy(_data, _pos, buffer, offset, cut); + _pos += cut; + return cut; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => Task.FromResult(Read(buffer, offset, count)); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + byte[] tmp = new byte[buffer.Length]; + int n = Read(tmp, 0, tmp.Length); + if (n > 0) + new ReadOnlySpan(tmp, 0, n).CopyTo(buffer.Span); + return ValueTask.FromResult(n); + } + + protected override void Dispose(bool disposing) + { + _disposed = true; + } + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + } + + internal sealed class WriteTrackerStream : MemoryStream + { + public bool WroteAfterDispose { get; private set; } + private bool _disposed; + + public WriteTrackerStream(byte[] initial) : base(initial) { } + + protected override void Dispose(bool disposing) + { + _disposed = true; + base.Dispose(disposing); + } + + public override void Write(byte[] buffer, int offset, int count) + { + if (_disposed) + WroteAfterDispose = true; + base.Write(buffer, offset, count); + } + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/SocketExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/SocketExtensionsTests.cs new file mode 100644 index 00000000..b3264693 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/SocketExtensionsTests.cs @@ -0,0 +1,128 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class SocketExtensionsTests + { + [TestMethod] + public void GetEndPointAnyFor_ShouldReturnV4Any() + { + IPEndPoint ep = InvokeInternal(AddressFamily.InterNetwork); + Assert.AreEqual(IPAddress.Any, ep.Address); + Assert.AreEqual(0, ep.Port); + } + + [TestMethod] + public void GetEndPointAnyFor_ShouldReturnV6Any() + { + IPEndPoint ep = InvokeInternal(AddressFamily.InterNetworkV6); + Assert.AreEqual(IPAddress.IPv6Any, ep.Address); + Assert.AreEqual(0, ep.Port); + } + + [TestMethod] + public void GetEndPointAnyFor_ShouldRejectUnsupported() + { + Assert.ThrowsExactly(() => + InvokeInternal(AddressFamily.AppleTalk), + "Unsupported AF must surface NotSupportedException."); + } + + [TestMethod] + public void Connect_ShouldFail_OnTimeoutHost() + { + using Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + Assert.ThrowsExactly(() => + s.Connect("192.0.2.1", 6555, timeout: 1), + "Unreachable host must timeout immediately."); + } + + [TestMethod] + public void Connect_EndPoint_ShouldFail_OnTimeout() + { + using Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + IPEndPoint unreachable = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 65000); + + Assert.ThrowsExactly(() => + s.Connect(unreachable, timeout: 1), + "Timeout on explicit endpoint must raise."); + } + + [TestMethod] + public async Task UdpQueryAsync_ShouldTimeout_WhenReceivingNothing() + { + using Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + server.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + using Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + byte[] req = new byte[] { 1, 2, 3 }; + byte[] resp = new byte[512]; + + IPEndPoint? remote = (IPEndPoint?)server.LocalEndPoint; + + await Assert.ThrowsExactlyAsync(async () => + { + await client.UdpQueryAsync( + request: req, + response: resp, + remoteEP: remote, + timeout: 50, + retries: 1, cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task CopyToAsync_ShouldThrowSocketException_WhenDestinationClosesMidSend() + { + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + using Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await client.ConnectAsync((IPEndPoint)listener.LocalEndpoint); + + using Socket server = await listener.AcceptSocketAsync(); + + Task copyTask = server.CopyToAsync(client); + + // Ensure data reaches read phase + await server.SendAsync(new byte[] { 1, 2, 3, 4 }, SocketFlags.None); + + // Give the receiving side time to begin processing + await Task.Delay(50); + + // Force destination break AFTER sending has begun + client.Close(); + + SocketException ex = await Assert.ThrowsExactlyAsync( + async () => await copyTask, + "Closing destination during active send must propagate socket failure."); + + Assert.AreNotEqual(SocketError.Success, ex.SocketErrorCode); + } + + private static IPEndPoint InvokeInternal(AddressFamily af) + { + MethodInfo method = typeof(SocketExtensions).GetMethod( + "GetEndPointAnyFor", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new MissingMethodException("SocketExtensions.GetEndPointAnyFor was not found."); + try + { + return (IPEndPoint)method.Invoke(null, new object[] { af })!; + } + catch (TargetInvocationException tie) when (tie.InnerException is not null) + { + // Preserve original intention + throw tie.InnerException; + } + } + + public TestContext TestContext { get; set; } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/WebUtilitiesTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/WebUtilitiesTests.cs new file mode 100644 index 00000000..43895747 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/WebUtilitiesTests.cs @@ -0,0 +1,355 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Mime; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Http.Client; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class WebUtilitiesTests + { + #region GetFormattedSize + + [TestMethod] + public void GetFormattedSize_ShouldFormatBytesUnderThousand_AsBytes() + { + string s = WebUtilities.GetFormattedSize(999); + + Assert.AreEqual("999 B", s, + "Values below 1000 must remain in bytes with ' B' suffix."); + } + + [TestMethod] + public void GetFormattedSize_ShouldFormatExactKiB_AsKB() + { + double bytes = 1024; // 1 KiB + + string s = WebUtilities.GetFormattedSize(bytes); + + Assert.AreEqual("1 KB", s, + "1024 bytes must be rendered as '1 KB' using 1024 divisor."); + } + + [TestMethod] + public void GetFormattedSize_ShouldFormatExactMiB_AsMB() + { + double bytes = 1024 * 1024; // 1 MiB + + string s = WebUtilities.GetFormattedSize(bytes); + + Assert.AreEqual("1 MB", s, + "1 MiB must be rendered as '1 MB'."); + } + + [TestMethod] + public void GetFormattedSize_ShouldFormatExactGiB_AsGB() + { + double bytes = 1024d * 1024 * 1024; // 1 GiB + + string s = WebUtilities.GetFormattedSize(bytes); + + Assert.AreEqual("1 GB", s, + "1 GiB must be rendered as '1 GB'."); + } + + #endregion + + #region GetFormattedSpeed + + [TestMethod] + public void GetFormattedSpeed_ShouldFormatSmallAsBitsPerSecond_ByDefault() + { + double bytesPerSecond = 100; // 800 bps + + string s = WebUtilities.GetFormattedSpeed(bytesPerSecond); + + Assert.AreEqual("800 bps", s, + "Default mode must convert bytes to bits and stay in 'bps' for values < 1000."); + } + + [TestMethod] + public void GetFormattedSpeed_ShouldFormatMegabitPerSecond() + { + double bytesPerSecond = 125_000; // 1_000_000 bits/s → 1 mbps + + string s = WebUtilities.GetFormattedSpeed(bytesPerSecond); + + Assert.AreEqual("1 mbps", s, + "125000 B/s must be formatted as '1 mbps'."); + } + + [TestMethod] + public void GetFormattedSpeed_ShouldFormatKiBPerSecond_WhenUsingBytesMode() + { + double bytesPerSecond = 1024; // 1 KiB/s + + string s = WebUtilities.GetFormattedSpeed(bytesPerSecond, bitsPerSecond: false); + + Assert.AreEqual("1 KB/s", s, + "In bytes mode, 1024 bytes per second must be rendered as '1 KB/s'."); + } + + #endregion + + #region GetFormattedTime + + [TestMethod] + public void GetFormattedTime_ShouldReturnZeroSeconds_ForZeroInput() + { + string s = WebUtilities.GetFormattedTime(0); + + Assert.AreEqual("0 sec", s, + "Zero seconds must render as '0 sec'."); + } + + [TestMethod] + public void GetFormattedTime_ShouldRenderMinutesAndSeconds() + { + string s = WebUtilities.GetFormattedTime(61); + + Assert.AreEqual("1 min 1 sec", s, + "61 seconds must be formatted as '1 min 1 sec'."); + } + + [TestMethod] + public void GetFormattedTime_ShouldRenderHoursMinutesSeconds() + { + int seconds = 1 * 3600 + 2 * 60 + 3; // 1h 2m 3s + + string s = WebUtilities.GetFormattedTime(seconds); + + Assert.AreEqual("1 hour 2 mins 3 sec", s, + "Composite time must express hour, minute(s), and seconds with pluralization."); + } + + [TestMethod] + public void GetFormattedTime_ShouldRenderDaysAndHours_ONLY_WhenNoLowerUnits() + { + int seconds = 2 * 24 * 3600 + 5 * 3600; // 2 days 5 hours + + string s = WebUtilities.GetFormattedTime(seconds); + + Assert.AreEqual("2 days 5 hours", s, + "Whole days and hours with zero minutes/seconds should omit lower units."); + } + + #endregion + + #region GetContentType + + [TestMethod] + public void GetContentType_ShouldReturnDefaultForUnknownExtension() + { + ContentType ct = WebUtilities.GetContentType("file.unknownext"); + + Assert.AreEqual("application/octet-stream", ct.MediaType, + "Unknown extensions must map to binary 'application/octet-stream'."); + } + + [TestMethod] + public void GetContentType_ShouldBeCaseInsensitive_OnExtension() + { + ContentType lower = WebUtilities.GetContentType("photo.jpg"); + ContentType upper = WebUtilities.GetContentType("PHOTO.JPG"); + + Assert.AreEqual("image/jpeg", lower.MediaType); + Assert.AreEqual("image/jpeg", upper.MediaType, + "Extension must be treated case-insensitively."); + } + + [TestMethod] + public void GetContentType_ShouldRecognizeCommonScriptAndDocumentTypes() + { + ContentType js = WebUtilities.GetContentType("app.js"); + ContentType pdf = WebUtilities.GetContentType("doc.pdf"); + ContentType xlsx = WebUtilities.GetContentType("sheet.xlsx"); + + Assert.AreEqual("application/javascript", js.MediaType, + "'.js' must resolve to 'application/javascript'."); + Assert.AreEqual("application/pdf", pdf.MediaType, + "'.pdf' must resolve to 'application/pdf'."); + Assert.AreEqual( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + xlsx.MediaType, + "'.xlsx' must resolve to correct OOXML spreadsheet MIME type."); + } + + #endregion + + #region IsWebAccessibleAsync + + [TestMethod] + public async Task IsWebAccessibleAsync_ShouldReturnFalse_ForAlwaysUnreachableUris() + { + // 198.51.100.0/24 is TEST-NET-2 (non-routed in normal internet). + Uri[] targets = + { + new Uri("http://198.51.100.1/"), + new Uri("http://198.51.100.2/") + }; + + bool ok = await WebUtilities.IsWebAccessibleAsync( + uriCheckList: targets, + proxy: null, + networkType: HttpClientNetworkType.Default, + timeout: 500, + throwException: false); + + Assert.IsFalse(ok, + "Unreachable test-net hosts must yield 'false' without throwing when throwException=false."); + } + + [TestMethod] + public async Task IsWebAccessibleAsync_ShouldThrowFailure_WhenThrowExceptionIsTrue() + { + Uri[] targets = + { + new Uri("http://198.51.100.1/"), + new Uri("http://198.51.100.2/") + }; + + try + { + await Assert.ThrowsExactlyAsync(async () => + { + _ = await WebUtilities.IsWebAccessibleAsync( + uriCheckList: targets, + proxy: null, + networkType: HttpClientNetworkType.Default, + timeout: 500, + throwException: true); + }); + } + catch (AssertFailedException) + { + await Assert.ThrowsExactlyAsync(async () => + { + _ = await WebUtilities.IsWebAccessibleAsync( + uriCheckList: targets, + proxy: null, + networkType: HttpClientNetworkType.Default, + timeout: 500, + throwException: true); + }); + } + } + + #endregion + + #region GetValidKestrelLocalAddresses + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldFilterUnsupportedFamilies() + { + // Only IPv4 Any and IPv6 Any are meaningful here; unsupported families are skipped by design. + List input = new List + { + IPAddress.Any, + IPAddress.IPv6Any + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + // Must never introduce new addresses, and must only contain supported families. + foreach (IPAddress addr in result) + { + Assert.IsTrue( + addr.AddressFamily == AddressFamily.InterNetwork + || addr.AddressFamily == AddressFamily.InterNetworkV6, + "Result must only contain IPv4 or IPv6 addresses."); + } + } + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldReplaceAnyWithLoopback_WhenUnicastPresent() + { + if (!Socket.OSSupportsIPv4) + Assert.Inconclusive("IPv4 not supported on this platform; skipping IPv4-specific behavior test."); + + List input = new List + { + IPAddress.Any, // 0.0.0.0 + IPAddress.Parse("10.0.0.1") // unicast + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + CollectionAssert.DoesNotContain( + (System.Collections.ICollection)result, + IPAddress.Any, + "When unicast IPv4 is present, '0.0.0.0' must be replaced, not preserved."); + + CollectionAssert.Contains( + (System.Collections.ICollection)result, + IPAddress.Loopback, + "'0.0.0.0' must be mapped to IPv4 loopback when unicast is also configured."); + + CollectionAssert.Contains( + (System.Collections.ICollection)result, + IPAddress.Parse("10.0.0.1"), + "Existing unicast IPv4 addresses must be preserved."); + } + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldPreferIPv6AnyOverIPv4Any_WhenNoUnicast() + { + if (!Socket.OSSupportsIPv4 || !Socket.OSSupportsIPv6) + Assert.Inconclusive("Both IPv4 and IPv6 support required to validate dual-stack 'Any' behavior."); + + List input = new List + { + IPAddress.Any, + IPAddress.IPv6Any + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + CollectionAssert.DoesNotContain( + (System.Collections.ICollection)result, + IPAddress.Any, + "When both 0.0.0.0 and [::] exist and no unicast is present, IPv4 Any must be removed."); + CollectionAssert.Contains( + (System.Collections.ICollection)result, + IPAddress.IPv6Any, + "[::] must remain when dual-stack Any was configured and no unicast exists."); + } + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldDeduplicateAddresses() + { + if (!Socket.OSSupportsIPv4) + Assert.Inconclusive("IPv4 not supported on this platform; skipping deduplication test."); + + IPAddress ip = IPAddress.Parse("192.0.2.10"); // TEST-NET-1 address + + List input = new List + { + ip, + ip, + IPAddress.Any + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + int countOfUnicast = 0; + foreach (IPAddress addr in result) + { + if (addr.Equals(ip)) + countOfUnicast++; + } + + Assert.AreEqual(1, countOfUnicast, + "Result must not contain duplicate unicast entries."); + } + + #endregion + + public TestContext TestContext { get; set; } + } +} 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/Dns/ResourceRecords/CanonicallySerializedResourceRecordTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/CanonicallySerializedResourceRecordTests.cs new file mode 100644 index 00000000..faa1c48b --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/CanonicallySerializedResourceRecordTests.cs @@ -0,0 +1,106 @@ +/* +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.IO; +using System.Net; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class CanonicallySerializedResourceRecordTests + { + [TestMethod] + public void CompareTo_UsesCanonicalRdataOrdering() + { + using MemoryStream buffer = new(); + + CanonicallySerializedResourceRecord low = CanonicallySerializedResourceRecord.Create( + "example.com", + DnsResourceRecordType.A, + DnsClass.IN, + 60, + new DnsARecordData(IPAddress.Parse("192.0.2.1")), + buffer); + + CanonicallySerializedResourceRecord high = CanonicallySerializedResourceRecord.Create( + "example.com", + DnsResourceRecordType.A, + DnsClass.IN, + 60, + new DnsARecordData(IPAddress.Parse("192.0.2.200")), + buffer); + + Assert.IsLessThan(0, low.CompareTo(high)); + Assert.IsGreaterThan(0, high.CompareTo(low)); + } + + [TestMethod] + public void Create_CanonicalizesOwnerName_ToLowercase() + { + using MemoryStream buffer = new(); + + CanonicallySerializedResourceRecord record = CanonicallySerializedResourceRecord.Create( + name: "Example.COM", + type: DnsResourceRecordType.A, + @class: DnsClass.IN, + originalTtl: 3600, + rData: new DnsARecordData(IPAddress.Parse("192.0.2.1")), + buffer: buffer); + + using MemoryStream ms = new(); + record.WriteTo(ms); + + string wire = System.Text.Encoding.ASCII.GetString(ms.ToArray()); + Assert.Contains("example", wire); + } + + [TestMethod] + public void WriteTo_IsDeterministic_ForSameInput() + { + using MemoryStream buffer = new(); + + CanonicallySerializedResourceRecord a = CanonicallySerializedResourceRecord.Create( + "example.com", + DnsResourceRecordType.A, + DnsClass.IN, + 60, + new DnsARecordData(IPAddress.Parse("192.0.2.1")), + buffer); + + CanonicallySerializedResourceRecord b = CanonicallySerializedResourceRecord.Create( + "example.com", + DnsResourceRecordType.A, + DnsClass.IN, + 60, + new DnsARecordData(IPAddress.Parse("192.0.2.1")), + buffer); + + using MemoryStream m1 = new(); + using MemoryStream m2 = new(); + + a.WriteTo(m1); + b.WriteTo(m2); + + CollectionAssert.AreEqual(m1.ToArray(), m2.ToArray()); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAAAARecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAAAARecordDataTests.cs new file mode 100644 index 00000000..58cbc34d --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAAAARecordDataTests.cs @@ -0,0 +1,126 @@ +/* +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.IO; +using System.Net; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsAAAARecordDataTests + { + [TestMethod] + public void Constructor_IPv4Address_Throws() + { + IPAddress ipv4 = IPAddress.Parse("192.0.2.1"); + + Assert.ThrowsExactly(() => + new DnsAAAARecordData(ipv4)); + } + + [TestMethod] + public void Constructor_ValidIPv6Address_Succeeds() + { + IPAddress address = IPAddress.Parse("2001:db8::1"); + + DnsAAAARecordData rdata = new DnsAAAARecordData(address); + + Assert.AreEqual(address, rdata.Address); + Assert.AreEqual(16, rdata.UncompressedLength); + } + + [TestMethod] + public void Equals_DifferentAddress_IsFalse() + { + DnsAAAARecordData a = new DnsAAAARecordData(IPAddress.Parse("2001:db8::1")); + DnsAAAARecordData b = new DnsAAAARecordData(IPAddress.Parse("2001:db8::2")); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameAddress_IsTrue() + { + IPAddress address = IPAddress.Parse("2001:db8::1"); + + DnsAAAARecordData a = new DnsAAAARecordData(address); + DnsAAAARecordData b = new DnsAAAARecordData(IPAddress.Parse("2001:db8::1")); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + IPAddress address = IPAddress.Parse("2001:db8::dead:beef"); + + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.AAAA, + DnsClass.IN, + 300, + new DnsAAAARecordData(address)); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + DnsAAAARecordData rdata = new DnsAAAARecordData(IPAddress.Parse("2001:db8::1")); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("IPAddress", json); + Assert.Contains("2001:db8::1", json); + } + + [TestMethod] + public void UncompressedLength_IsAlways16() + { + DnsAAAARecordData rdata = new DnsAAAARecordData(IPAddress.Parse("2001:db8::abcd")); + + Assert.AreEqual(16, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsALIASRecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsALIASRecordDataTests.cs new file mode 100644 index 00000000..2e133bc6 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsALIASRecordDataTests.cs @@ -0,0 +1,132 @@ +/* +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.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsALIASRecordDataTests + { + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + DnsALIASRecordData rdata = new DnsALIASRecordData( + DnsResourceRecordType.A, + "example.net"); + + Assert.AreEqual(DnsResourceRecordType.A, rdata.Type); + Assert.AreEqual("example.net", rdata.Domain); + } + + [TestMethod] + public void Equals_DifferentType_IsFalse() + { + DnsALIASRecordData a = new DnsALIASRecordData( + DnsResourceRecordType.A, + "example.com"); + + DnsALIASRecordData b = new DnsALIASRecordData( + DnsResourceRecordType.AAAA, + "example.com"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameTypeAndDomain_IgnoresCase() + { + DnsALIASRecordData a = new DnsALIASRecordData( + DnsResourceRecordType.AAAA, + "Example.COM"); + + DnsALIASRecordData b = new DnsALIASRecordData( + DnsResourceRecordType.AAAA, + "example.com"); + + Assert.AreEqual(a, b); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.ALIAS, + DnsClass.IN, + 300, + new DnsALIASRecordData( + DnsResourceRecordType.A, + "target.example")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + DnsALIASRecordData rdata = new DnsALIASRecordData( + DnsResourceRecordType.AAAA, + "example.net"); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("Type", json); + Assert.Contains("AAAA", json); + Assert.Contains("Domain", json); + Assert.Contains("example.net", json); + } + + [TestMethod] + public void UncompressedLength_IncludesTypePrefix() + { + DnsALIASRecordData rdata = new DnsALIASRecordData( + DnsResourceRecordType.A, + "example.com"); + + int baseLength = rdata.Domain.Length; // not exact, but sanity check + + Assert.IsGreaterThan(baseLength, rdata.UncompressedLength); + Assert.IsGreaterThanOrEqualTo(2, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAPLRecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAPLRecordDataTests.cs new file mode 100644 index 00000000..a24f698e --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsAPLRecordDataTests.cs @@ -0,0 +1,161 @@ +/* +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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsAPLRecordDataTests + { + [TestMethod] + public void Constructor_SingleNetworkAddress_Succeeds() + { + NetworkAddress network = new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24); + + DnsAPLRecordData rdata = new DnsAPLRecordData(network, negation: false); + + Assert.HasCount(1, rdata.APItems); + Assert.AreEqual(IanaAddressFamily.IPv4, rdata.APItems.First().AddressFamily); + Assert.AreEqual(24, rdata.APItems.First().Prefix); + Assert.IsFalse(rdata.APItems.First().Negation); + } + + [TestMethod] + public void APItem_NegationFlag_Preserved() + { + NetworkAddress network = new NetworkAddress(IPAddress.Parse("2001:db8::"), 32); + + DnsAPLRecordData.APItem item = new DnsAPLRecordData.APItem(network, negation: true); + + Assert.IsTrue(item.Negation); + Assert.AreEqual(IanaAddressFamily.IPv6, item.AddressFamily); + } + + [TestMethod] + public void Equals_SameItems_AreEqual() + { + List list1 = new List + { + new(new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false), + new(new NetworkAddress(IPAddress.Parse("198.51.100.0"), 24), true) + }; + + List list2 = new List + { + new(new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false), + new(new NetworkAddress(IPAddress.Parse("198.51.100.0"), 24), true) + }; + + DnsAPLRecordData a = new DnsAPLRecordData(list1); + DnsAPLRecordData b = new DnsAPLRecordData(list2); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentItems_AreNotEqual() + { + DnsAPLRecordData a = new DnsAPLRecordData( + new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false); + + DnsAPLRecordData b = new DnsAPLRecordData( + new NetworkAddress(IPAddress.Parse("192.0.2.0"), 25), false); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnsAPLRecordData rdata = new DnsAPLRecordData(new List + { + new(new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false), + new(new NetworkAddress(IPAddress.Parse("2001:db8::"), 32), true) + }); + + DnsResourceRecord rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.APL, + DnsClass.IN, + 300, + rdata); + + byte[] wire = Serialize(rr); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(rr, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesJsonArray() + { + DnsAPLRecordData rdata = new DnsAPLRecordData( + new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.StartsWith("[", json); + Assert.Contains("IPv4", json); + Assert.Contains("Prefix", json); + } + + [TestMethod] + public void UncompressedLength_MatchesSumOfItems() + { + List items = new List + { + new(new NetworkAddress(IPAddress.Parse("192.0.2.0"), 24), false), + new(new NetworkAddress(IPAddress.Parse("198.51.100.0"), 25), true) + }; + + DnsAPLRecordData rdata = new DnsAPLRecordData(items); + + int expected = 0; + foreach (DnsAPLRecordData.APItem item in items) + expected += item.UncompressedLength; + + Assert.AreEqual(expected, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsARecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsARecordDataTests.cs new file mode 100644 index 00000000..9aceb29a --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsARecordDataTests.cs @@ -0,0 +1,132 @@ +/* +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.IO; +using System.Net; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsARecordDataTests + { + [TestMethod] + public void Constructor_NonIPv4_Throws() + { + IPAddress ipv6 = IPAddress.Parse("2001:db8::1"); + + Assert.ThrowsExactly(() => + new DnsARecordData(ipv6)); + } + + [TestMethod] + public void Constructor_ValidIPv4_Succeeds() + { + IPAddress ip = IPAddress.Parse("192.0.2.1"); + + DnsARecordData rdata = new DnsARecordData(ip); + + Assert.AreEqual(ip, rdata.Address); + Assert.AreEqual(4, rdata.UncompressedLength); + } + + [TestMethod] + public void Equals_DifferentAddress_AreNotEqual() + { + DnsARecordData a = new DnsARecordData(IPAddress.Parse("203.0.113.1")); + DnsARecordData b = new DnsARecordData(IPAddress.Parse("203.0.113.2")); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameAddress_AreEqual() + { + DnsARecordData a = new DnsARecordData(IPAddress.Parse("203.0.113.10")); + DnsARecordData b = new DnsARecordData(IPAddress.Parse("203.0.113.10")); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.A, + DnsClass.IN, + 300, + new DnsARecordData(IPAddress.Parse("192.0.2.55"))); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + DnsARecordData rdata = new DnsARecordData(IPAddress.Parse("198.51.100.42")); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("IPAddress", json); + Assert.Contains("198.51.100.42", json); + } + + [TestMethod] + public void UncompressedLength_MatchesWireRdataLength() + { + DnsARecordData rdata = new DnsARecordData(IPAddress.Parse("192.0.2.9")); + + DnsResourceRecord rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.A, + DnsClass.IN, + 60, + rdata); + + byte[] wire = Serialize(rr); + + Assert.AreEqual(4, rdata.UncompressedLength); + Assert.IsGreaterThanOrEqualTo(rdata.UncompressedLength, wire.Length); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsApplicationRecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsApplicationRecordDataTests.cs new file mode 100644 index 00000000..8ef94ec9 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsApplicationRecordDataTests.cs @@ -0,0 +1,145 @@ +/* +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 System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsApplicationRecordDataTests + { + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + DnsApplicationRecordData rdata = new DnsApplicationRecordData( + "myApp", + "com.example.MyClass", + "{\"key\":\"value\"}"); + + Assert.AreEqual("myApp", rdata.AppName); + Assert.AreEqual("com.example.MyClass", rdata.ClassPath); + Assert.AreEqual("{\"key\":\"value\"}", rdata.Data); + } + + [TestMethod] + public void Constructor_InvalidJson_Throws() + { + Assert.Throws(() => + new DnsApplicationRecordData( + "app", + "path", + "{invalid-json")); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + DnsApplicationRecordData a = new DnsApplicationRecordData("a", "b", "c"); + DnsApplicationRecordData b = new DnsApplicationRecordData("a", "b", "c"); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentData_AreNotEqual() + { + DnsApplicationRecordData a = new DnsApplicationRecordData("a", "b", "c"); + DnsApplicationRecordData b = new DnsApplicationRecordData("a", "b", "d"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_ParsesAsUnknownRecord() + { + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.NULL, + DnsClass.IN, + 60, + new DnsApplicationRecordData( + "app", + "class.path", + "payload")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(DnsResourceRecordType.NULL, parsed.Type); + Assert.IsInstanceOfType(parsed.RDATA, typeof(DnsUnknownRecordData)); + } + + [TestMethod] + public void SerializeTo_ProducesValidJson() + { + DnsApplicationRecordData rdata = new DnsApplicationRecordData( + "app", + "path", + "data"); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("AppName", json); + Assert.Contains("ClassPath", json); + Assert.Contains("Data", json); + } + + [TestMethod] + public void UncompressedLength_MatchesWireLength() + { + DnsApplicationRecordData rdata = new DnsApplicationRecordData( + "a", + "b", + "c"); + + DnsResourceRecord rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.NULL, + DnsClass.IN, + 60, + rdata); + + byte[] wire = Serialize(rr); + + Assert.IsGreaterThan(0, rdata.UncompressedLength); + Assert.IsGreaterThanOrEqualTo(rdata.UncompressedLength, wire.Length); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCAARecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCAARecordDataTests.cs new file mode 100644 index 00000000..c1737bbd --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCAARecordDataTests.cs @@ -0,0 +1,162 @@ +/* +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.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsCAARecordDataTests + { + [TestMethod] + public void Constructor_EmptyTag_Throws() + { + Assert.ThrowsExactly(() => + new DnsCAARecordData( + 0, + "", + "value")); + } + + [TestMethod] + public void Constructor_TagIsLowercased() + { + DnsCAARecordData rdata = new DnsCAARecordData( + 0, + "ISSUE", + "ca.example"); + + Assert.AreEqual("issue", rdata.Tag); + } + + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + DnsCAARecordData rdata = new DnsCAARecordData( + flags: 0, + tag: "issue", + value: "letsencrypt.org"); + + Assert.AreEqual((byte)0, rdata.Flags); + Assert.AreEqual("issue", rdata.Tag); + Assert.AreEqual("letsencrypt.org", rdata.Value); + } + + [TestMethod] + public void Equals_DifferentFlags_AreNotEqual() + { + DnsCAARecordData a = new DnsCAARecordData(0, "issue", "ca.example"); + DnsCAARecordData b = new DnsCAARecordData(128, "issue", "ca.example"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_DifferentValue_AreNotEqual() + { + DnsCAARecordData a = new DnsCAARecordData(0, "issue", "ca.example"); + DnsCAARecordData b = new DnsCAARecordData(0, "issue", "other.example"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + DnsCAARecordData a = new DnsCAARecordData(0, "issue", "ca.example"); + DnsCAARecordData b = new DnsCAARecordData(0, "ISSUE", "ca.example"); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.CAA, + DnsClass.IN, + 300, + new DnsCAARecordData( + 0, + "issue", + "letsencrypt.org")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + DnsCAARecordData rdata = new DnsCAARecordData( + 128, + "iodef", + "mailto:security@example.com"); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("Flags", json); + Assert.Contains("128", json); + Assert.Contains("Tag", json); + Assert.Contains("iodef", json); + Assert.Contains("Value", json); + Assert.Contains("mailto:security@example.com", json); + } + + [TestMethod] + public void UncompressedLength_MatchesExpectedSize() + { + DnsCAARecordData rdata = new DnsCAARecordData( + 0, + "issue", + "ca.example"); + + int expected = + 1 + // flags + 1 + // tag length + "issue".Length + + "ca.example".Length; + + Assert.AreEqual(expected, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCNAMERecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCNAMERecordDataTests.cs new file mode 100644 index 00000000..55551add --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsCNAMERecordDataTests.cs @@ -0,0 +1,135 @@ +/* +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.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsCNAMERecordDataTests + { + [TestMethod] + public void Constructor_IDNDomain_IsConvertedToAscii() + { + DnsCNAMERecordData rdata = new DnsCNAMERecordData("bücher.example"); + + Assert.AreEqual("xn--bcher-kva.example", rdata.Domain); + } + + [TestMethod] + public void Constructor_InvalidDomain_Throws() + { + Assert.ThrowsExactly(() => + new DnsCNAMERecordData("invalid..domain")); + } + + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + DnsCNAMERecordData rdata = new DnsCNAMERecordData("example.net"); + + Assert.AreEqual("example.net", rdata.Domain); + } + + [TestMethod] + public void Equals_DifferentDomain_IsFalse() + { + DnsCNAMERecordData a = new DnsCNAMERecordData("a.example"); + DnsCNAMERecordData b = new DnsCNAMERecordData("b.example"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameDomain_IgnoresCase() + { + DnsCNAMERecordData a = new DnsCNAMERecordData("Example.COM"); + DnsCNAMERecordData b = new DnsCNAMERecordData("example.com"); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnsResourceRecord original = new DnsResourceRecord( + "www", + DnsResourceRecordType.CNAME, + DnsClass.IN, + 300, + new DnsCNAMERecordData("target.example")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + DnsCNAMERecordData rdata = new DnsCNAMERecordData("example.net"); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("Domain", json); + Assert.Contains("example.net", json); + } + + [TestMethod] + public void UncompressedLength_IsPositiveAndConsistent() + { + DnsCNAMERecordData rdata = new DnsCNAMERecordData("example.org"); + + Assert.IsGreaterThan(0, rdata.UncompressedLength); + + DnsResourceRecord rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.CNAME, + DnsClass.IN, + 60, + rdata); + + byte[] wire = Serialize(rr); + + Assert.IsGreaterThanOrEqualTo(rdata.UncompressedLength, wire.Length); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNAMERecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNAMERecordDataTests.cs new file mode 100644 index 00000000..b6fbe2c7 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNAMERecordDataTests.cs @@ -0,0 +1,143 @@ +/* +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 System.IO; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsDNAMERecordDataTests + { + [TestMethod] + public void Constructor_ValidInput_NormalizesDomain() + { + DnsDNAMERecordData rdata = new DnsDNAMERecordData("Example.COM"); + + Assert.AreEqual("example.com", rdata.Domain); + } + + [TestMethod] + public void Equals_DifferentDomain_IsFalse() + { + DnsDNAMERecordData a = new DnsDNAMERecordData("example.com"); + DnsDNAMERecordData b = new DnsDNAMERecordData("example.net"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameDomain_IgnoresCase() + { + DnsDNAMERecordData a = new DnsDNAMERecordData("Example.COM"); + DnsDNAMERecordData b = new DnsDNAMERecordData("example.com"); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.DNAME, + DnsClass.IN, + 300, + new DnsDNAMERecordData("target.example")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + DnsDNAMERecordData rdata = new DnsDNAMERecordData("example.net"); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("Domain", json); + Assert.Contains("example.net", json); + } + + [TestMethod] + public void Substitute_QnameNotInOwnerSubtree_Throws() + { + DnsDNAMERecordData rdata = new DnsDNAMERecordData("target.example"); + + Assert.ThrowsExactly(() => + rdata.Substitute( + qname: "www.other.com", + owner: "example.com")); + } + + [TestMethod] + public void Substitute_ReplacesOwnerSuffix_PerRFC6672() + { + DnsDNAMERecordData rdata = new DnsDNAMERecordData("target.example"); + + string result = rdata.Substitute( + qname: "www.sub.example.com", + owner: "example.com"); + + Assert.AreEqual("www.sub.target.example", result); + } + + [TestMethod] + public void Substitute_ToRoot_RemovesOwnerSuffix() + { + DnsDNAMERecordData rdata = new DnsDNAMERecordData(""); + + string result = rdata.Substitute( + qname: "www.example.com", + owner: "example.com"); + + Assert.AreEqual("www", result); + } + + [TestMethod] + public void UncompressedLength_IsNonZero() + { + DnsDNAMERecordData rdata = new DnsDNAMERecordData("example.com"); + + Assert.IsGreaterThan(0, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNSKEYRecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNSKEYRecordDataTests.cs new file mode 100644 index 00000000..00c9dc42 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDNSKEYRecordDataTests.cs @@ -0,0 +1,207 @@ +/* +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.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.Dnssec; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsDNSKEYRecordDataTests + { + private static DnssecPublicKey CreateTestRsaKey() + { + // Minimal RSA public key material for deterministic testing + // (exponent + modulus per DNSSEC wire format) + byte[] rawKey = + { + 0x01, 0x00, 0x01, // exponent 65537 + 0xAA, 0xBB, 0xCC, 0xDD, 0xEE // dummy modulus bytes + }; + + return DnssecPublicKey.Parse( + DnssecAlgorithm.RSASHA256, + rawKey); + } + + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + DnssecPublicKey key = CreateTestRsaKey(); + + DnsDNSKEYRecordData rdata = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key); + + Assert.AreEqual(DnsDnsKeyFlag.ZoneKey, rdata.Flags); + Assert.AreEqual((byte)3, rdata.Protocol); + Assert.AreEqual(DnssecAlgorithm.RSASHA256, rdata.Algorithm); + Assert.HasCount(key.RawPublicKey.Length, rdata.PublicKey.RawPublicKey); + Assert.IsGreaterThan(0, rdata.ComputedKeyTag); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + DnssecPublicKey key = CreateTestRsaKey(); + + DnsDNSKEYRecordData a = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey | DnsDnsKeyFlag.SecureEntryPoint, + 3, + DnssecAlgorithm.RSASHA256, + key); + + DnsDNSKEYRecordData b = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey | DnsDnsKeyFlag.SecureEntryPoint, + 3, + DnssecAlgorithm.RSASHA256, + key); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentAlgorithm_IsFalse() + { + DnssecPublicKey key = CreateTestRsaKey(); + + DnsDNSKEYRecordData a = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key); + + DnsDNSKEYRecordData b = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA1, + key); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnssecPublicKey key = CreateTestRsaKey(); + + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.DNSKEY, + DnsClass.IN, + 3600, + new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key)); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void CreateDS_And_IsDnsKeyValid_WorkTogether() + { + DnssecPublicKey key = CreateTestRsaKey(); + + DnsDNSKEYRecordData dnskey = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key); + + DnsDSRecordData ds = dnskey.CreateDS( + "Example.COM.", + DnssecDigestType.SHA256); + + Assert.IsTrue( + dnskey.IsDnsKeyValid("example.com.", ds), + "DNSKEY must validate its own DS regardless of case"); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + DnssecPublicKey key = CreateTestRsaKey(); + + DnsDNSKEYRecordData rdata = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("Flags", json); + Assert.Contains("Protocol", json); + Assert.Contains("Algorithm", json); + Assert.Contains("PublicKey", json); + Assert.Contains("ComputedKeyTag", json); + } + + [TestMethod] + public void UncompressedLength_MatchesWireRdataLength() + { + DnssecPublicKey key = CreateTestRsaKey(); + + DnsDNSKEYRecordData rdata = new DnsDNSKEYRecordData( + DnsDnsKeyFlag.ZoneKey, + 3, + DnssecAlgorithm.RSASHA256, + key); + + DnsResourceRecord rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.DNSKEY, + DnsClass.IN, + 3600, + rdata); + + byte[] wire = Serialize(rr); + + Assert.IsGreaterThan(0, rdata.UncompressedLength); + Assert.IsGreaterThanOrEqualTo(rdata.UncompressedLength, wire.Length); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDSRecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDSRecordDataTests.cs new file mode 100644 index 00000000..8d344274 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsDSRecordDataTests.cs @@ -0,0 +1,197 @@ +/* +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 System.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsDSRecordDataTests + { + [TestMethod] + public void Constructor_InvalidDigestLength_Throws() + { + byte[] invalidDigest = new byte[10]; + + Assert.ThrowsExactly(() => + new DnsDSRecordData( + keyTag: 1, + algorithm: DnssecAlgorithm.RSASHA256, + digestType: DnssecDigestType.SHA256, + digest: invalidDigest)); + } + + [TestMethod] + public void Constructor_ValidSHA256_Succeeds() + { + byte[] digest = new byte[32]; + Random.Shared.NextBytes(digest); + + DnsDSRecordData rdata = new DnsDSRecordData( + keyTag: 12345, + algorithm: DnssecAlgorithm.RSASHA256, + digestType: DnssecDigestType.SHA256, + digest: digest); + + Assert.AreEqual((ushort)12345, rdata.KeyTag); + Assert.AreEqual(DnssecAlgorithm.RSASHA256, rdata.Algorithm); + Assert.AreEqual(DnssecDigestType.SHA256, rdata.DigestType); + CollectionAssert.AreEqual(digest, rdata.Digest); + } + + [TestMethod] + public void Equals_DifferentDigest_IsFalse() + { + byte[] digestA = new byte[20]; + byte[] digestB = new byte[20]; + Random.Shared.NextBytes(digestA); + Random.Shared.NextBytes(digestB); + + DnsDSRecordData a = new DnsDSRecordData( + 10, + DnssecAlgorithm.RSASHA1, + DnssecDigestType.SHA1, + digestA); + + DnsDSRecordData b = new DnsDSRecordData( + 10, + DnssecAlgorithm.RSASHA1, + DnssecDigestType.SHA1, + digestB); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + byte[] digest = new byte[20]; + Random.Shared.NextBytes(digest); + + DnsDSRecordData a = new DnsDSRecordData( + 10, + DnssecAlgorithm.RSASHA1, + DnssecDigestType.SHA1, + digest); + + DnsDSRecordData b = new DnsDSRecordData( + 10, + DnssecAlgorithm.RSASHA1, + DnssecDigestType.SHA1, + digest); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void IsDigestTypeSupported_WorksAsSpecified() + { + Assert.IsTrue(DnsDSRecordData.IsDigestTypeSupported(DnssecDigestType.SHA1)); + Assert.IsTrue(DnsDSRecordData.IsDigestTypeSupported(DnssecDigestType.SHA256)); + Assert.IsFalse(DnsDSRecordData.IsDigestTypeSupported(DnssecDigestType.GOST_R_34_11_94)); + } + + [TestMethod] + public void IsDnssecAlgorithmSupported_WorksAsSpecified() + { + Assert.IsTrue(DnsDSRecordData.IsDnssecAlgorithmSupported(DnssecAlgorithm.RSASHA256)); + Assert.IsTrue(DnsDSRecordData.IsDnssecAlgorithmSupported(DnssecAlgorithm.ED25519)); + Assert.IsFalse(DnsDSRecordData.IsDnssecAlgorithmSupported(DnssecAlgorithm.RSAMD5)); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + byte[] digest = new byte[32]; + Random.Shared.NextBytes(digest); + + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.DS, + DnsClass.IN, + 3600, + new DnsDSRecordData( + 54321, + DnssecAlgorithm.RSASHA256, + DnssecDigestType.SHA256, + digest)); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + byte[] digest = new byte[32]; + Random.Shared.NextBytes(digest); + + DnsDSRecordData rdata = new DnsDSRecordData( + 100, + DnssecAlgorithm.RSASHA256, + DnssecDigestType.SHA256, + digest); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("KeyTag", json); + Assert.Contains("Algorithm", json); + Assert.Contains("DigestType", json); + Assert.Contains("Digest", json); + } + + [TestMethod] + public void UncompressedLength_MatchesExpected() + { + byte[] digest = new byte[48]; + Random.Shared.NextBytes(digest); + + DnsDSRecordData rdata = new DnsDSRecordData( + 1, + DnssecAlgorithm.ECDSAP384SHA384, + DnssecDigestType.SHA384, + digest); + + Assert.AreEqual(2 + 1 + 1 + 48, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsForwarderRecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsForwarderRecordDataTests.cs new file mode 100644 index 00000000..ddea93d8 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsForwarderRecordDataTests.cs @@ -0,0 +1,232 @@ +/* +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.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsForwarderRecordDataTests + { + [TestMethod] + public void Constructor_MinimalValidInput_Succeeds() + { + DnsForwarderRecordData rdata = new DnsForwarderRecordData( + DnsTransportProtocol.Udp, + "8.8.8.8", + dnssecValidation: false, + proxyType: DnsForwarderRecordProxyType.None, + proxyAddress: null, + proxyPort: 0, + proxyUsername: null, + proxyPassword: null, + priority: 10); + + Assert.AreEqual(DnsTransportProtocol.Udp, rdata.Protocol); + Assert.AreEqual("8.8.8.8", rdata.Forwarder); + Assert.AreEqual(10, rdata.Priority); + Assert.IsFalse(rdata.DnssecValidation); + Assert.AreEqual(DnsForwarderRecordProxyType.None, rdata.ProxyType); + } + + [TestMethod] + public void Equals_PartialRecord_IgnoresOptionalFields() + { + DnsForwarderRecordData partial = DnsForwarderRecordData.CreatePartialRecordData( + DnsTransportProtocol.Udp, + "9.9.9.9"); + + DnsForwarderRecordData full = new DnsForwarderRecordData( + DnsTransportProtocol.Udp, + "9.9.9.9", + dnssecValidation: true, + proxyType: DnsForwarderRecordProxyType.Http, + proxyAddress: "proxy.local", + proxyPort: 8080, + proxyUsername: "user", + proxyPassword: "pass", + priority: 100); + + Assert.IsTrue(partial.Equals(full)); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + DnsForwarderRecordData a = new DnsForwarderRecordData( + DnsTransportProtocol.Tcp, + "1.1.1.1", + true, + DnsForwarderRecordProxyType.None, + null, + 0, + null, + null, + 1); + + DnsForwarderRecordData b = new DnsForwarderRecordData( + DnsTransportProtocol.Tcp, + "1.1.1.1", + true, + DnsForwarderRecordProxyType.None, + null, + 0, + null, + null, + 1); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void GetProxy_ReturnsConfiguredProxy() + { + DnsForwarderRecordData rdata = new DnsForwarderRecordData( + DnsTransportProtocol.Tcp, + "8.8.8.8", + dnssecValidation: false, + proxyType: DnsForwarderRecordProxyType.Socks5, + proxyAddress: "proxy.local", + proxyPort: 1080, + proxyUsername: "u", + proxyPassword: "p", + priority: 0); + + NetProxy proxy = rdata.GetProxy(null); + + Assert.IsNotNull(proxy); + Assert.AreEqual(NetProxyType.Socks5, proxy.Type); + } + + [TestMethod] + public void HttpProxy_IsSerializedAndParsedCorrectly() + { + DnsForwarderRecordData rdata = new DnsForwarderRecordData( + DnsTransportProtocol.Tcp, + "1.1.1.1", + dnssecValidation: true, + proxyType: DnsForwarderRecordProxyType.Http, + proxyAddress: "proxy.example", + proxyPort: 3128, + proxyUsername: "user", + proxyPassword: "pass", + priority: 20); + + DnsResourceRecord rr = new DnsResourceRecord( + "example", + DnsResourceRecordType.FWD, + DnsClass.IN, + 60, + rdata); + + byte[] wire = Serialize(rr); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(rr, parsed); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.FWD, + DnsClass.IN, + 60, + new DnsForwarderRecordData( + DnsTransportProtocol.Tcp, + "8.8.4.4", + dnssecValidation: true, + proxyType: DnsForwarderRecordProxyType.None, + proxyAddress: null, + proxyPort: 0, + proxyUsername: null, + proxyPassword: null, + priority: 5)); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + DnsForwarderRecordData rdata = new DnsForwarderRecordData( + DnsTransportProtocol.Udp, + "8.8.8.8", + dnssecValidation: false, + proxyType: DnsForwarderRecordProxyType.None, + proxyAddress: null, + proxyPort: 0, + proxyUsername: null, + proxyPassword: null, + priority: 1); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("Protocol", json); + Assert.Contains("Forwarder", json); + Assert.Contains("Priority", json); + Assert.Contains("DnssecValidation", json); + } + + [TestMethod] + public void UncompressedLength_IsNonZero() + { + DnsForwarderRecordData rdata = new DnsForwarderRecordData( + DnsTransportProtocol.Udp, + "1.1.1.1", + false, + DnsForwarderRecordProxyType.None, + null, + 0, + null, + null, + 0); + + Assert.IsGreaterThan(0, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsHINFORecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsHINFORecordDataTests.cs new file mode 100644 index 00000000..325bff10 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsHINFORecordDataTests.cs @@ -0,0 +1,126 @@ +/* +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.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsHINFORecordDataTests + { + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + DnsHINFORecordData rdata = new DnsHINFORecordData( + cpu: "INTEL", + os: "LINUX"); + + Assert.AreEqual("INTEL", rdata.CPU); + Assert.AreEqual("LINUX", rdata.OS); + } + + [TestMethod] + public void Equals_SameValues_AreEqual() + { + DnsHINFORecordData a = new DnsHINFORecordData("AMD64", "WINDOWS"); + DnsHINFORecordData b = new DnsHINFORecordData("AMD64", "WINDOWS"); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentCpu_IsFalse() + { + DnsHINFORecordData a = new DnsHINFORecordData("INTEL", "LINUX"); + DnsHINFORecordData b = new DnsHINFORecordData("ARM", "LINUX"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_DifferentOs_IsFalse() + { + DnsHINFORecordData a = new DnsHINFORecordData("INTEL", "LINUX"); + DnsHINFORecordData b = new DnsHINFORecordData("INTEL", "BSD"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.HINFO, + DnsClass.IN, + 3600, + new DnsHINFORecordData("INTEL", "LINUX")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + DnsHINFORecordData rdata = new DnsHINFORecordData("ARM", "IOS"); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("CPU", json); + Assert.Contains("ARM", json); + Assert.Contains("OS", json); + Assert.Contains("IOS", json); + } + + [TestMethod] + public void UncompressedLength_MatchesExpectedFormula() + { + DnsHINFORecordData rdata = new DnsHINFORecordData("CPU", "OS"); + + int expected = + 1 + "CPU".Length + + 1 + "OS".Length; + + Assert.AreEqual(expected, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsMXRecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsMXRecordDataTests.cs new file mode 100644 index 00000000..b7ab4473 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsMXRecordDataTests.cs @@ -0,0 +1,148 @@ +/* +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.IO; +using System.Text; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsMXRecordDataTests + { + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + DnsMXRecordData rdata = new DnsMXRecordData( + preference: 10, + exchange: "mail.example.com"); + + Assert.AreEqual((ushort)10, rdata.Preference); + Assert.AreEqual("mail.example.com", rdata.Exchange); + } + + [TestMethod] + public void Equals_SameValues_IgnoresCaseOnExchange() + { + DnsMXRecordData a = new DnsMXRecordData( + 10, + "Mail.EXAMPLE.COM"); + + DnsMXRecordData b = new DnsMXRecordData( + 10, + "mail.example.com"); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_DifferentPreference_IsFalse() + { + DnsMXRecordData a = new DnsMXRecordData(10, "mail.example.com"); + DnsMXRecordData b = new DnsMXRecordData(20, "mail.example.com"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_DifferentExchange_IsFalse() + { + DnsMXRecordData a = new DnsMXRecordData(10, "mail1.example.com"); + DnsMXRecordData b = new DnsMXRecordData(10, "mail2.example.com"); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void CompareTo_OrdersByPreference() + { + DnsMXRecordData low = new DnsMXRecordData(5, "a.example"); + DnsMXRecordData high = new DnsMXRecordData(20, "b.example"); + + Assert.IsLessThan(0, low.CompareTo(high)); + Assert.IsGreaterThan(0, high.CompareTo(low)); + Assert.AreEqual(0, low.CompareTo(new DnsMXRecordData(5, "other.example"))); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnsResourceRecord original = new DnsResourceRecord( + "example", + DnsResourceRecordType.MX, + DnsClass.IN, + 3600, + new DnsMXRecordData( + 10, + "mail.example")); + + byte[] wire = Serialize(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void SerializeTo_ProducesExpectedJson() + { + DnsMXRecordData rdata = new DnsMXRecordData( + 10, + "mail.example.com"); + + using MemoryStream ms = new(); + using System.Text.Json.Utf8JsonWriter writer = new System.Text.Json.Utf8JsonWriter(ms); + + rdata.SerializeTo(writer); + writer.Flush(); + + string json = Encoding.UTF8.GetString(ms.ToArray()); + + Assert.Contains("Preference", json); + Assert.Contains("10", json); + Assert.Contains("Exchange", json); + Assert.Contains("mail.example.com", json); + } + + [TestMethod] + public void UncompressedLength_MatchesFormula() + { + DnsMXRecordData rdata = new DnsMXRecordData( + 5, + "mail.example"); + + int expected = + 2 + DnsDatagram.GetSerializeDomainNameLength("mail.example"); + + Assert.AreEqual(expected, rdata.UncompressedLength); + } + + private static byte[] Serialize(DnsResourceRecord rr) + { + using MemoryStream ms = new(); + rr.WriteTo(ms); + return ms.ToArray(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsNAPTRRecordDataTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsNAPTRRecordDataTests.cs new file mode 100644 index 00000000..fd94d6eb --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Dns/ResourceRecords/DnsNAPTRRecordDataTests.cs @@ -0,0 +1,206 @@ +/* +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 System.IO; +using System.Linq; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Dns.ResourceRecords +{ + [TestClass] + public class DnsNAPTRRecordDataTests + { + private static byte[] SerializeRecord(DnsResourceRecord record) + { + using MemoryStream ms = new(); + record.WriteTo(ms); + return ms.ToArray(); + } + + [TestMethod] + public void Constructor_ValidInput_Succeeds() + { + DnsNAPTRRecordData rdata = new DnsNAPTRRecordData( + order: 100, + preference: 10, + flags: "U", + services: "SIP+D2U", + regexp: "!^.*$!sip:info@example.com!", + replacement: "Example.COM"); + + DnsResourceRecord rr = new DnsResourceRecord( + "example.com", + DnsResourceRecordType.NAPTR, + DnsClass.IN, + 60, + rdata); + + Assert.AreEqual("U", rdata.Flags); + Assert.AreEqual("Example.COM", rdata.Replacement); + Assert.IsNotNull(rr); + } + + [TestMethod] + public void Constructor_CharacterStringTooLong_Throws() + { + string longValue = new string('a', 256); + + Assert.ThrowsExactly(() => + new DnsNAPTRRecordData( + 0, 0, + longValue, + "", + "", + "")); + } + + [TestMethod] + public void Constructor_NonAsciiCharacter_Throws() + { + Assert.ThrowsExactly(() => + new DnsNAPTRRecordData( + 0, 0, + "Ü", + "", + "", + "")); + } + + [TestMethod] + public void WriteTo_PreservesOriginalCaseOnWire() + { + DnsNAPTRRecordData rdata = new DnsNAPTRRecordData( + 1, 1, "U", "SIP+D2U", "", "Example.COM"); + + DnsResourceRecord rr = new DnsResourceRecord( + "Example.COM", + DnsResourceRecordType.NAPTR, + DnsClass.IN, + 60, + rdata); + + byte[] bytes = SerializeRecord(rr); + + // Ensure uppercase bytes are present + Assert.IsTrue(bytes.Contains((byte)'E')); + Assert.IsTrue(bytes.Contains((byte)'C')); + } + + [TestMethod] + public void RoundTrip_StreamConstructor_PreservesEquality() + { + DnsNAPTRRecordData originalRdata = new DnsNAPTRRecordData( + 50, + 20, + "U", + "SIP+D2T", + "!^.*$!sip:test@example.net!", + "example.net"); // replacement MAY be absolute + + DnsResourceRecord original = new DnsResourceRecord( + "example.net", // owner MUST be relative + DnsResourceRecordType.NAPTR, + DnsClass.IN, + 120, + originalRdata); + + byte[] wire = SerializeRecord(original); + + using MemoryStream ms = new(wire); + DnsResourceRecord parsed = new DnsResourceRecord(ms); + + Assert.AreEqual(original, parsed); + } + + [TestMethod] + public void Equals_IsCaseInsensitivePerDnsRules() + { + DnsNAPTRRecordData a = new DnsNAPTRRecordData( + 10, 10, + "U", + "SIP+D2U", + "", + "example.org"); + + DnsNAPTRRecordData b = new DnsNAPTRRecordData( + 10, 10, + "u", + "sip+d2u", + "", + "EXAMPLE.ORG"); + + Assert.AreEqual(a, b); + } + + [TestMethod] + public void UncompressedLength_MatchesWireRdataLength() + { + DnsNAPTRRecordData rdata = new DnsNAPTRRecordData( + 1, 1, + "U", + "SIP+D2U", + "", + "example.org"); + + DnsResourceRecord rr = new DnsResourceRecord( + "example.org", + DnsResourceRecordType.NAPTR, + DnsClass.IN, + 60, + rdata); + + byte[] wire = SerializeRecord(rr); + + // Strip NAME + TYPE + CLASS + TTL + RDLENGTH (minimum DNS RR header) + int rdataOffset = wire.Length - rdata.UncompressedLength; + + Assert.AreEqual( + rdata.UncompressedLength, + wire.Length - rdataOffset); + } + + [TestMethod] + public void ToString_ProducesZoneFileCompatibleOutput() + { + DnsNAPTRRecordData rdata = new DnsNAPTRRecordData( + 100, 10, + "U", + "SIP+D2U", + "!^.*$!sip:info@example.com!", + "example.com"); + + DnsResourceRecord rr = new DnsResourceRecord( + "example.com", + DnsResourceRecordType.NAPTR, + DnsClass.IN, + 60, + rdata); + + string text = rr.ToString(); + + Assert.Contains("NAPTR", text); + Assert.Contains("SIP+D2U", text); + Assert.Contains("example.com", text); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/DomainEndPointTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/DomainEndPointTests.cs new file mode 100644 index 00000000..b4bbec0e --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/DomainEndPointTests.cs @@ -0,0 +1,334 @@ +/* +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 System.Net.Sockets; +using System.Text; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Dns; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class DomainEndPointTests + { + // ================================================================ + // CONSTRUCTOR – SUCCESS CASES + // ================================================================ + + [TestMethod] + public void Constructor_ShouldAcceptAsciiDomain_AndStorePort() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 853); + + Assert.AreEqual("example.com", ep.Address, + "Constructor must preserve ASCII domain without alteration."); + Assert.AreEqual(853, ep.Port, + "Constructor must store provided port value exactly."); + Assert.AreEqual(AddressFamily.Unspecified, ep.AddressFamily, + "Domain endpoints must remain AddressFamily.Unspecified for defensive correctness."); + } + + [TestMethod] + public void Constructor_ShouldNormalizeUnicodeToAscii() + { + DomainEndPoint ep = new DomainEndPoint("münich.de", 443); + + Assert.AreEqual("xn--mnich-kva.de", ep.Address, + "Constructor must normalize Unicode domain into IDN ASCII equivalent."); + Assert.AreEqual(443, ep.Port, + "Port must remain exactly as provided."); + } + + // ================================================================ + // CONSTRUCTOR – FAILURE CASES + // ================================================================ + + [TestMethod] + public void Constructor_ShouldFailFast_WhenAddressIsNull() + { + ArgumentNullException ex = Assert.ThrowsExactly( + () => _ = new DomainEndPoint(null!, 53), + "Null address must be rejected to prevent partially invalid instance."); + + Assert.AreEqual("address", ex.ParamName, + "Thrown exception must identify the faulty parameter."); + } + + [TestMethod] + public void Constructor_ShouldRejectIPv4Literal() + { + Assert.ThrowsExactly( + () => _ = new DomainEndPoint("192.168.1.1", 80), + "Constructor must reject IP literals to preserve domain-only invariant."); + } + + [TestMethod] + public void Constructor_ShouldRejectObviouslyMalformedDomain() + { + DnsClientException ex = Assert.ThrowsExactly( + () => _ = new DomainEndPoint("exa mple.com", 853), + "Constructor must reject syntactically invalid domain by failing fast through validation-layer exception."); + + Assert.Contains("exa mple.com", ex.Message, "Thrown validation exception must include original input for caller diagnostic correctness."); + } + + // ================================================================ + // TRY PARSE – SUCCESS CASES + // ================================================================ + + [TestMethod] + public void TryParse_ShouldParseDomainWithoutPort_DefaultPortZero() + { + bool ok = DomainEndPoint.TryParse("example.com", out DomainEndPoint? ep); + + Assert.IsTrue(ok, "TryParse must succeed for valid domain without port."); + Assert.IsNotNull(ep, "Successful TryParse must produce a concrete instance."); + Assert.AreEqual("example.com", ep.Address, + "Domain segment must remain unchanged."); + Assert.AreEqual(0, ep.Port, + "No explicit port must result in Port=0."); + } + + [TestMethod] + public void TryParse_ShouldParseDomainWithPort() + { + bool ok = DomainEndPoint.TryParse("example.com:445", out DomainEndPoint? ep); + + Assert.IsTrue(ok, + "TryParse must succeed for expected domain:port format."); + Assert.AreEqual("example.com", ep!.Address); + Assert.AreEqual(445, ep.Port); + } + + [TestMethod] + public void TryParse_ShouldNormalizeUnicodeDomain() + { + bool ok = DomainEndPoint.TryParse("münich.de:80", out DomainEndPoint? ep); + + Assert.IsTrue(ok, "Valid Unicode domain must be accepted."); + Assert.AreEqual("xn--mnich-kva.de", ep!.Address, + "Unicode must normalize predictably to ASCII."); + Assert.AreEqual(80, ep.Port, + "Port must reflect provided integer value."); + } + + [TestMethod] + public void TryParse_ShouldRoundtripSuccessfully() + { + const string original = "example.com:853"; + + Assert.IsTrue(DomainEndPoint.TryParse(original, out DomainEndPoint? ep1), + "TryParse must succeed on valid input."); + + string serialized = ep1!.ToString(); + Assert.IsTrue(DomainEndPoint.TryParse(serialized, out DomainEndPoint? ep2), + "Re-parsing output must succeed."); + + Assert.AreEqual(ep1.Address, ep2!.Address, + "Roundtrip must preserve domain identity exactly."); + Assert.AreEqual(ep1.Port, ep2.Port, + "Roundtrip must preserve port identity exactly."); + } + + // ================================================================ + // TRY PARSE – FAILURE CASES + // ================================================================ + + // FIXME: DomainEndPoint.TryParse must fail when input is null or empty string. + [TestMethod] + public void TryParse_ShouldFail_WhenInputIsNull() + { + bool ok = DomainEndPoint.TryParse(null, out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Null value cannot represent valid domain endpoint."); + Assert.IsNull(ep, "Endpoint must remain null when parsing fails."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenEmptyString() + { + bool ok = DomainEndPoint.TryParse("", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Empty string cannot represent valid domain endpoint."); + Assert.IsNull(ep, "Endpoint must remain null when parsing fails."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenWhitespaceOnly() + { + bool ok = DomainEndPoint.TryParse(" ", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Whitespace-only input cannot represent valid domain endpoint."); + Assert.IsNull(ep, "Result object must remain null on failure."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenTooManyColons() + { + bool ok = DomainEndPoint.TryParse("a:b:c", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Multiple separators violate predictable domain:port format."); + Assert.IsNull(ep, "Endpoint must remain null to avoid partially valid identity."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenDomainIsIPAddress() + { + bool ok = DomainEndPoint.TryParse("127.0.0.1:81", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "IP literal parsing must be rejected consistently."); + Assert.IsNull(ep, "Null endpoint is required defensive failure output."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenNonNumericPort() + { + bool ok = DomainEndPoint.TryParse("example.com:abc", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Port must parse strictly as numeric."); + Assert.IsNull(ep, "Failure scenario must not yield partially created endpoint."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenPortOutOfRange() + { + bool ok = DomainEndPoint.TryParse("example.com:70000", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Ports exceeding UInt16 range cannot be treated as valid."); + Assert.IsNull(ep, "No endpoint must be generated."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenDomainContainsSpaces() + { + bool ok = DomainEndPoint.TryParse("exa mple.com:53", out DomainEndPoint? ep); + + Assert.IsFalse(ok, "Invalid domain format must not succeed."); + Assert.IsNull(ep, "Endpoint must remain null upon failure."); + } + + // ================================================================ + // ADDRESS BYTES + // ================================================================ + + [TestMethod] + public void GetAddressBytes_MustReturnLengthPrefixedAsciiBytes() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 80); + byte[] result = ep.GetAddressBytes(); + + byte[] ascii = Encoding.ASCII.GetBytes("example.com"); + + Assert.AreEqual(ascii.Length, result[0], + "Length prefix must exactly match ASCII length of the address."); + for (int i = 0; i < ascii.Length; i++) + { + Assert.AreEqual(ascii[i], result[i + 1], + $"Byte index {i} must reflect ASCII domain payload."); + } + } + + [TestMethod] + public void GetAddressBytes_MustReturnIndependentBuffers() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 80); + + byte[] a = ep.GetAddressBytes(); + a[1] ^= 0xFF; + + byte[] b = ep.GetAddressBytes(); + + Assert.AreNotEqual(a[1], b[1], + "Returned byte arrays must not expose internal mutable buffers."); + } + + // ================================================================ + // EQUALITY & HASH + // ================================================================ + + [TestMethod] + public void Equals_MustBeCaseInsensitiveForDomain_AndStrictOnPort() + { + DomainEndPoint ep1 = new DomainEndPoint("Example.com", 443); + DomainEndPoint ep2 = new DomainEndPoint("example.com", 443); + DomainEndPoint ep3 = new DomainEndPoint("example.com", 853); + + Assert.IsTrue(ep1.Equals(ep2), + "Domain equality must ignore case differences."); + Assert.IsFalse(ep1.Equals(ep3), + "Different ports must break equality even when domain matches."); + } + + [TestMethod] + public void GetHashCode_MustBeStableAcrossRepeatedCalls() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 443); + + int h1 = ep.GetHashCode(); + int h2 = ep.GetHashCode(); + + Assert.AreEqual(h1, h2, + "Hash code must remain stable to support predictable dictionary usage."); + } + + [TestMethod] + public void Equals_MustReturnFalse_ForDifferentTypeAndNull() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 80); + + Assert.IsFalse(ep.Equals(null), + "Comparing against null must never produce equality."); + Assert.IsFalse(ep.Equals("example.com:80"), + "Comparing against non-endpoint type must not succeed."); + } + + // ================================================================ + // PROPERTY SETTERS + // ================================================================ + + [TestMethod] + public void Address_Setter_MustNotCorruptUnrelatedState() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 53) + { + Address = "192.168.9.10" + }; + + Assert.AreEqual("192.168.9.10", ep.Address, + "Setter does not re-validate by design; caller assumes responsibility."); + Assert.AreEqual(53, ep.Port, + "Setter mutation must not affect unrelated fields."); + } + + [TestMethod] + public void Port_Setter_MustAllowCallerProvidedValueAsIs() + { + DomainEndPoint ep = new DomainEndPoint("example.com", 53) + { + Port = -1 + }; + + Assert.AreEqual(-1, ep.Port, + "Setter must store raw caller intent; constraints belong outside endpoint abstraction."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/EndPointExtensionsTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/EndPointExtensionsTests.cs new file mode 100644 index 00000000..16388cfb --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/EndPointExtensionsTests.cs @@ -0,0 +1,271 @@ +/* +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 System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class EndPointExtensionsTests + { + [TestMethod] + public void WriteRead_RoundTrip_IPv4() + { + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("192.168.10.25"), 853); + + using MemoryStream ms = new MemoryStream(); + using BinaryWriter bw = new BinaryWriter(ms); + + ep.WriteTo(bw); + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + EndPoint reloaded = EndPointExtensions.ReadFrom(br); + + Assert.AreEqual(ep.Address.ToString(), reloaded.GetAddress(), + "Round-trip must preserve IPv4 address."); + Assert.AreEqual(ep.Port, reloaded.GetPort(), + "Round-trip must preserve port."); + } + + [TestMethod] + public void WriteRead_RoundTrip_IPv6() + { + IPEndPoint ep = new IPEndPoint(IPAddress.IPv6Loopback, 853); + + using MemoryStream ms = new MemoryStream(); + using BinaryWriter bw = new BinaryWriter(ms); + + ep.WriteTo(bw); + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + EndPoint reloaded = EndPointExtensions.ReadFrom(br); + + Assert.AreEqual("::1", reloaded.GetAddress(), + "Round-trip must preserve IPv6 loopback."); + Assert.AreEqual(853, reloaded.GetPort(), + "Round-trip must preserve port."); + } + + [TestMethod] + public void WriteRead_RoundTrip_Domain() + { + DomainEndPoint dep = new DomainEndPoint("example.org", 853); + + using MemoryStream ms = new MemoryStream(); + using BinaryWriter bw = new BinaryWriter(ms); + + dep.WriteTo(bw); + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + EndPoint reloaded = EndPointExtensions.ReadFrom(br); + + Assert.AreEqual("example.org", reloaded.GetAddress(), + "Domain must survive round-trip serialization."); + Assert.AreEqual(853, reloaded.GetPort(), + "Port must survive round-trip serialization."); + } + + [TestMethod] + public void ReadFrom_ShouldFail_OnUnsupportedDiscriminator() + { + using MemoryStream ms = new MemoryStream(); + using BinaryWriter bw = new BinaryWriter(ms); + + bw.Write((byte)99); // invalid discriminator + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + Assert.ThrowsExactly( + () => _ = EndPointExtensions.ReadFrom(br), + "Unsupported prefix must trigger deterministic failure."); + } + + [TestMethod] + public void GetAddress_ShouldReturn_IPString() + { + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("1.2.3.4"), 1234); + Assert.AreEqual("1.2.3.4", ep.GetAddress(), + "Address must be returned as textual IPv4."); + } + + [TestMethod] + public void GetAddress_ShouldReturn_DomainString() + { + DomainEndPoint ep = new DomainEndPoint("dns.google", 53); + Assert.AreEqual("dns.google", ep.GetAddress(), + "Domain must be returned as raw host label."); + } + + [TestMethod] + public void GetPort_ShouldReturn_Port() + { + IPEndPoint ep = new IPEndPoint(IPAddress.Loopback, 1111); + Assert.AreEqual(1111, ep.GetPort(), "Port must be returned unchanged."); + } + + [TestMethod] + public void SetPort_ShouldMutate_IPPort() + { + IPEndPoint ep = new IPEndPoint(IPAddress.Loopback, 53); + ep.SetPort(443); + + Assert.AreEqual(443, ep.Port, "Mutated port must be observable."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldReturn_IP_WhenAlreadyIPEndPoint() + { + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9000); + + IPEndPoint result = await ep.GetIPEndPointAsync(cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual(ep.Address, result.Address, + "Resolved IP must match source."); + Assert.AreEqual(ep.Port, result.Port, + "Resolved port must match source."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldResolve_Localhost_Predictably() + { + DomainEndPoint dep = new DomainEndPoint("localhost", 443); + + IPEndPoint resolved = await dep.GetIPEndPointAsync(AddressFamily.InterNetwork, cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual(443, resolved.Port, "Resolved port must match declared port."); + Assert.AreEqual(AddressFamily.InterNetwork, resolved.Address.AddressFamily, + "Requested AF must be honored when at least one matching address exists."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldFail_WhenDNSReturnsEmpty() + { + DomainEndPoint dep = new DomainEndPoint("test-invalid-unresolvable-domain.local", 5000); + + await Assert.ThrowsExactlyAsync( + async () => await dep.GetIPEndPointAsync(cancellationToken: TestContext.CancellationToken), + "Unresolvable name must trigger HostNotFound."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldFallback_WhenRequestedFamilyUnsupported() + { + DomainEndPoint dep = new DomainEndPoint("localhost", 853); + + IPEndPoint ep = await dep.GetIPEndPointAsync(AddressFamily.AppleTalk, cancellationToken: TestContext.CancellationToken); + + Assert.IsNotNull(ep); + Assert.AreEqual(853, ep.Port, "Port must be preserved."); + Assert.IsInstanceOfType(ep, "Returned endpoint must still be resolved."); + } + + [TestMethod] + public void GetEndPoint_ShouldReturn_IPEndpoint_OnLiteralIP() + { + EndPoint ep = EndPointExtensions.GetEndPoint("10.20.30.40", 8080); + + Assert.IsInstanceOfType(ep, "Literal IP input must produce IPEndPoint."); + } + + [TestMethod] + public void GetEndPoint_ShouldReturn_DomainEndPoint_OnHostName() + { + EndPoint ep = EndPointExtensions.GetEndPoint("dns.google", 53); + + Assert.IsInstanceOfType(ep, typeof(DomainEndPoint), + "Non-IP literal must produce domain endpoint."); + } + + [TestMethod] + public void TryParse_ShouldReturnTrue_ForIPEndPointSyntax() + { + Assert.IsTrue(EndPointExtensions.TryParse("5.6.7.8:22", out EndPoint? ep), + "Valid IP must be parsed."); + Assert.IsInstanceOfType(ep); + } + + [TestMethod] + public void TryParse_ShouldReturnTrue_ForDomainSyntax() + { + Assert.IsTrue(EndPointExtensions.TryParse("example.com:25", out EndPoint? ep), + "Valid domain:port must be parsed."); + Assert.IsInstanceOfType(ep, typeof(DomainEndPoint)); + } + + [TestMethod] + public void TryParse_ShouldSetPort0_WhenMissingPort() + { + bool ok = EndPointExtensions.TryParse("example.com", out EndPoint? ep); + Assert.IsTrue(ok, "Missing port must parse successfully."); + Assert.AreEqual(0, ep.GetPort()); + } + + [TestMethod] + public void IsEquals_ShouldCompare_IPCorrectly() + { + IPEndPoint a = new IPEndPoint(IPAddress.Parse("1.1.1.1"), 853); + IPEndPoint b = new IPEndPoint(IPAddress.Parse("1.1.1.1"), 853); + + Assert.IsTrue(a.IsEquals(b), + "IPEndPoint equality must fully honor IP + port."); + } + + [TestMethod] + public void IsEquals_ShouldCompare_DomainCorrectly() + { + DomainEndPoint a = new DomainEndPoint("example.org", 443); + DomainEndPoint b = new DomainEndPoint("example.org", 443); + + Assert.IsTrue(a.IsEquals(b), + "Domain endpoints must compare by semantic equality."); + } + + [TestMethod] + public void IsEquals_MustReturnFalse_OnDifferentAddresses() + { + DomainEndPoint a = new DomainEndPoint("example.org", 443); + DomainEndPoint b = new DomainEndPoint("example.net", 443); + + Assert.IsFalse(a.IsEquals(b), + "Different hostnames must not compare equal."); + } + + [TestMethod] + public void IsEquals_MustReturnFalse_OnDifferentPorts() + { + IPEndPoint a = new IPEndPoint(IPAddress.Parse("8.8.8.8"), 53); + IPEndPoint b = new IPEndPoint(IPAddress.Parse("8.8.8.8"), 853); + + Assert.IsFalse(a.IsEquals(b), + "Same address but different port must not compare equal."); + } + + public TestContext TestContext { get; set; } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Http/Client/HttpClientNetworkHandlerTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Http/Client/HttpClientNetworkHandlerTests.cs new file mode 100644 index 00000000..e7cf4394 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Http/Client/HttpClientNetworkHandlerTests.cs @@ -0,0 +1,139 @@ +/* +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 System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Http.Client; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Http.Client +{ + [TestClass] + public class HttpClientNetworkHandlerTests + { + [TestMethod] + public void Constructor_InitializesSocketsHttpHandlerCorrectly() + { + using HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + + Assert.IsNotNull( + handler.InnerHandler, + "InnerHandler must be initialized."); + + Assert.IsTrue( + handler.InnerHandler.EnableMultipleHttp2Connections, + "Handler must enable multiple HTTP/2 connections."); + } + + [TestMethod] + public void NetworkType_Property_RoundTrips() + { + using HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + + handler.NetworkType = HttpClientNetworkType.IPv6Only; + + Assert.AreEqual( + HttpClientNetworkType.IPv6Only, + handler.NetworkType); + } + + [TestMethod] + public void Send_WhenHttpVersion30_IsDowngradedToHttp2() + { + using HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + using HttpMessageInvoker invoker = new HttpMessageInvoker(handler); + + using HttpRequestMessage request = new HttpRequestMessage( + HttpMethod.Get, + "http://example.com") + { + Version = HttpVersion.Version30 + }; + + Assert.AreEqual( + HttpVersion.Version30, + request.Version, + "Precondition: request must start as HTTP/3."); + + Assert.ThrowsExactly(() => + { + invoker.Send(request, CancellationToken.None); + }); + + Assert.AreEqual( + HttpVersion.Version20, + request.Version, + "Handler must downgrade HTTP/3 to HTTP/2 even when the send fails."); + } + + [TestMethod] + public void Send_WhenSocketsHttpHandlerProxyIsUsed_ThrowsHttpRequestException() + { + using HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + + handler.InnerHandler.UseProxy = true; + handler.InnerHandler.Proxy = new WebProxy("http://127.0.0.1:8080"); + + using HttpMessageInvoker invoker = new HttpMessageInvoker(handler); + + using HttpRequestMessage request = new HttpRequestMessage( + HttpMethod.Get, + "http://example.com"); + + Assert.ThrowsExactly(() => + { + invoker.Send(request, CancellationToken.None); + }); + } + + [TestMethod] + public async Task SendAsync_WhenHttpVersion30_IsDowngradedToHttp2() + { + using HttpClientNetworkHandler handler = new HttpClientNetworkHandler(); + using HttpMessageInvoker invoker = new HttpMessageInvoker(handler); + + using HttpRequestMessage request = new HttpRequestMessage( + HttpMethod.Get, + "http://example.com") + { + Version = HttpVersion.Version30 + }; + + // We do NOT assert on success or failure of the send itself. + // The contract we enforce here is the version downgrade. + try + { + await invoker.SendAsync(request, CancellationToken.None); + } + catch + { + // Outcome of the send is environment-dependent and not part of the contract. + } + + Assert.AreEqual( + HttpVersion.Version20, + request.Version, + "Async path must downgrade HTTP/3 to HTTP/2."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Http/HttpRequestTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Http/HttpRequestTests.cs new file mode 100644 index 00000000..5a152c60 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Http/HttpRequestTests.cs @@ -0,0 +1,298 @@ +/* +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 System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Http; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Http +{ + [TestClass] + public class HttpRequestTests + { + public TestContext TestContext { get; set; } + + [TestMethod] + public async Task ReadRequestAsync_ParsesQueryStringCorrectly() + { + string raw = + "GET /search?q=test&flag HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual("/search", req.RequestPath); + Assert.AreEqual("test", req.QueryString["q"]); + Assert.IsNull(req.QueryString["flag"]); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenBodyIsTruncated_ReturnsEOFWithoutThrowing() + { + string raw = + "POST /data HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Content-Length: 10\r\n" + + "\r\n" + + "short"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + byte[] buffer = new byte[16]; + + int totalRead = 0; + int r; + + while ((r = await req.InputStream.ReadAsync( + buffer, 0, buffer.Length, TestContext.CancellationToken)) > 0) + { + totalRead += r; + } + + Assert.AreEqual( + 5, + totalRead, + "InputStream must expose only the bytes actually available."); + + Assert.AreEqual( + 0, + r, + "InputStream must signal truncation via EOF, not via exception."); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenChunkedBodyExceedsMaxContentLength_ThrowsHttpRequestException() + { + string raw = + "POST /x HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "4\r\nWiki\r\n" + + "0\r\n\r\n"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + maxContentLength: 3, + cancellationToken: TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await ReadAllAsciiAsync(req.InputStream, TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenChunkedEndsImmediately_ReturnsEmptyBody() + { + string raw = + "POST /x HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "0\r\n\r\n"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + string body = await ReadAllAsciiAsync(req.InputStream, TestContext.CancellationToken); + Assert.AreEqual(string.Empty, body); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenChunkedTruncated_ThrowsEndOfStreamOnBodyRead() + { + string raw = + "POST /x HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "5\r\nabc"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await ReadAllAsciiAsync(req.InputStream, TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenConnectionClosedBeforeRequest_ReturnsNull() + { + using MemoryStream stream = new MemoryStream(Array.Empty()); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + Assert.IsNull(req, "Graceful close before request must return null."); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenContentLengthExceedsMax_ThrowsHttpRequestException() + { + string raw = + "POST /data HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Content-Length: 100\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpRequest.ReadRequestAsync( + stream, + maxContentLength: 10, + cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenHeaderIsTruncated_ThrowsEndOfStream() + { + string raw = + "GET / HTTP/1.1\r\n" + + "Host: example.com\r\n"; // missing terminating CRLF + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenHeaderLineIsInvalid_ThrowsInvalidData() + { + string raw = + "GET / HTTP/1.1\r\n" + + "Host example.com\r\n" + // missing colon + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenRequestLineIsInvalid_ThrowsInvalidData() + { + string raw = + "GET /only-two-parts\r\n" + + "Host: example.com\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenTransferEncodingChunked_ExposesDecodedBody() + { + string raw = + "POST /submit HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "4\r\nWiki\r\n" + + "5\r\npedia\r\n" + + "0\r\n\r\n"; + + using MemoryStream stream = MakeStream(raw); + + HttpRequest req = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual("POST", req.HttpMethod); + Assert.AreEqual("/submit", req.RequestPath); + + string body = await ReadAllAsciiAsync(req.InputStream, TestContext.CancellationToken); + Assert.AreEqual("Wikipedia", body); + } + + [TestMethod] + public async Task ReadRequestAsync_WhenTransferEncodingUnsupported_ThrowsHttpRequestException() + { + string raw = + "POST /x HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Transfer-Encoding: gzip\r\n" + + "\r\n"; + + using MemoryStream stream = MakeStream(raw); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpRequest.ReadRequestAsync( + stream, + cancellationToken: TestContext.CancellationToken); + }); + } + + private static MemoryStream MakeStream(string ascii) => new MemoryStream(Encoding.ASCII.GetBytes(ascii)); + + private static async Task ReadAllAsciiAsync(Stream s, CancellationToken ct) + { + using MemoryStream ms = new MemoryStream(); + await s.CopyToAsync(ms, 8192, ct); + return Encoding.ASCII.GetString(ms.ToArray()); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Http/HttpResponseTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Http/HttpResponseTests.cs new file mode 100644 index 00000000..8c7e3590 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Http/HttpResponseTests.cs @@ -0,0 +1,199 @@ +/* +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 System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Http; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Http +{ + [TestClass] + public class HttpResponseTests + { + public TestContext TestContext { get; set; } + + [TestMethod] + public async Task ReadResponseAsync_WhenChunkedTruncated_ThrowsEndOfStreamOnBodyRead() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "5\r\nabc"; + + using MemoryStream stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + + HttpResponse resp = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await ReadAllAsciiAsync(resp.OutputStream, TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenHeaderLineIsInvalid_ThrowsInvalidData() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Content-Length 10\r\n" + // missing colon + "\r\n"; + + using MemoryStream stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenHeadersAreTruncated_ThrowsEndOfStream() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n"; // missing terminating CRLF + + using MemoryStream stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenStatusCodeIsNonNumeric_ThrowsFormatException() + { + string raw = + "HTTP/1.1 OK OK\r\n" + + "Content-Length: 0\r\n" + + "\r\n"; + + using MemoryStream stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenStatusLineIsInvalid_ThrowsInvalidData() + { + string raw = + "HTTP/1.1 200\r\n" + // missing reason phrase + "Content-Length: 0\r\n" + + "\r\n"; + + using MemoryStream stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenTransferEncodingChunked_ExposesDecodedBody() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "3\r\nfoo\r\n" + + "3\r\nbar\r\n" + + "0\r\n\r\n"; + + using MemoryStream stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + + HttpResponse resp = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + + Assert.AreEqual("HTTP/1.1", resp.Protocol); + Assert.AreEqual(200, resp.StatusCode); + + string body = await ReadAllAsciiAsync(resp.OutputStream, TestContext.CancellationToken); + Assert.AreEqual("foobar", body); + } + + [TestMethod] + public async Task ReadResponseAsync_WhenTransferEncodingUnsupported_ThrowsHttpRequestException() + { + string raw = + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: br\r\n" + + "\r\n"; + + using MemoryStream stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + + await Assert.ThrowsExactlyAsync(async () => + { + _ = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ReadResponseAsync_WithContentLength_ThrowsWhenBodyIsLargerThanContentLength() + { + // RFC 9112 §6.3 — MUST NOT read beyond Content-Length + string raw = + "HTTP/1.1 200 OK\r\n" + + "Content-Length: 4\r\n" + + "\r\n" + + "TestEXTRA"; + + + await Assert.ThrowsExactlyAsync(async () => + { + using MemoryStream stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + + HttpResponse resp = await HttpResponse.ReadResponseAsync( + stream, + TestContext.CancellationToken); + }); + } + + private static async Task ReadAllAsciiAsync(Stream s, CancellationToken ct) + { + using MemoryStream ms = new MemoryStream(); + await s.CopyToAsync(ms, 8192, ct); + return Encoding.ASCII.GetString(ms.ToArray()); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs new file mode 100644 index 00000000..01954497 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs @@ -0,0 +1,462 @@ +/* +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 System.IO; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class IPAddressExtensionsTests + { + private static MemoryStream NewStream(byte[]? initial = null) => + initial is null ? new MemoryStream() : new MemoryStream(initial, writable: true); + + // ------------------------------------------------------ + // WRITE & READ (BINARY FORMAT) + // ------------------------------------------------------ + + [TestMethod] + public void WriteTo_ThenReadFrom_ShouldRoundtrip_IPv4() + { + // GIVEN + IPAddress ip = IPAddress.Parse("1.2.3.4"); + using MemoryStream ms = NewStream(); + + // WHEN + ip.WriteTo(ms); + ms.Position = 0; + IPAddress read = IPAddressExtensions.ReadFrom(ms); + + // THEN + Assert.AreEqual(ip, read, "WriteTo/ReadFrom must preserve IPv4 address bits exactly."); + Assert.AreEqual(ms.Length, ms.Position, + "ReadFrom must consume exactly one encoded address and no more bytes."); + } + + [TestMethod] + public void WriteTo_ThenReadFrom_ShouldRoundtrip_IPv6() + { + // GIVEN + IPAddress ip = IPAddress.Parse("2001:db8::1"); + using MemoryStream ms = NewStream(); + + // WHEN + ip.WriteTo(ms); + ms.Position = 0; + IPAddress read = IPAddressExtensions.ReadFrom(ms); + + // THEN + Assert.AreEqual(ip, read, "WriteTo/ReadFrom must preserve IPv6 address bits exactly."); + Assert.AreEqual(ms.Length, ms.Position, + "ReadFrom must consume exactly one encoded IPv6 address and no extra bytes."); + } + + [TestMethod] + public void WriteTo_WithBinaryWriter_ShouldProduceSameFormat() + { + // GIVEN + IPAddress ip = IPAddress.Parse("10.20.30.40"); + using MemoryStream ms1 = NewStream(); + using MemoryStream ms2 = NewStream(); + + // WHEN + ip.WriteTo(ms1); // direct Stream overload + + using (BinaryWriter writer = new BinaryWriter(ms2, System.Text.Encoding.UTF8, leaveOpen: true)) + { + ip.WriteTo(writer); + } + + // THEN + CollectionAssert.AreEqual(ms1.ToArray(), ms2.ToArray(), + "WriteTo(BinaryWriter) must delegate to identical wire format as WriteTo(Stream)."); + } + + [TestMethod] + public void ReadFrom_ShouldThrowEndOfStream_WhenNoFamilyMarkerAvailable() + { + // GIVEN + using MemoryStream ms = NewStream(Array.Empty()); + long startPos = ms.Position; + + // WHEN - THEN + Assert.ThrowsExactly( + () => IPAddressExtensions.ReadFrom(ms), + "ReadFrom must fail fast when stream ends before family marker."); + + Assert.AreEqual(startPos, ms.Position, + "On EOS, ReadFrom must not advance stream position."); + } + + [TestMethod] + public void ReadFrom_ShouldThrowNotSupported_WhenFamilyMarkerUnknown() + { + // GIVEN: marker 3 (unsupported) + one extra byte (must remain unread) + using MemoryStream ms = NewStream(new byte[] { 3, 0xFF }); + + // WHEN + Assert.ThrowsExactly( + () => IPAddressExtensions.ReadFrom(ms), + "ReadFrom must reject unsupported address family markers deterministically."); + + // THEN + Assert.AreEqual(1L, ms.Position, + "On unsupported family marker, ReadFrom must consume only the marker byte and leave payload intact."); + Assert.AreEqual(2L, ms.Length); + } + + // ------------------------------------------------------ + // IPv4 <-> NUMBER CONVERSION + // ------------------------------------------------------ + + [TestMethod] + public void ConvertIpToNumber_ThenBack_ShouldRoundtrip_IPv4() + { + // GIVEN + IPAddress ip = IPAddress.Parse("1.2.3.4"); + + // WHEN + uint number = ip.ConvertIpToNumber(); + IPAddress roundtrip = IPAddressExtensions.ConvertNumberToIp(number); + + // THEN + Assert.AreEqual("1.2.3.4", roundtrip.ToString(), + "ConvertNumberToIp(ConvertIpToNumber(ip)) must yield the original IPv4 address."); + } + + [TestMethod] + public void ConvertIpToNumber_ShouldThrow_WhenAddressIsIPv6() + { + // GIVEN + IPAddress ip = IPAddress.Parse("::1"); + + // WHEN - THEN + Assert.ThrowsExactly( + () => ip.ConvertIpToNumber(), + "ConvertIpToNumber must reject non-IPv4 addresses with ArgumentException."); + } + + // ------------------------------------------------------ + // SUBNET MASK HELPERS + // ------------------------------------------------------ + + [TestMethod] + public void GetSubnetMask_ShouldReturnCorrectMasks_ForBoundaryPrefixLengths() + { + // WHEN + IPAddress mask0 = IPAddressExtensions.GetSubnetMask(0); + IPAddress mask24 = IPAddressExtensions.GetSubnetMask(24); + IPAddress mask32 = IPAddressExtensions.GetSubnetMask(32); + + // THEN + Assert.AreEqual("0.0.0.0", mask0.ToString(), + "Prefix length 0 must map to all-zero IPv4 mask."); + Assert.AreEqual("255.255.255.0", mask24.ToString(), + "Prefix length 24 must map to 255.255.255.0."); + Assert.AreEqual("255.255.255.255", mask32.ToString(), + "Prefix length 32 must map to 255.255.255.255."); + } + + [TestMethod] + public void GetSubnetMask_ShouldThrow_WhenPrefixExceedsIPv4Width() + { + Assert.ThrowsExactly( + () => IPAddressExtensions.GetSubnetMask(33), + "GetSubnetMask must reject prefix lengths greater than 32."); + } + + [TestMethod] + public void GetSubnetMaskWidth_ShouldReturnCorrectWidth_ForValidMasks() + { + // GIVEN + IPAddress mask0 = IPAddress.Parse("0.0.0.0"); + IPAddress mask8 = IPAddress.Parse("255.0.0.0"); + IPAddress mask24 = IPAddress.Parse("255.255.255.0"); + + // WHEN + int width0 = mask0.GetSubnetMaskWidth(); + int width8 = mask8.GetSubnetMaskWidth(); + int width24 = mask24.GetSubnetMaskWidth(); + + // THEN + Assert.AreEqual(0, width0, "Mask 0.0.0.0 must have width 0."); + Assert.AreEqual(8, width8, "Mask 255.0.0.0 must have width 8."); + Assert.AreEqual(24, width24, "Mask 255.255.255.0 must have width 24."); + } + + [TestMethod] + public void GetSubnetMaskWidth_ShouldThrow_WhenMaskIsNotIPv4() + { + // GIVEN + IPAddress ipv6Mask = IPAddress.Parse("ffff::"); + + // WHEN - THEN + Assert.ThrowsExactly( + () => ipv6Mask.GetSubnetMaskWidth(), + "GetSubnetMaskWidth must reject non-IPv4 subnet masks."); + } + + // ------------------------------------------------------ + // GET NETWORK ADDRESS + // ------------------------------------------------------ + + [TestMethod] + public void GetNetworkAddress_ShouldZeroOutHostBits_ForIPv4() + { + // GIVEN + IPAddress ip = IPAddress.Parse("192.168.10.123"); + + // WHEN + IPAddress network24 = ip.GetNetworkAddress(24); + IPAddress network16 = ip.GetNetworkAddress(16); + IPAddress network0 = ip.GetNetworkAddress(0); + + // THEN + Assert.AreEqual("192.168.10.0", network24.ToString(), + "Prefix 24 must zero out last octet."); + Assert.AreEqual("192.168.0.0", network16.ToString(), + "Prefix 16 must zero out last two octets."); + Assert.AreEqual("0.0.0.0", network0.ToString(), + "Prefix 0 must zero out all IPv4 bits."); + } + + [TestMethod] + public void GetNetworkAddress_ShouldReturnSameAddress_ForFullPrefixLength() + { + // GIVEN + IPAddress ip4 = IPAddress.Parse("10.0.0.42"); + IPAddress ip6 = IPAddress.Parse("2001:db8::dead:beef"); + + // WHEN + IPAddress net4 = ip4.GetNetworkAddress(32); + IPAddress net6 = ip6.GetNetworkAddress(128); + + // THEN + Assert.AreEqual(ip4, net4, + "IPv4 prefix 32 must leave the address unchanged."); + Assert.AreEqual(ip6, net6, + "IPv6 prefix 128 must leave the address unchanged."); + } + + [TestMethod] + public void GetNetworkAddress_ShouldThrow_WhenPrefixTooLargeForFamily() + { + // GIVEN + IPAddress ip4 = IPAddress.Parse("192.168.1.1"); + IPAddress ip6 = IPAddress.Parse("2001:db8::1"); + + // WHEN - THEN + Assert.ThrowsExactly( + () => ip4.GetNetworkAddress(33), + "IPv4 network prefix > 32 must be rejected."); + Assert.ThrowsExactly( + () => ip6.GetNetworkAddress(129), + "IPv6 network prefix > 128 must be rejected."); + } + + // ------------------------------------------------------ + // REVERSE DOMAIN GENERATION + // ------------------------------------------------------ + + [TestMethod] + public void GetReverseDomain_ShouldReturnCorrectIPv4PtrName() + { + // GIVEN + IPAddress ip = IPAddress.Parse("192.168.10.1"); + + // WHEN + string ptr = ip.GetReverseDomain(); + + // THEN + Assert.AreEqual("1.10.168.192.in-addr.arpa", ptr, + "IPv4 reverse domain must list octets in reverse order followed by in-addr.arpa."); + } + + [TestMethod] + public void GetReverseDomain_ThenParseReverseDomain_ShouldRoundtrip_IPv4() + { + // GIVEN + IPAddress ip = IPAddress.Parse("10.20.30.40"); + + // WHEN + string ptr = ip.GetReverseDomain(); + IPAddress parsed = IPAddressExtensions.ParseReverseDomain(ptr); + + // THEN + Assert.AreEqual(ip, parsed, + "ParseReverseDomain(GetReverseDomain(ip)) must roundtrip IPv4 address exactly."); + } + + [TestMethod] + public void GetReverseDomain_ThenParseReverseDomain_ShouldRoundtrip_IPv6() + { + // GIVEN + IPAddress ip = IPAddress.Parse("2001:db8::8b3b:3eb"); + + // WHEN + string ptr = ip.GetReverseDomain(); + IPAddress parsed = IPAddressExtensions.ParseReverseDomain(ptr); + + // THEN + Assert.AreEqual(ip, parsed, + "ParseReverseDomain(GetReverseDomain(ip)) must roundtrip IPv6 address exactly, including all nibbles."); + } + + // ------------------------------------------------------ + // TRY PARSE REVERSE DOMAIN – FAILURE HYGIENE + // ------------------------------------------------------ + + [TestMethod] + public void TryParseReverseDomain_ShouldReturnFalseAndNull_ForUnknownSuffix() + { + // WHEN + bool ok = IPAddressExtensions.TryParseReverseDomain("example.com", out IPAddress? parsed); + + // THEN + Assert.IsFalse(ok, "TryParseReverseDomain must return false for non-PTR domains."); + Assert.IsNull(parsed, + "On failure, TryParseReverseDomain must set out address to null to avoid stale references."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldReturnFalseAndNull_WhenIPv4LabelsAreNotNumeric() + { + // GIVEN + const string invalidPtr = "x.10.168.192.in-addr.arpa"; + + // WHEN + bool ok = IPAddressExtensions.TryParseReverseDomain(invalidPtr, out IPAddress? parsed); + + // THEN + Assert.IsFalse(ok, "Non-numeric IPv4 labels must cause TryParseReverseDomain to fail cleanly."); + Assert.IsNull(parsed, + "On invalid IPv4 PTR, out address must be null to avoid partial parsing."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldRejectShortIPv4Ptr() + { + const string ptr = "3.2.1.in-addr.arpa"; + + bool ok = IPAddressExtensions.TryParseReverseDomain(ptr, out IPAddress? parsed); + + Assert.IsFalse(ok, "Short IPv4 PTR is not RFC-compliant and must not be accepted."); + Assert.IsNull(parsed, "No mapping exists for truncated PTR names."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldReturnFalseAndNull_WhenIPv6NibbleInvalid() + { + // GIVEN: invalid hex nibble "Z" + const string ptr = "Z.0.0.0.ip6.arpa"; + + // WHEN + bool ok = IPAddressExtensions.TryParseReverseDomain(ptr, out IPAddress? parsed); + + // THEN + Assert.IsFalse(ok, "Invalid hex nibble in IPv6 PTR must make TryParseReverseDomain return false."); + Assert.IsNull(parsed, + "Out address must be null when IPv6 PTR parsing fails."); + } + + [TestMethod] + public void ParseReverseDomain_ShouldThrowNotSupported_WhenTryParseWouldFail() + { + // GIVEN + const string ptr = "not-a-valid.ptr.domain"; + + // WHEN - THEN + Assert.ThrowsExactly( + () => IPAddressExtensions.ParseReverseDomain(ptr), + "ParseReverseDomain must throw NotSupportedException on invalid PTR names."); + } + + [TestMethod] + public void WriteTo_ShouldWriteIPv4Correctly() + { + IPAddress ipv4 = IPAddress.Parse("1.2.3.4"); + using MemoryStream ms = new MemoryStream(); + + ipv4.WriteTo(ms); + + byte[] data = ms.ToArray(); + Assert.AreEqual(1, data[0], "First byte encodes IPv4 family discriminator."); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3, 4 }, data[1..5], "IPv4 bytes must be written exactly."); + } + + [TestMethod] + public void WriteTo_ShouldWriteIPv6Correctly() + { + IPAddress ipv6 = IPAddress.Parse("2001:db8::1"); + using MemoryStream ms = new MemoryStream(); + + ipv6.WriteTo(ms); + + byte[] data = ms.ToArray(); + Assert.AreEqual(2, data[0], "First byte encodes IPv6 family discriminator."); + Assert.AreEqual(16, data.Length - 1, "IPv6 must write exactly 16 bytes."); + } + + [TestMethod] + public void GetSubnetMaskWidth_ShouldNotSilentlyAcceptNonContiguousMasks() + { + IPAddress mask = IPAddress.Parse("255.0.255.0"); + + // current behavior + int width = mask.GetSubnetMaskWidth(); + + Assert.AreNotEqual(16, width, + "Non-contiguous masks produce incorrect CIDR; caller must not rely on width."); + } + + [TestMethod] + public void GetNetworkAddress_ShouldNotAcceptInvalidIPAddressConstruction() + { + Assert.ThrowsExactly(() => _ = new IPAddress(Array.Empty()), + "IPAddress itself must reject invalid byte arrays at construction time."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldRejectTooManyIPv4Labels() + { + bool ok = IPAddressExtensions.TryParseReverseDomain( + "1.2.3.4.5.in-addr.arpa", out IPAddress? ip); + + Assert.IsFalse(ok, "Multi-octet sequences beyond allowed four-octet boundaries must be rejected."); + Assert.IsNull(ip, "Returned value must remain null on malformed reverse domain."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldMapShortNibblesIntoLeadingBytes() + { + bool ok = IPAddressExtensions.TryParseReverseDomain("A.B.C.ip6.arpa", out IPAddress? ip); + + Assert.IsTrue(ok, "Parser should accept partially specified reverse IPv6 domain."); + + Assert.IsNotNull(ip); + Assert.AreEqual(IPAddress.Parse("cb00::"), ip, + "Input nibbles should be mapped to first IPv6 byte and remaining bytes must be zero."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetUtilitiesTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetUtilitiesTests.cs new file mode 100644 index 00000000..028ff91d --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetUtilitiesTests.cs @@ -0,0 +1,222 @@ +/* +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 System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class NetUtilitiesTests + { + [TestMethod] + public void IsPrivateIPv4_ShouldClassify_RFC1918_Correctly() + { + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("10.0.1.2")), + "10.x must be private."); + + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("192.168.1.55")), + "192.168.x must be private."); + + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("172.16.5.8")), + "172.16/12 must be private."); + + Assert.IsFalse(NetUtilities.IsPrivateIPv4(IPAddress.Parse("11.1.1.1")), + "Non-reserved space must not be treated private."); + } + + [TestMethod] + public void IsPrivateIPv4_ShouldRecognize_CarrierGradeNat() + { + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("100.64.10.10")), + "100.64/10 must be private."); + + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("100.127.20.30")), + "Upper CGNAT boundary must remain private."); + + Assert.IsFalse(NetUtilities.IsPrivateIPv4(IPAddress.Parse("100.128.10.10")), + "Outside CGNAT must be classified public."); + } + + [TestMethod] + public void IsPrivateIPv4_ShouldReject_NonIPv4() + { + Assert.ThrowsExactly( + () => NetUtilities.IsPrivateIPv4(IPAddress.IPv6Loopback), + "Method must reject IPv6 input explicitly."); + } + + [TestMethod] + public void IsPrivateIP_ShouldMap_MappedIPv6_ToIPv4() + { + IPAddress mapped = IPAddress.Parse("::ffff:192.168.1.10"); + + Assert.IsTrue(NetUtilities.IsPrivateIP(mapped), + "Mapped IPv6 pointing to private IPv4 must classify private."); + } + + [TestMethod] + public void IsPrivateIP_ShouldTreat_NonGlobalIPv6_AsPrivate() + { + // fd00::/8 → Unique local + IPAddress ula = IPAddress.Parse("fd00::1"); + + Assert.IsTrue(NetUtilities.IsPrivateIP(ula), + "Unique local must be private."); + } + + [TestMethod] + public void IsPrivateIP_ShouldThrow_WhenNullInput() + { + Assert.ThrowsExactly(() => + NetUtilities.IsPrivateIP(null!), + "Null input must be rejected immediately."); + } + + [TestMethod] + public void IsPrivateIP_ShouldNotThrow_ForIPv4() + { + IPAddress ip = IPAddress.Parse("192.168.1.10"); + Assert.IsTrue(NetUtilities.IsPrivateIP(ip)); + } + + [TestMethod] + public void IsPrivateIP_ShouldNotThrow_ForIPv6() + { + IPAddress ip = IPAddress.Parse("2001:db8::1"); + Assert.IsFalse(NetUtilities.IsPrivateIP(ip)); + } + + [TestMethod] + public void IsPublicIPv6_ShouldBeTrue_For2000Prefix() + { + IPAddress ip = IPAddress.Parse("2001:db8::1"); + + Assert.IsTrue(NetUtilities.IsPublicIPv6(ip), + "2000::/3 must be classified public."); + } + + [TestMethod] + public void IsPublicIPv6_ShouldBeFalse_WhenNotUnderGlobalRange() + { + IPAddress ip = IPAddress.Parse("fd00::1"); + + Assert.IsFalse(NetUtilities.IsPublicIPv6(ip), + "fd00:: is ULA and must not be public."); + } + + [TestMethod] + public void IsPublicIPv6_ShouldReject_IPv4() + { + Assert.ThrowsExactly(() => + NetUtilities.IsPublicIPv6(IPAddress.Parse("10.0.0.1")), + "IPv6-only API must reject IPv4 explicitly."); + } + + [TestMethod] + public void NetworkInfoIPv4_ShouldComputeBroadcastCorrectly() + { + System.Net.NetworkInformation.NetworkInterface nic = FakeInterface.GetDummy(); + IPAddress local = IPAddress.Parse("192.168.5.10"); + IPAddress mask = IPAddress.Parse("255.255.255.0"); + + NetworkInfo info = new NetworkInfo(nic, local, mask); + + Assert.AreEqual(IPAddress.Parse("192.168.5.255"), info.BroadcastIP, + "Broadcast must OR mask inverse properly."); + } + + [TestMethod] + public void NetworkInfoIPv6_ShouldRejectIPv4() + { + System.Net.NetworkInformation.NetworkInterface nic = FakeInterface.GetDummy(); + + Assert.ThrowsExactly(() => + new NetworkInfo(nic, IPAddress.Parse("10.0.0.10")), + "Constructor must reject non-IPv6 selectively."); + } + + [TestMethod] + public void NetworkInfoIPv4_ShouldRejectIPv6() + { + System.Net.NetworkInformation.NetworkInterface nic = FakeInterface.GetDummy(); + IPAddress local = IPAddress.Parse("fd00::1"); + IPAddress mask = IPAddress.Parse("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"); + + Assert.ThrowsExactly(() => + new NetworkInfo(nic, local, mask), + "IPv4 constructor must reject IPv6 local address."); + } + + [TestMethod] + public void NetworkInfoEquality_ShouldBeTrue_WhenIPAndInterfaceMatch() + { + System.Net.NetworkInformation.NetworkInterface nic = FakeInterface.GetDummy(); + + NetworkInfo a = new NetworkInfo(nic, IPAddress.IPv6Loopback); + NetworkInfo b = new NetworkInfo(nic, IPAddress.IPv6Loopback); + + Assert.IsTrue(a.Equals(b), + "Equality must hold across semantically identical instances."); + } + + [TestMethod] + public void NetworkInfoEquality_ShouldFail_OnDifferentIPs() + { + System.Net.NetworkInformation.NetworkInterface nic = FakeInterface.GetDummy(); + + NetworkInfo a = new NetworkInfo(nic, IPAddress.IPv6Loopback); + NetworkInfo b = new NetworkInfo(nic, IPAddress.Parse("2001:db8::1")); + + Assert.IsFalse(a.Equals(b), + "Different addresses cannot compare equal."); + } + } + + internal static class FakeInterface + { + public static System.Net.NetworkInformation.NetworkInterface GetDummy() + { + // Fully stubbed mock via nested fake + return new DummyNic(); + } + + private sealed class DummyNic : System.Net.NetworkInformation.NetworkInterface + { + public override string Description => "dummy"; + public override string Id => "dummy"; + public override bool IsReceiveOnly => false; + public override string Name => "dummy0"; + + public override System.Net.NetworkInformation.NetworkInterfaceType NetworkInterfaceType => + System.Net.NetworkInformation.NetworkInterfaceType.Loopback; + + public override System.Net.NetworkInformation.OperationalStatus OperationalStatus => + System.Net.NetworkInformation.OperationalStatus.Up; + + public override long Speed => 1; + + public override System.Net.NetworkInformation.IPInterfaceProperties GetIPProperties() => + throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetworkAccessControlTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetworkAccessControlTests.cs new file mode 100644 index 00000000..70902e32 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetworkAccessControlTests.cs @@ -0,0 +1,184 @@ +/* +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 System.IO; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class NetworkAccessControlTests + { + [TestMethod] + public void Parse_ShouldParseAllowRule() + { + NetworkAccessControl nac = NetworkAccessControl.Parse("192.168.1.0/24"); + + Assert.IsFalse(nac.Deny); + Assert.AreEqual("192.168.1.0/24", nac.ToString()); + } + + [TestMethod] + public void Parse_ShouldParseDenyRule() + { + NetworkAccessControl nac = NetworkAccessControl.Parse("!10.0.0.0/8"); + + Assert.IsTrue(nac.Deny); + Assert.AreEqual("!10.0.0.0/8", nac.ToString()); + } + + [TestMethod] + public void Parse_ShouldThrow_OnInvalidAddress() + { + Assert.ThrowsExactly( + () => NetworkAccessControl.Parse("!!bad"), + "Invalid rules must trigger FormatException."); + } + + [TestMethod] + public void TryParse_ShouldReturnFalse_OnMalformed() + { + bool ok = NetworkAccessControl.TryParse("invalid", out NetworkAccessControl? nac); + + Assert.IsFalse(ok); + Assert.IsNull(nac); + } + + [TestMethod] + public void TryMatch_ShouldReturnTrueOnMatch() + { + NetworkAccessControl nac = new NetworkAccessControl(IPAddress.Parse("192.168.1.0"), 24); + + bool matched = nac.TryMatch(IPAddress.Parse("192.168.1.42"), out bool allowed); + + Assert.IsTrue(matched, "Prefix match expected."); + Assert.IsTrue(allowed, "Positive rule must allow."); + } + + [TestMethod] + public void TryMatch_ShouldReturnFalseWhenNotInNetwork() + { + NetworkAccessControl nac = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8); + + bool matched = nac.TryMatch(IPAddress.Parse("11.0.0.1"), out bool allowed); + + Assert.IsFalse(matched); + Assert.IsFalse(allowed); + } + + [TestMethod] + public void TryMatch_ShouldHonorNegation() + { + NetworkAccessControl nac = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8, deny: true); + + bool matched = nac.TryMatch(IPAddress.Parse("10.0.55.77"), out bool allowed); + + Assert.IsTrue(matched); + Assert.IsFalse(allowed, "Deny rule must return allowed=false."); + } + + [TestMethod] + public void IsAddressAllowed_ShouldReturnFirstMatchingResult() + { + NetworkAccessControl[] acl = new[] + { + new NetworkAccessControl(IPAddress.Parse("10.0.1.0"), 24, deny:true), // deny first + new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8), // allow + }; + + bool allowed = NetworkAccessControl.IsAddressAllowed(IPAddress.Parse("10.0.1.42"), acl); + + Assert.IsFalse(allowed, "First matching entry (deny) must determine result."); + } + + [TestMethod] + public void IsAddressAllowed_ShouldReturnLoopbackWhenNoMatch() + { + bool allowed = NetworkAccessControl.IsAddressAllowed( + IPAddress.Loopback, + acl: null, + allowLoopbackWhenNoMatch: true); + + Assert.IsTrue(allowed); + } + + [TestMethod] + public void IsAddressAllowed_ShouldReturnFalseWithoutMatchAndNoLoopbackMode() + { + bool allowed = NetworkAccessControl.IsAddressAllowed( + IPAddress.Parse("5.5.5.5"), + new NetworkAccessControl[0], + allowLoopbackWhenNoMatch: false); + + Assert.IsFalse(allowed); + } + + [TestMethod] + public void WriteTo_ShouldRoundtrip() + { + NetworkAccessControl original = new NetworkAccessControl(IPAddress.Parse("10.2.3.0"), 24, deny: true); + + using MemoryStream ms = new MemoryStream(); + using BinaryWriter bw = new BinaryWriter(ms); + + original.WriteTo(bw); + bw.Flush(); + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + NetworkAccessControl read = NetworkAccessControl.ReadFrom(br); + + Assert.IsTrue(original.Equals(read), "Binary round trip must preserve rule."); + Assert.AreEqual(original.ToString(), read.ToString()); + } + + [TestMethod] + public void Equals_ShouldReturnTrue_WhenEquivalent() + { + NetworkAccessControl a = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8, deny: true); + NetworkAccessControl b = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8, deny: true); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_ShouldReturnFalse_WhenDifferentAddress() + { + NetworkAccessControl a = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8); + NetworkAccessControl b = new NetworkAccessControl(IPAddress.Parse("10.1.0.0"), 16); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void ToString_ShouldRenderCorrectly() + { + NetworkAccessControl allow = new NetworkAccessControl(IPAddress.Parse("192.168.0.0"), 16); + NetworkAccessControl deny = new NetworkAccessControl(IPAddress.Parse("100.64.0.0"), 10, deny: true); + + Assert.AreEqual("192.168.0.0/16", allow.ToString()); + Assert.AreEqual("!100.64.0.0/10", deny.ToString()); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetworkAddressTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetworkAddressTests.cs new file mode 100644 index 00000000..073a3a37 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetworkAddressTests.cs @@ -0,0 +1,229 @@ +/* +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 System.IO; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net +{ + [TestClass] + public class NetworkAddressTests + { + [TestMethod] + public void Constructor_ShouldNormalizeToNetworkBoundary_IPv4() + { + NetworkAddress addr = new NetworkAddress(IPAddress.Parse("10.1.2.99"), 24); + + Assert.AreEqual("10.1.2.0", addr.Address.ToString(), + "NetworkAddress constructor must mask host bits."); + Assert.AreEqual((byte)24, addr.PrefixLength); + } + + [TestMethod] + public void Constructor_ShouldNormalizeToNetworkBoundary_IPv6() + { + NetworkAddress addr = new NetworkAddress(IPAddress.Parse("2001:db8::1234"), 64); + + Assert.AreEqual("2001:db8::", addr.Address.ToString(), + "NetworkAddress must enforce network mask."); + Assert.AreEqual((byte)64, addr.PrefixLength); + } + + [TestMethod] + public void Constructor_ShouldReject_InvalidPrefix_IPv4() + { + Assert.ThrowsExactly( + () => new NetworkAddress(IPAddress.Parse("1.2.3.4"), 33), + "IPv4 prefix >32 must be rejected."); + } + + [TestMethod] + public void Constructor_ShouldReject_InvalidPrefix_IPv6() + { + Assert.ThrowsExactly( + () => new NetworkAddress(IPAddress.Parse("2001::1"), 129), + "IPv6 prefix >128 must be rejected."); + } + + [TestMethod] + public void Parse_ShouldSupportNoPrefix_IPv4_DefaultsTo32Bits() + { + NetworkAddress n = NetworkAddress.Parse("8.8.8.8"); + + Assert.AreEqual("8.8.8.8", n.Address.ToString()); + Assert.AreEqual((byte)32, n.PrefixLength); + Assert.IsTrue(n.IsHostAddress); + } + + [TestMethod] + public void Parse_ShouldSupportPrefix_IPv4() + { + NetworkAddress n = NetworkAddress.Parse("10.0.0.123/8"); + + Assert.AreEqual("10.0.0.0", n.Address.ToString()); + Assert.AreEqual((byte)8, n.PrefixLength); + } + + [TestMethod] + public void Parse_ShouldFail_IfBaseAddressInvalid() + { + Assert.ThrowsExactly( + () => NetworkAddress.Parse("notAnIP/16"), + "Invalid IP should fail parsing."); + } + + [TestMethod] + public void Parse_ShouldFail_IfPrefixInvalid() + { + Assert.ThrowsExactly( + () => NetworkAddress.Parse("10.0.0.1/notanumber"), + "Prefix must be numeric."); + } + + [TestMethod] + public void TryParse_ShouldReturnFalse_OnMalformedInput() + { + bool ok = NetworkAddress.TryParse("hello", out NetworkAddress? result); + + Assert.IsFalse(ok); + Assert.IsNull(result); + } + + [TestMethod] + public void Contains_ShouldReturnTrue_ForMatchingAddress() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("192.168.10.0"), 24); + + Assert.IsTrue(net.Contains(IPAddress.Parse("192.168.10.55"))); + } + + [TestMethod] + public void Contains_ShouldReturnFalse_ForDifferentNetwork() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("192.168.10.0"), 24); + + Assert.IsFalse(net.Contains(IPAddress.Parse("192.168.11.1"))); + } + + [TestMethod] + public void Contains_ShouldReturnFalse_WhenAddressFamilyDiffers() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + + Assert.IsFalse(net.Contains(IPAddress.IPv6Loopback)); + } + + [TestMethod] + public void GetLastAddress_ShouldReturnBroadcastIPv4() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("192.168.50.0"), 24); + + IPAddress last = net.GetLastAddress(); + + Assert.AreEqual("192.168.50.255", last.ToString()); + } + + [TestMethod] + public void GetLastAddress_ShouldReturnBroadcastIPv6() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("2001:db8::"), 64); + + IPAddress last = net.GetLastAddress(); + + IPAddress expected = IPAddress.Parse("2001:db8:0:0:ffff:ffff:ffff:ffff"); + + Assert.AreEqual(expected, last, + "Last IPv6 address must have all host bits set."); + } + + [TestMethod] + public void ToString_ShouldOmitPrefix_WhenHostAddressIPv4() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("9.9.9.9"), 32); + + Assert.AreEqual("9.9.9.9", net.ToString(), + "Full host prefix must not show /32"); + } + + [TestMethod] + public void ToString_ShouldIncludePrefix_WhenNotHostIPv4() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("9.9.9.0"), 24); + + Assert.AreEqual("9.9.9.0/24", net.ToString()); + } + + [TestMethod] + public void ToString_ShouldOmitPrefix_WhenHostAddressIPv6() + { + NetworkAddress net = new NetworkAddress(IPAddress.Parse("2001::1"), 128); + + Assert.AreEqual("2001::1", net.ToString()); + } + + [TestMethod] + public void Roundtrip_BinarySerialization_Works() + { + NetworkAddress original = new NetworkAddress(IPAddress.Parse("10.20.30.40"), 20); + + using MemoryStream ms = new MemoryStream(); + using (BinaryWriter bw = new BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true)) + original.WriteTo(bw); + + ms.Position = 0; + + using BinaryReader br = new BinaryReader(ms); + NetworkAddress roundtrip = NetworkAddress.ReadFrom(br); + + Assert.AreEqual(original, roundtrip); + } + + [TestMethod] + public void Equals_ShouldReturnTrue_ForSameValue() + { + NetworkAddress a = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + NetworkAddress b = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_ShouldReturnFalse_WhenPrefixDiffers() + { + NetworkAddress a = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + NetworkAddress b = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 16); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_ShouldReturnFalse_WhenAddressDiffers() + { + NetworkAddress a = new NetworkAddress(IPAddress.Parse("192.168.0.0"), 24); + NetworkAddress b = new NetworkAddress(IPAddress.Parse("192.168.1.0"), 24); + + Assert.IsFalse(a.Equals(b)); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetworkMapTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetworkMapTests.cs new file mode 100644 index 00000000..a04dcb4c --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/NetworkMapTests.cs @@ -0,0 +1,224 @@ +/* +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.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class NetworkMapTests + { + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenMapIsEmpty() + { + NetworkMap map = new NetworkMap(); + + bool ok = map.TryGetValue("10.1.2.3", out string? value); + + Assert.IsFalse(ok, "Empty map must not resolve any address."); + Assert.IsNull(value, "Value must be null when lookup fails."); + } + + [TestMethod] + public void TryGetValue_ShouldReturnAssignedValue_ForExactSingleHost() + { + NetworkMap map = new NetworkMap(); + map.Add("192.168.1.10/32", "local"); + + Assert.IsTrue(map.TryGetValue("192.168.1.10", out string? value), + "Exact host entry must be resolved."); + + Assert.AreEqual("local", value, + "Resolved value must match inserted value."); + } + + [TestMethod] + public void TryGetValue_ShouldMatchWithinRange_ForIPv4Subnet() + { + NetworkMap map = new NetworkMap(); + map.Add("10.0.0.0/24", 42); + map.Add("10.0.1.0/24", 43); + + Assert.IsTrue(map.TryGetValue("10.0.0.255", out int v1), + "Boundary address belongs to first range."); + Assert.AreEqual(42, v1); + + Assert.IsTrue(map.TryGetValue("10.0.1.0", out int v2), + "Exact lower bound of second range should match."); + Assert.AreEqual(43, v2); + + Assert.IsTrue(map.TryGetValue("10.0.1.255", out int v3), + "Upper bound of second range should match."); + Assert.AreEqual(43, v3); + + Assert.IsFalse(map.TryGetValue("10.0.1.1", out _), + "Interior values cannot match because floor and ceiling belong to different ranges."); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenAddressOutsideRange() + { + NetworkMap map = new NetworkMap(); + map.Add("10.0.0.0/24", 11); + + bool ok = map.TryGetValue("10.0.1.1", out int value); + + Assert.IsFalse(ok, "Address outside stored range must not match."); + Assert.AreEqual(default, value, "Value must reset on failure."); + } + + [TestMethod] + public void TryGetValue_ShouldPreferNearestMatchingRange_OnSortedInsertionOrder() + { + NetworkMap map = new NetworkMap(); + + // Notice insertion bias: bigger range, then narrower override + map.Add("192.168.0.0/16", "WIDE"); + map.Add("192.168.100.0/24", "TIGHT"); + + Assert.IsTrue(map.TryGetValue("192.168.100.10", out string? value), + "Lookup must still resolve correct nearest boundary."); + + Assert.AreEqual("TIGHT", value, + "More specific entry must apply implicitly via boundary comparison."); + } + + [TestMethod] + public void Remove_ShouldReturnTrue_WhenEntryExists() + { + NetworkMap map = new NetworkMap(); + map.Add("10.10.10.0/24", "x"); + + bool removed = map.Remove("10.10.10.0/24"); + + Assert.IsTrue(removed, "Remove must return true when both start and last entries are removed."); + } + + [TestMethod] + public void Remove_ShouldReturnFalse_WhenEntryDoesNotExist() + { + NetworkMap map = new NetworkMap(); + map.Add("192.168.1.0/24", 1); + + bool removed = map.Remove("192.168.2.0/24"); + + Assert.IsFalse(removed, "Remove must fail if ranges never existed."); + } + + [TestMethod] + public void AfterRemove_ShouldNotResolve() + { + NetworkMap map = new NetworkMap(); + map.Add("10.0.0.0/8", "meta"); + + Assert.IsTrue(map.TryGetValue("10.20.30.40", out _), + "Initial resolution must work."); + + map.Remove("10.0.0.0/8"); + + Assert.IsFalse(map.TryGetValue("10.20.30.40", out string? now), + "After removal no resolution must survive."); + + Assert.IsNull(now, "Value must reset on failure."); + } + + [TestMethod] + public void TryGetValue_ShouldResolveIPv6Range() + { + NetworkMap map = new NetworkMap(); + map.Add("2001:db8::/64", "v6"); + + Assert.IsTrue(map.TryGetValue(IPAddress.Parse("2001:db8::abcd"), out string? value), + "IPv6 inside range must resolve correctly."); + + Assert.AreEqual("v6", value); + } + + // TODO: TryGetValue should use try-catch block + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenIPv4QueryAgainstIPv6Range() + { + NetworkMap map = new NetworkMap(); + map.Add("2001:db8::/64", 99); + + bool ok = map.TryGetValue("10.0.0.1", out int val); + + Assert.IsFalse(ok, "Mismatched families must not resolve."); + Assert.AreEqual(default, val); + } + + [TestMethod] + public void AddingMultipleRanges_ShouldNotRequireManualSorting() + { + NetworkMap map = new NetworkMap(); + + map.Add("10.0.0.0/24", "A"); + map.Add("10.0.1.0/24", "B"); + map.Add("10.0.2.0/24", "C"); + + // The absence of prior TryGetValue calls guarantees lazy sorting is triggered here. + Assert.IsTrue(map.TryGetValue("10.0.2.9", out string? value), + "Lookup must not depend on explicit sorting."); + + Assert.AreEqual("C", value); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenFloorIsNull() + { + NetworkMap map = new NetworkMap(); + + map.Add("100.0.0.0/8", "x"); + + bool ok = map.TryGetValue(IPAddress.Parse("1.1.1.1"), out string? result); + + Assert.IsFalse(ok, "When requested IP precedes first boundary, match must fail."); + Assert.IsNull(result); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenCeilingIsNull() + { + NetworkMap map = new NetworkMap(); + + map.Add("10.0.0.0/8", "x"); + + bool ok = map.TryGetValue(IPAddress.Parse("200.200.200.200"), out string? result); + + Assert.IsFalse(ok, "When requested IP exceeds last boundary, match must fail."); + Assert.IsNull(result); + } + + [TestMethod] + public void ValuesMustBeMatchedByReference_WhenBothBoundsHoldSameInstance() + { + object payload = new object(); + NetworkMap map = new NetworkMap(); + + map.Add("10.20.30.0/24", payload); + + Assert.IsTrue(map.TryGetValue("10.20.30.50", out object? resolved)); + Assert.AreSame(payload, resolved, + "When value instance is identical, resolution must return exact object reference."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/DefaultProxyServerConnectionManagerTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/DefaultProxyServerConnectionManagerTests.cs new file mode 100644 index 00000000..5f40cfe3 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/DefaultProxyServerConnectionManagerTests.cs @@ -0,0 +1,162 @@ +/* +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 System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class DefaultProxyServerConnectionManagerTests + { + public TestContext TestContext { get; set; } + + private static TcpListener StartLoopbackListener(AddressFamily family, out IPEndPoint ep) + { + IPAddress addr = family == AddressFamily.InterNetwork ? + IPAddress.Loopback : + IPAddress.IPv6Loopback; + + TcpListener listener = new TcpListener(addr, 0); + listener.Start(); + ep = (IPEndPoint)listener.LocalEndpoint; + return listener; + } + + [TestMethod] + public async Task ConnectAsync_WithIPEndPoint_ConnectsSuccessfully() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEp); + + DefaultProxyServerConnectionManager manager = new DefaultProxyServerConnectionManager(); + + using Socket client = await manager.ConnectAsync(serverEp, TestContext.CancellationToken); + + Assert.IsTrue(client.Connected, "Socket must connect successfully to loopback listener."); + + using Socket server = await listener.AcceptSocketAsync(TestContext.CancellationToken); + Assert.IsTrue(server.Connected, "Listener must accept connection."); + + Assert.IsTrue(client.NoDelay, "ConnectAsync must set NoDelay=true."); + + client.Dispose(); + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_WithDnsEndPoint_ExplicitIPv4_ResolvesAndConnects() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEp); + + DefaultProxyServerConnectionManager manager = new DefaultProxyServerConnectionManager(); + + // DnsEndPoint → IPv4 resolution is supported when family is explicitly InterNetwork. + DnsEndPoint dns = new DnsEndPoint("localhost", serverEp.Port, AddressFamily.InterNetwork); + + using Socket client = await manager.ConnectAsync(dns, TestContext.CancellationToken); + + Assert.IsTrue(client.Connected); + + using Socket server = await listener.AcceptSocketAsync(TestContext.CancellationToken); + Assert.IsTrue(server.Connected); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_WithDnsEndPoint_ExplicitIPv6_ResolvesAndConnects_IfIPv6Available() + { + // Skip test on machines without IPv6 enabled. + if (!Socket.OSSupportsIPv6) + { + Assert.Inconclusive("IPv6 not supported on this system."); + return; + } + + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetworkV6, out IPEndPoint serverEp); + + DefaultProxyServerConnectionManager manager = new DefaultProxyServerConnectionManager(); + + DnsEndPoint dns = new DnsEndPoint("localhost", serverEp.Port, AddressFamily.InterNetworkV6); + + using Socket client = await manager.ConnectAsync(dns, TestContext.CancellationToken); + + Assert.IsTrue(client.Connected); + + using Socket server = await listener.AcceptSocketAsync(TestContext.CancellationToken); + Assert.IsTrue(server.Connected); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_WithDnsEndPoint_AddressFamilyMismatch_ThrowsSocketException() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEp); + + DefaultProxyServerConnectionManager manager = new DefaultProxyServerConnectionManager(); + + // Force IPv6 resolution against an IPv4 listener → mismatch. + DnsEndPoint dns = new DnsEndPoint("localhost", serverEp.Port, AddressFamily.InterNetworkV6); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(dns, TestContext.CancellationToken)); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_UnspecifiedAddressFamilyDns_ThrowsNotSupportedException() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEp); + + DefaultProxyServerConnectionManager manager = new DefaultProxyServerConnectionManager(); + + DnsEndPoint dns = new DnsEndPoint("localhost", serverEp.Port, AddressFamily.Unspecified); + + // Implementation explicitly throws NotSupportedException through GetIPEndPointAsync + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(dns, TestContext.CancellationToken)); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_AddressFamilyMismatchWithIPEndPoint_ThrowsSocketException() + { + // Listener is IPv4 + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEp); + + DefaultProxyServerConnectionManager manager = new DefaultProxyServerConnectionManager(); + + // Try to connect using IPv6 to IPv4 listener + IPEndPoint ipv6Target = new IPEndPoint(IPAddress.IPv6Loopback, serverEp.Port); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(ipv6Target, TestContext.CancellationToken)); + + listener.Stop(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyAuthenticationFailedExceptionTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyAuthenticationFailedExceptionTests.cs new file mode 100644 index 00000000..a5270dcd --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyAuthenticationFailedExceptionTests.cs @@ -0,0 +1,89 @@ +/* +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.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class HttpProxyAuthenticationFailedExceptionTests + { + [TestMethod] + public void DefaultConstructor_SetsDefaultMessage() + { + HttpProxyAuthenticationFailedException ex = new HttpProxyAuthenticationFailedException(); + + Assert.AreEqual( + expected: new HttpProxyAuthenticationFailedException().Message, + actual: ex.Message, + message: "Default constructor must provide the base exception message." + ); + + Assert.IsNull(ex.InnerException, "Default constructor must not assign an inner exception."); + } + + [TestMethod] + public void MessageConstructor_PreservesMessage() + { + string msg = "Proxy auth failed."; + HttpProxyAuthenticationFailedException ex = new HttpProxyAuthenticationFailedException(msg); + + Assert.AreEqual( + expected: msg, + actual: ex.Message, + message: "Message constructor must preserve the supplied message verbatim." + ); + } + + [TestMethod] + public void MessageAndInnerConstructor_PreservesBoth() + { + string msg = "Proxy authentication failed."; + InvalidOperationException inner = new InvalidOperationException("inner"); + HttpProxyAuthenticationFailedException ex = new HttpProxyAuthenticationFailedException(msg, inner); + + Assert.AreEqual( + expected: msg, + actual: ex.Message, + message: "Constructor must store the message." + ); + + Assert.AreSame( + expected: inner, + actual: ex.InnerException, + message: "Constructor must attach the inner exception." + ); + } + + [TestMethod] + public void ExceptionType_IsCorrect() + { + HttpProxyAuthenticationFailedException ex = new HttpProxyAuthenticationFailedException(); + + Assert.AreEqual( + expected: typeof(HttpProxyAuthenticationFailedException), + actual: ex.GetType(), + message: "Exception type must remain stable for consumer type checks." + ); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyExceptionTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyExceptionTests.cs new file mode 100644 index 00000000..4296e818 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyExceptionTests.cs @@ -0,0 +1,98 @@ +/* +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.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class HttpProxyExceptionTests + { + [TestMethod] + public void DefaultConstructor_ProvidesNonNullMessage() + { + HttpProxyException ex = new HttpProxyException(); + + Assert.IsFalse( + string.IsNullOrWhiteSpace(ex.Message), + "Default constructor must provide a non-empty diagnostic message." + ); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not assign an inner exception." + ); + + Assert.AreEqual( + expected: typeof(HttpProxyException), + actual: ex.GetType(), + message: "Exception type must remain stable for typed exception handling." + ); + } + + [TestMethod] + public void MessageConstructor_PreservesMessage() + { + string msg = "HTTP proxy operation failed."; + HttpProxyException ex = new HttpProxyException(msg); + + Assert.AreEqual( + expected: msg, + actual: ex.Message, + message: "Message constructor must preserve supplied message verbatim." + ); + } + + [TestMethod] + public void MessageAndInnerExceptionConstructor_PreservesBoth() + { + string msg = "Proxy protocol error."; + InvalidOperationException inner = new InvalidOperationException("inner"); + + HttpProxyException ex = new HttpProxyException(msg, inner); + + Assert.AreEqual( + expected: msg, + actual: ex.Message, + message: "Exception must preserve its message." + ); + + Assert.AreSame( + expected: inner, + actual: ex.InnerException, + message: "Exception must preserve the supplied inner exception." + ); + } + + [TestMethod] + public void TypeIdentity_RemainsStable() + { + HttpProxyException ex = new HttpProxyException(); + + Assert.AreEqual( + expected: typeof(HttpProxyException), + actual: ex.GetType(), + message: "Typed exceptions must preserve exact runtime type identity." + ); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyServerExceptionTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyServerExceptionTests.cs new file mode 100644 index 00000000..530b4dac --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyServerExceptionTests.cs @@ -0,0 +1,91 @@ +/* +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.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class HttpProxyServerExceptionTests + { + [TestMethod] + public void DefaultConstructor_SetsDefaultMessage_AndNullInnerException() + { + HttpProxyServerException ex = new HttpProxyServerException(); + + // .NET default message for exceptions with no message explicitly passed + // always includes the fully-qualified type name. + string expectedTypeName = typeof(HttpProxyServerException).FullName!; + + Assert.Contains( + expectedTypeName, + ex.Message, + "Default constructor must include the exception type name." + ); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not provide an inner exception." + ); + } + + [TestMethod] + public void MessageConstructor_SetsMessage_AndNullInnerException() + { + const string msg = "Server failure"; + + HttpProxyServerException ex = new HttpProxyServerException(msg); + + Assert.AreEqual( + msg, + ex.Message, + "Message constructor must store the provided message." + ); + + Assert.IsNull( + ex.InnerException, + "Message constructor must not set an inner exception." + ); + } + + [TestMethod] + public void MessageAndInnerConstructor_SetsMessage_AndInnerException() + { + const string msg = "Server failure"; + InvalidOperationException inner = new InvalidOperationException("inner"); + + HttpProxyServerException ex = new HttpProxyServerException(msg, inner); + + Assert.AreEqual( + msg, + ex.Message, + "Message+Inner constructor must store the provided message." + ); + + Assert.AreSame( + inner, + ex.InnerException, + "The provided inner exception must be preserved." + ); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyServerTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyServerTests.cs new file mode 100644 index 00000000..cfdde371 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyServerTests.cs @@ -0,0 +1,484 @@ +/* +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 System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class HttpProxyServerTests + { + public TestContext TestContext { get; set; } + + #region helpers + + /// + /// Connects a TcpClient to the proxy server's listening endpoint. + /// + private async Task ConnectClientAsync(HttpProxyServer server) + { + TcpClient client = new TcpClient(); + IPEndPoint ep = server.LocalEndPoint; + + Assert.IsNotNull(ep, "LocalEndPoint must be initialized before accepting connections."); + + await client.ConnectAsync( + ep.Address.ToString(), + ep.Port, + TestContext.CancellationToken); + + return client; + } + + /// + /// Reads a single response frame from the server into a string. + /// Used for small HTTP status responses. + /// + private static async Task ReadResponseAsync(NetworkStream stream, CancellationToken cancellationToken) + { + byte[] buffer = new byte[4096]; + int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + + return Encoding.ASCII.GetString(buffer, 0, bytesRead); + } + + /// + /// Reads everything the server has written to the given socket until it closes + /// or no more data arrives. Intended for capturing forwarded HTTP requests. + /// + private static async Task ReadFromSocketAsync(Socket socket, CancellationToken cancellationToken) + { + await using MemoryStream ms = new MemoryStream(); + using NetworkStream networkStream = new NetworkStream(socket, ownsSocket: false); + + byte[] buffer = new byte[4096]; + + while (!cancellationToken.IsCancellationRequested) + { + if (!networkStream.CanRead) + break; + + if (!socket.Connected) + break; + + int read; + try + { + read = await networkStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + } + catch (IOException) + { + break; + } + + if (read <= 0) + break; + + await ms.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + + // For our tests, a single HTTP request is enough; break if end-of-headers reached. + if (ms.Length > 4) + { + byte[] data = ms.ToArray(); + string text = Encoding.ASCII.GetString(data); + if (text.Contains("\r\n\r\n", StringComparison.Ordinal)) + break; + } + } + + return Encoding.ASCII.GetString(ms.ToArray()); + } + + #endregion helpers + + #region tests + + [TestMethod] + public void Constructor_UsesLoopbackAndEphemeralPort() + { + using HttpProxyServer server = new HttpProxyServer(); + + IPEndPoint ep = server.LocalEndPoint; + + Assert.IsNotNull(ep, "LocalEndPoint must be non-null after construction."); + Assert.IsTrue(IPAddress.IsLoopback(ep.Address), "HttpProxyServer must bind only to loopback by default to avoid exposing an open proxy."); + Assert.IsGreaterThan(0, ep.Port, "HttpProxyServer must bind to an ephemeral port when 0 is specified."); + } + + [TestMethod] + public async Task ConnectMethod_ValidConnectRequest_RespondsWith200AndUsesConnectionManager() + { + using RecordingConnectionManager connectionManager = new RecordingConnectionManager(); + using HttpProxyServer server = new HttpProxyServer(connectionManager); + + using TcpClient client = await ConnectClientAsync(server); + using NetworkStream clientStream = client.GetStream(); + + const string host = "198.51.100.10"; + const int port = 443; + + string request = + $"CONNECT {host}:{port} HTTP/1.1\r\n" + + $"Host: {host}:{port}\r\n" + + "\r\n"; + + byte[] requestBytes = Encoding.ASCII.GetBytes(request); + await clientStream.WriteAsync(requestBytes.AsMemory(0, requestBytes.Length), TestContext.CancellationToken); + await clientStream.FlushAsync(TestContext.CancellationToken); + + string response = await ReadResponseAsync(clientStream, TestContext.CancellationToken); + + Assert.StartsWith( + "HTTP/1.1 200 OK", + response, + "CONNECT must be acknowledged with 200 OK when the connection manager succeeds."); + + Assert.HasCount( + 1, + connectionManager.ConnectedEndpoints, + "Proxy server must delegate exactly one CONNECT to the connection manager."); + + Assert.IsInstanceOfType(connectionManager.ConnectedEndpoints[0], "CONNECT target must be resolved to an IPEndPoint."); + + IPEndPoint ep = (IPEndPoint)connectionManager.ConnectedEndpoints[0]; + + Assert.AreEqual( + IPAddress.Parse(host), + ep.Address, + "CONNECT must target the exact IP address parsed from the request path."); + + Assert.AreEqual( + port, + ep.Port, + "CONNECT must target the exact TCP port parsed from the request path."); + } + + [TestMethod] + public async Task ConnectMethod_ConnectWithoutPort_Returns500InternalServerError() + { + using RecordingConnectionManager connectionManager = new RecordingConnectionManager(); + using HttpProxyServer server = new HttpProxyServer(connectionManager); + + using TcpClient client = await ConnectClientAsync(server); + using NetworkStream clientStream = client.GetStream(); + + const string host = "example.com"; + + string request = + $"CONNECT {host} HTTP/1.1\r\n" + + $"Host: {host}\r\n" + + "\r\n"; + + byte[] requestBytes = Encoding.ASCII.GetBytes(request); + await clientStream.WriteAsync(requestBytes.AsMemory(0, requestBytes.Length), TestContext.CancellationToken); + await clientStream.FlushAsync(TestContext.CancellationToken); + + string response = await ReadResponseAsync(clientStream, TestContext.CancellationToken); + + Assert.StartsWith( + "HTTP/1.1 500", + response, + "CONNECT without port is invalid per server contract and must return 500."); + + Assert.IsEmpty( + connectionManager.ConnectedEndpoints, + "Invalid CONNECT target must not trigger downstream connection attempts."); + } + + [TestMethod] + public async Task ConnectMethod_InvalidTarget_Returns500InternalServerError() + { + using RecordingConnectionManager connectionManager = new RecordingConnectionManager(); + using HttpProxyServer server = new HttpProxyServer(connectionManager); + + using TcpClient client = await ConnectClientAsync(server); + using NetworkStream clientStream = client.GetStream(); + + // Request path that EndPointExtensions.TryParse cannot interpret as an endpoint. + string request = + "CONNECT /not-an-endpoint HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n"; + + byte[] requestBytes = Encoding.ASCII.GetBytes(request); + await clientStream.WriteAsync(requestBytes.AsMemory(0, requestBytes.Length), TestContext.CancellationToken); + await clientStream.FlushAsync(TestContext.CancellationToken); + + string response = await ReadResponseAsync(clientStream, TestContext.CancellationToken); + + Assert.StartsWith( + "HTTP/1.1 500 500 Internal Server Error", + response, + "Invalid CONNECT request must be surfaced as 500 Internal Server Error according to server behavior."); + + Assert.IsEmpty( + connectionManager.ConnectedEndpoints, + "Invalid CONNECT target must not trigger any downstream connection attempts."); + } + + [TestMethod] + public async Task Forwarding_NonConnectAbsoluteUri_RewritesPathAndStripsProxyHeaders() + { + using CapturingConnectionManager connectionManager = new CapturingConnectionManager(); + using HttpProxyServer server = new HttpProxyServer(connectionManager); + + using TcpClient client = await ConnectClientAsync(server); + using NetworkStream clientStream = client.GetStream(); + + const string targetUri = "http://example.com/resource/path?q=1"; + + string request = + $"GET {targetUri} HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Proxy-Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==\r\n" + + "Proxy-Connection: keep-alive\r\n" + + "\r\n"; + + byte[] requestBytes = Encoding.ASCII.GetBytes(request); + await clientStream.WriteAsync(requestBytes.AsMemory(0, requestBytes.Length), TestContext.CancellationToken); + await clientStream.FlushAsync(TestContext.CancellationToken); + + // Wait until the proxy has established a remote connection. + Assert.IsTrue( + connectionManager.WaitForAcceptedSocket(TimeSpan.FromSeconds(5)), + "Proxy server must establish a remote connection for non-CONNECT requests with absolute URI."); + + Socket remoteSocket = connectionManager.AcceptedSockets[0]; + + string forwardedRequest = await ReadFromSocketAsync(remoteSocket, TestContext.CancellationToken); + + Assert.StartsWith( + "GET /resource/path?q=1 HTTP/1.1", + forwardedRequest, + "Proxy must rewrite the request line to use the origin-form path and query, not the absolute URI."); + + Assert.IsFalse( + forwardedRequest.Contains("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase), + "Proxy-Authorization header must be stripped before forwarding to the origin server to prevent credential leakage."); + + Assert.IsFalse( + forwardedRequest.Contains("Proxy-Connection:", StringComparison.OrdinalIgnoreCase), + "Proxy-specific connection headers must be stripped before forwarding to the origin server."); + } + + [TestMethod] + public void Dispose_MultipleCalls_AreIdempotentAndCloseListener() + { + HttpProxyServer server = new HttpProxyServer(); + + IPEndPoint ep = server.LocalEndPoint; + + Assert.IsNotNull(ep, "LocalEndPoint must be available before disposal."); + + // First dispose should close underlying listener and all sessions. + server.Dispose(); + + // Second dispose must be a no-op (no ObjectDisposedException, no side-effects). + server.Dispose(); + } + + #endregion tests + + #region fakes + + /// + /// Minimal connection manager that records the endpoints it is asked to connect to, + /// and returns a connected loopback socket for each request. + /// Suitable for CONNECT tests that only care about the handshake and not data relay. + /// + private sealed class RecordingConnectionManager : IProxyServerConnectionManager, IDisposable + { + private readonly List _allocatedSockets = new(); + + public IList ConnectedEndpoints { get; } = new List(); + + public async Task ConnectAsync(EndPoint remoteEP, CancellationToken cancellationToken = default) + { + // Record the requested endpoint without performing any external network calls. + ConnectedEndpoints.Add(remoteEP); + + // Create a loopback-connected socket pair so that the proxy has + // a valid, connected socket to work with. + TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + EndPoint? epObj = listener.Server.LocalEndPoint; + Assert.IsNotNull(epObj, "Listener.LocalEndPoint must not be null after Start()."); + IPEndPoint listenerEp = (IPEndPoint)epObj; + + Socket clientSocket = new Socket(listenerEp.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + + await clientSocket.ConnectAsync(listenerEp, cancellationToken); + Socket serverSocket = await listener.AcceptSocketAsync(cancellationToken); + + listener.Stop(); + listener.Dispose(); + + // We only return the client side to the proxy; the server side is discarded. + serverSocket.Dispose(); + + clientSocket.NoDelay = true; + _allocatedSockets.Add(clientSocket); + + return clientSocket; + } + + public Task GetBindHandlerAsync(AddressFamily family) + { + throw new NotSupportedException("Bind is not required for HttpProxyServer unit tests."); + } + + public Task GetUdpAssociateHandlerAsync(EndPoint localEP) + { + throw new NotSupportedException("UDP associate is not required for HttpProxyServer unit tests."); + } + + public void Dispose() + { + foreach (Socket s in _allocatedSockets) + { + try + { + s.Dispose(); + } + catch + { + // Ignore cleanup errors in test fake. + } + } + + _allocatedSockets.Clear(); + } + } + + /// + /// Connection manager that exposes the server-side sockets so tests can + /// inspect the HTTP request bytes forwarded by the proxy. + /// + private sealed class CapturingConnectionManager : IProxyServerConnectionManager, IDisposable + { + private readonly List _listeners = new(); + private readonly List _clientSockets = new(); + + private readonly List _acceptedSockets = new(); + private readonly List _endpoints = new(); + + private readonly AutoResetEvent _hasAccepted = new(false); + + public IList AcceptedSockets => _acceptedSockets; + public IList ConnectedEndpoints => _endpoints; + + public async Task ConnectAsync(EndPoint remoteEP, CancellationToken cancellationToken = default) + { + _endpoints.Add(remoteEP); + + TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + _listeners.Add(listener); + + EndPoint? epObj = listener.Server.LocalEndPoint; + Assert.IsNotNull(epObj, "Listener.LocalEndPoint must not be null after Start()."); + IPEndPoint listenerEp = (IPEndPoint)epObj; + + Socket clientSocket = new Socket(listenerEp.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + _clientSockets.Add(clientSocket); + + await clientSocket.ConnectAsync(listenerEp, cancellationToken); + Socket serverSocket = await listener.AcceptSocketAsync(cancellationToken); + + _acceptedSockets.Add(serverSocket); + _hasAccepted.Set(); + + clientSocket.NoDelay = true; + + return clientSocket; + } + + public Task GetBindHandlerAsync(AddressFamily family) + { + throw new NotSupportedException("Bind is not required for HttpProxyServer forwarding tests."); + } + + public Task GetUdpAssociateHandlerAsync(EndPoint localEP) + { + throw new NotSupportedException("UDP associate is not required for HttpProxyServer forwarding tests."); + } + + public bool WaitForAcceptedSocket(TimeSpan timeout) + { + return _hasAccepted.WaitOne(timeout); + } + + public void Dispose() + { + foreach (Socket s in _clientSockets) + { + try + { + s.Dispose(); + } + catch + { + // Ignore cleanup errors in test fake. + } + } + + foreach (Socket s in _acceptedSockets) + { + try + { + s.Dispose(); + } + catch + { + // Ignore cleanup errors in test fake. + } + } + + foreach (TcpListener l in _listeners) + { + try + { + l.Stop(); + } + catch + { + // Ignore cleanup errors in test fake. + } + } + + _clientSockets.Clear(); + _acceptedSockets.Clear(); + _listeners.Clear(); + } + } + + #endregion fakes + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyTests.cs new file mode 100644 index 00000000..42fbc46c --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/HttpProxyTests.cs @@ -0,0 +1,269 @@ +/* +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 System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class HttpProxyTests + { + public TestContext TestContext { get; set; } + + private static Task<(TcpListener listener, int port)> StartListenerAsync() + { + TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + return Task.FromResult((listener, port)); + } + + /// + /// Reads a complete HTTP request from the given socket until the end-of-headers + /// marker ("\r\n\r\n") is observed or the socket closes. This is robust against + /// TCP fragmentation of the CONNECT and Proxy-Authorization lines. + /// + private static async Task ReadHttpRequestAsync(Socket socket, CancellationToken cancellationToken) + { + byte[] buffer = new byte[2048]; + StringBuilder builder = new StringBuilder(); + + while (!cancellationToken.IsCancellationRequested) + { + int read = await socket.ReceiveAsync(buffer.AsMemory(0, buffer.Length), SocketFlags.None, cancellationToken); + if (read <= 0) + break; + + builder.Append(Encoding.ASCII.GetString(buffer, 0, read)); + + if (builder.ToString().Contains("\r\n\r\n", StringComparison.Ordinal)) + break; + } + + return builder.ToString(); + } + + private static Task RespondAsync(Socket socket, string httpResponse, CancellationToken cancellationToken) + { + byte[] bytes = Encoding.ASCII.GetBytes(httpResponse); + return socket.SendAsync(bytes.AsMemory(0, bytes.Length), SocketFlags.None, cancellationToken).AsTask(); + } + + // ------------------------------------------------------------ + // 200 OK + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_When200_ReturnsConnectedSocket() + { + (TcpListener listener, int port) = await StartListenerAsync(); + + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port)); + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 5555); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + string request = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + Console.WriteLine("REQUEST RAW:"); + Console.WriteLine(request); + + Assert.IsTrue( + request.StartsWith("CONNECT ", StringComparison.Ordinal), + "Proxy must send a CONNECT request line to the upstream proxy." + ); + + Assert.Contains( + value: request, + substring: destination.ToString(), + message: "CONNECT request must contain 'host:port'." + ); + + await RespondAsync(serverSide, "HTTP/1.0 200 Connection Established\r\n\r\n", TestContext.CancellationToken); + + Socket result = await connectTask; + Assert.IsNotNull(result, "ConnectAsync must return a non-null Socket when the proxy responds 200."); + Assert.IsTrue(result.Connected, "Socket must be connected after a 200 OK response from the HTTP proxy."); + + result.Dispose(); + listener.Stop(); + listener.Dispose(); + } + + // ------------------------------------------------------------ + // 407 Authentication Required + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_When407_ThrowsAuthenticationFailed() + { + (TcpListener listener, int port) = await StartListenerAsync(); + NetworkCredential creds = new NetworkCredential("alice", "secret"); + + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port), creds); + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 8080); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + + string request = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + string expectedAuth = Convert.ToBase64String( + Encoding.ASCII.GetBytes("alice:secret") + ); + + Assert.Contains( + value: request, + substring: expectedAuth, + message: "CONNECT request must include Proxy-Authorization header with Base64 credentials." + ); + + await RespondAsync(serverSide, "HTTP/1.0 407 Proxy Authentication Required\r\n\r\n", TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(() => connectTask); + + listener.Stop(); + listener.Dispose(); + } + + // ------------------------------------------------------------ + // 500 Internal Server Error + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_When500_ThrowsHttpProxyException() + { + (TcpListener listener, int port) = await StartListenerAsync(); + + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port)); + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 9090); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + + string request = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + Assert.IsTrue( + request.StartsWith("CONNECT ", StringComparison.Ordinal), + "Proxy must issue a CONNECT before receiving a 500 response." + ); + + await RespondAsync(serverSide, "HTTP/1.0 500 Internal Server Error\r\n\r\n", TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(() => connectTask); + + listener.Stop(); + listener.Dispose(); + } + + // ------------------------------------------------------------ + // Malformed response + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_WhenMalformedResponse_ThrowsHttpProxyException() + { + (TcpListener listener, int port) = await StartListenerAsync(); + + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port)); + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 8081); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + _ = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + await RespondAsync(serverSide, "NOTVALID\r\n\r\n", TestContext.CancellationToken); + + await Assert.ThrowsExactlyAsync(() => connectTask); + + listener.Stop(); + listener.Dispose(); + } + + // ------------------------------------------------------------ + // Zero-byte receive + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_WhenZeroByteResponse_ThrowsHttpProxyException() + { + (TcpListener listener, int port) = await StartListenerAsync(); + + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port)); + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 6060); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + _ = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + serverSide.Shutdown(SocketShutdown.Both); + serverSide.Close(); + + await Assert.ThrowsExactlyAsync(() => connectTask); + + listener.Stop(); + listener.Dispose(); + } + + // ------------------------------------------------------------ + // Basic auth header correctness + // ------------------------------------------------------------ + [TestMethod] + public async Task ConnectAsync_IncludesBasicAuthHeader_WhenCredentialsProvided() + { + (TcpListener listener, int port) = await StartListenerAsync(); + + NetworkCredential creds = new NetworkCredential("userX", "pa$$word"); + HttpProxy proxy = new HttpProxy(new IPEndPoint(IPAddress.Loopback, port), creds); + + // Use a non-bypassed address + IPEndPoint destination = new IPEndPoint(IPAddress.Parse("192.0.2.1"), 7007); + + Task connectTask = proxy.ConnectAsync(destination, TestContext.CancellationToken); + + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + string request = await ReadHttpRequestAsync(serverSide, TestContext.CancellationToken); + + string expected = Convert.ToBase64String(Encoding.ASCII.GetBytes("userX:pa$$word")); + + Assert.Contains( + value: request, + substring: expected, + message: "CONNECT request must include Proxy-Authorization header with Base64 credentials." + ); + + await RespondAsync(serverSide, "HTTP/1.0 200 OK\r\n\r\n", TestContext.CancellationToken); + + Socket finalSocket = await connectTask; + Assert.IsTrue(finalSocket.Connected, "Socket must remain connected after a successful authenticated CONNECT."); + + finalSocket.Dispose(); + listener.Stop(); + listener.Dispose(); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/IProxyServerAuthenticationManagerTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/IProxyServerAuthenticationManagerTests.cs new file mode 100644 index 00000000..dee1266f --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/IProxyServerAuthenticationManagerTests.cs @@ -0,0 +1,100 @@ +/* +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 TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class IProxyServerAuthenticationManagerTests + { + [TestMethod] + public void Authenticate_ReturnsTrue_AllowsAccess() + { + FakeAuthManager auth = new FakeAuthManager(result: true); + + bool ok = auth.Authenticate("alice", "secret"); + + Assert.IsTrue(ok, "Authentication manager should return true when credentials are accepted."); + Assert.AreEqual("alice", auth.LastUser); + Assert.AreEqual("secret", auth.LastPass); + } + + [TestMethod] + public void Authenticate_ReturnsFalse_DeniesAccess() + { + FakeAuthManager auth = new FakeAuthManager(result: false); + + bool ok = auth.Authenticate("bob", "wrong"); + + Assert.IsFalse(ok, "Authentication manager should return false when credentials are rejected."); + Assert.AreEqual("bob", auth.LastUser); + Assert.AreEqual("wrong", auth.LastPass); + } + + [TestMethod] + public void Authenticate_HandlesNulls() + { + FakeAuthManager auth = new FakeAuthManager(result: false); + +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + bool ok = auth.Authenticate(null, null); +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + + Assert.IsFalse(ok, "Null credentials must be treated as failed authentication."); + Assert.IsNull(auth.LastUser); + Assert.IsNull(auth.LastPass); + } + + [TestMethod] + public void Authenticate_CalledExactlyOncePerInvocation() + { + FakeAuthManager auth = new FakeAuthManager(result: true); + + _ = auth.Authenticate("u", "p"); + _ = auth.Authenticate("u", "p"); + + Assert.AreEqual(2, auth.Calls, "Authenticate method must be invoked exactly once per request."); + } + + private sealed class FakeAuthManager : IProxyServerAuthenticationManager + { + private readonly bool _result; + + public int Calls { get; private set; } + public string? LastUser { get; private set; } + public string? LastPass { get; private set; } + + public FakeAuthManager(bool result) + { + _result = result; + } + + public bool Authenticate(string username, string password) + { + Calls++; + LastUser = username; + LastPass = password; + return _result; + } + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/IProxyServerConnectionManagerTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/IProxyServerConnectionManagerTests.cs new file mode 100644 index 00000000..462af8af --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/IProxyServerConnectionManagerTests.cs @@ -0,0 +1,129 @@ +/* +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 System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class IProxyServerConnectionManagerTests + { + [TestMethod] + public async Task ConnectAsync_MustHonorCancellation() + { + using CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + + IProxyServerConnectionManager manager = new ContractTestConnectionManager(); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, 1), + cts.Token), + "ConnectAsync must honor pre-cancelled tokens deterministically."); + } + + [TestMethod] + public async Task ConnectAsync_MustNotLeakSocket_OnCancellation() + { + using CancellationTokenSource cts = + new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + IProxyServerConnectionManager manager = new ContractTestConnectionManager(); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync( + new IPEndPoint(IPAddress.Parse("192.0.2.1"), 65000), + cts.Token), + "ConnectAsync must release resources cleanly when cancelled during connection attempt."); + } + + [TestMethod] + public async Task ConnectAsync_MustRejectUnsupportedEndpointTypes() + { + IProxyServerConnectionManager manager = new ContractTestConnectionManager(); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync( + new DnsEndPoint("example.com", 80), + CancellationToken.None), + "Unsupported EndPoint types must be rejected deterministically."); + } + + [TestMethod] + public async Task ConnectAsync_MustReturnConnectedSocket_OnSuccess() + { + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + IPEndPoint target = (IPEndPoint)listener.LocalEndpoint; + + IProxyServerConnectionManager manager = new ContractTestConnectionManager(); + + using Socket client = await manager.ConnectAsync(target, TestContext.CancellationToken); + + Assert.IsTrue(client.Connected, + "ConnectAsync must return a socket that is already connected."); + + using Socket server = await listener.AcceptSocketAsync(TestContext.CancellationToken); + Assert.IsTrue(server.Connected, + "Returned socket must result in an observable server-side connection."); + } + + private sealed class ContractTestConnectionManager : IProxyServerConnectionManager + { + public async Task ConnectAsync(EndPoint remoteEP, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (remoteEP is not IPEndPoint ip) + throw new NotSupportedException("Only IPEndPoint supported by contract test."); + + Socket socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + + try + { + await socket.ConnectAsync(ip, cancellationToken); + socket.NoDelay = true; + return socket; + } + catch + { + socket.Dispose(); + throw; + } + } + + public Task GetBindHandlerAsync(AddressFamily family) + => throw new NotSupportedException(); + + public Task GetUdpAssociateHandlerAsync(EndPoint localEP) + => throw new NotSupportedException(); + } + + public TestContext TestContext { get; set; } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/InterfaceBoundProxyServerConnectionManagerTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/InterfaceBoundProxyServerConnectionManagerTests.cs new file mode 100644 index 00000000..76736d81 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/InterfaceBoundProxyServerConnectionManagerTests.cs @@ -0,0 +1,193 @@ +/* +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 System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class InterfaceBoundProxyServerConnectionManagerTests + { + public TestContext TestContext { get; set; } + + private static TcpListener StartLoopbackListener(AddressFamily family, out IPEndPoint localEndPoint) + { + IPAddress address = family switch + { + AddressFamily.InterNetwork => IPAddress.Loopback, + AddressFamily.InterNetworkV6 => IPAddress.IPv6Loopback, + _ => throw new NotSupportedException("Only IPv4 and IPv6 are supported in test helper.") + }; + + TcpListener listener = new TcpListener(address, 0); + listener.Start(); + + Assert.IsNotNull(listener.LocalEndpoint, "Listener.LocalEndpoint must be initialized after Start()."); + Assert.IsInstanceOfType( + listener.LocalEndpoint, + "Listener.LocalEndpoint must be an IPEndPoint instance."); + + // Null-forgiving operator to satisfy nullable analysis; we already asserted non-null + type. + localEndPoint = (IPEndPoint)listener.LocalEndpoint!; + return listener; + } + + [TestMethod] + public void Constructor_ExposesBindAddress() + { + IPAddress bindAddress = IPAddress.Loopback; + + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(bindAddress); + + Assert.AreEqual( + bindAddress, + manager.BindAddress, + "BindAddress property must reflect the constructor-provided bind address."); + } + + [TestMethod] + public async Task ConnectAsync_WithMatchingAddressFamily_BindsAndConnectsFromBindAddress() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEndPoint); + + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + + Socket clientSocket = await manager.ConnectAsync(serverEndPoint, TestContext.CancellationToken); + + using Socket serverSocket = await listener.AcceptSocketAsync(TestContext.CancellationToken); + + Assert.IsTrue(clientSocket.Connected, "Client socket must be connected when address families match."); + Assert.IsTrue(serverSocket.Connected, "Server-side accepted socket must be connected."); + + Assert.IsNotNull(clientSocket.LocalEndPoint, "Client LocalEndPoint must be set after a successful connect."); + Assert.IsInstanceOfType( + clientSocket.LocalEndPoint, + "Client LocalEndPoint must be an IPEndPoint."); + + // Null-forgiving: guarded by IsNotNull + IsInstanceOfType above. + IPEndPoint local = (IPEndPoint)clientSocket.LocalEndPoint!; + Assert.AreEqual( + IPAddress.Loopback, + local.Address, + "Client must bind to the configured bind address for outbound connections."); + + clientSocket.Dispose(); + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_WithUnspecifiedDnsEndPoint_ThrowsNotSupported() + { + TcpListener listener = StartLoopbackListener(AddressFamily.InterNetwork, out IPEndPoint serverEndPoint); + + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + + DnsEndPoint dnsEp = new DnsEndPoint("localhost", serverEndPoint.Port, AddressFamily.Unspecified); + + await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(dnsEp, TestContext.CancellationToken), + "Unspecified DnsEndPoint with ambiguous resolution must fail with NotSupportedException when bound to a specific address family."); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_WithMismatchedFamily_ThrowsNetworkUnreachable() + { + // Bind manager to IPv4 but use IPv6 endpoint. + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + IPEndPoint remote = new IPEndPoint(IPAddress.IPv6Loopback, 443); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(remote, TestContext.CancellationToken), + "ConnectAsync must throw SocketException when the remote endpoint family does not match the bind address family."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "Mismatched family must surface NetworkUnreachable to the caller."); + } + + [TestMethod] + public async Task GetBindHandlerAsync_WithMatchingFamily_ReturnsHandler() + { + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + + IProxyServerBindHandler handler = await manager.GetBindHandlerAsync(AddressFamily.InterNetwork); + + Assert.IsNotNull(handler, "GetBindHandlerAsync must return a non-null handler for matching address family."); + + if (handler is IDisposable disposable) + { + disposable.Dispose(); + } + } + + [TestMethod] + public async Task GetBindHandlerAsync_WithMismatchedFamily_ThrowsNetworkUnreachable() + { + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.GetBindHandlerAsync(AddressFamily.InterNetworkV6), + "GetBindHandlerAsync must fail when the requested family does not match the bind address family."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "Bind handler lookup must surface NetworkUnreachable for mismatched family."); + } + + [TestMethod] + public async Task GetUdpAssociateHandlerAsync_WithMatchingFamily_ReturnsHandler() + { + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + IPEndPoint localEp = new IPEndPoint(IPAddress.Loopback, 0); + + IProxyServerUdpAssociateHandler handler = await manager.GetUdpAssociateHandlerAsync(localEp); + + Assert.IsNotNull(handler, "GetUdpAssociateHandlerAsync must return a non-null handler for matching family."); + + if (handler is IDisposable disposable) + disposable.Dispose(); + } + + [TestMethod] + public async Task GetUdpAssociateHandlerAsync_WithMismatchedFamily_ThrowsNetworkUnreachable() + { + InterfaceBoundProxyServerConnectionManager manager = new InterfaceBoundProxyServerConnectionManager(IPAddress.Loopback); + IPEndPoint localEp = new IPEndPoint(IPAddress.IPv6Loopback, 0); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.GetUdpAssociateHandlerAsync(localEp), + "UDP handler lookup must fail when the endpoint family does not match the bind address."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "UDP handler lookup must surface NetworkUnreachable for mismatched family."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/LoadBalancingProxyServerConnectionManagerTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/LoadBalancingProxyServerConnectionManagerTests.cs new file mode 100644 index 00000000..4a61b791 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/LoadBalancingProxyServerConnectionManagerTests.cs @@ -0,0 +1,521 @@ +/* +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 System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class LoadBalancingProxyServerConnectionManagerTests + { + public TestContext TestContext { get; set; } + + private static readonly EndPoint DummyConnectivityEndPoint = + new IPEndPoint(IPAddress.Loopback, 80); + + #region tests – ConnectAsync + + [TestMethod] + public async Task ConnectAsync_WithIPv4Endpoint_UsesIPv4ConnectionManager() + { + FakeConnectionManager ipv4Manager = new FakeConnectionManager(AddressFamily.InterNetwork); + FakeConnectionManager ipv6Manager = new FakeConnectionManager(AddressFamily.InterNetworkV6); + + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + new[] { ipv4Manager }, + new[] { ipv6Manager }, + new[] { DummyConnectivityEndPoint }); + + IPEndPoint target = new IPEndPoint(IPAddress.Loopback, 12345); + + using Socket socket = await manager.ConnectAsync(target, TestContext.CancellationToken); + + Assert.AreEqual( + 1, + ipv4Manager.ConnectCallCount, + "IPv4 endpoint must be delegated to an IPv4 connection manager."); + + Assert.AreEqual( + 0, + ipv6Manager.ConnectCallCount, + "IPv6 connection manager must not be used for IPv4 endpoints."); + + Assert.AreEqual( + target, + ipv4Manager.LastRemoteEndPoint, + "IPv4 manager must see the exact remote endpoint passed to ConnectAsync."); + + Assert.AreEqual( + AddressFamily.InterNetwork, + socket.AddressFamily, + "Returned socket family must match the selected IPv4 manager."); + } + + [TestMethod] + public async Task ConnectAsync_WithIPv6Endpoint_UsesIPv6ConnectionManager_IfSupported() + { + if (!Socket.OSSupportsIPv6) + { + Assert.Inconclusive("IPv6 is not supported on this system."); + return; + } + + FakeConnectionManager ipv4Manager = new FakeConnectionManager(AddressFamily.InterNetwork); + FakeConnectionManager ipv6Manager = new FakeConnectionManager(AddressFamily.InterNetworkV6); + + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + new[] { ipv4Manager }, + new[] { ipv6Manager }, + new[] { DummyConnectivityEndPoint }); + + IPEndPoint target = new IPEndPoint(IPAddress.IPv6Loopback, 12345); + + using Socket socket = await manager.ConnectAsync(target, TestContext.CancellationToken); + + Assert.AreEqual( + 0, + ipv4Manager.ConnectCallCount, + "IPv4 connection manager must not be used for IPv6 endpoints."); + + Assert.AreEqual( + 1, + ipv6Manager.ConnectCallCount, + "IPv6 endpoint must be delegated to an IPv6 connection manager."); + + Assert.AreEqual( + target, + ipv6Manager.LastRemoteEndPoint, + "IPv6 manager must see the exact remote endpoint passed to ConnectAsync."); + + Assert.AreEqual( + AddressFamily.InterNetworkV6, + socket.AddressFamily, + "Returned socket family must match the selected IPv6 manager."); + } + + [TestMethod] + public async Task ConnectAsync_WithUnspecifiedDomain_BothFamiliesAvailable_UsesOneFamilyConsistently() + { + FakeConnectionManager ipv4Manager = new FakeConnectionManager(AddressFamily.InterNetwork); + FakeConnectionManager ipv6Manager = new FakeConnectionManager(AddressFamily.InterNetworkV6); + + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + new[] { ipv4Manager }, + new[] { ipv6Manager }, + new[] { DummyConnectivityEndPoint }); + + // DomainEndPoint with AddressFamily.Unspecified – will be resolved by GetIPEndPointAsync. + DomainEndPoint domain = new DomainEndPoint("localhost", 443); + + using Socket socket = await manager.ConnectAsync(domain, TestContext.CancellationToken); + + int totalCalls = ipv4Manager.ConnectCallCount + ipv6Manager.ConnectCallCount; + + Assert.AreEqual( + 1, + totalCalls, + "Exactly one underlying connection manager must be used per ConnectAsync call."); + + FakeConnectionManager chosen = + ipv4Manager.ConnectCallCount == 1 ? ipv4Manager : ipv6Manager; + + Assert.IsNotNull( + chosen.LastRemoteEndPoint, + "Chosen manager must receive a resolved IPEndPoint."); + + Assert.IsInstanceOfType(chosen.LastRemoteEndPoint, "Unspecified domain endpoint must be resolved to an IPEndPoint."); + + IPEndPoint resolved = (IPEndPoint)chosen.LastRemoteEndPoint!; + Assert.AreEqual( + chosen.Family, + resolved.AddressFamily, + "Resolved endpoint family must match the chosen manager family."); + + Assert.AreEqual( + chosen.Family, + socket.AddressFamily, + "Returned socket family must match the chosen manager family."); + } + + [TestMethod] + public async Task ConnectAsync_WithUnspecifiedDomain_OnlyIPv4Available_ResolvesToIPv4() + { + FakeConnectionManager ipv4Manager = new FakeConnectionManager(AddressFamily.InterNetwork); + + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + new[] { ipv4Manager }, + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + DomainEndPoint domain = new DomainEndPoint("localhost", 80); + + using Socket socket = await manager.ConnectAsync(domain, TestContext.CancellationToken); + + Assert.AreEqual( + 1, + ipv4Manager.ConnectCallCount, + "With only IPv4 managers available, ConnectAsync must route to IPv4."); + + Assert.IsInstanceOfType(ipv4Manager.LastRemoteEndPoint, "DomainEndPoint must be resolved to an IPv4 IPEndPoint when only IPv4 is available."); + + IPEndPoint resolved = (IPEndPoint)ipv4Manager.LastRemoteEndPoint!; + Assert.AreEqual( + AddressFamily.InterNetwork, + resolved.AddressFamily, + "Resolved endpoint must be IPv4 when only IPv4 managers are available."); + + Assert.AreEqual( + AddressFamily.InterNetwork, + socket.AddressFamily, + "Returned socket family must be IPv4 when only IPv4 managers are available."); + } + + [TestMethod] + public async Task ConnectAsync_WithUnspecifiedDomain_OnlyIPv6Available_ResolvesToIPv6_IfSupported() + { + if (!Socket.OSSupportsIPv6) + { + Assert.Inconclusive("IPv6 is not supported on this system."); + return; + } + + FakeConnectionManager ipv6Manager = new FakeConnectionManager(AddressFamily.InterNetworkV6); + + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + Array.Empty(), + new[] { ipv6Manager }, + new[] { DummyConnectivityEndPoint }); + + DomainEndPoint domain = new DomainEndPoint("localhost", 80); + + using Socket socket = await manager.ConnectAsync(domain, TestContext.CancellationToken); + + Assert.AreEqual( + 1, + ipv6Manager.ConnectCallCount, + "With only IPv6 managers available, ConnectAsync must route to IPv6."); + + Assert.IsInstanceOfType(ipv6Manager.LastRemoteEndPoint, "DomainEndPoint must be resolved to an IPv6 IPEndPoint when only IPv6 is available."); + + IPEndPoint resolved = (IPEndPoint)ipv6Manager.LastRemoteEndPoint!; + Assert.AreEqual( + AddressFamily.InterNetworkV6, + resolved.AddressFamily, + "Resolved endpoint must be IPv6 when only IPv6 managers are available."); + + Assert.AreEqual( + AddressFamily.InterNetworkV6, + socket.AddressFamily, + "Returned socket family must be IPv6 when only IPv6 managers are available."); + } + + [TestMethod] + public async Task ConnectAsync_WithUnspecifiedDomain_NoWorkingManagers_ThrowsNetworkUnreachable() + { + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + Array.Empty(), + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + DomainEndPoint domain = new DomainEndPoint("localhost", 443); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.ConnectAsync(domain, TestContext.CancellationToken), + "When no working managers exist, ConnectAsync must fail with SocketError.NetworkUnreachable."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "ConnectAsync must surface NetworkUnreachable when no family is available."); + } + + [TestMethod] + public async Task ConnectAsync_WithRedundancyOnly_AlwaysUsesFirstWorkingManager() + { + FakeConnectionManager primary = new FakeConnectionManager(AddressFamily.InterNetwork); + FakeConnectionManager secondary = new FakeConnectionManager(AddressFamily.InterNetwork); + + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + new[] { primary, secondary }, + Array.Empty(), + new[] { DummyConnectivityEndPoint }, + redundancyOnly: true); + + IPEndPoint target = new IPEndPoint(IPAddress.Loopback, 8080); + + const int attempts = 5; + + for (int i = 0; i < attempts; i++) + { + using Socket socket = await manager.ConnectAsync(target, TestContext.CancellationToken); + } + + Assert.AreEqual( + attempts, + primary.ConnectCallCount, + "In redundancy-only mode, the first working manager must handle all IPv4 connections."); + + Assert.AreEqual( + 0, + secondary.ConnectCallCount, + "In redundancy-only mode, secondary managers must not be used while primary is healthy."); + } + + #endregion tests – ConnectAsync + + #region tests – Bind and UDP delegation + + [TestMethod] + public async Task GetBindHandlerAsync_DelegatesToCorrectFamilyManager() + { + FakeConnectionManager v4Primary = new FakeConnectionManager(AddressFamily.InterNetwork); + FakeConnectionManager v4Secondary = new FakeConnectionManager(AddressFamily.InterNetwork); + + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + new[] { v4Primary, v4Secondary }, + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + IProxyServerBindHandler handler = await manager.GetBindHandlerAsync(AddressFamily.InterNetwork); + + Assert.IsTrue( + ReferenceEquals(handler, v4Primary.BindHandler) || + ReferenceEquals(handler, v4Secondary.BindHandler), + "Bind handler must be obtained from one of the IPv4 managers."); + + int totalBindCalls = v4Primary.BindCallCount + v4Secondary.BindCallCount; + + Assert.AreEqual( + 1, + totalBindCalls, + "Load balancer must delegate a single bind request to exactly one manager."); + } + + [TestMethod] + public async Task GetBindHandlerAsync_NoManagersForFamily_ThrowsNetworkUnreachable() + { + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + Array.Empty(), + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.GetBindHandlerAsync(AddressFamily.InterNetwork), + "GetBindHandlerAsync must fail with NetworkUnreachable when no managers exist for the family."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "Bind handler lookup must surface NetworkUnreachable when no managers exist."); + } + + [TestMethod] + public async Task GetUdpAssociateHandlerAsync_DelegatesToCorrectFamilyManager() + { + FakeConnectionManager v4Manager = new FakeConnectionManager(AddressFamily.InterNetwork); + + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + new[] { v4Manager }, + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + IPEndPoint localEp = new IPEndPoint(IPAddress.Loopback, 0); + + IProxyServerUdpAssociateHandler handler = + await manager.GetUdpAssociateHandlerAsync(localEp); + + Assert.IsTrue( + ReferenceEquals(handler, v4Manager.UdpHandler), + "UDP associate handler must be obtained from the matching IPv4 manager."); + + Assert.AreEqual( + 1, + v4Manager.UdpCallCount, + "Exactly one UDP associate request must be delegated to the manager."); + } + + [TestMethod] + public async Task GetUdpAssociateHandlerAsync_NoManagersForFamily_ThrowsNetworkUnreachable() + { + using LoadBalancingProxyServerConnectionManager manager = new LoadBalancingProxyServerConnectionManager( + Array.Empty(), + Array.Empty(), + new[] { DummyConnectivityEndPoint }); + + IPEndPoint localEp = new IPEndPoint(IPAddress.Loopback, 0); + + SocketException ex = await Assert.ThrowsExactlyAsync( + () => manager.GetUdpAssociateHandlerAsync(localEp), + "GetUdpAssociateHandlerAsync must fail when no managers exist for the endpoint family."); + + Assert.AreEqual( + SocketError.NetworkUnreachable, + ex.SocketErrorCode, + "UDP associate lookup must surface NetworkUnreachable when no managers exist."); + } + + #endregion tests – Bind and UDP delegation + + #region fakes + + private sealed class FakeConnectionManager : IProxyServerConnectionManager, IDisposable + { + public AddressFamily Family { get; } + + public int ConnectCallCount { get; private set; } + + public EndPoint? LastRemoteEndPoint { get; private set; } + + public int BindCallCount { get; private set; } + + public int UdpCallCount { get; private set; } + + public bool ShouldThrow { get; } + + public SocketError ThrowError { get; set; } = SocketError.NetworkUnreachable; + + public IProxyServerBindHandler BindHandler { get; set; } + + public IProxyServerUdpAssociateHandler UdpHandler { get; set; } + + public FakeConnectionManager(AddressFamily family) + { + Family = family; + BindHandler = new FakeBindHandler(family); + UdpHandler = new FakeUdpHandler(family); + } + + public Task ConnectAsync(EndPoint remoteEP, CancellationToken cancellationToken = default) + { + ConnectCallCount++; + LastRemoteEndPoint = remoteEP; + + if (ShouldThrow) + throw new SocketException((int)ThrowError); + + using Socket socket = new Socket(Family, SocketType.Stream, ProtocolType.Tcp); + return Task.FromResult(socket); + } + + public Task GetBindHandlerAsync(AddressFamily family) + { + BindCallCount++; + + if (ShouldThrow) + throw new SocketException((int)ThrowError); + + return Task.FromResult(BindHandler); + } + + public Task GetUdpAssociateHandlerAsync(EndPoint localEP) + { + UdpCallCount++; + + if (ShouldThrow) + throw new SocketException((int)ThrowError); + + return Task.FromResult(UdpHandler); + } + + public void Dispose() + { + // Nothing to dispose in this fake; sockets returned to tests are disposed there. + } + } + + private sealed class FakeBindHandler : IProxyServerBindHandler + { + public SocksProxyReplyCode ReplyCode { get; } + + public EndPoint ProxyRemoteEndPoint { get; } + + public EndPoint ProxyLocalEndPoint { get; } + + public FakeBindHandler(AddressFamily family) + { + IPAddress address = family == AddressFamily.InterNetwork + ? IPAddress.Loopback + : IPAddress.IPv6Loopback; + + ProxyLocalEndPoint = new IPEndPoint(address, 10000); + ProxyRemoteEndPoint = new IPEndPoint(address, 20000); + ReplyCode = SocksProxyReplyCode.Succeeded; + } + + public Task AcceptAsync(CancellationToken cancellationToken = default) + { + using Socket socket = new Socket( + ((IPEndPoint)ProxyLocalEndPoint).AddressFamily, + SocketType.Stream, + ProtocolType.Tcp); + + return Task.FromResult(socket); + } + + public void Dispose() + { + // No resources allocated by this fake. + } + } + + private sealed class FakeUdpHandler : IProxyServerUdpAssociateHandler + { + private readonly AddressFamily _family; + + public FakeUdpHandler(AddressFamily family) + { + _family = family; + } + + public Task SendToAsync(ArraySegment buffer, EndPoint remoteEP, CancellationToken cancellationToken = default) + { + // Echo back the buffer length to simulate a successful send. + return Task.FromResult(buffer.Count); + } + + public Task ReceiveFromAsync(ArraySegment buffer, CancellationToken cancellationToken = default) + { + SocketReceiveFromResult result = new SocketReceiveFromResult + { + ReceivedBytes = 0, + RemoteEndPoint = new IPEndPoint( + _family == AddressFamily.InterNetwork ? IPAddress.Loopback : IPAddress.IPv6Loopback, + 53) + }; + + return Task.FromResult(result); + } + + public void Dispose() + { + // Nothing to dispose in this fake. + } + } + + #endregion fakes + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyAuthenticationFailedExceptionTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyAuthenticationFailedExceptionTests.cs new file mode 100644 index 00000000..6a921616 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyAuthenticationFailedExceptionTests.cs @@ -0,0 +1,102 @@ +/* +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.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class NetProxyAuthenticationFailedExceptionTests + { + [TestMethod] + public void DefaultConstructor_SetsMessage_AndNullInnerException() + { + NetProxyAuthenticationFailedException ex = new NetProxyAuthenticationFailedException(); + + Assert.IsNotNull( + ex.Message, + "Default constructor must set a non-null message." + ); + + Assert.IsGreaterThan( +0, + ex.Message.Length, "Default constructor must provide a meaningful error message." + ); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not assign an inner exception." + ); + } + + [TestMethod] + public void Constructor_WithMessage_PreservesMessage_AndNullInnerException() + { + const string message = "Authentication failed due to invalid credentials."; + + NetProxyAuthenticationFailedException ex = new NetProxyAuthenticationFailedException(message); + + Assert.AreEqual( + message, + ex.Message, + "Message-only constructor must preserve the provided message verbatim." + ); + + Assert.IsNull( + ex.InnerException, + "Message-only constructor must not assign an inner exception." + ); + } + + [TestMethod] + public void Constructor_WithMessageAndInnerException_PreservesBoth() + { + const string message = "Authentication failed."; + InvalidOperationException inner = new InvalidOperationException("Inner failure"); + + NetProxyAuthenticationFailedException ex = new NetProxyAuthenticationFailedException(message, inner); + + Assert.AreEqual( + message, + ex.Message, + "Constructor must preserve the provided message verbatim." + ); + + Assert.AreSame( + inner, + ex.InnerException, + "Constructor must preserve the provided inner exception reference." + ); + } + + [TestMethod] + public void Exception_IsNetProxyException() + { + NetProxyAuthenticationFailedException ex = new NetProxyAuthenticationFailedException(); + + Assert.IsInstanceOfType( + ex, + "NetProxyAuthenticationFailedException must inherit from NetProxyException." + ); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyBypassItemTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyBypassItemTests.cs new file mode 100644 index 00000000..6d5d8701 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyBypassItemTests.cs @@ -0,0 +1,185 @@ +/* +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.Net; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class NetProxyBypassItemTests + { + // ------------------------------------------------------------ + // IPv4 CIDR + // ------------------------------------------------------------ + + [TestMethod] + public void IsMatching_Ipv4Cidr_MatchesAddressInsideRange() + { + NetProxyBypassItem bypass = new NetProxyBypassItem("192.168.1.0/24"); + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("192.168.1.42"), 80); + + Assert.IsTrue( + bypass.IsMatching(ep), + "IPv4 address inside CIDR range must bypass the proxy." + ); + } + + [TestMethod] + public void IsMatching_Ipv4Cidr_DoesNotMatchOutsideRange() + { + NetProxyBypassItem bypass = new NetProxyBypassItem("192.168.1.0/24"); + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("192.168.2.1"), 80); + + Assert.IsFalse( + bypass.IsMatching(ep), + "IPv4 address outside CIDR range must not bypass the proxy." + ); + } + + // ------------------------------------------------------------ + // Exact IP match + // ------------------------------------------------------------ + + [TestMethod] + public void IsMatching_ExactIpv4Address_MatchesOnlySameAddress() + { + NetProxyBypassItem bypass = new NetProxyBypassItem("10.0.0.5"); + + Assert.IsTrue( + bypass.IsMatching(new IPEndPoint(IPAddress.Parse("10.0.0.5"), 1234)), + "Exact IPv4 address must match regardless of port." + ); + + Assert.IsFalse( + bypass.IsMatching(new IPEndPoint(IPAddress.Parse("10.0.0.6"), 1234)), + "Different IPv4 address must not match exact bypass entry." + ); + } + + // ------------------------------------------------------------ + // IPv6 CIDR + // ------------------------------------------------------------ + + [TestMethod] + public void IsMatching_Ipv6Cidr_MatchesAddressInsideRange() + { + NetProxyBypassItem bypass = new NetProxyBypassItem("fe80::/10"); + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("fe80::1"), 443); + + Assert.IsTrue( + bypass.IsMatching(ep), + "IPv6 address inside CIDR range must bypass the proxy." + ); + } + + [TestMethod] + public void IsMatching_Ipv6Cidr_DoesNotMatchOutsideRange() + { + NetProxyBypassItem bypass = new NetProxyBypassItem("fe80::/10"); + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("2001:db8::1"), 443); + + Assert.IsFalse( + bypass.IsMatching(ep), + "IPv6 address outside CIDR range must not bypass the proxy." + ); + } + + // ------------------------------------------------------------ + // Hostname matching + // ------------------------------------------------------------ + + [TestMethod] + public void IsMatching_Localhost_BypassesLoopbackIp() + { + NetProxyBypassItem bypass = new NetProxyBypassItem("localhost"); + + Assert.IsTrue( + bypass.IsMatching(new IPEndPoint(IPAddress.Loopback, 80)), + "Bypass entry 'localhost' must match IPv4 loopback address." + ); + + Assert.IsTrue( + bypass.IsMatching(new IPEndPoint(IPAddress.IPv6Loopback, 80)), + "Bypass entry 'localhost' must match IPv6 loopback address." + ); + } + + [TestMethod] + public void IsMatching_Localhost_DoesNotMatchDnsEndPoint() + { + NetProxyBypassItem bypass = new NetProxyBypassItem("localhost"); + + DnsEndPoint ep = new DnsEndPoint("localhost", 80); + + Assert.IsFalse( + bypass.IsMatching(ep), + "Bypass logic must not resolve or match DnsEndPoint hostnames." + ); + } + + [TestMethod] + public void IsMatching_Hostname_DoesNotMatchDifferentName() + { + NetProxyBypassItem bypass = new NetProxyBypassItem("localhost"); + + DnsEndPoint ep = new DnsEndPoint("example.com", 80); + + Assert.IsFalse( + bypass.IsMatching(ep), + "Different hostname must not bypass the proxy." + ); + } + + // ------------------------------------------------------------ + // Safety and stability + // ------------------------------------------------------------ + + [TestMethod] + public void IsMatching_UnsupportedEndpointType_ReturnsFalse() + { + NetProxyBypassItem bypass = new NetProxyBypassItem("127.0.0.1"); + + EndPoint unsupported = new IPEndPoint(IPAddress.IPv6Any, 0); + + Assert.IsFalse( + bypass.IsMatching(unsupported), + "Unsupported or non-matching endpoint types must fail safely." + ); + } + + [TestMethod] + public void IsMatching_RepeatedCalls_AreDeterministic() + { + NetProxyBypassItem bypass = new NetProxyBypassItem("192.168.0.0/16"); + IPEndPoint ep = new IPEndPoint(IPAddress.Parse("192.168.10.10"), 80); + + bool first = bypass.IsMatching(ep); + bool second = bypass.IsMatching(ep); + + Assert.AreEqual( + first, + second, + "Bypass decision must be deterministic across multiple invocations." + ); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyExceptionTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyExceptionTests.cs new file mode 100644 index 00000000..d64b5bec --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyExceptionTests.cs @@ -0,0 +1,92 @@ +/* +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.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class NetProxyExceptionTests + { + [TestMethod] + public void DefaultConstructor_MustProvideNonEmptyMessage_AndNullInnerException() + { + NetProxyException ex = new NetProxyException(); + + Assert.IsFalse( + string.IsNullOrWhiteSpace(ex.Message), + "Default constructor must provide a non-empty diagnostic message."); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not assign an inner exception."); + + Assert.AreEqual( + typeof(NetProxyException), + ex.GetType(), + "Runtime exception type must remain exactly NetProxyException."); + } + + [TestMethod] + public void MessageConstructor_MustPreserveMessage() + { + const string message = "Net proxy operation failed."; + + NetProxyException ex = new NetProxyException(message); + + Assert.AreEqual( + message, + ex.Message, + "Message constructor must preserve the supplied message verbatim."); + } + + [TestMethod] + public void MessageAndInnerExceptionConstructor_MustPreserveBoth() + { + const string message = "Proxy tunnel failure."; + InvalidOperationException inner = new InvalidOperationException("inner"); + + NetProxyException ex = new NetProxyException(message, inner); + + Assert.AreEqual( + message, + ex.Message, + "Exception must preserve the supplied message."); + + Assert.AreSame( + inner, + ex.InnerException, + "Exception must preserve the supplied inner exception reference."); + } + + [TestMethod] + public void ExceptionTypeIdentity_MustRemainStable() + { + Exception ex = new NetProxyException(); + + Assert.AreEqual( + typeof(NetProxyException), + ex.GetType(), + "Consumers rely on exact exception type identity for catch filters."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyTests.cs new file mode 100644 index 00000000..37c8e71c --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/NetProxyTests.cs @@ -0,0 +1,262 @@ +/* +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 System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public class NetProxyTests + { + public TestContext TestContext { get; set; } + + #region helpers + + private static TcpListener StartListener(IPAddress address, out IPEndPoint localEndPoint) + { + TcpListener listener = new TcpListener(address, 0); + listener.Start(); + + Assert.IsNotNull(listener.LocalEndpoint, "Listener.LocalEndpoint must be initialized after Start()."); + Assert.IsInstanceOfType(listener.LocalEndpoint, "Listener.LocalEndpoint must be an IPEndPoint instance."); + + localEndPoint = (IPEndPoint)listener.LocalEndpoint!; + return listener; + } + + /// + /// Concrete NetProxy implementation that simply returns the viaSocket, + /// while recording the parameters passed to the protected ConnectAsync. + /// + private sealed class TestNetProxy : NetProxy + { + public int ProtectedConnectCallCount { get; private set; } + + public EndPoint? LastRemoteEndPoint { get; private set; } + + public Socket? LastViaSocket { get; private set; } + + public TestNetProxy(EndPoint proxyEp) + : base(NetProxyType.Http, proxyEp) + { + } + + protected override Task ConnectAsync(EndPoint remoteEP, Socket viaSocket, CancellationToken cancellationToken) + { + ProtectedConnectCallCount++; + LastRemoteEndPoint = remoteEP; + LastViaSocket = viaSocket; + return Task.FromResult(viaSocket); + } + } + + /// + /// Concrete NetProxy used as viaProxy in chaining tests. + /// Records its protected ConnectAsync calls. + /// + private sealed class ChainedNetProxy : NetProxy + { + public int ProtectedConnectCallCount { get; private set; } + + public EndPoint? LastRemoteEndPoint { get; private set; } + + public EndPoint? LastProxyEndPointSeen { get; private set; } + + public ChainedNetProxy(EndPoint proxyEp) + : base(NetProxyType.Http, proxyEp) + { + } + + protected override Task ConnectAsync(EndPoint remoteEP, Socket viaSocket, CancellationToken cancellationToken) + { + ProtectedConnectCallCount++; + LastRemoteEndPoint = remoteEP; + LastProxyEndPointSeen = ProxyEndPoint; + return Task.FromResult(viaSocket); + } + } + + #endregion helpers + + #region tests + + [TestMethod] + public async Task ConnectAsync_BypassedEndpoint_UsesDirectTcpAndSkipsProtectedConnect() + { + // Arrange: loopback is in the default bypass list. + TcpListener listener = StartListener(IPAddress.Loopback, out IPEndPoint remoteEp); + + // proxyEP value is irrelevant for bypassed endpoints; it will not be used. + IPEndPoint proxyEp = new IPEndPoint(IPAddress.Loopback, 65000); + TestNetProxy proxy = new TestNetProxy(proxyEp); + + // Act + using Socket socket = await proxy.ConnectAsync(remoteEp, TestContext.CancellationToken); + + // Accept the incoming connection to complete the TCP handshake. + using Socket serverSide = await listener.AcceptSocketAsync(TestContext.CancellationToken); + + // Assert + Assert.IsTrue(socket.Connected, "Bypassed endpoint must result in a direct TCP connection to the remote endpoint."); + Assert.AreEqual( + 0, + proxy.ProtectedConnectCallCount, + "Protected ConnectAsync(remote, viaSocket) must not be called for bypassed endpoints."); + + listener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_NonBypassedEndpoint_ConnectsToProxyEndpointAndInvokesProtectedConnect() + { + // Arrange: choose an address that is NOT in the default bypass list (203.0.113.77). + IPEndPoint remote = new IPEndPoint(IPAddress.Parse("203.0.113.77"), 9000); + + // NetProxy must first connect to _proxyEP. + TcpListener proxyListener = StartListener(IPAddress.Loopback, out IPEndPoint proxyEp); + + TestNetProxy proxy = new TestNetProxy(proxyEp); + + // Act + using Socket socket = await proxy.ConnectAsync(remote, TestContext.CancellationToken); + + // Accept the TCP connection that GetTcpConnectionAsync opened to proxyEp. + using Socket serverSide = await proxyListener.AcceptSocketAsync(TestContext.CancellationToken); + + // Assert + Assert.AreEqual( + 1, + proxy.ProtectedConnectCallCount, + "Protected ConnectAsync must be called exactly once for non-bypassed endpoints."); + + Assert.AreEqual( + remote, + proxy.LastRemoteEndPoint, + "Protected ConnectAsync must see the original remote endpoint, not the proxy endpoint."); + + Assert.IsNotNull( + proxy.LastViaSocket, + "Protected ConnectAsync must receive a viaSocket representing a TCP connection to the proxy endpoint."); + + Assert.AreSame( + socket, + proxy.LastViaSocket, + "Public ConnectAsync must return exactly the viaSocket passed into the protected overload."); + + Assert.IsTrue( + socket.Connected, + "Socket returned by ConnectAsync must represent a live TCP connection to the proxy endpoint."); + + proxyListener.Stop(); + } + + [TestMethod] + public async Task ConnectAsync_ChainOfProxies_UsesViaProxyThenMainProxy() + { + // Arrange: + // viaProxy has its own proxy endpoint where it will open a TCP connection + // when connecting to mainProxy.ProxyEndPoint. + TcpListener viaProxyListener = StartListener(IPAddress.Loopback, out IPEndPoint viaProxyEp); + ChainedNetProxy viaProxy = new ChainedNetProxy(viaProxyEp) + { + // Ensure that mainProxy.ProxyEndPoint is NOT bypassed for viaProxy. + BypassList = Array.Empty() + }; + + // Main proxy has its own upstream endpoint; this is the remoteEP passed into viaProxy. + IPEndPoint mainProxyEp = new IPEndPoint(IPAddress.Loopback, 60000); + TestNetProxy mainProxy = new TestNetProxy(mainProxyEp) + { + ViaProxy = viaProxy + }; + + // Target endpoint is non-bypassed for mainProxy. + IPEndPoint target = new IPEndPoint(IPAddress.Parse("203.0.113.44"), 443); + + // Act + using Socket finalSocket = await mainProxy.ConnectAsync(target, TestContext.CancellationToken); + + // viaProxy must receive a ConnectAsync call with remoteEP = mainProxy.ProxyEndPoint + Assert.AreEqual( + 1, + viaProxy.ProtectedConnectCallCount, + "Via proxy must have its protected ConnectAsync invoked exactly once."); + + Assert.AreEqual( + mainProxyEp, + viaProxy.LastRemoteEndPoint, + "Via proxy must be asked to connect to the main proxy endpoint."); + + // Accept the TCP connection that viaProxy's GetTcpConnectionAsync opened + // to its own proxy endpoint. + using Socket viaProxyServerSide = await viaProxyListener.AcceptSocketAsync(TestContext.CancellationToken); + + // Then main proxy must be invoked with the final target. + Assert.AreEqual( + 1, + mainProxy.ProtectedConnectCallCount, + "Main proxy must have its protected ConnectAsync invoked exactly once."); + + Assert.AreEqual( + target, + mainProxy.LastRemoteEndPoint, + "Main proxy protected ConnectAsync must see the original target endpoint."); + + Assert.IsTrue( + finalSocket.Connected, + "Final socket must represent the TCP connection established by viaProxy to its own proxy endpoint."); + + viaProxyListener.Stop(); + } + + [TestMethod] + public void BypassList_CanBeReplacedAndAffectsIsBypassed() + { + IPEndPoint proxyEp = new IPEndPoint(IPAddress.Loopback, 8080); + TestNetProxy proxy = new TestNetProxy(proxyEp) + { + // Replace default bypass list with a custom one. + BypassList = new[] + { + new NetProxyBypassItem("192.168.10.0/24") + } + }; + + IPEndPoint bypassed = new IPEndPoint(IPAddress.Parse("192.168.10.5"), 80); + IPEndPoint notBypassed = new IPEndPoint(IPAddress.Loopback, 80); // not in our custom list + + Assert.IsTrue( + proxy.IsBypassed(bypassed), + "Endpoint inside configured CIDR must be treated as bypassed."); + + Assert.IsFalse( + proxy.IsBypassed(notBypassed), + "Endpoint outside custom bypass list must not be bypassed."); + } + + #endregion tests + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/SocksProxyAuthenticationFailedExceptionTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/SocksProxyAuthenticationFailedExceptionTests.cs new file mode 100644 index 00000000..0b8176d2 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/SocksProxyAuthenticationFailedExceptionTests.cs @@ -0,0 +1,94 @@ +/* +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.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class SocksProxyAuthenticationFailedExceptionTests + { + [TestMethod] + public void DefaultConstructor_MustProvideNonEmptyMessage_AndNullInnerException() + { + SocksProxyAuthenticationFailedException ex = new SocksProxyAuthenticationFailedException(); + + Assert.IsFalse( + string.IsNullOrWhiteSpace(ex.Message), + "Default constructor must provide a non-empty diagnostic message."); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not assign an inner exception."); + + Assert.AreEqual( + typeof(SocksProxyAuthenticationFailedException), + ex.GetType(), + "Exception type identity must remain stable."); + } + + [TestMethod] + public void MessageConstructor_MustPreserveMessage() + { + const string message = "SOCKS authentication failed."; + + SocksProxyAuthenticationFailedException ex = + new SocksProxyAuthenticationFailedException(message); + + Assert.AreEqual( + message, + ex.Message, + "Message constructor must preserve the supplied message verbatim."); + } + + [TestMethod] + public void MessageAndInnerExceptionConstructor_MustPreserveBoth() + { + const string message = "SOCKS auth rejected."; + InvalidOperationException inner = new InvalidOperationException("inner"); + + SocksProxyAuthenticationFailedException ex = + new SocksProxyAuthenticationFailedException(message, inner); + + Assert.AreEqual( + message, + ex.Message, + "Exception must preserve the supplied message."); + + Assert.AreSame( + inner, + ex.InnerException, + "Exception must preserve the supplied inner exception reference."); + } + + [TestMethod] + public void ExceptionTypeIdentity_MustRemainExact() + { + Exception ex = new SocksProxyAuthenticationFailedException(); + + Assert.AreEqual( + typeof(SocksProxyAuthenticationFailedException), + ex.GetType(), + "Consumers rely on exact exception type identity for authentication failure handling."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/SocksProxyExceptionTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/SocksProxyExceptionTests.cs new file mode 100644 index 00000000..e725333c --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/SocksProxyExceptionTests.cs @@ -0,0 +1,92 @@ +/* +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.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class SocksProxyExceptionTests + { + [TestMethod] + public void DefaultConstructor_MustProvideNonEmptyMessage_AndNullInnerException() + { + SocksProxyException ex = new SocksProxyException(); + + Assert.IsFalse( + string.IsNullOrWhiteSpace(ex.Message), + "Default constructor must provide a non-empty diagnostic message."); + + Assert.IsNull( + ex.InnerException, + "Default constructor must not assign an inner exception."); + + Assert.AreEqual( + typeof(SocksProxyException), + ex.GetType(), + "Runtime exception type must remain exactly SocksProxyException."); + } + + [TestMethod] + public void MessageConstructor_MustPreserveMessage() + { + const string message = "SOCKS proxy operation failed."; + + SocksProxyException ex = new SocksProxyException(message); + + Assert.AreEqual( + message, + ex.Message, + "Message constructor must preserve the supplied message verbatim."); + } + + [TestMethod] + public void MessageAndInnerExceptionConstructor_MustPreserveBoth() + { + const string message = "SOCKS negotiation error."; + InvalidOperationException inner = new InvalidOperationException("inner"); + + SocksProxyException ex = new SocksProxyException(message, inner); + + Assert.AreEqual( + message, + ex.Message, + "Exception must preserve the supplied message."); + + Assert.AreSame( + inner, + ex.InnerException, + "Exception must preserve the supplied inner exception reference."); + } + + [TestMethod] + public void ExceptionTypeIdentity_MustRemainStable() + { + Exception ex = new SocksProxyException(); + + Assert.AreEqual( + typeof(SocksProxyException), + ex.GetType(), + "Consumers rely on exact exception type identity for SOCKS error handling."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/SocksProxyServerTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/SocksProxyServerTests.cs new file mode 100644 index 00000000..e6e2dedc --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/SocksProxyServerTests.cs @@ -0,0 +1,108 @@ +/* +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.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class SocksProxyServerTests + { + [TestMethod] + public void Constructor_Default_BindsLoopbackAndEphemeralPort() + { + using SocksProxyServer server = new SocksProxyServer(); + + IPEndPoint ep = server.LocalEndPoint; + + Assert.IsNotNull(ep, "LocalEndPoint must be non-null after construction."); + Assert.IsTrue(IPAddress.IsLoopback(ep.Address), + "Default SocksProxyServer must bind only to loopback to avoid exposing an open proxy."); + Assert.IsGreaterThan(0, +ep.Port, "Default SocksProxyServer must bind an ephemeral port (port > 0)."); + } + + [TestMethod] + public async Task Constructor_StartsListening_AndAcceptsTcpConnections() + { + using SocksProxyServer server = new SocksProxyServer(); + IPEndPoint ep = server.LocalEndPoint; + + using Socket client = new Socket(ep.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + await client.ConnectAsync(ep, TestContext.CancellationToken); + + Assert.IsTrue(client.Connected, + "Client must be able to connect immediately since SocksProxyServer listens in the constructor."); + } + + [TestMethod] + public async Task Negotiation_InvalidVersion_MustBeRejected_Safely() + { + using SocksProxyServer server = new SocksProxyServer(); + IPEndPoint ep = server.LocalEndPoint; + + using Socket client = new Socket(ep.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + await client.ConnectAsync(ep, TestContext.CancellationToken); + + // Invalid SOCKS greeting (version 0x04) + byte[] invalidGreeting = new byte[] { 0x04, 0x01, 0x00 }; + await client.SendAsync(invalidGreeting, SocketFlags.None); + + byte[] buffer = new byte[2]; + + try + { + int received = await client.ReceiveAsync(buffer, SocketFlags.None); + + // If bytes are received, connection must not proceed further + Assert.IsTrue( + received == 0 || received == 2, + "Server may either close immediately or send a minimal rejection response." + ); + } + catch (SocketException) + { + // Also acceptable: immediate connection reset + } + } + + [TestMethod] + public async Task Dispose_MustStopAcceptingNewConnections_AndBeIdempotent() + { + SocksProxyServer server = new SocksProxyServer(); + IPEndPoint ep = server.LocalEndPoint; + + server.Dispose(); + server.Dispose(); + + using Socket client = new Socket(ep.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + + await Assert.ThrowsExactlyAsync( + () => client.ConnectAsync(ep, TestContext.CancellationToken).AsTask(), + "Disposed SocksProxyServer must not accept new TCP connections."); + } + + public TestContext TestContext { get; set; } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/TransparentProxyServerTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/TransparentProxyServerTests.cs new file mode 100644 index 00000000..36b34569 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/TransparentProxyServerTests.cs @@ -0,0 +1,116 @@ +/* +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 System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class TransparentProxyServerTests + { + [TestMethod] + public void Constructor_BindsLocalEndPoint_Immediately() + { + using TransparentProxyServer server = + new TransparentProxyServer( + localEP: new IPEndPoint(IPAddress.Loopback, 0), + method: TransparentProxyServerMethod.Tunnel + ); + + IPEndPoint ep = server.LocalEndPoint; + + Assert.IsNotNull(ep, + "LocalEndPoint must be available immediately after construction."); + + Assert.IsGreaterThan(0, +ep.Port, "TransparentProxyServer must bind to an ephemeral port when port=0 is specified."); + } + + [TestMethod] + public async Task Server_MustAcceptTcpConnections_WhileAlive() + { + using TransparentProxyServer server = + new TransparentProxyServer( + localEP: new IPEndPoint(IPAddress.Loopback, 0), + method: TransparentProxyServerMethod.Tunnel + ); + + using Socket client = + new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + await client.ConnectAsync(server.LocalEndPoint, TestContext.CancellationToken); + + Assert.IsTrue(client.Connected, + "TransparentProxyServer must accept TCP connections while not disposed."); + } + + [TestMethod] + public async Task Dispose_MustStopAcceptingNewConnections() + { + TransparentProxyServer server = + new TransparentProxyServer( + localEP: new IPEndPoint(IPAddress.Loopback, 0), + method: TransparentProxyServerMethod.Tunnel + ); + + IPEndPoint ep = server.LocalEndPoint; + + server.Dispose(); + server.Dispose(); // idempotency + + using Socket client = + new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + await Assert.ThrowsExactlyAsync( + () => client.ConnectAsync(ep, TestContext.CancellationToken).AsTask(), + "Disposed TransparentProxyServer must not accept new TCP connections."); + } + + [TestMethod] + public void Constructor_DNAT_OnNonUnix_MustThrowNotSupportedException() + { + Assert.ThrowsExactly( + () => new TransparentProxyServer( + localEP: new IPEndPoint(IPAddress.Loopback, 0), + method: TransparentProxyServerMethod.DNAT + ), + "DNAT mode must throw on non-Unix platforms."); + } + + [TestMethod] + [OSCondition(OperatingSystems.Linux | OperatingSystems.OSX | OperatingSystems.FreeBSD)] + public void Constructor_DNAT_WithIPv6_MustThrowNotSupportedException() + { + Assert.ThrowsExactly( + () => new TransparentProxyServer( + localEP: new IPEndPoint(IPAddress.IPv6Loopback, 0), + method: TransparentProxyServerMethod.DNAT + ), + "DNAT mode must reject non-IPv4 local endpoints."); + } + + public TestContext TestContext { get; set; } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/TunnelProxyTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/TunnelProxyTests.cs new file mode 100644 index 00000000..0e5d8671 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/TunnelProxyTests.cs @@ -0,0 +1,157 @@ +/* +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.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class TunnelProxyTests + { + [TestMethod] + public async Task Constructor_MustExposeConnectableTunnelEndPoint() + { + using TcpListener remoteListener = new TcpListener(IPAddress.Loopback, 0); + remoteListener.Start(); + + using Socket remoteClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + Task acceptTask = remoteListener.AcceptSocketAsync(TestContext.CancellationToken).AsTask(); + await remoteClient.ConnectAsync(remoteListener.LocalEndpoint, TestContext.CancellationToken); + using Socket remoteServer = await acceptTask; + + using TunnelProxy tunnel = new TunnelProxy( + remoteServer, + remoteListener.LocalEndpoint, + enableSsl: false, + ignoreCertificateErrors: false); + + using Socket tunnelClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await tunnelClient.ConnectAsync(tunnel.TunnelEndPoint, TestContext.CancellationToken); + + Assert.IsTrue( + tunnelClient.Connected, + "TunnelProxy must expose a connectable tunnel endpoint immediately after construction."); + } + + [TestMethod] + public async Task Tunnel_MustForwardData_FromTunnelClient_ToRemoteSocket() + { + using TcpListener remoteListener = new TcpListener(IPAddress.Loopback, 0); + remoteListener.Start(); + + using Socket remoteClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + Task acceptTask = remoteListener.AcceptSocketAsync(TestContext.CancellationToken).AsTask(); + await remoteClient.ConnectAsync(remoteListener.LocalEndpoint, TestContext.CancellationToken); + using Socket remoteServer = await acceptTask; + + using TunnelProxy tunnel = new TunnelProxy( + remoteServer, + remoteListener.LocalEndpoint, + enableSsl: false, + ignoreCertificateErrors: false); + + using Socket tunnelClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await tunnelClient.ConnectAsync(tunnel.TunnelEndPoint, TestContext.CancellationToken); + + byte[] payload = Encoding.ASCII.GetBytes("ping"); + await tunnelClient.SendAsync(payload, SocketFlags.None); + + byte[] buffer = new byte[4]; + int received = await remoteClient.ReceiveAsync(buffer, SocketFlags.None); + + CollectionAssert.AreEqual( + payload, + buffer[..received], + "Bytes written to the tunnel endpoint must reach the remote socket without mutation."); + } + + [TestMethod] + public async Task Tunnel_MustForwardData_FromRemoteSocket_ToTunnelClient() + { + using TcpListener remoteListener = new TcpListener(IPAddress.Loopback, 0); + remoteListener.Start(); + + using Socket remoteClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + Task acceptTask = remoteListener.AcceptSocketAsync(TestContext.CancellationToken).AsTask(); + await remoteClient.ConnectAsync(remoteListener.LocalEndpoint, TestContext.CancellationToken); + using Socket remoteServer = await acceptTask; + + using TunnelProxy tunnel = new TunnelProxy( + remoteServer, + remoteListener.LocalEndpoint, + enableSsl: false, + ignoreCertificateErrors: false); + + using Socket tunnelClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await tunnelClient.ConnectAsync(tunnel.TunnelEndPoint, TestContext.CancellationToken); + + byte[] payload = Encoding.ASCII.GetBytes("pong"); + await remoteClient.SendAsync(payload, SocketFlags.None); + + byte[] buffer = new byte[4]; + int received = await tunnelClient.ReceiveAsync(buffer, SocketFlags.None); + + CollectionAssert.AreEqual( + payload, + buffer[..received], + "Bytes written by the remote socket must be forwarded to the tunnel client without mutation."); + } + + [TestMethod] + public async Task Dispose_MustBreakTunnelAndRejectNewConnections() + { + using TcpListener remoteListener = new TcpListener(IPAddress.Loopback, 0); + remoteListener.Start(); + + using Socket remoteClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + Task acceptTask = remoteListener.AcceptSocketAsync(TestContext.CancellationToken).AsTask(); + await remoteClient.ConnectAsync(remoteListener.LocalEndpoint, TestContext.CancellationToken); + using Socket remoteServer = await acceptTask; + + TunnelProxy tunnel = new TunnelProxy( + remoteServer, + remoteListener.LocalEndpoint, + enableSsl: false, + ignoreCertificateErrors: false); + + IPEndPoint tunnelEP = tunnel.TunnelEndPoint; + + tunnel.Dispose(); + tunnel.Dispose(); // idempotency + + Assert.IsTrue( + tunnel.IsBroken, + "Dispose must mark TunnelProxy as broken."); + + using Socket tunnelClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + await Assert.ThrowsExactlyAsync( + () => tunnelClient.ConnectAsync(tunnelEP, TestContext.CancellationToken).AsTask(), + "Disposed TunnelProxy must not accept new tunnel connections."); + } + + public TestContext TestContext { get; set; } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/UdpTunnelProxyTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/UdpTunnelProxyTests.cs new file mode 100644 index 00000000..ce064c18 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/Proxy/UdpTunnelProxyTests.cs @@ -0,0 +1,111 @@ +/* +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 System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Proxy; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.Proxy +{ + [TestClass] + public sealed class UdpTunnelProxyTests + { + [TestMethod] + public void Constructor_MustExposeTunnelEndPoint() + { + using Socket remoteSocket = + new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + + remoteSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + using UdpTunnelProxy tunnel = + new UdpTunnelProxy(remoteSocket, remoteSocket.LocalEndPoint); + + IPEndPoint tunnelEP = tunnel.TunnelEndPoint; + + Assert.IsNotNull( + tunnelEP, + "UdpTunnelProxy must expose a tunnel endpoint immediately after construction."); + + Assert.IsGreaterThan( +0, + tunnelEP.Port, "UdpTunnelProxy must bind an ephemeral UDP port."); + } + + [TestMethod] + public void Dispose_MustStopTunnelAndMarkBroken() + { + using Socket remoteSocket = + new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + + remoteSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + UdpTunnelProxy tunnel = + new UdpTunnelProxy(remoteSocket, remoteSocket.LocalEndPoint); + + tunnel.Dispose(); + tunnel.Dispose(); // idempotent + + Assert.IsTrue( + tunnel.IsBroken, + "Dispose must mark UdpTunnelProxy as broken and prevent further relay activity."); + } + + [TestMethod] + public async Task Tunnel_MustForwardDatagram_FromTunnelClient_ToRemoteSocket() + { + using Socket remoteSocket = + new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + + remoteSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + IPEndPoint remoteEP = (IPEndPoint)remoteSocket.LocalEndPoint!; + + using UdpTunnelProxy tunnel = + new UdpTunnelProxy(remoteSocket, remoteEP); + + using Socket tunnelClient = + new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + + byte[] payload = Encoding.ASCII.GetBytes("udp-ping"); + + byte[] buffer = new byte[32]; + EndPoint anyEP = new IPEndPoint(IPAddress.Any, 0); + + Task receiveTask = + remoteSocket.ReceiveFromAsync(buffer, SocketFlags.None, anyEP); + + await tunnelClient.SendToAsync( + payload, + SocketFlags.None, + tunnel.TunnelEndPoint); + + SocketReceiveFromResult result = await receiveTask; + + CollectionAssert.AreEqual( + payload, + buffer.AsSpan(0, result.ReceivedBytes).ToArray(), + "Datagram sent to TunnelEndPoint must reach the remote socket unmodified."); + } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/ProxyProtocol/ProxyProtocolStreamTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/ProxyProtocol/ProxyProtocolStreamTests.cs new file mode 100644 index 00000000..b243f920 --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/ProxyProtocol/ProxyProtocolStreamTests.cs @@ -0,0 +1,447 @@ +/* +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 System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.ProxyProtocol; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net.ProxyProtocol +{ + [TestClass] + public class ProxyProtocolStreamTests + { + [TestMethod] + public async Task CreateAsServerAsync_V1_MemoryStream_ParsesMetadataAndExposesPayload() + { + string line = "PROXY TCP4 192.168.0.1 192.168.0.11 56324 443"; + byte[] header = MakeV1(line); + byte[] payload = Encoding.ASCII.GetBytes("HELLO"); + byte[] source = Join(header, payload); + + using MemoryStream baseStream = new(source); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream, TestContext.CancellationToken); + + Assert.AreEqual(1, proxy.ProtocolVersion, "ProtocolVersion must be 1."); + Assert.AreEqual(AddressFamily.InterNetwork, proxy.AddressFamily, "TCP4 must map to IPv4."); + Assert.AreEqual(SocketType.Stream, proxy.SocketType); + Assert.AreEqual(IPAddress.Parse("192.168.0.1"), proxy.SourceAddress); + Assert.AreEqual(IPAddress.Parse("192.168.0.11"), proxy.DestinationAddress); + Assert.AreEqual(56324, proxy.SourcePort); + Assert.AreEqual(443, proxy.DestinationPort); + Assert.AreEqual(header.Length, proxy.DataOffset, "DataOffset must equal the header length."); + + byte[] buffer = new byte[payload.Length]; + int read = proxy.Read(buffer, 0, buffer.Length); + + Assert.AreEqual(payload.Length, read, "Read must return only payload bytes."); + Assert.AreEqual("HELLO", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task CreateAsServerAsync_V1_FragmentedStream_ReadsHeaderAcrossMultipleChunks() + { + string line = "PROXY TCP4 10.0.0.1 10.0.0.2 1000 2000"; + byte[] header = MakeV1(line); + byte[] payload = Encoding.ASCII.GetBytes("PAYLOAD"); + byte[] source = Join(header, payload); + + using FragmentedReadStream baseStream = + new(source, header.Length); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream, TestContext.CancellationToken); + + Assert.AreEqual(1, proxy.ProtocolVersion); + Assert.AreEqual("10.0.0.1", proxy.SourceAddress.ToString()); + Assert.AreEqual("10.0.0.2", proxy.DestinationAddress.ToString()); + Assert.AreEqual(1000, proxy.SourcePort); + Assert.AreEqual(2000, proxy.DestinationPort); + Assert.AreEqual(header.Length, proxy.DataOffset); + + byte[] buffer = new byte[payload.Length]; + int read = proxy.Read(buffer, 0, buffer.Length); + + Assert.AreEqual(payload.Length, read); + Assert.AreEqual("PAYLOAD", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task CreateAsServerAsync_V2_IPv4_MemoryStream_CorrectMetadataAndPayload() + { + IPAddress src = IPAddress.Parse("192.0.2.1"); + IPAddress dst = IPAddress.Parse("198.51.100.2"); + ushort srcPort = 12345; + ushort dstPort = 443; + + byte[] header = MakeV2v4(src, dst, srcPort, dstPort, local: false, streamProto: true); + byte[] payload = Encoding.ASCII.GetBytes("DATA"); + byte[] source = Join(header, payload); + + using MemoryStream baseStream = new(source); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream, TestContext.CancellationToken); + + Assert.AreEqual(2, proxy.ProtocolVersion); + Assert.IsFalse(proxy.IsLocal); + Assert.AreEqual(AddressFamily.InterNetwork, proxy.AddressFamily); + Assert.AreEqual(SocketType.Stream, proxy.SocketType); + Assert.AreEqual(src, proxy.SourceAddress); + Assert.AreEqual(dst, proxy.DestinationAddress); + Assert.AreEqual(srcPort, proxy.SourcePort); + Assert.AreEqual(dstPort, proxy.DestinationPort); + Assert.AreEqual(28, proxy.DataOffset, "IPv4 PROXY v2 header must be 16 + 12 bytes."); + + byte[] buffer = new byte[payload.Length]; + _ = proxy.Read(buffer, 0, buffer.Length); + + Assert.AreEqual("DATA", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task CreateAsServerAsync_V2_IPv4_Fragmented_StillParsesCorrectly() + { + IPAddress src = IPAddress.Parse("203.0.113.10"); + IPAddress dst = IPAddress.Parse("203.0.113.20"); + ushort srcPort = 8080; + ushort dstPort = 8443; + + byte[] header = MakeV2v4(src, dst, srcPort, dstPort, local: false, streamProto: true); + byte[] payload = Encoding.ASCII.GetBytes("FRAG"); + byte[] source = Join(header, payload); + + using FragmentedReadStream baseStream = + new(source, header.Length); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream, TestContext.CancellationToken); + + Assert.AreEqual(2, proxy.ProtocolVersion); + Assert.AreEqual(src, proxy.SourceAddress); + Assert.AreEqual(dst, proxy.DestinationAddress); + Assert.AreEqual(8080, proxy.SourcePort); + Assert.AreEqual(8443, proxy.DestinationPort); + + byte[] buffer = new byte[payload.Length]; + _ = proxy.Read(buffer, 0, buffer.Length); + + Assert.AreEqual("FRAG", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task CreateAsServerAsync_InvalidPrefix_ThrowsInvalidDataException() + { + // Arrange: must provide enough bytes to exceed detection thresholds (>=16 bytes) + byte[] bad = + { + 0xFF, 0xFF, 0xFF, 0xFF, + 0xAA, 0xAA, 0xAA, 0xAA, + 0xEE, 0xEE, 0xEE, 0xEE, + 0xCC, 0xCC, 0xCC, 0xCC, + 0x00 // ensures not EOF boundary + }; + + using MemoryStream baseStream = new(bad); + + // Act & Assert + InvalidDataException ex = + await Assert.ThrowsExactlyAsync( + () => ProxyProtocolStream.CreateAsServerAsync(baseStream, TestContext.CancellationToken)); + + Assert.IsTrue( + ex.Message.Contains("PROXY", StringComparison.OrdinalIgnoreCase), + "Exception message must indicate invalid PROXY protocol header."); + } + + [TestMethod] + public async Task CreateAsServerAsync_EndOfStreamBeforeHeader_ThrowsEndOfStreamException() + { + using MemoryStream baseStream = new(Encoding.ASCII.GetBytes("PROX")); + + _ = await Assert.ThrowsExactlyAsync( + () => ProxyProtocolStream.CreateAsServerAsync(baseStream, TestContext.CancellationToken)); + } + + [TestMethod] + public async Task Dispose_IsIdempotent_AndDisposesUnderlyingStream() + { + string line = "PROXY TCP4 127.0.0.1 127.0.0.1 1 2"; + byte[] header = MakeV1(line); + using MemoryStream underlying = new(header); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(underlying, TestContext.CancellationToken); + + proxy.Dispose(); + proxy.Dispose(); // must not throw + + Assert.ThrowsExactly( + () => underlying.ReadByte(), + "Underlying stream must actually be disposed."); + } + + [TestMethod] + public async Task FlushAfterDispose_Throws_ObjectDisposedException() + { + string line = "PROXY TCP4 127.0.0.1 127.0.0.1 10 20"; + byte[] header = MakeV1(line); + using MemoryStream underlying = new(header); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(underlying, TestContext.CancellationToken); + + proxy.Dispose(); + + Assert.ThrowsExactly( + () => proxy.Flush(), + "Flush() after disposal must signal ODE."); + } + + [TestMethod] + public async Task WriteAfterDispose_DoesNotWriteAndThrows() + { + string line = "PROXY TCP4 127.0.0.1 127.0.0.1 30 40"; + byte[] header = MakeV1(line); + using WriteTrackerStream baseStream = new(header); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream, TestContext.CancellationToken); + + proxy.Dispose(); + + byte[] bytes = Encoding.ASCII.GetBytes("NOPE"); + + Assert.ThrowsExactly( + () => proxy.Write(bytes, 0, bytes.Length)); + + Assert.IsFalse(baseStream.WroteAfterDispose, + "Write() must not propagate to underlying after disposal."); + } + + [TestMethod] + public async Task ReadAsyncBufferedData_ReturnsPayloadWithoutTouchingBaseStream() + { + string line = "PROXY TCP4 192.168.1.1 192.168.1.2 100 200"; + byte[] header = MakeV1(line); + byte[] payload = Encoding.ASCII.GetBytes("ASYNC"); + byte[] source = Join(header, payload); + + using MemoryStream baseStream = new(source); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream, TestContext.CancellationToken); + + byte[] buffer = new byte[payload.Length]; + int read = await proxy.ReadAsync(buffer, TestContext.CancellationToken); + + Assert.AreEqual(payload.Length, read); + Assert.AreEqual("ASYNC", Encoding.ASCII.GetString(buffer)); + } + + [TestMethod] + public async Task CapabilityAndSeekContract_IsCorrect() + { + string line = "PROXY TCP4 127.0.0.1 127.0.0.1 5 6"; + byte[] header = MakeV1(line); + + using MemoryStream baseStream = new(header); + + ProxyProtocolStream proxy = + await ProxyProtocolStream.CreateAsServerAsync(baseStream, TestContext.CancellationToken); + + Assert.IsTrue(proxy.CanRead); + Assert.IsFalse(proxy.CanSeek); + Assert.IsTrue(proxy.CanWrite); + + _ = Assert.ThrowsExactly(() => proxy.Length); + _ = Assert.ThrowsExactly(() => proxy.Seek(0, SeekOrigin.Begin)); + _ = Assert.ThrowsExactly(() => proxy.SetLength(0)); + } + + // -------------------- + // Helper functions + // -------------------- + private static byte[] MakeV1(string headerLineNoCrlf) + { + string full = headerLineNoCrlf + "\r\n"; + return Encoding.ASCII.GetBytes(full); + } + + private static byte[] Join(byte[] left, byte[] right) + { + byte[] result = new byte[left.Length + right.Length]; + Buffer.BlockCopy(left, 0, result, 0, left.Length); + Buffer.BlockCopy(right, 0, result, left.Length, right.Length); + return result; + } + + private static byte[] MakeV2sig() + { + return new byte[] + { + 0x0D,0x0A,0x0D,0x0A, + 0x00,0x0D,0x0A,0x51, + 0x55,0x49,0x54,0x0A + }; + } + + private static byte[] MakeV2v4( + IPAddress src, + IPAddress dst, + ushort srcPort, + ushort dstPort, + bool local, + bool streamProto) + { + byte[] sig = MakeV2sig(); + byte command = (byte)(local ? 0x0 : 0x1); // LOCAL or PROXY + byte versionNibble = 0x2; + byte verCmd = (byte)(versionNibble << 4 | command); + + byte afNibble = 1; + byte protoNibble = streamProto ? (byte)1 : (byte)2; + byte famProto = (byte)(afNibble << 4 | protoNibble); + + ushort len = 12; + byte[] h = new byte[16 + len]; + + Buffer.BlockCopy(sig, 0, h, 0, sig.Length); + h[12] = verCmd; + h[13] = famProto; + h[14] = (byte)(len >> 8); + h[15] = (byte)(len & 0xFF); + + byte[] srcb = src.GetAddressBytes(); + byte[] dstb = dst.GetAddressBytes(); + + Buffer.BlockCopy(srcb, 0, h, 16, 4); + Buffer.BlockCopy(dstb, 0, h, 20, 4); + + h[24] = (byte)(srcPort >> 8); + h[25] = (byte)(srcPort & 0xFF); + h[26] = (byte)(dstPort >> 8); + h[27] = (byte)(dstPort & 0xFF); + + return h; + } + + internal sealed class FragmentedReadStream : Stream + { + private readonly byte[] _data; + private readonly int _chunkSize; + private int _pos; + private bool _disposed; + + public FragmentedReadStream(byte[] data, int chunkSize) + { + _data = data; + _chunkSize = chunkSize; + } + + public override bool CanRead => !_disposed; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { } + + public override int Read(byte[] buffer, int offset, int count) + { + ObjectDisposedException.ThrowIf(_disposed, nameof(FragmentedReadStream)); + + if (_pos >= _data.Length) + return 0; + + int cut = Math.Min(count, _chunkSize); + cut = Math.Min(cut, _data.Length - _pos); + + Buffer.BlockCopy(_data, _pos, buffer, offset, cut); + _pos += cut; + return cut; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => Task.FromResult(Read(buffer, offset, count)); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + byte[] tmp = new byte[buffer.Length]; + int n = Read(tmp, 0, tmp.Length); + if (n > 0) + new ReadOnlySpan(tmp, 0, n).CopyTo(buffer.Span); + return ValueTask.FromResult(n); + } + + protected override void Dispose(bool disposing) + { + _disposed = true; + } + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + } + + internal sealed class WriteTrackerStream : MemoryStream + { + public bool WroteAfterDispose { get; private set; } + private bool _disposed; + + public WriteTrackerStream(byte[] initial) : base(initial) + { + } + + protected override void Dispose(bool disposing) + { + _disposed = true; + base.Dispose(disposing); + } + + public override void Write(byte[] buffer, int offset, int count) + { + if (_disposed) + WroteAfterDispose = true; + base.Write(buffer, offset, count); + } + } + + public TestContext TestContext { get; set; } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/SocketExtensionsTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/SocketExtensionsTests.cs new file mode 100644 index 00000000..87cac1bf --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/SocketExtensionsTests.cs @@ -0,0 +1,148 @@ +/* +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 System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class SocketExtensionsTests + { + [TestMethod] + public void GetEndPointAnyFor_ShouldReturnV4Any() + { + IPEndPoint ep = InvokeInternal(AddressFamily.InterNetwork); + Assert.AreEqual(IPAddress.Any, ep.Address); + Assert.AreEqual(0, ep.Port); + } + + [TestMethod] + public void GetEndPointAnyFor_ShouldReturnV6Any() + { + IPEndPoint ep = InvokeInternal(AddressFamily.InterNetworkV6); + Assert.AreEqual(IPAddress.IPv6Any, ep.Address); + Assert.AreEqual(0, ep.Port); + } + + [TestMethod] + public void GetEndPointAnyFor_ShouldRejectUnsupported() + { + Assert.ThrowsExactly(() => + InvokeInternal(AddressFamily.AppleTalk), + "Unsupported AF must surface NotSupportedException."); + } + + [TestMethod] + public void Connect_ShouldFail_OnTimeoutHost() + { + using Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + Assert.ThrowsExactly(() => + s.Connect("192.0.2.1", 6555, timeout: 1), + "Unreachable host must timeout immediately."); + } + + [TestMethod] + public void Connect_EndPoint_ShouldFail_OnTimeout() + { + using Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + IPEndPoint unreachable = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 65000); + + Assert.ThrowsExactly(() => + s.Connect(unreachable, timeout: 1), + "Timeout on explicit endpoint must raise."); + } + + [TestMethod] + public async Task UdpQueryAsync_ShouldTimeout_WhenReceivingNothing() + { + using Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + server.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + using Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + byte[] req = new byte[] { 1, 2, 3 }; + byte[] resp = new byte[512]; + + IPEndPoint? remote = (IPEndPoint?)server.LocalEndPoint; + + await Assert.ThrowsExactlyAsync(async () => + { + await client.UdpQueryAsync( + request: req, + response: resp, + remoteEP: remote, + timeout: 50, + retries: 1, cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task CopyToAsync_ShouldThrowSocketException_WhenDestinationClosesMidSend() + { + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + using Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await client.ConnectAsync((IPEndPoint)listener.LocalEndpoint, TestContext.CancellationToken); + + using Socket server = await listener.AcceptSocketAsync(TestContext.CancellationToken); + + Task copyTask = server.CopyToAsync(client); + + // Ensure data reaches read phase + await server.SendAsync(new byte[] { 1, 2, 3, 4 }, SocketFlags.None); + + // Give the receiving side time to begin processing + await Task.Delay(50, TestContext.CancellationToken); + + // Force destination break AFTER sending has begun + client.Close(); + + SocketException ex = await Assert.ThrowsExactlyAsync( + async () => await copyTask, + "Closing destination during active send must propagate socket failure."); + + Assert.AreNotEqual(SocketError.Success, ex.SocketErrorCode); + } + + private static IPEndPoint InvokeInternal(AddressFamily af) + { + MethodInfo method = typeof(SocketExtensions).GetMethod( + "GetEndPointAnyFor", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new MissingMethodException("SocketExtensions.GetEndPointAnyFor was not found."); + try + { + return (IPEndPoint)method.Invoke(null, new object[] { af })!; + } + catch (TargetInvocationException tie) when (tie.InnerException is not null) + { + // Preserve original intention + throw tie.InnerException; + } + } + + public TestContext TestContext { get; set; } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/WebUtilitiesTests.cs b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/WebUtilitiesTests.cs new file mode 100644 index 00000000..613e1fab --- /dev/null +++ b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.Net/WebUtilitiesTests.cs @@ -0,0 +1,375 @@ +/* +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 System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Mime; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Http.Client; + +namespace TechnitiumLibrary.UnitTests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class WebUtilitiesTests + { + #region GetFormattedSize + + [TestMethod] + public void GetFormattedSize_ShouldFormatBytesUnderThousand_AsBytes() + { + string s = WebUtilities.GetFormattedSize(999); + + Assert.AreEqual("999 B", s, + "Values below 1000 must remain in bytes with ' B' suffix."); + } + + [TestMethod] + public void GetFormattedSize_ShouldFormatExactKiB_AsKB() + { + double bytes = 1024; // 1 KiB + + string s = WebUtilities.GetFormattedSize(bytes); + + Assert.AreEqual("1 KB", s, + "1024 bytes must be rendered as '1 KB' using 1024 divisor."); + } + + [TestMethod] + public void GetFormattedSize_ShouldFormatExactMiB_AsMB() + { + double bytes = 1024 * 1024; // 1 MiB + + string s = WebUtilities.GetFormattedSize(bytes); + + Assert.AreEqual("1 MB", s, + "1 MiB must be rendered as '1 MB'."); + } + + [TestMethod] + public void GetFormattedSize_ShouldFormatExactGiB_AsGB() + { + double bytes = 1024d * 1024 * 1024; // 1 GiB + + string s = WebUtilities.GetFormattedSize(bytes); + + Assert.AreEqual("1 GB", s, + "1 GiB must be rendered as '1 GB'."); + } + + #endregion GetFormattedSize + + #region GetFormattedSpeed + + [TestMethod] + public void GetFormattedSpeed_ShouldFormatSmallAsBitsPerSecond_ByDefault() + { + double bytesPerSecond = 100; // 800 bps + + string s = WebUtilities.GetFormattedSpeed(bytesPerSecond); + + Assert.AreEqual("800 bps", s, + "Default mode must convert bytes to bits and stay in 'bps' for values < 1000."); + } + + [TestMethod] + public void GetFormattedSpeed_ShouldFormatMegabitPerSecond() + { + double bytesPerSecond = 125_000; // 1_000_000 bits/s → 1 mbps + + string s = WebUtilities.GetFormattedSpeed(bytesPerSecond); + + Assert.AreEqual("1 mbps", s, + "125000 B/s must be formatted as '1 mbps'."); + } + + [TestMethod] + public void GetFormattedSpeed_ShouldFormatKiBPerSecond_WhenUsingBytesMode() + { + double bytesPerSecond = 1024; // 1 KiB/s + + string s = WebUtilities.GetFormattedSpeed(bytesPerSecond, bitsPerSecond: false); + + Assert.AreEqual("1 KB/s", s, + "In bytes mode, 1024 bytes per second must be rendered as '1 KB/s'."); + } + + #endregion GetFormattedSpeed + + #region GetFormattedTime + + [TestMethod] + public void GetFormattedTime_ShouldReturnZeroSeconds_ForZeroInput() + { + string s = WebUtilities.GetFormattedTime(0); + + Assert.AreEqual("0 sec", s, + "Zero seconds must render as '0 sec'."); + } + + [TestMethod] + public void GetFormattedTime_ShouldRenderMinutesAndSeconds() + { + string s = WebUtilities.GetFormattedTime(61); + + Assert.AreEqual("1 min 1 sec", s, + "61 seconds must be formatted as '1 min 1 sec'."); + } + + [TestMethod] + public void GetFormattedTime_ShouldRenderHoursMinutesSeconds() + { + int seconds = 1 * 3600 + 2 * 60 + 3; // 1h 2m 3s + + string s = WebUtilities.GetFormattedTime(seconds); + + Assert.AreEqual("1 hour 2 mins 3 sec", s, + "Composite time must express hour, minute(s), and seconds with pluralization."); + } + + [TestMethod] + public void GetFormattedTime_ShouldRenderDaysAndHours_ONLY_WhenNoLowerUnits() + { + int seconds = 2 * 24 * 3600 + 5 * 3600; // 2 days 5 hours + + string s = WebUtilities.GetFormattedTime(seconds); + + Assert.AreEqual("2 days 5 hours", s, + "Whole days and hours with zero minutes/seconds should omit lower units."); + } + + #endregion GetFormattedTime + + #region GetContentType + + [TestMethod] + public void GetContentType_ShouldReturnDefaultForUnknownExtension() + { + ContentType ct = WebUtilities.GetContentType("file.unknownext"); + + Assert.AreEqual("application/octet-stream", ct.MediaType, + "Unknown extensions must map to binary 'application/octet-stream'."); + } + + [TestMethod] + public void GetContentType_ShouldBeCaseInsensitive_OnExtension() + { + ContentType lower = WebUtilities.GetContentType("photo.jpg"); + ContentType upper = WebUtilities.GetContentType("PHOTO.JPG"); + + Assert.AreEqual("image/jpeg", lower.MediaType); + Assert.AreEqual("image/jpeg", upper.MediaType, + "Extension must be treated case-insensitively."); + } + + [TestMethod] + public void GetContentType_ShouldRecognizeCommonScriptAndDocumentTypes() + { + ContentType js = WebUtilities.GetContentType("app.js"); + ContentType pdf = WebUtilities.GetContentType("doc.pdf"); + ContentType xlsx = WebUtilities.GetContentType("sheet.xlsx"); + + Assert.AreEqual("application/javascript", js.MediaType, + "'.js' must resolve to 'application/javascript'."); + Assert.AreEqual("application/pdf", pdf.MediaType, + "'.pdf' must resolve to 'application/pdf'."); + Assert.AreEqual( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + xlsx.MediaType, + "'.xlsx' must resolve to correct OOXML spreadsheet MIME type."); + } + + #endregion GetContentType + + #region IsWebAccessibleAsync + + [TestMethod] + public async Task IsWebAccessibleAsync_ShouldReturnFalse_ForAlwaysUnreachableUris() + { + // 198.51.100.0/24 is TEST-NET-2 (non-routed in normal internet). + Uri[] targets = + { + new Uri("http://198.51.100.1/"), + new Uri("http://198.51.100.2/") + }; + + bool ok = await WebUtilities.IsWebAccessibleAsync( + uriCheckList: targets, + proxy: null, + networkType: HttpClientNetworkType.Default, + timeout: 500, + throwException: false); + + Assert.IsFalse(ok, + "Unreachable test-net hosts must yield 'false' without throwing when throwException=false."); + } + + [TestMethod] + public async Task IsWebAccessibleAsync_ShouldThrowFailure_WhenThrowExceptionIsTrue() + { + Uri[] targets = + { + new Uri("http://198.51.100.1/"), + new Uri("http://198.51.100.2/") + }; + + try + { + await Assert.ThrowsExactlyAsync(async () => + { + _ = await WebUtilities.IsWebAccessibleAsync( + uriCheckList: targets, + proxy: null, + networkType: HttpClientNetworkType.Default, + timeout: 500, + throwException: true); + }); + } + catch (AssertFailedException) + { + await Assert.ThrowsExactlyAsync(async () => + { + _ = await WebUtilities.IsWebAccessibleAsync( + uriCheckList: targets, + proxy: null, + networkType: HttpClientNetworkType.Default, + timeout: 500, + throwException: true); + }); + } + } + + #endregion IsWebAccessibleAsync + + #region GetValidKestrelLocalAddresses + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldFilterUnsupportedFamilies() + { + // Only IPv4 Any and IPv6 Any are meaningful here; unsupported families are skipped by design. + List input = new List + { + IPAddress.Any, + IPAddress.IPv6Any + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + // Must never introduce new addresses, and must only contain supported families. + foreach (IPAddress addr in result) + { + Assert.IsTrue( + addr.AddressFamily == AddressFamily.InterNetwork + || addr.AddressFamily == AddressFamily.InterNetworkV6, + "Result must only contain IPv4 or IPv6 addresses."); + } + } + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldReplaceAnyWithLoopback_WhenUnicastPresent() + { + if (!Socket.OSSupportsIPv4) + Assert.Inconclusive("IPv4 not supported on this platform; skipping IPv4-specific behavior test."); + + List input = new List + { + IPAddress.Any, // 0.0.0.0 + IPAddress.Parse("10.0.0.1") // unicast + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + CollectionAssert.DoesNotContain( + (System.Collections.ICollection)result, + IPAddress.Any, + "When unicast IPv4 is present, '0.0.0.0' must be replaced, not preserved."); + + CollectionAssert.Contains( + (System.Collections.ICollection)result, + IPAddress.Loopback, + "'0.0.0.0' must be mapped to IPv4 loopback when unicast is also configured."); + + CollectionAssert.Contains( + (System.Collections.ICollection)result, + IPAddress.Parse("10.0.0.1"), + "Existing unicast IPv4 addresses must be preserved."); + } + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldPreferIPv6AnyOverIPv4Any_WhenNoUnicast() + { + if (!Socket.OSSupportsIPv4 || !Socket.OSSupportsIPv6) + Assert.Inconclusive("Both IPv4 and IPv6 support required to validate dual-stack 'Any' behavior."); + + List input = new List + { + IPAddress.Any, + IPAddress.IPv6Any + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + CollectionAssert.DoesNotContain( + (System.Collections.ICollection)result, + IPAddress.Any, + "When both 0.0.0.0 and [::] exist and no unicast is present, IPv4 Any must be removed."); + CollectionAssert.Contains( + (System.Collections.ICollection)result, + IPAddress.IPv6Any, + "[::] must remain when dual-stack Any was configured and no unicast exists."); + } + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldDeduplicateAddresses() + { + if (!Socket.OSSupportsIPv4) + Assert.Inconclusive("IPv4 not supported on this platform; skipping deduplication test."); + + IPAddress ip = IPAddress.Parse("192.0.2.10"); // TEST-NET-1 address + + List input = new List + { + ip, + ip, + IPAddress.Any + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + int countOfUnicast = 0; + foreach (IPAddress addr in result) + { + if (addr.Equals(ip)) + countOfUnicast++; + } + + Assert.AreEqual(1, countOfUnicast, + "Result must not contain duplicate unicast entries."); + } + + #endregion GetValidKestrelLocalAddresses + + public TestContext TestContext { get; set; } + } +} \ No newline at end of file diff --git a/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj b/TechnitiumLibrary.UnitTests/TechnitiumLibrary.UnitTests.csproj new file mode 100644 index 00000000..ae7f29e2 --- /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