Skip to content

Commit 9209240

Browse files
abnegateclaude
andcommitted
(test): comprehensive unit tests for adapter, resolver, DNS, and validation
Add 80 new test methods covering previously untested code paths: - All 28 protocol port detections in TCP adapter - IPv6 SSRF validation (::1, fe80::, fc00::, fd00::, ::ffff:) - Adapter.parseEndpoint() edge cases - Adapter.route() callback paths (string return, no resolver) - TCP adapter closeConnection/isSockmapActive/setSockmap - Resolver\Fixed resolve behavior - DNS resolve non-coroutine path, cache, IPv6 literals - Port edge cases (0, 1, negative, non-numeric) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8a309b3 commit 9209240

6 files changed

Lines changed: 748 additions & 0 deletions

File tree

tests/AdapterActionsTest.php

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,108 @@ public function testSetSkipValidationReturnsSelf(): void
8585
$result = $adapter->setSkipValidation(true);
8686
$this->assertSame($adapter, $result);
8787
}
88+
89+
public function testSetCacheTtlReturnsSelf(): void
90+
{
91+
$adapter = new Adapter($this->resolver, protocol: Protocol::HTTP);
92+
93+
$result = $adapter->setCacheTTL(120);
94+
$this->assertSame($adapter, $result);
95+
}
96+
97+
public function testGetProtocolReturnsConstructedProtocol(): void
98+
{
99+
$http = new Adapter($this->resolver, protocol: Protocol::HTTP);
100+
$smtp = new Adapter($this->resolver, protocol: Protocol::SMTP);
101+
$tcp = new Adapter($this->resolver, protocol: Protocol::TCP);
102+
103+
$this->assertSame(Protocol::HTTP, $http->getProtocol());
104+
$this->assertSame(Protocol::SMTP, $smtp->getProtocol());
105+
$this->assertSame(Protocol::TCP, $tcp->getProtocol());
106+
}
107+
108+
public function testDefaultProtocolIsTcp(): void
109+
{
110+
$adapter = new Adapter($this->resolver);
111+
$this->assertSame(Protocol::TCP, $adapter->getProtocol());
112+
}
113+
114+
public function testRouteCallbackReturningStringEndpoint(): void
115+
{
116+
$adapter = new Adapter($this->resolver, protocol: Protocol::HTTP);
117+
$adapter->setSkipValidation(true);
118+
$adapter->onResolve(function (string $data): string {
119+
return '10.0.0.1:8080';
120+
});
121+
122+
$result = $adapter->route('test');
123+
$this->assertSame('10.0.0.1:8080', $result->endpoint);
124+
$this->assertSame(Protocol::HTTP, $result->protocol);
125+
$this->assertFalse($result->metadata['cached']);
126+
}
127+
128+
public function testRouteThrowsWhenNoResolverAndNoCallback(): void
129+
{
130+
$adapter = new Adapter(null, protocol: Protocol::HTTP);
131+
132+
$this->expectException(ResolverException::class);
133+
$this->expectExceptionMessage('No resolver or resolve callback configured');
134+
135+
$adapter->route('test');
136+
}
137+
138+
public function testRouteCallbackReturningInvalidTypeThrows(): void
139+
{
140+
$adapter = new Adapter($this->resolver, protocol: Protocol::HTTP);
141+
$adapter->setSkipValidation(true);
142+
$adapter->onResolve(function (string $data): int {
143+
return 42;
144+
});
145+
146+
$this->expectException(ResolverException::class);
147+
$this->expectExceptionMessage('Resolve callback must return Result or string');
148+
149+
$adapter->route('test');
150+
}
151+
152+
public function testRouteWithNullResolverButValidCallback(): void
153+
{
154+
$adapter = new Adapter(null, protocol: Protocol::TCP);
155+
$adapter->setSkipValidation(true);
156+
$adapter->onResolve(function (string $data): string {
157+
return '10.0.0.1:5432';
158+
});
159+
160+
$result = $adapter->route('my-resource');
161+
$this->assertSame('10.0.0.1:5432', $result->endpoint);
162+
}
163+
164+
public function testRouteProtocolIsPreservedInResult(): void
165+
{
166+
$this->resolver->setEndpoint('8.8.8.8:80');
167+
$adapter = new Adapter($this->resolver, protocol: Protocol::SMTP);
168+
169+
$result = $adapter->route('test');
170+
$this->assertSame(Protocol::SMTP, $result->protocol);
171+
}
172+
173+
public function testRouteMetadataFromResolverIsMerged(): void
174+
{
175+
$this->resolver->setEndpoint('8.8.8.8:80');
176+
$adapter = new Adapter($this->resolver, protocol: Protocol::HTTP);
177+
178+
$result = $adapter->route('my-input');
179+
$this->assertSame('my-input', $result->metadata['data']);
180+
$this->assertFalse($result->metadata['cached']);
181+
}
182+
183+
public function testTcpAdapterRouteUsesParentProtocol(): void
184+
{
185+
$this->resolver->setEndpoint('8.8.8.8:5432');
186+
$adapter = new TCPAdapter(port: 5432, resolver: $this->resolver);
187+
$adapter->setSkipValidation(true);
188+
189+
$result = $adapter->route('data');
190+
$this->assertSame(Protocol::PostgreSQL, $result->protocol);
191+
}
88192
}

tests/DnsTest.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ public function testEmptyStringPassesThrough(): void
3030

3131
public function testUnresolvableHostnameReturnsInput(): void
3232
{
33+
if (!\extension_loaded('swoole')) {
34+
$this->markTestSkipped('ext-swoole is required for hostname resolution.');
35+
}
36+
3337
$host = 'this-hostname-definitely-does-not-exist-12345.invalid';
3438
$this->assertSame($host, Dns::resolve($host));
3539
}
@@ -46,4 +50,86 @@ public function testClearEmptiesCache(): void
4650
Dns::clear();
4751
$this->addToAssertionCount(1);
4852
}
53+
54+
public function testResolvableHostnameReturnsIp(): void
55+
{
56+
if (!\extension_loaded('swoole')) {
57+
$this->markTestSkipped('ext-swoole is required for hostname resolution.');
58+
}
59+
60+
// google.com should resolve to a valid IP
61+
$result = Dns::resolve('google.com');
62+
$this->assertNotSame('google.com', $result);
63+
$this->assertNotFalse(\filter_var($result, FILTER_VALIDATE_IP));
64+
}
65+
66+
public function testCacheHitReturnsSameIp(): void
67+
{
68+
if (!\extension_loaded('swoole')) {
69+
$this->markTestSkipped('ext-swoole is required for hostname resolution.');
70+
}
71+
72+
Dns::clear();
73+
Dns::setTtl(60);
74+
75+
$first = Dns::resolve('google.com');
76+
$second = Dns::resolve('google.com');
77+
78+
$this->assertSame($first, $second);
79+
}
80+
81+
public function testCacheExpiry(): void
82+
{
83+
if (!\extension_loaded('swoole')) {
84+
$this->markTestSkipped('ext-swoole is required for hostname resolution.');
85+
}
86+
87+
Dns::clear();
88+
Dns::setTtl(1);
89+
90+
$first = Dns::resolve('google.com');
91+
$this->assertNotSame('google.com', $first);
92+
93+
sleep(2);
94+
95+
// After TTL expires, it re-resolves (should still return a valid IP)
96+
$second = Dns::resolve('google.com');
97+
$this->assertNotFalse(\filter_var($second, FILTER_VALIDATE_IP));
98+
}
99+
100+
public function testSetTtlToZeroDisablesCache(): void
101+
{
102+
Dns::clear();
103+
Dns::setTtl(0);
104+
$this->assertSame(0, Dns::ttl());
105+
}
106+
107+
public function testMultipleHostsAreCachedIndependently(): void
108+
{
109+
if (!\extension_loaded('swoole')) {
110+
$this->markTestSkipped('ext-swoole is required for hostname resolution.');
111+
}
112+
113+
Dns::clear();
114+
Dns::setTtl(60);
115+
116+
$google = Dns::resolve('google.com');
117+
$cloudflare = Dns::resolve('one.one.one.one');
118+
119+
// Both should resolve to valid IPs, and they should differ
120+
$this->assertNotFalse(\filter_var($google, FILTER_VALIDATE_IP));
121+
$this->assertNotFalse(\filter_var($cloudflare, FILTER_VALIDATE_IP));
122+
}
123+
124+
public function testIpv6LiteralPassesThrough(): void
125+
{
126+
$this->assertSame('::1', Dns::resolve('::1'));
127+
$this->assertSame('fe80::1', Dns::resolve('fe80::1'));
128+
}
129+
130+
public function testDefaultTtlIsSixty(): void
131+
{
132+
// After setUp resets it to 60
133+
$this->assertSame(60, Dns::ttl());
134+
}
49135
}

tests/EndpointValidationTest.php

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,165 @@ public function testAcceptsPortZeroImplicit(): void
258258
$result = $adapter->route('test');
259259
$this->assertSame('8.8.8.8', $result->endpoint);
260260
}
261+
262+
public function testRejectsIpv6Loopback(): void
263+
{
264+
$this->resolver->setEndpoint('::1');
265+
$adapter = $this->createAdapter();
266+
267+
$this->expectException(ResolverException::class);
268+
$this->expectExceptionMessage('private/reserved IPv6');
269+
270+
$adapter->route('test');
271+
}
272+
273+
public function testRejectsIpv6LinkLocal(): void
274+
{
275+
$this->resolver->setEndpoint('fe80::1');
276+
$adapter = $this->createAdapter();
277+
278+
$this->expectException(ResolverException::class);
279+
$this->expectExceptionMessage('private/reserved IPv6');
280+
281+
$adapter->route('test');
282+
}
283+
284+
public function testRejectsIpv6UniqueLocalFc00(): void
285+
{
286+
$this->resolver->setEndpoint('fc00::1');
287+
$adapter = $this->createAdapter();
288+
289+
$this->expectException(ResolverException::class);
290+
$this->expectExceptionMessage('private/reserved IPv6');
291+
292+
$adapter->route('test');
293+
}
294+
295+
public function testRejectsIpv6UniqueLocalFd00(): void
296+
{
297+
$this->resolver->setEndpoint('fd00::1');
298+
$adapter = $this->createAdapter();
299+
300+
$this->expectException(ResolverException::class);
301+
$this->expectExceptionMessage('private/reserved IPv6');
302+
303+
$adapter->route('test');
304+
}
305+
306+
public function testRejectsIpv6MappedIpv4(): void
307+
{
308+
$this->resolver->setEndpoint('::ffff:127.0.0.1');
309+
$adapter = $this->createAdapter();
310+
311+
$this->expectException(ResolverException::class);
312+
$this->expectExceptionMessage('private/reserved IPv6');
313+
314+
$adapter->route('test');
315+
}
316+
317+
public function testRejectsIpv6MappedIpv4UpperCase(): void
318+
{
319+
$this->resolver->setEndpoint('::FFFF:10.0.0.1');
320+
$adapter = $this->createAdapter();
321+
322+
$this->expectException(ResolverException::class);
323+
$this->expectExceptionMessage('private/reserved IPv6');
324+
325+
$adapter->route('test');
326+
}
327+
328+
public function testAcceptsPublicIpv6(): void
329+
{
330+
// 2001:4860:4860::8888 is Google's public IPv6 DNS
331+
$this->resolver->setEndpoint('2001:4860:4860::8888');
332+
$adapter = $this->createAdapter();
333+
334+
$result = $adapter->route('test');
335+
$this->assertSame('2001:4860:4860::8888', $result->endpoint);
336+
}
337+
338+
public function testSkipValidationAllowsIpv6Loopback(): void
339+
{
340+
$this->resolver->setEndpoint('::1');
341+
$adapter = $this->createAdapter();
342+
$adapter->setSkipValidation(true);
343+
344+
$result = $adapter->route('test');
345+
$this->assertSame('::1', $result->endpoint);
346+
}
347+
348+
public function testSkipValidationAllowsIpv6LinkLocal(): void
349+
{
350+
$this->resolver->setEndpoint('fe80::1');
351+
$adapter = $this->createAdapter();
352+
$adapter->setSkipValidation(true);
353+
354+
$result = $adapter->route('test');
355+
$this->assertSame('fe80::1', $result->endpoint);
356+
}
357+
358+
public function testRejectsPortZeroExplicit(): void
359+
{
360+
$this->resolver->setEndpoint('8.8.8.8:0');
361+
$adapter = $this->createAdapter();
362+
363+
$this->expectException(ResolverException::class);
364+
$this->expectExceptionMessage('Invalid port number');
365+
366+
$adapter->route('test');
367+
}
368+
369+
public function testAcceptsPortOne(): void
370+
{
371+
$this->resolver->setEndpoint('8.8.8.8:1');
372+
$adapter = $this->createAdapter();
373+
374+
$result = $adapter->route('test');
375+
$this->assertSame('8.8.8.8:1', $result->endpoint);
376+
}
377+
378+
public function testRejectsNegativePort(): void
379+
{
380+
$this->resolver->setEndpoint('8.8.8.8:-1');
381+
$adapter = $this->createAdapter();
382+
383+
$this->expectException(ResolverException::class);
384+
$this->expectExceptionMessage('Invalid port number');
385+
386+
$adapter->route('test');
387+
}
388+
389+
public function testRejectsNonNumericPort(): void
390+
{
391+
$this->resolver->setEndpoint('8.8.8.8:abc');
392+
$adapter = $this->createAdapter();
393+
394+
$this->expectException(ResolverException::class);
395+
$this->expectExceptionMessage('Invalid port number');
396+
397+
$adapter->route('test');
398+
}
399+
400+
public function testRejectsEmptyHost(): void
401+
{
402+
$this->resolver->setEndpoint(':8080');
403+
$adapter = $this->createAdapter();
404+
405+
$this->expectException(ResolverException::class);
406+
407+
$adapter->route('test');
408+
}
409+
410+
public function testValidateReplacesHostnameWithResolvedIp(): void
411+
{
412+
// google.com should resolve to a public IP
413+
$this->resolver->setEndpoint('google.com:80');
414+
$adapter = $this->createAdapter();
415+
416+
$result = $adapter->route('test');
417+
418+
// The endpoint should be an IP:port, not the hostname
419+
$this->assertMatchesRegularExpression('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:80$/', $result->endpoint);
420+
$this->assertStringNotContainsString('google.com', $result->endpoint);
421+
}
261422
}

0 commit comments

Comments
 (0)