await() — calling await() outside a fiber throws FiberError. * AsyncTestCase wraps each test in a fiber for us. */ final class IP4HelperTest extends AsyncTest { public function testMinimizeSubnetsEmpty(): void { self::assertSame([], IP4Helper::minimizeSubnets([])); } public function testMinimizeSubnetsSinglePassesThrough(): void { self::assertSame(['10.0.0.0/24'], IP4Helper::minimizeSubnets(['10.0.0.0/24'])); } public function testMinimizeSubnetsDropsSubsumedSubnets(): void { // /24 is contained in /8 → dropped. self::assertSame(['10.0.0.0/8'], IP4Helper::minimizeSubnets(['10.0.0.0/8', '10.0.0.0/24'])); } public function testMinimizeSubnetsHandlesUnorderedInput(): void { // The internal sortSubnets() must put the shorter prefix first so it // wins over the /24 regardless of input order. self::assertSame(['10.0.0.0/8'], IP4Helper::minimizeSubnets(['10.0.0.0/24', '10.0.0.0/8', '10.1.2.3/32'])); } public function testMinimizeSubnetsKeepsDistinctRanges(): void { $result = IP4Helper::minimizeSubnets(['10.0.0.0/24', '11.0.0.0/24', '192.0.2.0/24']); self::assertEqualsCanonicalizing(['10.0.0.0/24', '11.0.0.0/24', '192.0.2.0/24'], $result); } public function testMinimizeSubnetsFiltersFalsyEntries(): void { self::assertSame(['10.0.0.0/24'], IP4Helper::minimizeSubnets(['', '10.0.0.0/24', '0'])); } public function testSortSubnetsSortsByIpThenPrefix(): void { self::assertSame( ['1.0.0.0/24', '10.0.0.0/8', '10.0.0.0/24'], IP4Helper::sortSubnets(['10.0.0.0/24', '1.0.0.0/24', '10.0.0.0/8']) ); } public function testIsInCIDRMatchesContainedIp(): void { self::assertTrue(IP4Helper::isInCIDR('10.0.0.55', '10.0.0.0/24')); self::assertTrue(IP4Helper::isInCIDR('10.255.255.255', '10.0.0.0/8')); } public function testIsInCIDRRejectsOutOfRangeIp(): void { self::assertFalse(IP4Helper::isInCIDR('11.0.0.1', '10.0.0.0/24')); self::assertFalse(IP4Helper::isInCIDR('10.0.1.1', '10.0.0.0/24')); } public function testIsInRangeChecksAnyCidr(): void { self::assertTrue(IP4Helper::isInRange('192.0.2.5', ['10.0.0.0/8', '192.0.2.0/24'])); self::assertFalse(IP4Helper::isInRange('203.0.113.1', ['10.0.0.0/8', '192.0.2.0/24'])); self::assertFalse(IP4Helper::isInRange('10.0.0.1', [])); } public function testFormatShortIpMaskForCommonPrefixes(): void { self::assertSame('0.0.0.0', IP4Helper::formatShortIpMask('0')); self::assertSame('255.0.0.0', IP4Helper::formatShortIpMask('8')); self::assertSame('255.255.0.0', IP4Helper::formatShortIpMask('16')); self::assertSame('255.255.255.0', IP4Helper::formatShortIpMask('24')); self::assertSame('255.255.255.255', IP4Helper::formatShortIpMask('32')); } public function testFormatShortIpMaskDefaultsToSlash32OnEmpty(): void { self::assertSame('255.255.255.255', IP4Helper::formatShortIpMask('')); } public function testTrimCIDRsKeepsOnlyEntriesWithSlash(): void { self::assertSame( ['10.0.0.0/8', '192.0.2.0/24'], IP4Helper::trimCIDRs(['10.0.0.0/8', '1.2.3.4', 'not-a-cidr', '192.0.2.0/24']) ); } public function testProcessCIDREmptyInputReturnsEmpty(): void { self::assertSame([], IP4Helper::processCIDR([])); } public function testProcessCIDRSkipsLoopbackWithoutShelling(): void { // 127.0.0.1 is explicitly skipped at the top of the loop, so no shell // call fires and the returned set stays whatever was passed as $results. self::assertSame([], IP4Helper::processCIDR(['127.0.0.1'])); } public function testProcessCIDRSkipsIpAlreadyInExistingRange(): void { // 10.0.0.5 is contained in the pre-seeded 10.0.0.0/8 → early-return in // the async body before any shell_exec runs. The returned CIDRs are // the pre-seeded ones, passed through minimizeSubnets. self::assertSame(['10.0.0.0/8'], IP4Helper::processCIDR(['10.0.0.5'], ['10.0.0.0/8'])); } // ------------------------------------------------------------------ // growReplace / applyReplace — the CIDR-replacement feature (IPv4) // ------------------------------------------------------------------ /** * Builds a $replace-shaped stdClass for tests. cidr4 is an object * whose properties are CIDR strings mapping to arrays of replacement * entries — the same shape JSON decode produces. */ private function replace(array $cidr4 = [], array $cidr6 = []): object { $build = function (array $map): \stdClass { $obj = new \stdClass(); foreach ($map as $key => $values) { $obj->{$key} = $values; } return $obj; }; return (object) ['cidr4' => $build($cidr4), 'cidr6' => $build($cidr6)]; } public function testGrowReplaceAppendsContainedIpsAsSlash32(): void { $replace = $this->replace(['172.217.0.0/16' => []]); IP4Helper::growReplace($replace, ['172.217.17.206', '172.217.18.1', '1.2.3.4']); // 1.2.3.4 is outside the zone → not added. The two inside ones are // appended as /32. normalize + minimize leave them intact. self::assertEqualsCanonicalizing(['172.217.17.206/32', '172.217.18.1/32'], $replace->cidr4->{'172.217.0.0/16'}); } public function testGrowReplaceIsIdempotent(): void { // Second call with the same ips must not grow the list further. $replace = $this->replace(['172.217.0.0/16' => []]); IP4Helper::growReplace($replace, ['172.217.17.206', '172.217.18.1']); $after1 = $replace->cidr4->{'172.217.0.0/16'}; IP4Helper::growReplace($replace, ['172.217.17.206', '172.217.18.1']); $after2 = $replace->cidr4->{'172.217.0.0/16'}; self::assertSame($after1, $after2); } public function testGrowReplaceAbsorbsHostEntriesIntoAdminProvidedSubnet(): void { // Admin put /24 in the value array explicitly. A /32 inside that /24 // must be absorbed by minimizeSubnets, so the final list stays minimal. $replace = $this->replace(['172.217.0.0/16' => ['172.217.18.0/24']]); IP4Helper::growReplace($replace, ['172.217.18.5', '172.217.19.9']); self::assertEqualsCanonicalizing(['172.217.18.0/24', '172.217.19.9/32'], $replace->cidr4->{'172.217.0.0/16'}); } public function testGrowReplaceNoOpWhenCidr4MissingOrWrongType(): void { // Missing cidr4 — no-op, no exception. $replace = (object) []; IP4Helper::growReplace($replace, ['1.2.3.4']); self::assertFalse(isset($replace->cidr4)); // Wrong type on cidr4 (array, not object) — treated as missing. $replace = (object) ['cidr4' => []]; IP4Helper::growReplace($replace, ['1.2.3.4']); self::assertSame([], $replace->cidr4); } public function testGrowReplaceEmptyMapIsNoOp(): void { $replace = $this->replace([]); IP4Helper::growReplace($replace, ['1.2.3.4']); self::assertSame([], (array) $replace->cidr4); } public function testApplyReplaceDropsKeyAndSubstitutesValues(): void { $replace = $this->replace(['172.217.0.0/16' => ['172.217.17.206/32', '172.217.18.0/24']]); $result = IP4Helper::applyReplace(['10.0.0.0/8', '172.217.0.0/16', '203.0.113.0/24'], $replace); // Key removed, values appended. Order: leftover cidrs first, then values. self::assertSame(['10.0.0.0/8', '203.0.113.0/24', '172.217.17.206/32', '172.217.18.0/24'], $result); } public function testApplyReplaceKeepsKeyValuesWhenKeyAbsentFromCidrs(): void { // Admin may use `replace` to augment the output even when the // cidr is not present in the source list. $replace = $this->replace(['1.2.3.0/24' => ['1.2.3.4/32']]); $result = IP4Helper::applyReplace(['10.0.0.0/8'], $replace); self::assertSame(['10.0.0.0/8', '1.2.3.4/32'], $result); } public function testApplyReplaceEmptyValueArrayDropsKeyEntirely(): void { $replace = $this->replace(['172.217.0.0/16' => []]); $result = IP4Helper::applyReplace(['10.0.0.0/8', '172.217.0.0/16'], $replace); self::assertSame(['10.0.0.0/8'], $result); } public function testApplyReplaceReturnsCidrsUnchangedWhenCidr4Missing(): void { $replace = (object) []; self::assertSame( ['10.0.0.0/8', '172.217.0.0/16'], IP4Helper::applyReplace(['10.0.0.0/8', '172.217.0.0/16'], $replace) ); } public function testApplyReplaceReturnsCidrsUnchangedWhenCidr4Empty(): void { $replace = $this->replace([]); self::assertSame(['10.0.0.0/8'], IP4Helper::applyReplace(['10.0.0.0/8'], $replace)); } // ------------------------------------------------------------------ // aggregateSubnets — CIDR supernetting (IPv4) // ------------------------------------------------------------------ public function testAggregateSubnetsEmptyReturnsEmpty(): void { self::assertSame([], IP4Helper::aggregateSubnets([])); } public function testAggregateSubnetsMergesFourConsecutiveSlash32IntoSlash30(): void { self::assertSame( ['1.0.0.0/30'], IP4Helper::aggregateSubnets(['1.0.0.0/32', '1.0.0.1/32', '1.0.0.2/32', '1.0.0.3/32']) ); } public function testAggregateSubnets256ConsecutiveSlash32IntoSlash24(): void { $inputs = []; for ($i = 0; $i < 256; $i++) { $inputs[] = "10.0.0.$i/32"; } self::assertSame(['10.0.0.0/24'], IP4Helper::aggregateSubnets($inputs)); } public function testAggregateSubnetsMergesAdjacentRanges(): void { // 10.0.0.0/24 and 10.0.1.0/24 are adjacent → /23. self::assertSame(['10.0.0.0/23'], IP4Helper::aggregateSubnets(['10.0.0.0/24', '10.0.1.0/24'])); } public function testAggregateSubnetsCollapsesOverlappingRanges(): void { // /24 absorbs a /32 inside it; no residual /32 in output. self::assertSame(['10.0.0.0/24'], IP4Helper::aggregateSubnets(['10.0.0.0/24', '10.0.0.5/32'])); } public function testAggregateSubnetsKeepsDisjointRanges(): void { $result = IP4Helper::aggregateSubnets(['10.0.0.0/24', '192.0.2.0/24']); self::assertSame(['10.0.0.0/24', '192.0.2.0/24'], $result); } public function testAggregateSubnetsRespectsParentCapBelowFullCoverage(): void { // Four /32s filling a /30 can aggregate to /30, but the parent is /30. // Cap forces output strictly narrower than /30 → two /31 blocks. $result = IP4Helper::aggregateSubnets( ['1.0.0.0/32', '1.0.0.1/32', '1.0.0.2/32', '1.0.0.3/32'], '1.0.0.0/30' ); self::assertSame(['1.0.0.0/31', '1.0.0.2/31'], $result); } public function testAggregateSubnetsNeverEmitsParentItself(): void { // Input literally equals parent → must be split into two halves. self::assertSame( ['1.0.0.0/9', '1.128.0.0/9'], IP4Helper::aggregateSubnets(['1.0.0.0/8'], '1.0.0.0/8') ); } public function testAggregateSubnetsSkipsMalformedEntries(): void { self::assertSame( ['10.0.0.0/24'], IP4Helper::aggregateSubnets(['not-a-cidr', '', '10.0.0.0/24', '10.0.0.0/99', 'bogus/24']) ); } public function testAggregateSubnetsIdempotent(): void { $first = IP4Helper::aggregateSubnets(['1.0.0.0/32', '1.0.0.1/32', '1.0.0.2/32', '1.0.0.3/32']); $second = IP4Helper::aggregateSubnets($first); self::assertSame($first, $second); } public function testGrowReplaceWithAggregateFusesContiguousIps(): void { // 4 consecutive IPs inside the parent /16 — without aggregate they'd // stay as four /32 entries; with aggregate they collapse to one /30. $replace = $this->replace(['2.16.0.0/13' => []]); IP4Helper::growReplace( $replace, ['2.16.0.0', '2.16.0.1', '2.16.0.2', '2.16.0.3'], true ); self::assertSame(['2.16.0.0/30'], $replace->cidr4->{'2.16.0.0/13'}); } public function testGrowReplaceWithAggregateNeverProducesParentKey(): void { // Fill the entire parent /30 with /32s. Aggregation would normally // collapse to /30 (== parent), but the cap splits it into two /31s. $replace = $this->replace(['1.0.0.0/30' => []]); IP4Helper::growReplace( $replace, ['1.0.0.0', '1.0.0.1', '1.0.0.2', '1.0.0.3'], true ); self::assertSame(['1.0.0.0/31', '1.0.0.2/31'], $replace->cidr4->{'1.0.0.0/30'}); } public function testGrowReplaceDefaultDoesNotAggregate(): void { // Default behaviour preserved: without $aggregate, minimize keeps // individual /32 entries because they don't contain each other. $replace = $this->replace(['2.16.0.0/13' => []]); IP4Helper::growReplace($replace, ['2.16.0.0', '2.16.0.1']); self::assertEqualsCanonicalizing( ['2.16.0.0/32', '2.16.0.1/32'], $replace->cidr4->{'2.16.0.0/13'} ); } // ------------------------------------------------------------------ // Density collapse — lossy /24-bucket expansion (IPv4) // ------------------------------------------------------------------ public function testAggregateSubnetsDensityCollapsesScatteredSlash32(): void { // 10 sparse /32s inside a single /24 — none are adjacent, so lossless // aggregation would keep all 10. With densityThreshold=10 the whole // /24 is claimed. $cidrs = [ '2.16.0.5/32', '2.16.0.10/32', '2.16.0.20/32', '2.16.0.40/32', '2.16.0.50/32', '2.16.0.60/32', '2.16.0.70/32', '2.16.0.80/32', '2.16.0.90/32', '2.16.0.100/32', ]; self::assertSame(['2.16.0.0/24'], IP4Helper::aggregateSubnets($cidrs, '2.16.0.0/13', 10)); } public function testAggregateSubnetsDensityBelowThresholdKeepsOriginalBlocks(): void { $cidrs = [ '2.16.0.5/32', '2.16.0.10/32', '2.16.0.20/32', '2.16.0.40/32', '2.16.0.50/32', '2.16.0.60/32', '2.16.0.70/32', '2.16.0.80/32', '2.16.0.90/32', '2.16.0.100/32', ]; // Coverage = 10, threshold = 11 → no expansion. self::assertSame($cidrs, IP4Helper::aggregateSubnets($cidrs, '2.16.0.0/13', 11)); } public function testAggregateSubnetsDensityCountsCoveredAddressesNotBlocks(): void { // Four blocks: one /30 (4 addresses) + three /32s = 7 addresses. // Threshold 7 should trigger the collapse; threshold 8 should not. $cidrs = ['10.0.0.0/30', '10.0.0.100/32', '10.0.0.120/32', '10.0.0.150/32']; self::assertSame(['10.0.0.0/24'], IP4Helper::aggregateSubnets($cidrs, '10.0.0.0/16', 7)); self::assertSame($cidrs, IP4Helper::aggregateSubnets($cidrs, '10.0.0.0/16', 8)); } public function testAggregateSubnetsDensityMergesAdjacentExpandedBuckets(): void { // Two adjacent /24s each reach the threshold → both expand → they // touch at the boundary → re-merge fuses them into /23. $cidrs = array_merge( array_map(fn(int $i) => "1.0.0.$i/32", range(0, 9)), array_map(fn(int $i) => "1.0.1.$i/32", range(0, 9)) ); self::assertSame(['1.0.0.0/23'], IP4Helper::aggregateSubnets($cidrs, '1.0.0.0/8', 10)); } public function testAggregateSubnetsDensitySkippedWhenParentPrefixAtLeast24(): void { // Parent is /24 itself — no room for /24 buckets inside. Density // logic is skipped; standard aggregation applies (with parent cap // forcing /25+). $result = IP4Helper::aggregateSubnets( ['1.0.0.0/32', '1.0.0.1/32', '1.0.0.2/32'], '1.0.0.0/24', 2 ); self::assertSame(['1.0.0.0/31', '1.0.0.2/32'], $result); } public function testAggregateSubnetsDensityZeroIsIdentityToLossless(): void { $cidrs = ['1.0.0.0/32', '1.0.0.50/32', '1.0.0.100/32']; $lossless = IP4Helper::aggregateSubnets($cidrs, '1.0.0.0/8'); $withZero = IP4Helper::aggregateSubnets($cidrs, '1.0.0.0/8', 0); self::assertSame($lossless, $withZero); } public function testGrowReplacePassesDensityThresholdThrough(): void { $replace = $this->replace(['2.16.0.0/13' => []]); // 20 scattered IPs in /24 at 2.16.0.0 — enough to trip threshold 15. $ips = array_map(fn(int $i) => '2.16.0.' . ($i * 10), range(0, 19)); IP4Helper::growReplace($replace, $ips, true, 15); self::assertSame(['2.16.0.0/24'], $replace->cidr4->{'2.16.0.0/13'}); } // ------------------------------------------------------------------ // Density collapse — wide /16 tier // ------------------------------------------------------------------ public function testAggregateSubnetsDensityWideCollapsesAcrossSlash24s(): void { // 40 /32s spread across 4 different /24s within one /16. Each /24 // has only 10 covered addresses — under a narrow tier threshold of // 11 no /24 inflates. But the wide tier sees 40 addresses in the // /16 and claims the whole /16. $cidrs = []; for ($s = 0; $s < 4; $s++) { for ($i = 0; $i < 10; $i++) { $cidrs[] = "100.64.$s.$i/32"; } } self::assertSame(['100.64.0.0/16'], IP4Helper::aggregateSubnets($cidrs, '100.64.0.0/10', 11, 40)); } public function testAggregateSubnetsDensityTieredPyramidFeedsWideFromNarrow(): void { // Each /24 reaches narrow threshold (5) → inflated to full /24 (256 // addresses). Four inflated /24s give 1024 addresses in the /16 → // wide threshold (1000) triggers → /16 is claimed. $cidrs = []; for ($s = 0; $s < 4; $s++) { for ($i = 0; $i < 10; $i++) { $cidrs[] = "100.64.$s.$i/32"; } } self::assertSame(['100.64.0.0/16'], IP4Helper::aggregateSubnets($cidrs, '100.64.0.0/10', 5, 1000)); } public function testAggregateSubnetsDensityWideSkippedWhenParentPrefixAtLeast16(): void { // Parent /16 leaves no room for /16 buckets — wide tier skipped. // Narrow tier still runs (parent=16 < 24). $result = IP4Helper::aggregateSubnets( ['100.64.0.0/32', '100.64.0.1/32', '100.64.0.2/32', '100.64.0.3/32'], '100.64.0.0/16', 4, 4 ); // Narrow threshold 4 + 4 coverage → /24 inflates. Wide is skipped. self::assertSame(['100.64.0.0/24'], $result); } public function testAggregateSubnetsDensityWideZeroDoesNotTrigger(): void { // wide=0 means disabled — only narrow runs (if set). $cidrs = array_map(fn(int $i) => "100.64.$i.0/32", range(0, 9)); $result = IP4Helper::aggregateSubnets($cidrs, '100.64.0.0/10', 0, 0); self::assertSame($cidrs, $result); } public function testGrowReplacePassesBothDensityThresholdsThrough(): void { $replace = $this->replace(['100.64.0.0/10' => []]); $ips = []; for ($s = 0; $s < 4; $s++) { for ($i = 0; $i < 10; $i++) { $ips[] = "100.64.$s.$i"; } } IP4Helper::growReplace($replace, $ips, true, 5, 1000); self::assertSame(['100.64.0.0/16'], $replace->cidr4->{'100.64.0.0/10'}); } }