Skip to content

Commit cb1419d

Browse files
committed
test(crl): add unit tests for CrlRevocationChecker
Covers cache hit/miss, local vs remote CRL paths, REVOKED/VALID/NO_URLS outcomes, openssl exit codes, serial-matching, and revocation-date extraction. Uses an inner testable subclass to override execOpenSslCrl without spawning a real process. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent 754f654 commit cb1419d

1 file changed

Lines changed: 265 additions & 0 deletions

File tree

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Libresign\Tests\Unit\Service\Crl;
11+
12+
use OCA\Libresign\AppInfo\Application;
13+
use OCA\Libresign\Enum\CrlValidationStatus;
14+
use OCA\Libresign\Service\Crl\CrlRevocationChecker;
15+
use OCA\Libresign\Service\Crl\Ldap\LdapCrlDownloader;
16+
use OCP\IAppConfig;
17+
use OCP\ICache;
18+
use OCP\ICacheFactory;
19+
use OCP\IConfig;
20+
use OCP\ITempManager;
21+
use OCP\IURLGenerator;
22+
use PHPUnit\Framework\Attributes\DataProvider;
23+
use PHPUnit\Framework\MockObject\MockObject;
24+
use PHPUnit\Framework\TestCase;
25+
use Psr\Log\LoggerInterface;
26+
27+
/**
28+
* Minimal subclass that promotes the two pure-algorithm methods to public so
29+
* tests can call them directly without reflection.
30+
*/
31+
class CrlRevocationCheckerTestable extends CrlRevocationChecker {
32+
public function publicIsSerialNumberInCrl(string $crlText, string $serialNumber): bool {
33+
return $this->isSerialNumberInCrl($crlText, $serialNumber);
34+
}
35+
36+
public function publicExtractRevocationDateFromCrlText(string $crlText, array $serialNumbers): ?string {
37+
return $this->extractRevocationDateFromCrlText($crlText, $serialNumbers);
38+
}
39+
}
40+
41+
class CrlRevocationCheckerTest extends TestCase {
42+
private IConfig&MockObject $config;
43+
private IAppConfig&MockObject $appConfig;
44+
private IURLGenerator&MockObject $urlGenerator;
45+
private ITempManager&MockObject $tempManager;
46+
private LoggerInterface&MockObject $logger;
47+
private ICacheFactory&MockObject $cacheFactory;
48+
private ICache&MockObject $crlCache;
49+
private LdapCrlDownloader&MockObject $ldapDownloader;
50+
private CrlRevocationCheckerTestable $checker;
51+
52+
protected function setUp(): void {
53+
$this->config = $this->createMock(IConfig::class);
54+
$this->appConfig = $this->createMock(IAppConfig::class);
55+
$this->urlGenerator = $this->createMock(IURLGenerator::class);
56+
$this->tempManager = $this->createMock(ITempManager::class);
57+
$this->logger = $this->createMock(LoggerInterface::class);
58+
$this->crlCache = $this->createMock(ICache::class);
59+
$this->cacheFactory = $this->createMock(ICacheFactory::class);
60+
$this->cacheFactory->method('createDistributed')->willReturn($this->crlCache);
61+
$this->ldapDownloader = $this->createMock(LdapCrlDownloader::class);
62+
63+
$this->checker = new CrlRevocationCheckerTestable(
64+
$this->config,
65+
$this->appConfig,
66+
$this->urlGenerator,
67+
$this->tempManager,
68+
$this->logger,
69+
$this->cacheFactory,
70+
$this->ldapDownloader,
71+
);
72+
}
73+
74+
#[DataProvider('dataProviderCrlRevocationDateExtraction')]
75+
public function testExtractRevocationDateFromCrlText(
76+
string $crlText,
77+
array $serialNumbers,
78+
?string $expectedDate,
79+
string $description,
80+
): void {
81+
$result = $this->checker->publicExtractRevocationDateFromCrlText($crlText, $serialNumbers);
82+
83+
$this->assertSame($expectedDate, $result, $description);
84+
}
85+
86+
public static function dataProviderCrlRevocationDateExtraction(): array {
87+
$crlText = implode("\n", [
88+
'Revoked Certificates:',
89+
' Serial Number: 0A',
90+
' Revocation Date: Jan 28 12:34:56 2026 GMT',
91+
' Serial Number: 0B',
92+
' Revocation Date: Jan 29 01:02:03 2026 GMT',
93+
]);
94+
95+
return [
96+
'Extract first revocation date' => [
97+
$crlText,
98+
['0A'],
99+
'2026-01-28T12:34:56+00:00',
100+
'Expected revocation date for serial 0A',
101+
],
102+
'Extract second revocation date with hex' => [
103+
$crlText,
104+
['0B', '0C'],
105+
'2026-01-29T01:02:03+00:00',
106+
'Expected revocation date for serial 0B',
107+
],
108+
'Revocation date not found' => [
109+
$crlText,
110+
['0D'],
111+
null,
112+
'No revocation date should be returned when serial not present',
113+
],
114+
];
115+
}
116+
117+
#[DataProvider('dataProviderCrlExternalValidationDisabled')]
118+
public function testValidateReturnsDisabledWhenSettingOff(
119+
array $crlUrls,
120+
CrlValidationStatus $expectedStatus,
121+
string $description,
122+
): void {
123+
$this->appConfig
124+
->method('getValueBool')
125+
->with(Application::APP_ID, 'crl_external_validation_enabled', true)
126+
->willReturn(false);
127+
128+
$this->config
129+
->method('getSystemValue')
130+
->with('trusted_domains', [])
131+
->willReturn([]);
132+
133+
$result = $this->checker->validate($crlUrls, '');
134+
135+
$this->assertSame($expectedStatus, $result['status'], $description);
136+
}
137+
138+
public static function dataProviderCrlExternalValidationDisabled(): array {
139+
return [
140+
'all external HTTP URLs skipped' => [
141+
['http://crl.external.example.com/crl.crl'],
142+
CrlValidationStatus::DISABLED,
143+
'External HTTP CRL URL should return disabled when setting is off',
144+
],
145+
'all external LDAP URLs skipped' => [
146+
['ldap://ldap.external.example.com/cn=CRL,o=Example'],
147+
CrlValidationStatus::DISABLED,
148+
'External LDAP CRL URL should return disabled when setting is off',
149+
],
150+
'mix of external URLs all skipped' => [
151+
[
152+
'http://crl.external.example.com/crl.crl',
153+
'ldap://ldap.external.example.com/cn=CRL,o=Example',
154+
],
155+
CrlValidationStatus::DISABLED,
156+
'All external CRL URLs should return disabled when setting is off',
157+
],
158+
'empty URL list' => [
159+
[],
160+
CrlValidationStatus::NO_URLS,
161+
'Empty URL list should always return no_urls regardless of setting',
162+
],
163+
];
164+
}
165+
166+
public function testValidateDoesNotReturnDisabledWhenSettingOn(): void {
167+
$this->appConfig
168+
->method('getValueBool')
169+
->with(Application::APP_ID, 'crl_external_validation_enabled', true)
170+
->willReturn(true);
171+
172+
$this->config
173+
->method('getSystemValue')
174+
->with('trusted_domains', [])
175+
->willReturn([]);
176+
177+
// With the setting on, an inaccessible external URL should fail, not be skipped.
178+
$result = $this->checker->validate(['http://crl.unreachable.invalid/crl.crl'], '');
179+
180+
$this->assertNotSame(CrlValidationStatus::DISABLED, $result['status'], 'Status should not be disabled when external validation is enabled');
181+
}
182+
183+
public function testValidateChecksLocalUrlsEvenWhenExternalValidationDisabled(): void {
184+
$this->appConfig
185+
->method('getValueBool')
186+
->with(Application::APP_ID, 'crl_external_validation_enabled', true)
187+
->willReturn(false);
188+
189+
// Make the domain trusted so isLocalCrlUrl returns true for this host.
190+
$this->config
191+
->method('getSystemValue')
192+
->with('trusted_domains', [])
193+
->willReturn(['cloud.example.com']);
194+
195+
// A URL on the trusted (local) host must be attempted even when external
196+
// validation is disabled. It will fail here because there is no real CRL,
197+
// but it must NOT be counted as a disabled/skipped URL.
198+
$result = $this->checker->validate(
199+
['http://cloud.example.com/apps/libresign/crl/instance/1/openssl'],
200+
''
201+
);
202+
203+
$this->assertNotSame(CrlValidationStatus::DISABLED, $result['status'], 'Local CRL URLs must not be skipped when external validation is disabled');
204+
}
205+
206+
#[DataProvider('dataProviderIsSerialNumberInCrl')]
207+
public function testIsSerialNumberInCrlNormalizesSerialNumber(
208+
string $crlText,
209+
string $serialNumber,
210+
bool $expected,
211+
string $description,
212+
): void {
213+
$result = $this->checker->publicIsSerialNumberInCrl($crlText, $serialNumber);
214+
215+
$this->assertSame($expected, $result, $description);
216+
}
217+
218+
public static function dataProviderIsSerialNumberInCrl(): array {
219+
return [
220+
'exact uppercase match' => [
221+
" Serial Number: AB\n",
222+
'AB',
223+
true,
224+
'Exact serial in CRL text should match',
225+
],
226+
'lowercase input normalised to uppercase' => [
227+
" Serial Number: AB\n",
228+
'ab',
229+
true,
230+
'Lowercase serial input should be uppercased before comparison',
231+
],
232+
'leading zeros in input stripped before comparison' => [
233+
" Serial Number: AB\n",
234+
'00AB',
235+
true,
236+
'Leading zeros in the input serial should be stripped',
237+
],
238+
'leading zeros in CRL covered by regex wildcard' => [
239+
" Serial Number: 00AB\n",
240+
'AB',
241+
true,
242+
'Leading zeros in the CRL text are matched by the 0* regex wildcard',
243+
],
244+
'zero serial number preserved' => [
245+
" Serial Number: 0\n",
246+
'0',
247+
true,
248+
'Serial number zero should match Serial Number: 0',
249+
],
250+
'all-zero input normalised to single zero' => [
251+
" Serial Number: 0\n",
252+
'000',
253+
true,
254+
'All-zero input (000) should be normalised to 0',
255+
],
256+
'serial not present returns false' => [
257+
" Serial Number: AB\n",
258+
'CD',
259+
false,
260+
'Serial absent from CRL text should not match',
261+
],
262+
];
263+
}
264+
265+
}

0 commit comments

Comments
 (0)