From e11512987ed816946017f6e10d49538237729f64 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 11 Jun 2026 10:07:03 +0200 Subject: [PATCH 1/3] test: pin down CLI login token lifecycle and URL generation Mutation testing exposed four gaps in the CLI login flow tests: removing the TokenNotFoundException throw passed because the test caught the broad marker interface (a later CacheException also matched); the reverse username=>token cache entry could linger after redemption unnoticed; encodeKey was only tested via roundtrip, which is blind to dropping or reordering the collision-guard namespace; and the login URL was built from a stub that ignored its arguments, so the loginToken parameter could be dropped. Kills all 5 escaped mutants in src/Util and src/Command. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++++++ tests/Command/UserLoginCommandTest.php | 23 ++++++++++++++++ tests/Util/CliLoginHelperTest.php | 37 +++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e51abb7..988a9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Dev: strengthened CLI login flow tests based on mutation testing + findings — redeeming an unknown token is asserted to throw + `TokenNotFoundException` specifically, both cache entries (token and + reverse username entry) are asserted removed after a token is used, + `encodeKey` asserts the exact namespaced encoding instead of only an + encode/decode roundtrip, and the CLI login URL is asserted to receive + the login token and route. No effect on the published package. + - CI: bumped `codecov/codecov-action` from `v5` to `v7` (restores Codecov's GPG signing key after the `codecovsecurity` account was removed, and moves the bundled `github-script` to Node 24) and set `fail_ci_if_error: false` diff --git a/tests/Command/UserLoginCommandTest.php b/tests/Command/UserLoginCommandTest.php index 0c339c0..c1278e1 100644 --- a/tests/Command/UserLoginCommandTest.php +++ b/tests/Command/UserLoginCommandTest.php @@ -53,6 +53,29 @@ public function testExecuteSuccess(): void $this->assertStringContainsString('https://app.com/login?loginToken=generated-token', $tester->getDisplay()); } + public function testExecutePassesTokenAndRouteToUrlGenerator(): void + { + $this->stubCliLoginHelper + ->method('createToken') + ->willReturn('generated-token'); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->once()) + ->method('generate') + ->with('cli_login_route', ['loginToken' => 'generated-token'], UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturn('https://app.com/login?loginToken=generated-token'); + + $command = new UserLoginCommand( + $this->stubCliLoginHelper, + 'cli_login_route', + $urlGenerator, + $this->stubUserProvider + ); + + $tester = new CommandTester($command); + $this->assertSame(Command::SUCCESS, $tester->execute(['username' => 'testuser'])); + } + public function testExecuteUserNotFound(): void { $this->stubUserProvider diff --git a/tests/Util/CliLoginHelperTest.php b/tests/Util/CliLoginHelperTest.php index 2b234ef..ad099c0 100644 --- a/tests/Util/CliLoginHelperTest.php +++ b/tests/Util/CliLoginHelperTest.php @@ -4,6 +4,7 @@ use ItkDev\OpenIdConnectBundle\Exception\CacheException; use ItkDev\OpenIdConnectBundle\Exception\OpenIdConnectBundleExceptionInterface; +use ItkDev\OpenIdConnectBundle\Exception\TokenNotFoundException; use ItkDev\OpenIdConnectBundle\Util\CliLoginHelper; use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemInterface; @@ -40,7 +41,10 @@ public function testDecodeKeyReturnsInputWhenNotValidBase64(): void public function testThrowExceptionIfTokenDoesNotExist(): void { - $this->expectException(OpenIdConnectBundleExceptionInterface::class); + // TokenNotFoundException (not just the marker interface) is part of + // the public contract: CliLoginTokenAuthenticator catches it + // specifically to distinguish "no such token" from cache failures. + $this->expectException(TokenNotFoundException::class); $cache = new ArrayAdapter(); @@ -79,6 +83,37 @@ public function testTokenIsRemovedAfterUse(): void $cliHelper->getUsername($token); } + public function testBothCacheEntriesAreRemovedAfterUse(): void + { + $cache = new ArrayAdapter(); + + $cliHelper = new CliLoginHelper($cache); + + $testUser = 'test_user'; + $token = $cliHelper->createToken($testUser); + + $this->assertEquals($testUser, $cliHelper->getUsername($token)); + + // The reverse entry (username => token) must be gone too; otherwise + // createToken() would hand out the already-redeemed token again. + $this->assertFalse($cache->hasItem($cliHelper->encodeKey($testUser))); + + $newToken = $cliHelper->createToken($testUser); + $this->assertNotSame($token, $newToken); + $this->assertEquals($testUser, $cliHelper->getUsername($newToken)); + } + + public function testEncodeKeyPrependsNamespace(): void + { + $cache = new ArrayAdapter(); + $cliHelper = new CliLoginHelper($cache); + + // Assert the exact encoding, not just an encode/decode roundtrip: + // the namespace prefix guards against cache key collisions with the + // consuming application, and a roundtrip is blind to losing it. + $this->assertSame(base64_encode('itk-dev-cli-logintest_user'), $cliHelper->encodeKey('test_user')); + } + public function testCreateTokenAndGetUsername(): void { $cache = new ArrayAdapter(); From dcc72ece5072dac5c0ff0ac5d2090b800438816c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 11 Jun 2026 10:23:50 +0200 Subject: [PATCH 2/3] test: use RFC 2606 reserved domain example.org in command test fixtures Co-Authored-By: Claude Fable 5 --- tests/Command/UserLoginCommandTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Command/UserLoginCommandTest.php b/tests/Command/UserLoginCommandTest.php index c1278e1..ba2bbf9 100644 --- a/tests/Command/UserLoginCommandTest.php +++ b/tests/Command/UserLoginCommandTest.php @@ -44,13 +44,13 @@ public function testExecuteSuccess(): void $this->stubUrlGenerator ->method('generate') - ->willReturn('https://app.com/login?loginToken=generated-token'); + ->willReturn('https://example.org/login?loginToken=generated-token'); $tester = new CommandTester($this->command); $result = $tester->execute(['username' => 'testuser']); $this->assertSame(Command::SUCCESS, $result); - $this->assertStringContainsString('https://app.com/login?loginToken=generated-token', $tester->getDisplay()); + $this->assertStringContainsString('https://example.org/login?loginToken=generated-token', $tester->getDisplay()); } public function testExecutePassesTokenAndRouteToUrlGenerator(): void @@ -63,7 +63,7 @@ public function testExecutePassesTokenAndRouteToUrlGenerator(): void $urlGenerator->expects($this->once()) ->method('generate') ->with('cli_login_route', ['loginToken' => 'generated-token'], UrlGeneratorInterface::ABSOLUTE_URL) - ->willReturn('https://app.com/login?loginToken=generated-token'); + ->willReturn('https://example.org/login?loginToken=generated-token'); $command = new UserLoginCommand( $this->stubCliLoginHelper, From cab60df0f020be635dc89b656264adee577dce76 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 11 Jun 2026 10:27:44 +0200 Subject: [PATCH 3/3] test: use app.example.org subdomain for application-side URLs Co-Authored-By: Claude Fable 5 --- tests/Command/UserLoginCommandTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Command/UserLoginCommandTest.php b/tests/Command/UserLoginCommandTest.php index ba2bbf9..53ee001 100644 --- a/tests/Command/UserLoginCommandTest.php +++ b/tests/Command/UserLoginCommandTest.php @@ -44,13 +44,13 @@ public function testExecuteSuccess(): void $this->stubUrlGenerator ->method('generate') - ->willReturn('https://example.org/login?loginToken=generated-token'); + ->willReturn('https://app.example.org/login?loginToken=generated-token'); $tester = new CommandTester($this->command); $result = $tester->execute(['username' => 'testuser']); $this->assertSame(Command::SUCCESS, $result); - $this->assertStringContainsString('https://example.org/login?loginToken=generated-token', $tester->getDisplay()); + $this->assertStringContainsString('https://app.example.org/login?loginToken=generated-token', $tester->getDisplay()); } public function testExecutePassesTokenAndRouteToUrlGenerator(): void @@ -63,7 +63,7 @@ public function testExecutePassesTokenAndRouteToUrlGenerator(): void $urlGenerator->expects($this->once()) ->method('generate') ->with('cli_login_route', ['loginToken' => 'generated-token'], UrlGeneratorInterface::ABSOLUTE_URL) - ->willReturn('https://example.org/login?loginToken=generated-token'); + ->willReturn('https://app.example.org/login?loginToken=generated-token'); $command = new UserLoginCommand( $this->stubCliLoginHelper,