CidrTest.php 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Ip;
  4. use App\Domain\Ip\Cidr;
  5. use App\Domain\Ip\InvalidCidrException;
  6. use App\Domain\Ip\IpAddress;
  7. use PHPUnit\Framework\TestCase;
  8. final class CidrTest extends TestCase
  9. {
  10. public function testParsesSimpleIpv4Cidr(): void
  11. {
  12. $cidr = Cidr::fromString('10.0.0.0/8');
  13. $this->assertTrue($cidr->isIpv4());
  14. $this->assertSame(8, $cidr->originalPrefix());
  15. $this->assertSame(104, $cidr->prefixLength());
  16. $this->assertSame('10.0.0.0/8', $cidr->text());
  17. }
  18. public function testParsesSlash32SingleHost(): void
  19. {
  20. $cidr = Cidr::fromString('192.168.1.42/32');
  21. $this->assertTrue($cidr->contains(IpAddress::fromString('192.168.1.42')));
  22. $this->assertFalse($cidr->contains(IpAddress::fromString('192.168.1.43')));
  23. }
  24. public function testV4Slash24Containment(): void
  25. {
  26. $cidr = Cidr::fromString('10.0.0.0/24');
  27. $this->assertTrue($cidr->contains(IpAddress::fromString('10.0.0.0')));
  28. $this->assertTrue($cidr->contains(IpAddress::fromString('10.0.0.1')));
  29. $this->assertTrue($cidr->contains(IpAddress::fromString('10.0.0.255')));
  30. $this->assertFalse($cidr->contains(IpAddress::fromString('10.0.1.0')));
  31. $this->assertFalse($cidr->contains(IpAddress::fromString('11.0.0.0')));
  32. }
  33. public function testV4Slash16Containment(): void
  34. {
  35. $cidr = Cidr::fromString('172.16.0.0/16');
  36. $this->assertTrue($cidr->contains(IpAddress::fromString('172.16.0.0')));
  37. $this->assertTrue($cidr->contains(IpAddress::fromString('172.16.255.255')));
  38. $this->assertFalse($cidr->contains(IpAddress::fromString('172.17.0.0')));
  39. $this->assertFalse($cidr->contains(IpAddress::fromString('172.15.255.255')));
  40. }
  41. public function testV4Slash0MatchesAllV4(): void
  42. {
  43. $cidr = Cidr::fromString('0.0.0.0/0');
  44. $this->assertSame(96, $cidr->prefixLength());
  45. $this->assertTrue($cidr->contains(IpAddress::fromString('0.0.0.0')));
  46. $this->assertTrue($cidr->contains(IpAddress::fromString('255.255.255.255')));
  47. $this->assertTrue($cidr->contains(IpAddress::fromString('1.2.3.4')));
  48. // /0 of v4 = ::ffff:0:0/96 → does NOT match pure IPv6
  49. $this->assertFalse($cidr->contains(IpAddress::fromString('2001:db8::1')));
  50. }
  51. public function testV6Slash0MatchesEverything(): void
  52. {
  53. $cidr = Cidr::fromString('::/0');
  54. $this->assertSame(0, $cidr->prefixLength());
  55. $this->assertTrue($cidr->contains(IpAddress::fromString('::1')));
  56. $this->assertTrue($cidr->contains(IpAddress::fromString('2001:db8::1')));
  57. // Even v4 (which is stored as v4-mapped v6) matches /0
  58. $this->assertTrue($cidr->contains(IpAddress::fromString('1.2.3.4')));
  59. }
  60. public function testV6Slash128SingleHost(): void
  61. {
  62. $cidr = Cidr::fromString('2001:db8::1/128');
  63. $this->assertTrue($cidr->contains(IpAddress::fromString('2001:db8::1')));
  64. $this->assertFalse($cidr->contains(IpAddress::fromString('2001:db8::2')));
  65. }
  66. public function testV6Slash64Containment(): void
  67. {
  68. $cidr = Cidr::fromString('2001:db8::/64');
  69. $this->assertTrue($cidr->contains(IpAddress::fromString('2001:db8::1')));
  70. $this->assertTrue($cidr->contains(IpAddress::fromString('2001:db8::ffff:ffff:ffff:ffff')));
  71. $this->assertFalse($cidr->contains(IpAddress::fromString('2001:db9::1')));
  72. }
  73. public function testV6CidrDoesNotContainV4(): void
  74. {
  75. $cidr = Cidr::fromString('2001:db8::/32');
  76. $this->assertFalse($cidr->contains(IpAddress::fromString('1.2.3.4')));
  77. }
  78. public function testV4InV4MappedV6Cidr(): void
  79. {
  80. // ::ffff:1.0.0.0/120 covers v4-mapped 1.0.0.0/24
  81. $cidr = Cidr::fromString('::ffff:1.0.0.0/120');
  82. $this->assertFalse($cidr->isIpv4(), 'CIDR is in v6 syntax even though it covers v4');
  83. $this->assertSame(120, $cidr->prefixLength());
  84. $this->assertTrue($cidr->contains(IpAddress::fromString('1.0.0.5')));
  85. $this->assertTrue($cidr->contains(IpAddress::fromString('1.0.0.255')));
  86. $this->assertFalse($cidr->contains(IpAddress::fromString('1.0.1.0')));
  87. }
  88. public function testV4MappedV6AddressInsideV4Cidr(): void
  89. {
  90. $cidr = Cidr::fromString('1.0.0.0/24');
  91. // The v4-mapped IPv6 form must still be contained because internal storage is unified.
  92. $this->assertTrue($cidr->contains(IpAddress::fromString('::ffff:1.0.0.7')));
  93. }
  94. public function testNetworkAddressMaskedFromHostBits(): void
  95. {
  96. $cidr = Cidr::fromString('10.1.2.99/24');
  97. $expected = IpAddress::fromString('10.1.2.0');
  98. $this->assertSame($expected->binary(), $cidr->network());
  99. $this->assertSame('10.1.2.0/24', $cidr->text());
  100. }
  101. public function testV6NetworkAddressMaskedFromHostBits(): void
  102. {
  103. $cidr = Cidr::fromString('2001:db8:abcd:1234::ff/56');
  104. $this->assertSame('2001:db8:abcd:1200::/56', $cidr->text());
  105. }
  106. public function testRejectsMissingSlash(): void
  107. {
  108. $this->expectException(InvalidCidrException::class);
  109. Cidr::fromString('10.0.0.0');
  110. }
  111. public function testRejectsEmpty(): void
  112. {
  113. $this->expectException(InvalidCidrException::class);
  114. Cidr::fromString('');
  115. }
  116. public function testRejectsBadIp(): void
  117. {
  118. $this->expectException(InvalidCidrException::class);
  119. Cidr::fromString('garbage/8');
  120. }
  121. public function testRejectsNegativePrefix(): void
  122. {
  123. $this->expectException(InvalidCidrException::class);
  124. Cidr::fromString('10.0.0.0/-1');
  125. }
  126. public function testRejectsV4PrefixOver32(): void
  127. {
  128. $this->expectException(InvalidCidrException::class);
  129. Cidr::fromString('10.0.0.0/33');
  130. }
  131. public function testRejectsV6PrefixOver128(): void
  132. {
  133. $this->expectException(InvalidCidrException::class);
  134. Cidr::fromString('2001:db8::/129');
  135. }
  136. public function testRejectsNonNumericPrefix(): void
  137. {
  138. $this->expectException(InvalidCidrException::class);
  139. Cidr::fromString('10.0.0.0/abc');
  140. }
  141. public function testRejectsMultipleSlashes(): void
  142. {
  143. $this->expectException(InvalidCidrException::class);
  144. Cidr::fromString('10.0.0.0/24/8');
  145. }
  146. public function testRejectsLeadingWhitespace(): void
  147. {
  148. $this->expectException(InvalidCidrException::class);
  149. Cidr::fromString(' 10.0.0.0/24');
  150. }
  151. public function testRejectsEmptyPrefix(): void
  152. {
  153. $this->expectException(InvalidCidrException::class);
  154. Cidr::fromString('10.0.0.0/');
  155. }
  156. public function testNonByteAlignedV4Prefix(): void
  157. {
  158. // /20 fixes the first 20 bits = 8 (byte 0) + 8 (byte 1) + 4 (high
  159. // nibble of byte 2). Exercises the partial-byte mask path.
  160. // 10.16.0.0/20 covers 10.16.0.0 — 10.16.15.255 (4096 addresses).
  161. $cidr = Cidr::fromString('10.16.0.0/20');
  162. $this->assertTrue($cidr->contains(IpAddress::fromString('10.16.0.0')));
  163. $this->assertTrue($cidr->contains(IpAddress::fromString('10.16.15.255')));
  164. $this->assertTrue($cidr->contains(IpAddress::fromString('10.16.5.42')));
  165. $this->assertFalse($cidr->contains(IpAddress::fromString('10.16.16.0')));
  166. $this->assertFalse($cidr->contains(IpAddress::fromString('10.15.255.255')));
  167. $this->assertSame('10.16.0.0/20', $cidr->text());
  168. }
  169. public function testNonByteAlignedV6Prefix(): void
  170. {
  171. // /17 fixes 16 bits + 1 high bit of byte 2. 2001:8000::/17 covers
  172. // any address whose first 17 bits start with 0x2001 followed by a 1.
  173. $cidr = Cidr::fromString('2001:8000::/17');
  174. $this->assertTrue($cidr->contains(IpAddress::fromString('2001:8000::')));
  175. $this->assertTrue($cidr->contains(IpAddress::fromString('2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff')));
  176. $this->assertFalse($cidr->contains(IpAddress::fromString('2001:7fff:ffff::')));
  177. $this->assertFalse($cidr->contains(IpAddress::fromString('2002::')));
  178. }
  179. public function testNonByteAlignedHostMaskingClearsBits(): void
  180. {
  181. // 10.16.5.7/20 should mask the host bits to 10.16.0.0/20
  182. $cidr = Cidr::fromString('10.16.5.7/20');
  183. $this->assertSame('10.16.0.0/20', $cidr->text());
  184. }
  185. }