Skip to content

Commit bffebd1

Browse files
committed
Fix PHPStan, improve coverage to ~100% on new DNS classes
- Drop $apiToken property promotion (only used in constructor body; PHPStan property.onlyWritten) - Add curl_* namespace stubs in tests/Pest.php to exercise JsonHttpClient::send() without a real HTTP server — same pattern as existing sleep() overrides - Add 8 send() tests covering curl_init failure, connection errors, HTTP 4xx, and all HTTP method branches (GET, POST+body, POST-no-body, DELETE) - Add propagationDelay sleep-branch test and real pollForTxtRecord() test (uses .invalid TLD for instant NXDOMAIN; fails open in ~1 s)
1 parent 40ffd69 commit bffebd1

4 files changed

Lines changed: 175 additions & 1 deletion

File tree

src/Challenge/Dns/CloudflareDns01Handler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class CloudflareDns01Handler extends AbstractDns01Handler
3030
private JsonHttpClient $httpClient;
3131

3232
public function __construct(
33-
private readonly string $apiToken,
33+
string $apiToken,
3434
private readonly ?string $zoneId = null,
3535
?JsonHttpClient $httpClient = null,
3636
) {

tests/Pest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,85 @@ function sleep(int $seconds): void
3636
}
3737
}
3838

39+
// ── Override curl_* in the Challenge\Dns\Internal namespace (unit tests only) ──
40+
// JsonHttpClient::send() calls curl_*() without a backslash, so PHP resolves
41+
// them in the current namespace first. Defining stubs here lets unit tests
42+
// exercise the full send() code path without a real HTTP server.
43+
//
44+
// Activate a fixture by setting $GLOBALS['__test_curl'] to an array:
45+
// init => bool (false = curl_init() failure; default: true)
46+
// body => string|false (false = connection error body; default: '')
47+
// status => int (HTTP status code; default: 200)
48+
// error => string (curl_error() output; default: '')
49+
// Unset $GLOBALS['__test_curl'] (or leave it unset) to use real curl.
50+
51+
namespace CoyoteCert\Challenge\Dns\Internal {
52+
function curl_init(string $url = ''): object|false
53+
{
54+
if (!isset($GLOBALS['__test_curl'])) {
55+
return \curl_init($url);
56+
}
57+
58+
if (!($GLOBALS['__test_curl']['init'] ?? true)) {
59+
return false;
60+
}
61+
62+
return new \stdClass();
63+
}
64+
65+
function curl_setopt_array(object|false $handle, array $options): bool
66+
{
67+
if (isset($GLOBALS['__test_curl'])) {
68+
return true;
69+
}
70+
71+
return \curl_setopt_array($handle, $options);
72+
}
73+
74+
function curl_setopt(object|false $handle, int $option, mixed $value): bool
75+
{
76+
if (isset($GLOBALS['__test_curl'])) {
77+
return true;
78+
}
79+
80+
return \curl_setopt($handle, $option, $value);
81+
}
82+
83+
function curl_exec(object|false $handle): string|bool
84+
{
85+
if (isset($GLOBALS['__test_curl'])) {
86+
return $GLOBALS['__test_curl']['body'] ?? '';
87+
}
88+
89+
return \curl_exec($handle);
90+
}
91+
92+
function curl_getinfo(object|false $handle, int $option = 0): mixed
93+
{
94+
if (isset($GLOBALS['__test_curl'])) {
95+
return $GLOBALS['__test_curl']['status'] ?? 200;
96+
}
97+
98+
return \curl_getinfo($handle, $option);
99+
}
100+
101+
function curl_error(object|false $handle): string
102+
{
103+
if (isset($GLOBALS['__test_curl'])) {
104+
return $GLOBALS['__test_curl']['error'] ?? '';
105+
}
106+
107+
return \curl_error($handle);
108+
}
109+
110+
function curl_close(object|false $handle): void
111+
{
112+
if (!isset($GLOBALS['__test_curl'])) {
113+
\curl_close($handle);
114+
}
115+
}
116+
}
117+
39118
// ── Global helpers ────────────────────────────────────────────────────────────
40119

41120
namespace {

tests/Unit/Challenge/Dns/AbstractDns01HandlerTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,32 @@ public function name(string $domain): string
109109
expect($prop->getValue((new TestDns01Handler())->propagationTimeout(30)))->toBe(30);
110110
});
111111

112+
it('propagationDelay executes the sleep branch when the delay is positive', function () {
113+
$handler = (new TestDns01Handler())->propagationDelay(5);
114+
$handler->deploy('example.com', 'tok', 'keyauth');
115+
116+
// sleep() is a no-op in the test namespace — verifies the branch runs cleanly.
117+
expect($handler->deployed)->toHaveCount(1);
118+
});
119+
120+
it('real pollForTxtRecord fails open when the DNS lookup cannot be satisfied', function () {
121+
$handler = new class extends AbstractDns01Handler {
122+
public function deploy(string $domain, string $token, string $keyAuth): void
123+
{
124+
$this->awaitPropagation($domain, $keyAuth);
125+
}
126+
127+
public function cleanup(string $domain, string $token): void {}
128+
};
129+
130+
// Does not override pollForTxtRecord() — exercises the real implementation.
131+
// DNS fails for .invalid; propagationTimeout(1) keeps the poll window short.
132+
// Fails open: no exception is thrown when the timeout is reached.
133+
expect(
134+
fn() => $handler->propagationTimeout(1)->deploy('test.invalid', '', 'no-such-record'),
135+
)->not->toThrow(\Throwable::class);
136+
});
137+
112138
// ── DNS propagation check ─────────────────────────────────────────────────────
113139

114140
it('pollForTxtRecord is called by default after deploy', function () {

tests/Unit/Challenge/Dns/Internal/JsonHttpClientTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,72 @@ function httpError(int $status): array
146146
expect(fn() => $client->request('GET', '/zones'))
147147
->toThrow(ChallengeException::class, 'HTTP 500');
148148
});
149+
150+
// ── Real send() via curl namespace stubs ──────────────────────────────────────
151+
// The following tests exercise JsonHttpClient::send() directly (no TestableJsonHttpClient
152+
// override). The curl_* stubs in tests/Pest.php intercept all curl calls so no
153+
// real HTTP connection is made. $GLOBALS['__test_curl'] activates the stubs;
154+
// afterEach() clears it so fixture state never leaks across tests.
155+
156+
afterEach(function () {
157+
unset($GLOBALS['__test_curl']);
158+
});
159+
160+
it('send() throws when curl fails to initialise', function () {
161+
$GLOBALS['__test_curl'] = ['init' => false];
162+
$client = new JsonHttpClient('https://api.example.com');
163+
164+
expect(fn() => $client->request('GET', '/zones'))
165+
->toThrow(ChallengeException::class, 'Failed to initialise cURL');
166+
});
167+
168+
it('send() throws on connection error', function () {
169+
$GLOBALS['__test_curl'] = ['body' => false, 'status' => 0, 'error' => 'Connection timed out'];
170+
$client = new JsonHttpClient('https://api.example.com');
171+
172+
expect(fn() => $client->request('GET', '/zones'))
173+
->toThrow(ChallengeException::class, 'Connection timed out');
174+
});
175+
176+
it('send() decodes a successful JSON response', function () {
177+
$GLOBALS['__test_curl'] = ['body' => '{"id":"zone-1"}', 'status' => 200];
178+
$client = new JsonHttpClient('https://api.example.com');
179+
180+
expect($client->request('GET', '/zones'))->toBe(['id' => 'zone-1']);
181+
});
182+
183+
it('send() returns empty array for an empty body', function () {
184+
$GLOBALS['__test_curl'] = ['body' => '', 'status' => 200];
185+
$client = new JsonHttpClient('https://api.example.com');
186+
187+
expect($client->request('DELETE', '/records/1'))->toBe([]);
188+
});
189+
190+
it('send() throws on HTTP 4xx from the real send path', function () {
191+
$GLOBALS['__test_curl'] = ['body' => '{"error":"forbidden"}', 'status' => 403];
192+
$client = new JsonHttpClient('https://api.example.com');
193+
194+
expect(fn() => $client->request('POST', '/zones'))
195+
->toThrow(ChallengeException::class, 'HTTP 403');
196+
});
197+
198+
it('send() exercises the POST-with-body curl branch', function () {
199+
$GLOBALS['__test_curl'] = ['body' => '{"created":true}', 'status' => 200];
200+
$client = new JsonHttpClient('https://api.example.com');
201+
202+
expect($client->request('POST', '/records', ['type' => 'TXT']))->toBe(['created' => true]);
203+
});
204+
205+
it('send() exercises the POST-without-body curl branch', function () {
206+
$GLOBALS['__test_curl'] = ['body' => '{"ok":true}', 'status' => 200];
207+
$client = new JsonHttpClient('https://api.example.com');
208+
209+
expect($client->request('POST', '/trigger'))->toBe(['ok' => true]);
210+
});
211+
212+
it('send() exercises the DELETE curl branch', function () {
213+
$GLOBALS['__test_curl'] = ['body' => '', 'status' => 200];
214+
$client = new JsonHttpClient('https://api.example.com');
215+
216+
expect($client->request('DELETE', '/records/42'))->toBe([]);
217+
});

0 commit comments

Comments
 (0)