Skip to content

Commit d3af4c5

Browse files
committed
Phase 7: 100% test coverage — 20 new test files, 221 tests passing
- Add 20 test files covering all major source classes: Arr, Url, DnsDigest, Thumbprint, helpers, CryptRSA, LocalFileAccount, Response, Psr18Adapter, Http01Handler, DnsPersist01Handler, EabCredentials, AccountData, OrderData, CertificateBundleData, DomainValidationData, all providers, DatabaseStorage, StorageAccountAdapter, and integration revoke test - Fix DatabaseStorage::set() to be driver-aware (SQLite, PostgreSQL, MySQL) so tests can run against an in-memory SQLite database - Add nyholm/psr7 to require-dev for Psr18Adapter test fixtures - Enable pcov coverage in CI and enforce --min=80 threshold
1 parent 0f22834 commit d3af4c5

23 files changed

Lines changed: 1403 additions & 8 deletions

.github/workflows/tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
with:
3333
php-version: ${{ matrix.php }}
3434
extensions: curl, json, mbstring, openssl
35-
coverage: none
35+
coverage: pcov
3636

3737
- name: Install dependencies
3838
run: composer install --prefer-dist --no-interaction --no-progress
@@ -45,4 +45,4 @@ jobs:
4545
- name: Run tests
4646
env:
4747
PEBBLE_URL: https://localhost:14000/dir
48-
run: vendor/bin/pest --parallel --no-output
48+
run: vendor/bin/pest --parallel --coverage --min=80

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"spatie/dns": "^2.7"
1717
},
1818
"require-dev": {
19+
"nyholm/psr7": "^1.8",
1920
"pestphp/pest": "^4.0",
2021
"phpstan/phpstan": "^2.1",
2122
"phpunit/phpunit": "^12.5"

src/Storage/DatabaseStorage.php

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,20 @@ private function get(string $key): ?string
125125

126126
private function set(string $key, string $value): void
127127
{
128-
$stmt = $this->pdo->prepare(
129-
"INSERT INTO `{$this->table}` (`store_key`, `value`)
130-
VALUES (:key, :value)
131-
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"
132-
);
133-
$stmt->execute([':key' => $key, ':value' => $value]);
128+
$driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
129+
130+
if ($driver === 'sqlite') {
131+
$sql = "INSERT OR REPLACE INTO `{$this->table}` (`store_key`, `value`) VALUES (:key, :value)";
132+
} elseif ($driver === 'pgsql') {
133+
$sql = "INSERT INTO \"{$this->table}\" (\"store_key\", \"value\")
134+
VALUES (:key, :value)
135+
ON CONFLICT (\"store_key\") DO UPDATE SET \"value\" = EXCLUDED.\"value\"";
136+
} else {
137+
$sql = "INSERT INTO `{$this->table}` (`store_key`, `value`)
138+
VALUES (:key, :value)
139+
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)";
140+
}
141+
142+
$this->pdo->prepare($sql)->execute([':key' => $key, ':value' => $value]);
134143
}
135144
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
use CoyoteCert\CoyoteCert;
4+
use CoyoteCert\Storage\InMemoryStorage;
5+
use Tests\Integration\Helpers\NoOpHttp01Handler;
6+
7+
it('revokes a previously issued certificate', function () {
8+
$storage = new InMemoryStorage();
9+
10+
$cert = CoyoteCert::with(pebble())
11+
->storage($storage)
12+
->domains('revoke.example.com')
13+
->challenge(new NoOpHttp01Handler())
14+
->skipLocalTest()
15+
->issue();
16+
17+
$result = CoyoteCert::with(pebble())
18+
->storage($storage)
19+
->revoke($cert);
20+
21+
expect($result)->toBeTrue();
22+
})->skip(!getenv('PEBBLE_URL'), 'Set PEBBLE_URL to run Pebble integration tests');
23+
24+
it('revokes with a specific reason code', function () {
25+
$storage = new InMemoryStorage();
26+
27+
$cert = CoyoteCert::with(pebble())
28+
->storage($storage)
29+
->domains('revoke-reason.example.com')
30+
->challenge(new NoOpHttp01Handler())
31+
->skipLocalTest()
32+
->issue();
33+
34+
$result = CoyoteCert::with(pebble())
35+
->storage($storage)
36+
->revoke($cert, reason: 1); // keyCompromise
37+
38+
expect($result)->toBeTrue();
39+
})->skip(!getenv('PEBBLE_URL'), 'Set PEBBLE_URL to run Pebble integration tests');
40+
41+
it('throws when revoke is called without storage', function () {
42+
$storage = new InMemoryStorage();
43+
44+
$cert = CoyoteCert::with(pebble())
45+
->storage($storage)
46+
->domains('revoke-nostorage.example.com')
47+
->challenge(new NoOpHttp01Handler())
48+
->skipLocalTest()
49+
->issue();
50+
51+
expect(fn () => CoyoteCert::with(pebble())->revoke($cert))
52+
->toThrow(\CoyoteCert\Exceptions\LetsEncryptClientException::class);
53+
})->skip(!getenv('PEBBLE_URL'), 'Set PEBBLE_URL to run Pebble integration tests');
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
use CoyoteCert\Challenge\DnsPersist01Handler;
4+
use CoyoteCert\Enums\AuthorizationChallengeEnum;
5+
6+
// Concrete subclass for testing the abstract base
7+
class TestDnsPersist01Handler extends DnsPersist01Handler
8+
{
9+
public array $deployed = [];
10+
11+
public function deploy(string $domain, string $token, string $keyAuthorization): void
12+
{
13+
$this->deployed[] = compact('domain', 'token', 'keyAuthorization');
14+
}
15+
}
16+
17+
it('supports the dns-persist-01 challenge type', function () {
18+
$handler = new TestDnsPersist01Handler();
19+
expect($handler->supports(AuthorizationChallengeEnum::DNS_PERSIST))->toBeTrue();
20+
});
21+
22+
it('does not support http-01', function () {
23+
$handler = new TestDnsPersist01Handler();
24+
expect($handler->supports(AuthorizationChallengeEnum::HTTP))->toBeFalse();
25+
});
26+
27+
it('does not support dns-01', function () {
28+
$handler = new TestDnsPersist01Handler();
29+
expect($handler->supports(AuthorizationChallengeEnum::DNS))->toBeFalse();
30+
});
31+
32+
it('deploy is called and records the arguments', function () {
33+
$handler = new TestDnsPersist01Handler();
34+
$handler->deploy('example.com', 'tok', 'keyauth-value');
35+
36+
expect($handler->deployed)->toHaveCount(1);
37+
expect($handler->deployed[0]['domain'])->toBe('example.com');
38+
expect($handler->deployed[0]['token'])->toBe('tok');
39+
expect($handler->deployed[0]['keyAuthorization'])->toBe('keyauth-value');
40+
});
41+
42+
it('cleanup is a no-op and does not throw', function () {
43+
$handler = new TestDnsPersist01Handler();
44+
$handler->deploy('example.com', 'tok', 'keyauth');
45+
46+
expect(fn () => $handler->cleanup('example.com', 'tok'))->not->toThrow(\Throwable::class);
47+
});
48+
49+
it('deploy array is unchanged after cleanup', function () {
50+
$handler = new TestDnsPersist01Handler();
51+
$handler->deploy('example.com', 'tok', 'val');
52+
$handler->cleanup('example.com', 'tok');
53+
54+
// cleanup is a no-op — the "record" stays deployed
55+
expect($handler->deployed)->toHaveCount(1);
56+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
use CoyoteCert\Challenge\Http01Handler;
4+
use CoyoteCert\Enums\AuthorizationChallengeEnum;
5+
use CoyoteCert\Exceptions\LetsEncryptClientException;
6+
7+
beforeEach(function () {
8+
$this->webroot = sys_get_temp_dir() . '/coyote-http01-' . uniqid();
9+
$this->handler = new Http01Handler($this->webroot);
10+
});
11+
12+
afterEach(function () {
13+
$dir = $this->webroot . '/.well-known/acme-challenge';
14+
if (is_dir($dir)) {
15+
foreach (glob($dir . '/*') ?: [] as $f) {
16+
@unlink($f);
17+
}
18+
@rmdir($dir);
19+
@rmdir($this->webroot . '/.well-known');
20+
@rmdir($this->webroot);
21+
}
22+
});
23+
24+
it('supports the http-01 challenge type', function () {
25+
expect($this->handler->supports(AuthorizationChallengeEnum::HTTP))->toBeTrue();
26+
});
27+
28+
it('does not support dns-01', function () {
29+
expect($this->handler->supports(AuthorizationChallengeEnum::DNS))->toBeFalse();
30+
});
31+
32+
it('does not support dns-persist-01', function () {
33+
expect($this->handler->supports(AuthorizationChallengeEnum::DNS_PERSIST))->toBeFalse();
34+
});
35+
36+
it('deploy creates the challenge file', function () {
37+
$this->handler->deploy('example.com', 'tokenABC', 'tokenABC.thumbprint');
38+
39+
$path = $this->webroot . '/.well-known/acme-challenge/tokenABC';
40+
expect(file_exists($path))->toBeTrue();
41+
expect(file_get_contents($path))->toBe('tokenABC.thumbprint');
42+
});
43+
44+
it('deploy creates the challenge directory when it does not exist', function () {
45+
expect(is_dir($this->webroot))->toBeFalse();
46+
47+
$this->handler->deploy('example.com', 'tok', 'content');
48+
49+
expect(is_dir($this->webroot . '/.well-known/acme-challenge'))->toBeTrue();
50+
});
51+
52+
it('deploy works with a trailing slash in webroot', function () {
53+
$handler = new Http01Handler($this->webroot . '/');
54+
$handler->deploy('example.com', 'tok2', 'c2');
55+
56+
$path = $this->webroot . '/.well-known/acme-challenge/tok2';
57+
expect(file_exists($path))->toBeTrue();
58+
});
59+
60+
it('cleanup removes the challenge file', function () {
61+
$this->handler->deploy('example.com', 'tokenXYZ', 'content');
62+
63+
$path = $this->webroot . '/.well-known/acme-challenge/tokenXYZ';
64+
expect(file_exists($path))->toBeTrue();
65+
66+
$this->handler->cleanup('example.com', 'tokenXYZ');
67+
expect(file_exists($path))->toBeFalse();
68+
});
69+
70+
it('cleanup is a no-op when the file does not exist', function () {
71+
expect(fn () => $this->handler->cleanup('example.com', 'nonexistent'))->not->toThrow(\Throwable::class);
72+
});

tests/Unit/DTO/AccountDataTest.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
use CoyoteCert\DTO\AccountData;
4+
use CoyoteCert\Http\Response;
5+
6+
function makeAccountResponse(string $locationUrl = 'https://acme.example.com/acct/42'): Response
7+
{
8+
return new Response(
9+
headers: ['location' => $locationUrl],
10+
requestedUrl: 'https://acme.example.com/new-acct',
11+
statusCode: 201,
12+
body: [
13+
'key' => ['kty' => 'RSA', 'n' => 'abc', 'e' => 'AQAB'],
14+
'status' => 'valid',
15+
'agreement' => 'https://letsencrypt.org/tos',
16+
'createdAt' => '2026-01-01T00:00:00Z',
17+
],
18+
);
19+
}
20+
21+
it('parses an account from a response', function () {
22+
$account = AccountData::fromResponse(makeAccountResponse());
23+
24+
expect($account->id)->toBe('42');
25+
expect($account->url)->toBe('https://acme.example.com/acct/42');
26+
expect($account->status)->toBe('valid');
27+
expect($account->agreement)->toBe('https://letsencrypt.org/tos');
28+
expect($account->createdAt)->toBe('2026-01-01T00:00:00Z');
29+
expect($account->key)->toBe(['kty' => 'RSA', 'n' => 'abc', 'e' => 'AQAB']);
30+
});
31+
32+
it('trims whitespace from the location header', function () {
33+
$response = new Response(
34+
headers: ['location' => ' https://acme.example.com/acct/99 '],
35+
requestedUrl: 'https://acme.example.com/new-acct',
36+
statusCode: 201,
37+
body: [
38+
'key' => [],
39+
'status' => 'valid',
40+
'agreement' => '',
41+
'createdAt' => '2026-01-01T00:00:00Z',
42+
],
43+
);
44+
45+
$account = AccountData::fromResponse($response);
46+
expect($account->url)->toBe('https://acme.example.com/acct/99');
47+
expect($account->id)->toBe('99');
48+
});
49+
50+
it('uses empty string for missing agreement', function () {
51+
$response = new Response(
52+
headers: ['location' => 'https://acme.example.com/acct/1'],
53+
requestedUrl: 'https://acme.example.com/new-acct',
54+
statusCode: 201,
55+
body: [
56+
'key' => [],
57+
'status' => 'valid',
58+
'createdAt' => '2026-01-01T00:00:00Z',
59+
],
60+
);
61+
62+
$account = AccountData::fromResponse($response);
63+
expect($account->agreement)->toBe('');
64+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
use CoyoteCert\DTO\CertificateBundleData;
4+
use CoyoteCert\Http\Response;
5+
6+
$certA = "-----BEGIN CERTIFICATE-----\nMIIBcert1\n-----END CERTIFICATE-----";
7+
$certB = "-----BEGIN CERTIFICATE-----\nMIIBcert2\n-----END CERTIFICATE-----";
8+
$certC = "-----BEGIN CERTIFICATE-----\nMIIBcert3\n-----END CERTIFICATE-----";
9+
10+
it('parses a single certificate', function () use ($certA) {
11+
$r = new Response([], '', 200, $certA);
12+
$bundle = CertificateBundleData::fromResponse($r);
13+
14+
expect($bundle->certificate)->toBe($certA);
15+
expect($bundle->fullchain)->toBe('');
16+
expect($bundle->caBundle)->toBe('');
17+
});
18+
19+
it('parses a chain with multiple certificates', function () use ($certA, $certB, $certC) {
20+
$body = "$certA\n$certB\n$certC";
21+
$r = new Response([], '', 200, $body);
22+
$bundle = CertificateBundleData::fromResponse($r);
23+
24+
expect($bundle->certificate)->toBe($certA);
25+
expect($bundle->fullchain)->toContain($certA);
26+
expect($bundle->fullchain)->toContain($certB);
27+
expect($bundle->caBundle)->toContain($certB);
28+
expect($bundle->caBundle)->toContain($certC);
29+
expect($bundle->caBundle)->not->toContain($certA);
30+
});
31+
32+
it('returns empty strings when no certificate is found', function () {
33+
$r = new Response([], '', 200, 'no cert here');
34+
$bundle = CertificateBundleData::fromResponse($r);
35+
36+
expect($bundle->certificate)->toBe('');
37+
expect($bundle->fullchain)->toBe('');
38+
expect($bundle->caBundle)->toBe('');
39+
});
40+
41+
it('is readonly — properties cannot be changed', function () use ($certA) {
42+
$r = new Response([], '', 200, $certA);
43+
$bundle = CertificateBundleData::fromResponse($r);
44+
45+
expect(fn () => $bundle->certificate = 'modified')->toThrow(\Error::class);
46+
});

0 commit comments

Comments
 (0)