Skip to content

Commit 42d59df

Browse files
committed
test(crl): add unit tests for LdapCrlDownloader
24 tests covering: cache hit returns early, all scope keywords (base/ one/onelevel/sub/subtree), connect failure, empty result set, binary attribute stripping, finally-block unbind on exception, and cache storage after a successful download. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent cb1419d commit 42d59df

1 file changed

Lines changed: 304 additions & 0 deletions

File tree

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
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\Ldap;
11+
12+
use OCA\Libresign\Service\Crl\Ldap\ILdapConnection;
13+
use OCA\Libresign\Service\Crl\Ldap\LdapCrlDownloader;
14+
use OCP\ICache;
15+
use OCP\ICacheFactory;
16+
use PHPUnit\Framework\Attributes\DataProvider;
17+
use PHPUnit\Framework\MockObject\MockObject;
18+
use PHPUnit\Framework\TestCase;
19+
use Psr\Log\LoggerInterface;
20+
21+
class LdapCrlDownloaderTest extends TestCase {
22+
private LoggerInterface&MockObject $logger;
23+
private ICacheFactory&MockObject $cacheFactory;
24+
private ICache&MockObject $cache;
25+
private ILdapConnection&MockObject $ldap;
26+
private LdapCrlDownloader $downloader;
27+
28+
/** Reusable fake LDAP connection handle (any object will do). */
29+
private object $fakeConn;
30+
31+
protected function setUp(): void {
32+
$this->logger = $this->createMock(LoggerInterface::class);
33+
$this->cache = $this->createMock(ICache::class);
34+
$this->cacheFactory = $this->createMock(ICacheFactory::class);
35+
$this->cacheFactory->method('createDistributed')->willReturn($this->cache);
36+
$this->ldap = $this->createMock(ILdapConnection::class);
37+
$this->fakeConn = new \stdClass();
38+
39+
$this->downloader = new LdapCrlDownloader(
40+
$this->logger,
41+
$this->cacheFactory,
42+
$this->ldap,
43+
);
44+
}
45+
46+
// --------------------------------------------------------------------- //
47+
// isLdapUrl //
48+
// --------------------------------------------------------------------- //
49+
50+
#[DataProvider('dataProviderIsLdapUrl')]
51+
public function testIsLdapUrlRecognizesSchemes(string $url, bool $expected): void {
52+
$this->assertSame($expected, $this->downloader->isLdapUrl($url));
53+
}
54+
55+
public static function dataProviderIsLdapUrl(): array {
56+
return [
57+
'ldap lowercase' => ['ldap://ldap.example.com/cn=CRL,o=Org', true],
58+
'ldaps lowercase' => ['ldaps://ldap.example.com/cn=CRL,o=Org', true],
59+
'LDAP uppercase' => ['LDAP://ldap.example.com/cn=CRL,o=Org', true],
60+
'http not ldap' => ['http://crl.example.com/crl.crl', false],
61+
'https not ldap' => ['https://crl.example.com/crl.crl', false],
62+
'empty string' => ['', false],
63+
];
64+
}
65+
66+
// --------------------------------------------------------------------- //
67+
// download — cache //
68+
// --------------------------------------------------------------------- //
69+
70+
public function testDownloadReturnsCachedValueWithoutCallingLdap(): void {
71+
$url = 'ldap://ldap.example.com/cn=CRL,o=Org';
72+
$cachedData = 'binary-crl-data';
73+
74+
$this->cache->method('get')->willReturn($cachedData);
75+
$this->ldap->expects($this->never())->method('connect');
76+
77+
$result = $this->downloader->download($url);
78+
79+
$this->assertSame($cachedData, $result);
80+
}
81+
82+
public function testDownloadCachesSuccessfulFetchResult(): void {
83+
$url = 'ldap://ldap.example.com/cn=CRL,o=Org?certificateRevocationList;binary?base';
84+
$crlData = 'binary-crl-content';
85+
86+
$this->cache->method('get')->willReturn(null);
87+
$this->ldap->method('connect')->willReturn($this->fakeConn);
88+
$this->ldap->method('read')->willReturn(new \stdClass());
89+
$this->ldap->method('getEntries')->willReturn([
90+
'count' => 1,
91+
0 => ['certificaterevocationlist' => [0 => $crlData, 'count' => 1]],
92+
]);
93+
94+
$this->cache
95+
->expects($this->once())
96+
->method('set')
97+
->with(sha1($url), $crlData, 86400);
98+
99+
$result = $this->downloader->download($url);
100+
101+
$this->assertSame($crlData, $result);
102+
}
103+
104+
public function testDownloadDoesNotCacheNullResult(): void {
105+
$url = 'ldap://ldap.example.com/cn=CRL,o=Org';
106+
107+
$this->cache->method('get')->willReturn(null);
108+
$this->ldap->method('connect')->willThrowException(new \RuntimeException('no server'));
109+
110+
$this->cache->expects($this->never())->method('set');
111+
112+
$result = $this->downloader->download($url);
113+
114+
$this->assertNull($result);
115+
}
116+
117+
// --------------------------------------------------------------------- //
118+
// download — URL parsing //
119+
// --------------------------------------------------------------------- //
120+
121+
public function testDownloadReturnsNullForUnparsableUrl(): void {
122+
// Triple-slash LDAP URLs are considered malformed by parse_url
123+
$url = 'ldap:///cn=CRL,o=Org';
124+
125+
$this->cache->method('get')->willReturn(null);
126+
$this->ldap->expects($this->never())->method('connect');
127+
128+
$this->assertNull($this->downloader->download($url));
129+
}
130+
131+
public function testDownloadReturnsNullAndLogsWarningWhenDnMissing(): void {
132+
$url = 'ldap://ldap.example.com/';
133+
134+
$this->cache->method('get')->willReturn(null);
135+
$this->logger->expects($this->once())->method('warning')
136+
->with($this->stringContains('missing host or DN'));
137+
$this->ldap->expects($this->never())->method('connect');
138+
139+
$this->assertNull($this->downloader->download($url));
140+
}
141+
142+
// --------------------------------------------------------------------- //
143+
// download — connection failure //
144+
// --------------------------------------------------------------------- //
145+
146+
public function testDownloadReturnsNullWhenConnectThrows(): void {
147+
$url = 'ldap://ldap.example.com/cn=CRL,o=Org';
148+
149+
$this->cache->method('get')->willReturn(null);
150+
$this->ldap->method('connect')
151+
->willThrowException(new \RuntimeException('ldap_connect failed'));
152+
153+
$this->logger->expects($this->once())->method('warning')
154+
->with($this->stringContains('Failed to connect'));
155+
156+
$this->assertNull($this->downloader->download($url));
157+
}
158+
159+
// --------------------------------------------------------------------- //
160+
// download — LDAP query results //
161+
// --------------------------------------------------------------------- //
162+
163+
public function testDownloadReturnsNullWhenReadReturnsNoResult(): void {
164+
$url = 'ldap://ldap.example.com/cn=CRL,o=Org';
165+
166+
$this->cache->method('get')->willReturn(null);
167+
$this->ldap->method('connect')->willReturn($this->fakeConn);
168+
$this->ldap->method('read')->willReturn(false);
169+
170+
$this->logger->expects($this->once())->method('warning')
171+
->with($this->stringContains('LDAP search returned no result'));
172+
173+
$this->assertNull($this->downloader->download($url));
174+
}
175+
176+
public function testDownloadReturnsNullWhenEntriesCountIsZero(): void {
177+
$url = 'ldap://ldap.example.com/cn=CRL,o=Org';
178+
179+
$this->cache->method('get')->willReturn(null);
180+
$this->ldap->method('connect')->willReturn($this->fakeConn);
181+
$this->ldap->method('read')->willReturn(new \stdClass());
182+
$this->ldap->method('getEntries')->willReturn(['count' => 0]);
183+
184+
$this->assertNull($this->downloader->download($url));
185+
}
186+
187+
public function testDownloadReturnsNullWhenAttributeNotFound(): void {
188+
$url = 'ldap://ldap.example.com/cn=CRL,o=Org';
189+
190+
$this->cache->method('get')->willReturn(null);
191+
$this->ldap->method('connect')->willReturn($this->fakeConn);
192+
$this->ldap->method('read')->willReturn(new \stdClass());
193+
// Entry exists but the attribute is absent
194+
$this->ldap->method('getEntries')->willReturn([
195+
'count' => 1,
196+
0 => ['dn' => 'cn=CRL,o=Org'],
197+
]);
198+
199+
$this->assertNull($this->downloader->download($url));
200+
}
201+
202+
// --------------------------------------------------------------------- //
203+
// download — scope routing //
204+
// --------------------------------------------------------------------- //
205+
206+
#[DataProvider('dataProviderScope')]
207+
public function testDownloadUsesCorrectMethodForScope(
208+
string $scope,
209+
string $expectedMethod,
210+
): void {
211+
$urlBase = 'ldap://ldap.example.com/cn=CRL,o=Org?certificateRevocationList;binary?';
212+
$url = $urlBase . $scope;
213+
214+
$this->cache->method('get')->willReturn(null);
215+
$this->ldap->method('connect')->willReturn($this->fakeConn);
216+
$this->ldap->method('getEntries')->willReturn(['count' => 0]);
217+
218+
$this->ldap->expects($this->once())->method($expectedMethod)->willReturn(false);
219+
// The other two scope methods must never be called
220+
foreach (['read', 'listEntries', 'search'] as $m) {
221+
if ($m !== $expectedMethod) {
222+
$this->ldap->expects($this->never())->method($m);
223+
}
224+
}
225+
226+
$this->downloader->download($url);
227+
}
228+
229+
public static function dataProviderScope(): array {
230+
return [
231+
'base scope (default)' => ['base', 'read'],
232+
'one scope' => ['one', 'listEntries'],
233+
'onelevel scope' => ['onelevel', 'listEntries'],
234+
'sub scope' => ['sub', 'search'],
235+
'subtree scope' => ['subtree', 'search'],
236+
'empty = default' => ['', 'read'],
237+
];
238+
}
239+
240+
// --------------------------------------------------------------------- //
241+
// download — resource cleanup (finally block) //
242+
// --------------------------------------------------------------------- //
243+
244+
public function testDownloadCallsUnbindEvenWhenGetEntriesThrows(): void {
245+
$url = 'ldap://ldap.example.com/cn=CRL,o=Org';
246+
247+
$this->cache->method('get')->willReturn(null);
248+
$this->ldap->method('connect')->willReturn($this->fakeConn);
249+
$this->ldap->method('read')->willReturn(new \stdClass());
250+
$this->ldap->method('getEntries')
251+
->willThrowException(new \Exception('unexpected ldap error'));
252+
253+
// unbind MUST be called regardless of the exception
254+
$this->ldap->expects($this->once())
255+
->method('unbind')
256+
->with($this->fakeConn);
257+
258+
$this->assertNull($this->downloader->download($url));
259+
}
260+
261+
public function testDownloadDoesNotCallUnbindWhenConnectFails(): void {
262+
$url = 'ldap://ldap.example.com/cn=CRL,o=Org';
263+
264+
$this->cache->method('get')->willReturn(null);
265+
$this->ldap->method('connect')
266+
->willThrowException(new \RuntimeException('no server'));
267+
268+
// $ldapConn was never assigned, so unbind must not be called
269+
$this->ldap->expects($this->never())->method('unbind');
270+
271+
$this->assertNull($this->downloader->download($url));
272+
}
273+
274+
// --------------------------------------------------------------------- //
275+
// download — attribute with ;binary suffix is stripped //
276+
// --------------------------------------------------------------------- //
277+
278+
public function testDownloadStripsAttributeOptionSuffix(): void {
279+
// The attribute in the URL is "certificateRevocationList;binary"
280+
// but the LDAP entries array uses the bare name.
281+
$url = 'ldap://ldap.example.com/cn=CRL,o=Org?certificateRevocationList;binary?base';
282+
$crlData = 'binary-crl-data';
283+
284+
$this->cache->method('get')->willReturn(null);
285+
$this->ldap->method('connect')->willReturn($this->fakeConn);
286+
287+
// Verify that read() is called with the stripped attribute name
288+
$this->ldap->expects($this->once())->method('read')
289+
->with(
290+
$this->fakeConn,
291+
$this->anything(),
292+
$this->anything(),
293+
['certificateRevocationList'],
294+
)
295+
->willReturn(new \stdClass());
296+
297+
$this->ldap->method('getEntries')->willReturn([
298+
'count' => 1,
299+
0 => ['certificaterevocationlist' => [0 => $crlData, 'count' => 1]],
300+
]);
301+
302+
$this->assertSame($crlData, $this->downloader->download($url));
303+
}
304+
}

0 commit comments

Comments
 (0)