Skip to content

Commit c8a3c91

Browse files
committed
Do factories coverage
1 parent 799d777 commit c8a3c91

13 files changed

Lines changed: 1847 additions & 0 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Test\Module\oidc\unit\Factories;
6+
7+
use DateInterval;
8+
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
9+
use PHPUnit\Framework\Attributes\CoversClass;
10+
use PHPUnit\Framework\MockObject\MockObject;
11+
use PHPUnit\Framework\TestCase;
12+
use ReflectionProperty;
13+
use SimpleSAML\Module\oidc\Factories\CoreFactory;
14+
use SimpleSAML\Module\oidc\ModuleConfig;
15+
use SimpleSAML\Module\oidc\Services\LoggerService;
16+
use SimpleSAML\OpenID\Core;
17+
use SimpleSAML\OpenID\Decorators\DateIntervalDecorator;
18+
use SimpleSAML\OpenID\SupportedAlgorithms;
19+
use SimpleSAML\OpenID\SupportedSerializers;
20+
21+
/**
22+
* The factory behind the library's Core service.
23+
*
24+
* `routing/services/services.yml` names `build` as the factory of the `SimpleSAML\OpenID\Core` service, and
25+
* LogoutTokenBuilder builds a Core of its own through this factory when it is given none. What the factory
26+
* decides is what the library runs with: the configured signature algorithms and timestamp validation
27+
* leeway, and the module's logger. The serializers are left at the library's default, which is the module's
28+
* own set (compact only). The Core keeps all of it to itself, so the tests read it back by reflection.
29+
*/
30+
#[CoversClass(CoreFactory::class)]
31+
#[AllowMockObjectsWithoutExpectations]
32+
class CoreFactoryTest extends TestCase
33+
{
34+
protected MockObject $moduleConfigMock;
35+
36+
protected MockObject $loggerServiceMock;
37+
38+
protected SupportedAlgorithms $supportedAlgorithms;
39+
40+
protected DateInterval $timestampValidationLeeway;
41+
42+
43+
protected function setUp(): void
44+
{
45+
$this->supportedAlgorithms = new SupportedAlgorithms();
46+
$this->timestampValidationLeeway = new DateInterval('PT3M');
47+
48+
$this->moduleConfigMock = $this->createMock(ModuleConfig::class);
49+
$this->moduleConfigMock->method('getSupportedAlgorithms')->willReturn($this->supportedAlgorithms);
50+
$this->moduleConfigMock->method('getTimestampValidationLeeway')
51+
->willReturn($this->timestampValidationLeeway);
52+
53+
$this->loggerServiceMock = $this->createMock(LoggerService::class);
54+
}
55+
56+
57+
protected function sut(): CoreFactory
58+
{
59+
return new CoreFactory($this->moduleConfigMock, $this->loggerServiceMock);
60+
}
61+
62+
63+
protected function propertyOf(Core $core, string $property): mixed
64+
{
65+
return (new ReflectionProperty($core, $property))->getValue($core);
66+
}
67+
68+
69+
public function testCanCreateInstance(): void
70+
{
71+
$this->assertInstanceOf(CoreFactory::class, $this->sut());
72+
}
73+
74+
75+
/**
76+
* The configured algorithms and leeway are handed over as the very objects, the leeway wrapped in the
77+
* library's decorator, the logger is the module's, and the serializers are what a bare Core has.
78+
*/
79+
public function testBuildsTheCoreAroundTheConfiguredAlgorithmsAndLeewayAndTheLogger(): void
80+
{
81+
$core = $this->sut()->build();
82+
83+
$this->assertSame($this->supportedAlgorithms, $this->propertyOf($core, 'supportedAlgorithms'));
84+
$leewayDecorator = $this->propertyOf($core, 'timestampValidationLeewayDecorator');
85+
$this->assertInstanceOf(DateIntervalDecorator::class, $leewayDecorator);
86+
$this->assertSame($this->timestampValidationLeeway, $leewayDecorator->dateInterval);
87+
$this->assertSame($this->loggerServiceMock, $this->propertyOf($core, 'logger'));
88+
$this->assertEquals(new SupportedSerializers(), $this->propertyOf($core, 'supportedSerializers'));
89+
}
90+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Test\Module\oidc\unit\Factories;
6+
7+
use Closure;
8+
use League\OAuth2\Server\CryptKey;
9+
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
10+
use PHPUnit\Framework\Attributes\CoversClass;
11+
use PHPUnit\Framework\Attributes\DataProvider;
12+
use PHPUnit\Framework\MockObject\MockObject;
13+
use PHPUnit\Framework\TestCase;
14+
use RuntimeException;
15+
use SimpleSAML\Error\ConfigurationError;
16+
use SimpleSAML\Module\oidc\Factories\CryptKeyFactory;
17+
use SimpleSAML\Module\oidc\ModuleConfig;
18+
use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum;
19+
20+
/**
21+
* The factory behind the protocol signing keys.
22+
*
23+
* `routing/services/services.yml` names `buildPrivateKey` and `buildPublicKey` as the factories of the
24+
* `oidc.key.private` and `oidc.key.public` services, League CryptKeys; the AuthorizationServerFactory and the
25+
* TokenResponseFactory take the private one, and nothing under `src/` takes the public one. What the factory
26+
* decides is which key pair that is: the first of the configured protocol signature key pairs, whatever it is
27+
* keyed by and however many follow, handed to the configuration for validation, or refused as a configuration
28+
* error when it is not an array at all. Each key is then read from its file: the private key with the
29+
* configured password and with League's permission check on, which is a notice on a key file readable by
30+
* others; the public key with neither.
31+
*
32+
* The key files are copies of the test key pair in the temporary directory, since that check reads the file
33+
* mode, and the mode of a checked-out file is whatever the umask made it.
34+
*/
35+
#[CoversClass(CryptKeyFactory::class)]
36+
#[AllowMockObjectsWithoutExpectations]
37+
class CryptKeyFactoryTest extends TestCase
38+
{
39+
/** The first configured key pair as written; the configuration validates it into the shape the factory reads. */
40+
protected const array FIRST_KEY_PAIR = [
41+
ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256,
42+
ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key',
43+
ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt',
44+
];
45+
46+
/** A second pair, which the factory passes over: the configuration is never asked to validate it. */
47+
protected const array SECOND_KEY_PAIR = [
48+
ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::ES256,
49+
ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'other.key',
50+
ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'other.crt',
51+
];
52+
53+
protected const string PRIVATE_KEY_PASSWORD = 'private-key-password';
54+
55+
56+
protected MockObject $moduleConfigMock;
57+
58+
protected string $privateKeyPath;
59+
60+
protected string $publicKeyPath;
61+
62+
63+
protected function setUp(): void
64+
{
65+
$this->privateKeyPath = $this->copyOfTheTestKeyFile('oidc_module.key', 0600);
66+
$this->publicKeyPath = $this->copyOfTheTestKeyFile('oidc_module.crt', 0644);
67+
68+
$this->moduleConfigMock = $this->createMock(ModuleConfig::class);
69+
}
70+
71+
72+
protected function tearDown(): void
73+
{
74+
foreach ([$this->privateKeyPath, $this->publicKeyPath] as $path) {
75+
if (is_file($path)) {
76+
unlink($path);
77+
}
78+
}
79+
}
80+
81+
82+
/**
83+
* A copy of one of the test key files, with the mode League's permission check will read.
84+
*/
85+
protected function copyOfTheTestKeyFile(string $name, int $mode): string
86+
{
87+
$path = tempnam(sys_get_temp_dir(), 'oidc-module-' . $name . '-');
88+
89+
if ($path === false || !copy(dirname(__DIR__, 3) . '/cert/' . $name, $path) || !chmod($path, $mode)) {
90+
throw new RuntimeException('Could not set up a copy of the test key file ' . $name . '.');
91+
}
92+
93+
return $path;
94+
}
95+
96+
97+
/**
98+
* The first pair is configured under a name, not at index zero, and a second follows it.
99+
*/
100+
protected function sut(mixed $firstKeyPair = self::FIRST_KEY_PAIR): CryptKeyFactory
101+
{
102+
$this->moduleConfigMock->method('getProtocolSignatureKeyPairs')
103+
->willReturn(['default' => $firstKeyPair, 'next' => self::SECOND_KEY_PAIR]);
104+
105+
return new CryptKeyFactory($this->moduleConfigMock);
106+
}
107+
108+
109+
/**
110+
* The configuration validates the pair it is handed, once per key built, and answers with the absolute
111+
* file paths and the password. Only the first pair may be handed to it.
112+
*/
113+
protected function expectTheFirstKeyPairValidated(?string $privateKeyPassword = null, int $builds = 1): void
114+
{
115+
$this->moduleConfigMock->expects($this->exactly($builds))->method('getValidatedSignatureKeyPairArray')
116+
->with($this->identicalTo(self::FIRST_KEY_PAIR))
117+
->willReturn([
118+
ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256,
119+
ModuleConfig::KEY_PRIVATE_KEY_FILENAME => $this->privateKeyPath,
120+
ModuleConfig::KEY_PUBLIC_KEY_FILENAME => $this->publicKeyPath,
121+
ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => $privateKeyPassword,
122+
ModuleConfig::KEY_KEY_ID => null,
123+
]);
124+
}
125+
126+
127+
public function testCanCreateInstance(): void
128+
{
129+
$this->assertInstanceOf(CryptKeyFactory::class, $this->sut());
130+
}
131+
132+
133+
/**
134+
* The private key is read from the first pair's private key file, with the password the pair configures,
135+
* which may be none.
136+
*/
137+
#[DataProvider('privateKeyPasswordProvider')]
138+
public function testBuildsThePrivateKeyFromTheFirstConfiguredKeyPair(?string $privateKeyPassword): void
139+
{
140+
$this->expectTheFirstKeyPairValidated($privateKeyPassword);
141+
142+
$privateKey = $this->sut()->buildPrivateKey();
143+
144+
$this->assertSame('file://' . $this->privateKeyPath, $privateKey->getKeyPath());
145+
$this->assertStringEqualsFile($this->privateKeyPath, $privateKey->getKeyContents());
146+
$this->assertSame($privateKeyPassword, $privateKey->getPassPhrase());
147+
}
148+
149+
150+
public static function privateKeyPasswordProvider(): array
151+
{
152+
return [
153+
'with a password' => [self::PRIVATE_KEY_PASSWORD],
154+
'without one' => [null],
155+
];
156+
}
157+
158+
159+
/**
160+
* The public key is read from the first pair's public key file; the pair's password is the private key's
161+
* and is not given to it.
162+
*/
163+
public function testBuildsThePublicKeyFromTheFirstConfiguredKeyPair(): void
164+
{
165+
$this->expectTheFirstKeyPairValidated(self::PRIVATE_KEY_PASSWORD);
166+
167+
$publicKey = $this->sut()->buildPublicKey();
168+
169+
$this->assertSame('file://' . $this->publicKeyPath, $publicKey->getKeyPath());
170+
$this->assertStringEqualsFile($this->publicKeyPath, $publicKey->getKeyContents());
171+
$this->assertNull($publicKey->getPassPhrase());
172+
}
173+
174+
175+
/**
176+
* A first pair which is not an array is refused before the configuration is asked to validate anything;
177+
* the configuration's own check would have refused it too, with a message of its own, but never gets it.
178+
*/
179+
#[DataProvider('builderProvider')]
180+
public function testRefusesAKeyPairsConfigurationWhoseFirstPairIsNotAnArray(Closure $build): void
181+
{
182+
$this->moduleConfigMock->expects($this->never())->method('getValidatedSignatureKeyPairArray');
183+
$sut = $this->sut(firstKeyPair: 'oidc_module.key');
184+
185+
$this->expectException(ConfigurationError::class);
186+
$this->expectExceptionMessage('Invalid protocol signature key pairs config.');
187+
188+
$build($sut);
189+
}
190+
191+
192+
public static function builderProvider(): array
193+
{
194+
return [
195+
'the private key' => [static fn(CryptKeyFactory $sut): CryptKey => $sut->buildPrivateKey()],
196+
'the public key' => [static fn(CryptKeyFactory $sut): CryptKey => $sut->buildPublicKey()],
197+
];
198+
}
199+
200+
201+
/**
202+
* League's permission check is on for the private key alone: on a key file readable by others it is a
203+
* notice naming the file. The public key file, readable by others as such a file is, draws none.
204+
*/
205+
public function testHasThePermissionsOfThePrivateKeyFileCheckedButNotThePublicKeyFiles(): void
206+
{
207+
if (PHP_OS_FAMILY === 'Windows') {
208+
$this->markTestSkipped('League leaves the permission check out on Windows.');
209+
}
210+
211+
$this->assertTrue(chmod($this->privateKeyPath, 0644));
212+
$this->expectTheFirstKeyPairValidated(builds: 2);
213+
/** @var list<array{int, string}> $notices */
214+
$notices = [];
215+
set_error_handler(
216+
static function (int $level, string $message) use (&$notices): bool {
217+
$notices[] = [$level, $message];
218+
219+
return true;
220+
},
221+
);
222+
223+
try {
224+
$sut = $this->sut();
225+
$sut->buildPrivateKey();
226+
$sut->buildPublicKey();
227+
} finally {
228+
restore_error_handler();
229+
}
230+
231+
$this->assertCount(1, $notices);
232+
$this->assertSame(E_USER_NOTICE, $notices[0][0]);
233+
$this->assertStringContainsString('file://' . $this->privateKeyPath, $notices[0][1]);
234+
}
235+
}

0 commit comments

Comments
 (0)