From cbb4e9f7ef8583525629d01d32e0a5a25fdaafb7 Mon Sep 17 00:00:00 2001 From: Rick Lambrechts Date: Fri, 31 Mar 2023 11:19:13 +0200 Subject: [PATCH] Add tests for login controller (#7) * Update LoginResponse to implement LoginResponseInterface * Updated test namespace * Overwritten OpenIDConnectClient redirect function to use Laravel abort method * Login tests * Add response test --- src/Http/Responses/LoginResponse.php | 3 +- src/OpenIDConnectClient.php | 13 ++ .../Http/Controllers/LoginControllerTest.php | 104 +++++++++ .../OpenIDConfigurationLoaderTest.php | 2 +- .../Http/Controllers/LoginControllerTest.php | 204 ++++++++++++++++++ 5 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/Http/Controllers/LoginControllerTest.php rename tests/Feature/{ => OpenIDConfiguration}/OpenIDConfigurationLoaderTest.php (99%) create mode 100644 tests/Unit/Http/Controllers/LoginControllerTest.php diff --git a/src/Http/Responses/LoginResponse.php b/src/Http/Responses/LoginResponse.php index f988cf0..ab1f910 100644 --- a/src/Http/Responses/LoginResponse.php +++ b/src/Http/Responses/LoginResponse.php @@ -4,12 +4,11 @@ namespace MinVWS\OpenIDConnectLaravel\Http\Responses; -use Illuminate\Contracts\Support\Responsable; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; -class LoginResponse implements Responsable +class LoginResponse implements LoginResponseInterface { public function __construct( protected object $userInfo diff --git a/src/OpenIDConnectClient.php b/src/OpenIDConnectClient.php index f5ab59a..6623266 100644 --- a/src/OpenIDConnectClient.php +++ b/src/OpenIDConnectClient.php @@ -4,6 +4,7 @@ namespace MinVWS\OpenIDConnectLaravel; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Session; use Illuminate\Support\Str; use Jumbojett\OpenIDConnectClient as BaseOpenIDConnectClient; @@ -108,4 +109,16 @@ protected function getWellKnownConfigValue($param, $default = null): string|arra return $config->{$param}; } + + /** + * Overwrite the redirect method to use Laravel's abort method. + * Sometimes the error 'Cannot modify header information - headers already sent' was thrown. + * By using Laravel's abort method, this error is prevented. + * @param string $url + * @return void + */ + public function redirect($url): void + { + App::abort(302, '', ['Location' => $url]); + } } diff --git a/tests/Feature/Http/Controllers/LoginControllerTest.php b/tests/Feature/Http/Controllers/LoginControllerTest.php new file mode 100644 index 0000000..40aef7d --- /dev/null +++ b/tests/Feature/Http/Controllers/LoginControllerTest.php @@ -0,0 +1,104 @@ +mockOpenIDConfigurationLoader(); + + config()->set('oidc.client_id', 'test-client-id'); + + $response = $this->get(route('oidc.login')); + $response + ->assertStatus(302) + ->assertRedirectContains("https://provider.rdobeheer.nl/authorize") + ->assertRedirectContains('test-client-id'); + } + + public function testLoginRouteReturnsUserInfoWitchMockedClient(): void + { + $mockClient = Mockery::mock(OpenIDConnectClient::class); + $mockClient + ->shouldReceive('authenticate') + ->once(); + + $mockClient + ->shouldReceive('requestUserInfo') + ->andReturn((object) [ + 'sub' => 'test-sub', + 'name' => 'test-name', + 'email' => 'test-email', + ]); + + $this->app->instance(OpenIDConnectClient::class, $mockClient); + + $response = $this->get(route('oidc.login')); + $response + ->assertJson([ + 'userInfo' => [ + 'sub' => 'test-sub', + 'name' => 'test-name', + 'email' => 'test-email', + ], + ]); + } + + protected function mockOpenIDConfigurationLoader(): void + { + $mock = Mockery::mock(OpenIDConfigurationLoader::class); + $mock + ->shouldReceive('getConfiguration') + ->andReturn($this->exampleOpenIDConfiguration()); + + $this->app->instance(OpenIDConfigurationLoader::class, $mock); + } + + protected function exampleOpenIDConfiguration(): OpenIDConfiguration + { + return new OpenIDConfiguration( + version: "3.0", + tokenEndpointAuthMethodsSupported: ["none"], + claimsParameterSupported: true, + requestParameterSupported: false, + requestUriParameterSupported: true, + requireRequestUriRegistration: false, + grantTypesSupported: ["authorization_code"], + frontchannelLogoutSupported: false, + frontchannelLogoutSessionSupported: false, + backchannelLogoutSupported: false, + backchannelLogoutSessionSupported: false, + issuer: "https://provider.rdobeheer.nl", + authorizationEndpoint: "https://provider.rdobeheer.nl/authorize", + jwksUri: "https://provider.rdobeheer.nl/jwks", + tokenEndpoint: "https://provider.rdobeheer.nl/token", + scopesSupported: ["openid"], + responseTypesSupported: ["code"], + responseModesSupported: ["query"], + subjectTypesSupported: ["pairwise"], + idTokenSigningAlgValuesSupported: ["RS256"], + userinfoEndpoint: "https://provider.rdobeheer.nl/userinfo", + codeChallengeMethodsSupported: ["S256"], + ); + } +} diff --git a/tests/Feature/OpenIDConfigurationLoaderTest.php b/tests/Feature/OpenIDConfiguration/OpenIDConfigurationLoaderTest.php similarity index 99% rename from tests/Feature/OpenIDConfigurationLoaderTest.php rename to tests/Feature/OpenIDConfiguration/OpenIDConfigurationLoaderTest.php index 7bf330f..e95e8e1 100644 --- a/tests/Feature/OpenIDConfigurationLoaderTest.php +++ b/tests/Feature/OpenIDConfiguration/OpenIDConfigurationLoaderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace MinVWS\OpenIDConnectLaravel\Tests\Feature; +namespace MinVWS\OpenIDConnectLaravel\Tests\Feature\OpenIDConfiguration; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; diff --git a/tests/Unit/Http/Controllers/LoginControllerTest.php b/tests/Unit/Http/Controllers/LoginControllerTest.php new file mode 100644 index 0000000..34474d3 --- /dev/null +++ b/tests/Unit/Http/Controllers/LoginControllerTest.php @@ -0,0 +1,204 @@ +bind(LoginResponseInterface::class, LoginResponse::class); + } + + protected function tearDown(): void + { + // Flush so the LoginResponseInterface binding is removed + app()->flush(); + + parent::tearDown(); + } + + public function testLoginControllerCanBeCreated(): void + { + $loginController = new LoginController( + new OpenIDConnectClient(), + new OpenIDConnectExceptionHandler(), + ); + $this->assertInstanceOf(LoginController::class, $loginController); + } + + public function testExceptionHandlerIsCalledWhenAuthenticateThrowsException(): void + { + $mockClient = Mockery::mock(OpenIDConnectClient::class); + $mockClient + ->shouldReceive('authenticate') + ->andThrow(OpenIDConnectClientException::class); + + $mockExceptionHandler = Mockery::mock(OpenIDConnectExceptionHandler::class); + $mockExceptionHandler + ->shouldReceive('handleExceptionWhileAuthenticate') + ->once(); + + $loginController = new LoginController( + $mockClient, + $mockExceptionHandler, + ); + + $loginController->__invoke(); + } + + public function testExceptionHandlerIsCalledWhenRequestUserInfoDoesNotReturnAnObject(): void + { + $mockClient = Mockery::mock(OpenIDConnectClient::class); + $mockClient->shouldReceive('authenticate')->once(); + $mockClient + ->shouldReceive('requestUserInfo') + ->andReturn('not an object') + ->once(); + + $mockExceptionHandler = Mockery::mock(OpenIDConnectExceptionHandler::class); + $mockExceptionHandler + ->shouldReceive('handleExceptionWhileRequestUserInfo') + ->withArgs(function (OpenIDConnectClientException $e) { + return $e->getMessage() === 'Received user info is not an object'; + }) + ->once(); + + $loginController = new LoginController( + $mockClient, + $mockExceptionHandler, + ); + + $loginController->__invoke(); + } + + public function testExceptionHandlerIsCalledWhenRequestUserInfoThrowsAnException(): void + { + $mockClient = Mockery::mock(OpenIDConnectClient::class); + $mockClient->shouldReceive('authenticate')->once(); + $mockClient + ->shouldReceive('requestUserInfo') + ->andThrow(OpenIDConnectClientException::class, 'Something went wrong') + ->once(); + + $mockExceptionHandler = Mockery::mock(OpenIDConnectExceptionHandler::class); + $mockExceptionHandler + ->shouldReceive('handleExceptionWhileRequestUserInfo') + ->withArgs(function (OpenIDConnectClientException $e) { + return $e->getMessage() === 'Something went wrong'; + }) + ->once(); + + $loginController = new LoginController( + $mockClient, + $mockExceptionHandler, + ); + + $loginController->__invoke(); + } + + public function testExceptionHandlerIsCalledWhenRequestUserInfoThrowsAnJweDecryptException(): void + { + $mockClient = Mockery::mock(OpenIDConnectClient::class); + $mockClient->shouldReceive('authenticate')->once(); + $mockClient + ->shouldReceive('requestUserInfo') + ->andThrow(JweDecryptException::class, 'Something went wrong') + ->once(); + + $mockExceptionHandler = Mockery::mock(OpenIDConnectExceptionHandler::class); + $mockExceptionHandler + ->shouldReceive('handleException') + ->withArgs(function (Exception $e) { + return $e->getMessage() === 'Something went wrong'; + }) + ->once(); + + $loginController = new LoginController( + $mockClient, + $mockExceptionHandler, + ); + + $loginController->__invoke(); + } + + public function testLoginResponseIsReturnedWithUserInfo(): void + { + $mockClient = Mockery::mock(OpenIDConnectClient::class); + $mockClient->shouldReceive('authenticate')->once(); + $mockClient + ->shouldReceive('requestUserInfo') + ->andReturn($this->exampleUserInfo()) + ->once(); + + $mockExceptionHandler = Mockery::mock(OpenIDConnectExceptionHandler::class); + + $loginController = new LoginController( + $mockClient, + $mockExceptionHandler, + ); + + $response = $loginController->__invoke(); + + $this->assertInstanceOf(LoginResponseInterface::class, $response); + $this->assertInstanceOf(Responsable::class, $response); + } + + public function testUserInfoIsReturned(): void + { + $mockClient = Mockery::mock(OpenIDConnectClient::class); + $mockClient->shouldReceive('authenticate')->once(); + $mockClient + ->shouldReceive('requestUserInfo') + ->andReturn($this->exampleUserInfo()) + ->once(); + + $mockExceptionHandler = Mockery::mock(OpenIDConnectExceptionHandler::class); + + $loginController = new LoginController( + $mockClient, + $mockExceptionHandler, + ); + + $loginResponse = $loginController->__invoke(); + $response = $loginResponse->toResponse(Mockery::mock(Request::class)); + + $this->assertSame(json_encode([ + 'userInfo' => $this->exampleUserInfo(), + ]), $response->getContent()); + } + + protected function exampleUserInfo(): object + { + return (object) [ + 'sub' => '1234567890', + 'name' => 'John Doe', + 'given_name' => 'John', + 'family_name' => 'Doe', + 'middle_name' => 'Middle', + 'nickname' => 'JD', + 'preferred_username' => 'johndoe', + 'email' => '', + ]; + } +}