diff --git a/lib/Service/PfxProvider.php b/lib/Service/PfxProvider.php index df4b9c948f..60e780e8b2 100644 --- a/lib/Service/PfxProvider.php +++ b/lib/Service/PfxProvider.php @@ -66,8 +66,12 @@ public function getOrGeneratePfx( } } + $uid = \preg_replace('/^account:/', '', $userUniqueIdentifier); + return [ - 'pfx' => $engine->getPfxOfCurrentSigner(), + 'pfx' => $engine + ->setPassword($effectivePassword) + ->getPfxOfCurrentSigner($uid), 'password' => $effectivePassword !== '' ? $effectivePassword : null, ]; } diff --git a/tests/php/Unit/Service/PfxProviderTest.php b/tests/php/Unit/Service/PfxProviderTest.php new file mode 100644 index 0000000000..a0d2b13e97 --- /dev/null +++ b/tests/php/Unit/Service/PfxProviderTest.php @@ -0,0 +1,301 @@ +eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->secureRandom = $this->createMock(ISecureRandom::class); + } + + private function createProvider(): PfxProvider { + return new PfxProvider( + new CertificateValidityPolicy(), + $this->eventDispatcher, + $this->secureRandom, + ); + } + + private function createEngine(): FakeSignEngine { + return new FakeSignEngine(); + } + + private function configurePasswordEvent(string $password): void { + $this->eventDispatcher + ->method('dispatchTyped') + ->willReturnCallback(function (GenerateSecurePasswordEvent $event) use ($password) { + $event->setPassword($password); + }); + } + + public function testReturnsExistingCertificateWithoutGenerating(): void { + $engine = $this->createEngine(); + $engine->storedCertificate = 'existing-cert'; + + $result = $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: false, + signatureMethodName: null, + userUniqueIdentifier: 'account:john', + friendlyName: 'John Doe', + password: 'mypass', + ); + + $this->assertSame('existing-cert', $result['pfx']); + $this->assertSame('mypass', $result['password']); + $this->assertEmpty($engine->generateCalls); + $this->assertEmpty($engine->leafExpiryCalls); + } + + public function testClickToSignGeneratesShortLivedCertificate(): void { + $this->configurePasswordEvent('temp-pass-123'); + $engine = $this->createEngine(); + + $result = $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: true, + signatureMethodName: ISignatureMethod::SIGNATURE_METHOD_CLICK_TO_SIGN, + userUniqueIdentifier: 'account:alice', + friendlyName: 'Alice Smith', + ); + + $this->assertCount(1, $engine->generateCalls); + $this->assertSame([ + 'host' => 'account:alice', + 'uid' => 'account:alice', + 'name' => 'Alice Smith', + ], $engine->generateCalls[0]['user']); + $this->assertSame('temp-pass-123', $engine->generateCalls[0]['signPassword']); + $this->assertSame('Alice Smith', $engine->generateCalls[0]['friendlyName']); + + $this->assertSame([1, null], $engine->leafExpiryCalls); + } + + public function testSignWithoutPasswordButNonClickToSignSkipsExpiryOverride(): void { + $this->configurePasswordEvent('temp-pass'); + $engine = $this->createEngine(); + + $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: true, + signatureMethodName: 'password', + userUniqueIdentifier: 'user@example.com', + friendlyName: 'User Example', + ); + + $this->assertCount(1, $engine->generateCalls); + $this->assertEmpty($engine->leafExpiryCalls); + } + + public function testSignWithPasswordDoesNotGenerateCertificate(): void { + $engine = $this->createEngine(); + + $result = $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: false, + signatureMethodName: null, + userUniqueIdentifier: 'account:bob', + friendlyName: 'Bob Jones', + password: 'user-password', + ); + + $this->assertEmpty($engine->generateCalls); + $this->assertSame('user-password', $result['password']); + $this->assertSame('fake-pfx-content', $result['pfx']); + } + + public function testStripsAccountPrefixFromUid(): void { + $engine = $this->createEngine(); + + $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: false, + signatureMethodName: null, + userUniqueIdentifier: 'account:john', + friendlyName: 'John', + password: 'pass', + ); + + $this->assertSame(['john'], $engine->getPfxCalls); + } + + public function testPreservesUidWithoutAccountPrefix(): void { + $engine = $this->createEngine(); + + $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: false, + signatureMethodName: null, + userUniqueIdentifier: 'user@example.com', + friendlyName: 'User', + password: 'pass', + ); + + $this->assertSame(['user@example.com'], $engine->getPfxCalls); + } + + public function testEmptyPasswordReturnsNullInResult(): void { + $engine = $this->createEngine(); + + $result = $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: false, + signatureMethodName: null, + userUniqueIdentifier: 'account:user1', + friendlyName: 'User One', + password: '', + ); + + $this->assertNull($result['password']); + } + + public function testTemporaryPasswordComesFromEvent(): void { + $this->configurePasswordEvent('event-generated-pw'); + $engine = $this->createEngine(); + + $result = $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: true, + signatureMethodName: 'password', + userUniqueIdentifier: 'account:user1', + friendlyName: 'User', + ); + + $this->assertSame('event-generated-pw', $result['password']); + $this->assertSame('event-generated-pw', $engine->generateCalls[0]['signPassword']); + } + + public function testFallsBackToSecureRandomWhenEventReturnsNoPassword(): void { + $this->eventDispatcher->method('dispatchTyped'); + $this->secureRandom + ->expects($this->once()) + ->method('generate') + ->with(20) + ->willReturn('random-20-char-value!'); + + $engine = $this->createEngine(); + + $result = $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: true, + signatureMethodName: 'password', + userUniqueIdentifier: 'account:user1', + friendlyName: 'User', + ); + + $this->assertSame('random-20-char-value!', $result['password']); + } + + public function testExpiryOverrideIsCleanedUpOnGenerationFailure(): void { + $this->configurePasswordEvent('temp'); + $engine = $this->createEngine(); + $engine->shouldFailOnGenerate = true; + + try { + $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: true, + signatureMethodName: ISignatureMethod::SIGNATURE_METHOD_CLICK_TO_SIGN, + userUniqueIdentifier: 'account:user1', + friendlyName: 'User', + ); + $this->fail('Expected RuntimeException'); + } catch (\RuntimeException) { + // Expected + } + + $this->assertSame([1, null], $engine->leafExpiryCalls); + $this->assertNull($engine->currentLeafExpiry); + } + + public function testGeneratedPasswordIsSetOnEngineBeforeGettingPfx(): void { + $this->configurePasswordEvent('generated-pass'); + $engine = $this->createEngine(); + + $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: true, + signatureMethodName: 'password', + userUniqueIdentifier: 'account:signer', + friendlyName: 'Signer Name', + ); + + $this->assertContains('generated-pass', $engine->setPasswordCalls); + $this->assertNotEmpty($engine->getPfxCalls); + } + + #[DataProvider('providerSignatureMethodExpiryBehavior')] + public function testSignatureMethodExpiryBehavior( + ?string $signatureMethodName, + array $expectedExpiryCalls, + ): void { + $this->configurePasswordEvent('temp'); + $engine = $this->createEngine(); + + $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: true, + signatureMethodName: $signatureMethodName, + userUniqueIdentifier: 'account:user1', + friendlyName: 'User', + ); + + $this->assertSame($expectedExpiryCalls, $engine->leafExpiryCalls); + } + + public static function providerSignatureMethodExpiryBehavior(): array { + return [ + 'click-to-sign sets 1-day expiry then cleans up' => [ + ISignatureMethod::SIGNATURE_METHOD_CLICK_TO_SIGN, + [1, null], + ], + 'password method does not set expiry' => [ + 'password', + [], + ], + 'null method does not set expiry' => [ + null, + [], + ], + ]; + } + + public function testUserDataPassedCorrectlyToGenerateCertificate(): void { + $this->configurePasswordEvent('pass'); + $engine = $this->createEngine(); + + $this->createProvider()->getOrGeneratePfx( + $engine, + signWithoutPassword: true, + signatureMethodName: 'password', + userUniqueIdentifier: 'external@company.com', + friendlyName: 'External Signer', + ); + + $call = $engine->generateCalls[0]; + $this->assertSame('external@company.com', $call['user']['host']); + $this->assertSame('external@company.com', $call['user']['uid']); + $this->assertSame('External Signer', $call['user']['name']); + $this->assertSame('External Signer', $call['friendlyName']); + } +} diff --git a/tests/php/fixtures/FakeSignEngine.php b/tests/php/fixtures/FakeSignEngine.php new file mode 100644 index 0000000000..bc9001fa7a --- /dev/null +++ b/tests/php/fixtures/FakeSignEngine.php @@ -0,0 +1,72 @@ + */ + public array $leafExpiryCalls = []; + /** @var list */ + public array $generateCalls = []; + /** @var list */ + public array $setPasswordCalls = []; + /** @var list */ + public array $getPfxCalls = []; + public string $pfxToReturn = 'fake-pfx-content'; + public bool $shouldFailOnGenerate = false; + + public function __construct() { + // No infrastructure dependencies needed + } + + public function getCertificate(): string { + return $this->storedCertificate; + } + + public function setLeafExpiryOverrideInDays(?int $days): self { + $this->currentLeafExpiry = $days; + $this->leafExpiryCalls[] = $days; + return $this; + } + + public function generateCertificate(array $user, string $signPassword, string $friendlyName): string { + if ($this->shouldFailOnGenerate) { + throw new \RuntimeException('Certificate generation failed'); + } + $this->generateCalls[] = [ + 'user' => $user, + 'signPassword' => $signPassword, + 'friendlyName' => $friendlyName, + ]; + $this->storedCertificate = 'generated-cert'; + return $this->storedCertificate; + } + + public function setPassword(string $password): self { + $this->setPasswordCalls[] = $password; + return $this; + } + + public function getPfxOfCurrentSigner(?string $uid = null): string { + $this->getPfxCalls[] = $uid; + return $this->pfxToReturn; + } + + public function sign(): File { + throw new \LogicException('Not used by PfxProvider'); + } + + public function getCertificateChain($resource): array { + throw new \LogicException('Not used by PfxProvider'); + } +}