Skip to content

Commit 6894b03

Browse files
committed
Phase 3: full unit and integration test suite with CI
- Unit tests for Base64, KeyType, RenewalWindow, StoredCertificate, JWK, JWS, KeyId, InMemoryStorage, FilesystemStorage - Integration tests against Pebble (skipped unless PEBBLE_URL is set) - NoOpHttp01Handler for Pebble's PEBBLE_VA_ALWAYS_VALID=1 mode - GitHub Actions workflow running Pebble as a service container - Fix remainingDays() to return 0 for expired certificates - Use pre-generated EC key PEMs in tests to avoid openssl_pkey_new() env issues
1 parent dbc558f commit 6894b03

14 files changed

Lines changed: 754 additions & 4 deletions

.github/workflows/tests.yml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,20 @@ jobs:
1212
strategy:
1313
fail-fast: false
1414
matrix:
15-
php: ["8.3", "8.4", "8.5"]
15+
php: ["8.3", "8.4"]
16+
17+
services:
18+
pebble:
19+
image: letsencrypt/pebble:latest
20+
ports:
21+
- 14000:14000
22+
- 15000:15000
23+
env:
24+
PEBBLE_VA_NOSLEEP: "1"
25+
PEBBLE_VA_ALWAYS_VALID: "1"
1626

1727
steps:
18-
- uses: actions/checkout@v6
28+
- uses: actions/checkout@v4
1929

2030
- name: Set up PHP ${{ matrix.php }}
2131
uses: shivammathur/setup-php@v2
@@ -27,5 +37,12 @@ jobs:
2737
- name: Install dependencies
2838
run: composer install --prefer-dist --no-interaction --no-progress
2939

40+
- name: Wait for Pebble
41+
run: |
42+
timeout 30 bash -c \
43+
'until curl -sk https://localhost:14000/dir > /dev/null 2>&1; do sleep 1; done'
44+
3045
- name: Run tests
46+
env:
47+
PEBBLE_URL: https://localhost:14000/dir
3148
run: vendor/bin/pest --parallel --no-output

src/Storage/StoredCertificate.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public static function fromArray(array $data): self
4343

4444
public function remainingDays(): int
4545
{
46-
return (int) (new \DateTimeImmutable())->diff($this->expiresAt)->days;
46+
$diff = (new \DateTimeImmutable())->diff($this->expiresAt);
47+
48+
return $diff->invert ? 0 : (int) $diff->days;
4749
}
4850
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
use CoyoteCert\CoyoteCert;
4+
use CoyoteCert\Enums\KeyType;
5+
use CoyoteCert\Provider\Pebble;
6+
use CoyoteCert\Storage\InMemoryStorage;
7+
use CoyoteCert\Storage\StoredCertificate;
8+
use Tests\Integration\Helpers\NoOpHttp01Handler;
9+
10+
$pebbleUrl = getenv('PEBBLE_URL') ?: 'https://localhost:14000/dir';
11+
12+
function pebble(): Pebble
13+
{
14+
return new Pebble(url: getenv('PEBBLE_URL') ?: 'https://localhost:14000/dir', verifyTls: false);
15+
}
16+
17+
it('issues a certificate with default key types (RSA account, EC_P256 cert)', function () use ($pebbleUrl) {
18+
$cert = CoyoteCert::with(pebble())
19+
->storage(new InMemoryStorage())
20+
->domains('test.example.com')
21+
->challenge(new NoOpHttp01Handler())
22+
->skipLocalTest()
23+
->issue();
24+
25+
expect($cert)->toBeInstanceOf(StoredCertificate::class);
26+
expect($cert->certificate)->toContain('-----BEGIN CERTIFICATE-----');
27+
expect($cert->privateKey)->toContain('-----BEGIN');
28+
expect($cert->fullchain)->toContain('-----BEGIN CERTIFICATE-----');
29+
expect($cert->domains)->toBe(['test.example.com']);
30+
expect(openssl_x509_parse($cert->certificate))->toBeArray();
31+
})->skip(!getenv('PEBBLE_URL'), 'Set PEBBLE_URL to run Pebble integration tests');
32+
33+
it('issues a certificate with an EC P-256 account key', function () {
34+
$cert = CoyoteCert::with(pebble())
35+
->storage(new InMemoryStorage())
36+
->domains('ec-account.example.com')
37+
->accountKeyType(KeyType::EC_P256)
38+
->challenge(new NoOpHttp01Handler())
39+
->skipLocalTest()
40+
->issue();
41+
42+
expect($cert->certificate)->toContain('-----BEGIN CERTIFICATE-----');
43+
})->skip(!getenv('PEBBLE_URL'), 'Set PEBBLE_URL to run Pebble integration tests');
44+
45+
it('reuses an existing account on a second issue', function () {
46+
$storage = new InMemoryStorage();
47+
48+
$first = CoyoteCert::with(pebble())
49+
->storage($storage)
50+
->domains('reuse.example.com')
51+
->challenge(new NoOpHttp01Handler())
52+
->skipLocalTest()
53+
->issue();
54+
55+
// Second call reuses the account key already in $storage
56+
$second = CoyoteCert::with(pebble())
57+
->storage($storage)
58+
->domains('reuse.example.com')
59+
->challenge(new NoOpHttp01Handler())
60+
->skipLocalTest()
61+
->issue();
62+
63+
expect($first->certificate)->not->toBe($second->certificate);
64+
expect($second->certificate)->toContain('-----BEGIN CERTIFICATE-----');
65+
})->skip(!getenv('PEBBLE_URL'), 'Set PEBBLE_URL to run Pebble integration tests');
66+
67+
it('issueOrRenew returns the cached cert when still valid', function () {
68+
$storage = new InMemoryStorage();
69+
70+
$issued = CoyoteCert::with(pebble())
71+
->storage($storage)
72+
->domains('cached.example.com')
73+
->challenge(new NoOpHttp01Handler())
74+
->skipLocalTest()
75+
->issue();
76+
77+
$returned = CoyoteCert::with(pebble())
78+
->storage($storage)
79+
->domains('cached.example.com')
80+
->challenge(new NoOpHttp01Handler())
81+
->skipLocalTest()
82+
->issueOrRenew(daysBeforeExpiry: 1); // very low threshold — cert won't expire this soon
83+
84+
expect($returned->certificate)->toBe($issued->certificate);
85+
})->skip(!getenv('PEBBLE_URL'), 'Set PEBBLE_URL to run Pebble integration tests');
86+
87+
it('issues a certificate with the shortlived profile', function () {
88+
$cert = CoyoteCert::with(pebble())
89+
->storage(new InMemoryStorage())
90+
->domains('profile.example.com')
91+
->profile('shortlived')
92+
->challenge(new NoOpHttp01Handler())
93+
->skipLocalTest()
94+
->issue();
95+
96+
expect($cert->certificate)->toContain('-----BEGIN CERTIFICATE-----');
97+
})->skip(!getenv('PEBBLE_URL'), 'Set PEBBLE_URL to run Pebble integration tests');
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace Tests\Integration\Helpers;
4+
5+
use CoyoteCert\Enums\AuthorizationChallengeEnum;
6+
use CoyoteCert\Interfaces\ChallengeHandlerInterface;
7+
8+
/**
9+
* Challenge handler used in integration tests.
10+
* Does nothing — Pebble runs with PEBBLE_VA_ALWAYS_VALID=1 so no real file is needed.
11+
*/
12+
class NoOpHttp01Handler implements ChallengeHandlerInterface
13+
{
14+
public function supports(AuthorizationChallengeEnum $type): bool
15+
{
16+
return $type === AuthorizationChallengeEnum::HTTP;
17+
}
18+
19+
public function deploy(string $domain, string $token, string $keyAuth): void {}
20+
21+
public function cleanup(string $domain, string $token): void {}
22+
}

tests/Pest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
use Tests\TestCase;
44

5-
pest()->extend(TestCase::class)->in('Feature');
5+
pest()->extend(TestCase::class)->in('Unit', 'Integration');
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
use CoyoteCert\DTO\RenewalWindow;
4+
5+
it('isOpen returns true when now is inside the window', function () {
6+
$window = new RenewalWindow(
7+
start: new DateTimeImmutable('-1 hour'),
8+
end: new DateTimeImmutable('+1 hour'),
9+
);
10+
11+
expect($window->isOpen())->toBeTrue();
12+
});
13+
14+
it('isOpen returns false when now is before the window', function () {
15+
$window = new RenewalWindow(
16+
start: new DateTimeImmutable('+1 day'),
17+
end: new DateTimeImmutable('+2 days'),
18+
);
19+
20+
expect($window->isOpen())->toBeFalse();
21+
});
22+
23+
it('isOpen returns false when now is after the window', function () {
24+
$window = new RenewalWindow(
25+
start: new DateTimeImmutable('-2 days'),
26+
end: new DateTimeImmutable('-1 day'),
27+
);
28+
29+
expect($window->isOpen())->toBeFalse();
30+
});
31+
32+
it('exposes start, end, and explanationUrl', function () {
33+
$start = new DateTimeImmutable('2026-01-01T00:00:00Z');
34+
$end = new DateTimeImmutable('2026-01-07T00:00:00Z');
35+
$window = new RenewalWindow(start: $start, end: $end, explanationUrl: 'https://example.com');
36+
37+
expect($window->start)->toBe($start);
38+
expect($window->end)->toBe($end);
39+
expect($window->explanationUrl)->toBe('https://example.com');
40+
});
41+
42+
it('explanationUrl defaults to null', function () {
43+
$window = new RenewalWindow(
44+
start: new DateTimeImmutable(),
45+
end: new DateTimeImmutable('+1 hour'),
46+
);
47+
48+
expect($window->explanationUrl)->toBeNull();
49+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
use CoyoteCert\Storage\StoredCertificate;
4+
5+
function makeCert(array $overrides = []): StoredCertificate
6+
{
7+
return new StoredCertificate(
8+
certificate: $overrides['certificate'] ?? 'cert-pem',
9+
privateKey: $overrides['privateKey'] ?? 'key-pem',
10+
fullchain: $overrides['fullchain'] ?? 'fullchain-pem',
11+
caBundle: $overrides['caBundle'] ?? 'ca-bundle-pem',
12+
issuedAt: $overrides['issuedAt'] ?? new DateTimeImmutable('2026-01-01T00:00:00+00:00'),
13+
expiresAt: $overrides['expiresAt'] ?? new DateTimeImmutable('2026-04-01T00:00:00+00:00'),
14+
domains: $overrides['domains'] ?? ['example.com'],
15+
);
16+
}
17+
18+
it('roundtrips through toArray/fromArray', function () {
19+
$cert = makeCert();
20+
21+
expect(StoredCertificate::fromArray($cert->toArray())->toArray())->toBe($cert->toArray());
22+
});
23+
24+
it('toArray contains all expected keys', function () {
25+
$data = makeCert()->toArray();
26+
27+
expect($data)->toHaveKeys(['certificate', 'private_key', 'fullchain', 'ca_bundle', 'issued_at', 'expires_at', 'domains']);
28+
});
29+
30+
it('remainingDays returns approximate days until expiry', function () {
31+
$cert = makeCert([
32+
'expiresAt' => new DateTimeImmutable('+30 days'),
33+
]);
34+
35+
expect($cert->remainingDays())->toBeBetween(29, 31);
36+
});
37+
38+
it('remainingDays returns zero for an expired certificate', function () {
39+
$cert = makeCert([
40+
'expiresAt' => new DateTimeImmutable('-1 day'),
41+
]);
42+
43+
expect($cert->remainingDays())->toBe(0);
44+
});
45+
46+
it('preserves all domain entries', function () {
47+
$cert = makeCert(['domains' => ['example.com', 'www.example.com', '*.example.com']]);
48+
49+
expect(StoredCertificate::fromArray($cert->toArray())->domains)
50+
->toBe(['example.com', 'www.example.com', '*.example.com']);
51+
});

tests/Unit/Enums/KeyTypeTest.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
use CoyoteCert\Enums\KeyType;
4+
5+
it('returns the correct JWS algorithm', function (KeyType $type, string $alg) {
6+
expect($type->jwsAlgorithm())->toBe($alg);
7+
})->with([
8+
[KeyType::RSA_2048, 'RS256'],
9+
[KeyType::RSA_4096, 'RS256'],
10+
[KeyType::EC_P256, 'ES256'],
11+
[KeyType::EC_P384, 'ES384'],
12+
]);
13+
14+
it('returns the correct bit count for RSA', function (KeyType $type, int $bits) {
15+
expect($type->bits())->toBe($bits);
16+
})->with([
17+
[KeyType::RSA_2048, 2048],
18+
[KeyType::RSA_4096, 4096],
19+
]);
20+
21+
it('returns null bits for EC types', function (KeyType $type) {
22+
expect($type->bits())->toBeNull();
23+
})->with([[KeyType::EC_P256], [KeyType::EC_P384]]);
24+
25+
it('returns the correct curve name', function (KeyType $type, string $curve) {
26+
expect($type->curveName())->toBe($curve);
27+
})->with([
28+
[KeyType::EC_P256, 'prime256v1'],
29+
[KeyType::EC_P384, 'secp384r1'],
30+
]);
31+
32+
it('returns null curve name for RSA', function (KeyType $type) {
33+
expect($type->curveName())->toBeNull();
34+
})->with([[KeyType::RSA_2048], [KeyType::RSA_4096]]);
35+
36+
it('correctly identifies RSA vs EC', function (KeyType $type, bool $isRsa, bool $isEc) {
37+
expect($type->isRsa())->toBe($isRsa);
38+
expect($type->isEc())->toBe($isEc);
39+
})->with([
40+
[KeyType::RSA_2048, true, false],
41+
[KeyType::RSA_4096, true, false],
42+
[KeyType::EC_P256, false, true],
43+
[KeyType::EC_P384, false, true],
44+
]);
45+
46+
it('returns the correct openssl key type constant', function (KeyType $type, int $const) {
47+
expect($type->openSslType())->toBe($const);
48+
})->with([
49+
[KeyType::RSA_2048, OPENSSL_KEYTYPE_RSA],
50+
[KeyType::RSA_4096, OPENSSL_KEYTYPE_RSA],
51+
[KeyType::EC_P256, OPENSSL_KEYTYPE_EC],
52+
[KeyType::EC_P384, OPENSSL_KEYTYPE_EC],
53+
]);

0 commit comments

Comments
 (0)