diff --git a/solid/lib/BaseServerConfig.php b/solid/lib/BaseServerConfig.php index 7c391893..ab967dea 100644 --- a/solid/lib/BaseServerConfig.php +++ b/solid/lib/BaseServerConfig.php @@ -202,7 +202,7 @@ public function setUserSubDomainsEnabled($enabled) { ////////////////////////////// UTILITY METHODS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ - private function castToBool(string $mixedValue): bool + private function castToBool(?string $mixedValue): bool { $type = gettype($mixedValue); diff --git a/solid/lib/Controller/ServerController.php b/solid/lib/Controller/ServerController.php index 06f18c9f..cbef27b7 100644 --- a/solid/lib/Controller/ServerController.php +++ b/solid/lib/Controller/ServerController.php @@ -23,6 +23,7 @@ class ServerController extends Controller { use DpopFactoryTrait; + public const ERROR_UNREGISTERED_URI = 'Provided redirect URI "%s" does not match any registered URIs'; private $userId; /* @var IUserManager */ @@ -220,10 +221,28 @@ public function authorize() { return $result; // ->addHeader('Access-Control-Allow-Origin', '*'); } - $parsedOrigin = parse_url($clientRegistration['redirect_uris'][0]); + if (isset($getVars['redirect_uri'])) { + $redirectUri = $getVars['redirect_uri']; + if (! isset($clientRegistration['redirect_uris']) || ! is_array($clientRegistration['redirect_uris'])) { + return new JSONResponse('Invalid client registration, no redirect URIs found', Http::STATUS_BAD_REQUEST); + } + + $redirectUris = $clientRegistration['redirect_uris']; + + $validRedirectUris = array_filter($redirectUris, function ($uri) use ($redirectUri) { + return $uri === $redirectUri; + }); + + if (count($validRedirectUris) === 0) { + $message = vsprintf(self::ERROR_UNREGISTERED_URI, [$redirectUri]); + return new JSONResponse($message, Http::STATUS_BAD_REQUEST); + } + } + + $parsedOrigin = parse_url($redirectUri); if ( - $parsedOrigin['scheme'] != "https" && - $parsedOrigin['scheme'] != "http" && + $parsedOrigin['scheme'] !== "https" && + $parsedOrigin['scheme'] !== "http" && !isset($_GET['customscheme']) ) { $result = new JSONResponse('Custom schema'); @@ -348,8 +367,8 @@ public function logout() { public function register() { $clientData = file_get_contents('php://input'); $clientData = json_decode($clientData, true); - if (!$clientData['redirect_uris']) { - return new JSONResponse("Missing redirect URIs"); + if (! isset($clientData['redirect_uris'])) { + return new JSONResponse("Missing redirect URIs", Http::STATUS_BAD_REQUEST); } $clientData['client_id_issued_at'] = time(); $parsedOrigin = parse_url($clientData['redirect_uris'][0]); diff --git a/solid/tests/Unit/BaseServerConfigTest.php b/solid/tests/Unit/BaseServerConfigTest.php index aeb70bad..cd60af07 100644 --- a/solid/tests/Unit/BaseServerConfigTest.php +++ b/solid/tests/Unit/BaseServerConfigTest.php @@ -1,19 +1,31 @@ createMock(IConfig::class); - $configMock->method('getAppValue')->willReturn($expected); + $configMock->method('getAppValue')->willReturn($value); $baseServerConfig = new BaseServerConfig($configMock); $actual = $baseServerConfig->getUserSubDomainsEnabled(); @@ -78,10 +91,11 @@ public function testGetUserSubDomainsEnabledFromAppConfig() /** * @testdox BaseServerConfig should set value in AppConfig when asked to set UserSubDomainsEnabled * @covers ::setUserSubDomainsEnabled + * @covers ::castToBool * * @dataProvider provideBooleans */ - public function testSetUserSubDomainsEnabled($expected) + public function testSetUserSubDomainsEnabled($value, $expected) { $configMock = $this->createMock(IConfig::class); $configMock->expects($this->atLeast(1)) @@ -90,7 +104,239 @@ public function testSetUserSubDomainsEnabled($expected) ; $baseServerConfig = new BaseServerConfig($configMock); - $baseServerConfig->setUserSubDomainsEnabled($expected); + $baseServerConfig->setUserSubDomainsEnabled($value); + } + + /** + * @testdox BaseServerConfig should retrieve client ID AppValue when asked to GetClientRegistration for existing client + * @covers ::getClientRegistration + */ + public function testGetClientRegistrationForExistingClient() + { + $configMock = $this->createMock(IConfig::class); + $baseServerConfig = new BaseServerConfig($configMock); + + $expected = ['mock' => 'client']; + + $configMock->expects($this->once()) + ->method('getAppValue') + ->with(Application::APP_ID, 'client-' . self::MOCK_CLIENT_ID) + ->willReturn(json_encode($expected)); + + $actual = $baseServerConfig->getClientRegistration(self::MOCK_CLIENT_ID); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox BaseServerConfig should return empty array when asked to GetClientRegistration for non-existing client + * @covers ::getClientRegistration + */ + public function testGetClientRegistrationForNonExistingClient() + { + $configMock = $this->createMock(IConfig::class); + $baseServerConfig = new BaseServerConfig($configMock); + + $expected = []; + + $configMock->expects($this->once()) + ->method('getAppValue') + ->with(Application::APP_ID, 'client-' . self::MOCK_CLIENT_ID) + ->willReturnArgument(2); + + $actual = $baseServerConfig->getClientRegistration(self::MOCK_CLIENT_ID); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox BaseServerConfig should complain when asked to save ClientRegistration without origin + * @covers ::saveClientRegistration + */ + public function testSaveClientRegistrationWithoutOrigin() + { + $this->expectException(TypeError::class); + $this->expectExceptionMessage('Too few arguments to function'); + + $configMock = $this->createMock(IConfig::class); + $baseServerConfig = new BaseServerConfig($configMock); + + $baseServerConfig->saveClientRegistration(); + } + + /** + * @testdox BaseServerConfig should complain when asked to save ClientRegistration without client data + * @covers ::saveClientRegistration + */ + public function testSaveClientRegistrationWithoutClientData() + { + $this->expectException(TypeError::class); + $this->expectExceptionMessage('Too few arguments to function'); + + $configMock = $this->createMock(IConfig::class); + $baseServerConfig = new BaseServerConfig($configMock); + + $baseServerConfig->saveClientRegistration(self::MOCK_ORIGIN); + } + + /** + * @testdox BaseServerConfig should save ClientRegistration when asked to save ClientRegistration for new client + * @covers ::saveClientRegistration + */ + public function testSaveClientRegistrationForNewClient() + { + $configMock = $this->createMock(IConfig::class); + + $configMock->expects($this->once()) + ->method('getAppValue') + ->with(Application::APP_ID, 'client-' . md5(self::MOCK_ORIGIN)) + ->willReturnArgument(2); + + $expected = [ + 'client_id' => md5(self::MOCK_ORIGIN), + 'client_name' => self::MOCK_ORIGIN, + 'client_secret' => md5(self::MOCK_RANDOM_BYTES), + ]; + + $configMock->expects($this->exactly(2)) + ->method('setAppValue') + ->willReturnMap([ + // Using willReturnMap as withConsecutive is removed since PHPUnit 10 + [Application::APP_ID, 'client-' . md5(self::MOCK_ORIGIN), json_encode($expected)], + [Application::APP_ID, 'client-' . self::MOCK_ORIGIN, json_encode($expected)] + ]); + + $baseServerConfig = new BaseServerConfig($configMock); + + $actual = $baseServerConfig->saveClientRegistration(self::MOCK_ORIGIN, []); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox BaseServerConfig should save ClientRegistration when asked to save ClientRegistration for existing client + * @covers ::saveClientRegistration + */ + public function testSaveClientRegistrationForExistingClient() + { + $configMock = $this->createMock(IConfig::class); + + $expected = [ + 'client_id' => md5(self::MOCK_ORIGIN), + 'client_name' => self::MOCK_ORIGIN, + 'client_secret' => md5(self::MOCK_RANDOM_BYTES), + 'redirect_uris' => [self::MOCK_REDIRECT_URI], + ]; + + $configMock->expects($this->once()) + ->method('getAppValue') + ->with(Application::APP_ID, 'client-' . md5(self::MOCK_ORIGIN)) + ->willReturn(json_encode($expected)); + + $configMock->expects($this->exactly(2)) + ->method('setAppValue') + ->willReturnMap([ + // Using willReturnMap as withConsecutive is deprecated since PHPUnit 10 + [Application::APP_ID, 'client-' . md5(self::MOCK_ORIGIN), json_encode($expected)], + [Application::APP_ID, 'client-' . self::MOCK_ORIGIN, json_encode($expected)] + ]); + + $baseServerConfig = new BaseServerConfig($configMock); + + $actual = $baseServerConfig->saveClientRegistration(self::MOCK_ORIGIN, []); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox BaseServerConfig should save ClientRegistration when asked to save ClientRegistration for blocked client + * @covers ::saveClientRegistration + */ + public function testSaveClientRegistrationForBlockedClient() + { + $configMock = $this->createMock(IConfig::class); + + $expected = [ + 'client_id' => md5(self::MOCK_ORIGIN), + 'client_name' => self::MOCK_ORIGIN, + 'client_secret' => md5(self::MOCK_RANDOM_BYTES), + 'redirect_uris' => [self::MOCK_REDIRECT_URI], + 'blocked' => true, + ]; + + $configMock->expects($this->once()) + ->method('getAppValue') + ->with(Application::APP_ID, 'client-' . md5(self::MOCK_ORIGIN)) + ->willReturn(json_encode($expected)); + + $configMock->expects($this->exactly(2)) + ->method('setAppValue') + ->willReturnMap([ + // Using willReturnMap as withConsecutive is deprecated since PHPUnit 10 + [Application::APP_ID, 'client-' . md5(self::MOCK_ORIGIN), json_encode($expected)], + [Application::APP_ID, 'client-' . self::MOCK_ORIGIN, json_encode($expected)] + ]); + + $baseServerConfig = new BaseServerConfig($configMock); + + $actual = $baseServerConfig->saveClientRegistration(self::MOCK_ORIGIN, $expected); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox BaseServerConfig should always "blocked" to existing value when asked to save ClientRegistration for blocked client + * @covers ::saveClientRegistration + */ + public function testSaveClientRegistrationSetsBlocked() + { + $configMock = $this->createMock(IConfig::class); + + $expected = [ + 'client_id' => md5(self::MOCK_ORIGIN), + 'client_name' => self::MOCK_ORIGIN, + 'client_secret' => md5(self::MOCK_RANDOM_BYTES), + 'redirect_uris' => [self::MOCK_REDIRECT_URI], + 'blocked' => true, + ]; + + $configMock->expects($this->once()) + ->method('getAppValue') + ->with(Application::APP_ID, 'client-' . md5(self::MOCK_ORIGIN)) + ->willReturn(json_encode($expected)); + + $clientData = $expected; + $clientData['blocked'] = false; + + $configMock->expects($this->exactly(2)) + ->method('setAppValue') + ->willReturnMap([ + // Using willReturnMap as withConsecutive is deprecated since PHPUnit 10 + [Application::APP_ID, 'client-' . md5(self::MOCK_ORIGIN), json_encode($expected)], + [Application::APP_ID, 'client-' . self::MOCK_ORIGIN, json_encode($expected)] + ]); + + $baseServerConfig = new BaseServerConfig($configMock); + + $actual = $baseServerConfig->saveClientRegistration(self::MOCK_ORIGIN, $clientData); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox BaseServerConfig should remove ClientRegistration when asked to remove ClientRegistration + * @covers ::removeClientRegistration + */ + public function testRemoveClientRegistration() + { + $configMock = $this->createMock(IConfig::class); + $baseServerConfig = new BaseServerConfig($configMock); + + $configMock->expects($this->once()) + ->method('deleteAppValue') + ->with(Application::APP_ID, 'client-' . self::MOCK_CLIENT_ID); + + $baseServerConfig->removeClientRegistration(self::MOCK_CLIENT_ID); } /////////////////////////////// DATAPROVIDERS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ @@ -98,8 +344,32 @@ public function testSetUserSubDomainsEnabled($expected) public function provideBooleans() { return [ - 'false' => [false], - 'true' => [true], + // Only 'boolean', 'NULL', 'integer', 'string' are allowed + // @TODO: Add test for type that trigger a TypeError: + // - array + // - callable + // - float + // - object + // - resource + // @TODO: Add test for values that trigger a TypeError + // 'integer:-1' => ['value'=> -1], + // 'integer:2' => ['value'=> 2], + // 'string:-1' => ['value'=> '-1'], + // 'string:2' => ['value'=> '2'], + // 'string:foo' => ['value'=> 'foo'], + // 'string:NULL' => ['value'=> 'NULL'], + 'boolean:false' => ['value'=> false, 'expected' => false], + 'boolean:true' => ['value'=> true, 'expected' => true], + 'integer:0' => ['value'=> 0, 'expected' => false], + 'integer:1' => ['value'=> 1, 'expected' => true], + 'NULL' => ['value'=> null, 'expected' => false], + 'string:0' => ['value'=> '0', 'expected' => false], + 'string:1' => ['value'=> '1', 'expected' => true], + 'string:empty' => ['value'=> '', 'expected' => false], + 'string:false' => ['value'=> 'false', 'expected' => false], + 'string:FALSE' => ['value'=> 'FALSE', 'expected' => false], + 'string:true' => ['value'=> 'true', 'expected' => true], + 'string:TRUE' => ['value'=> 'TRUE', 'expected' => true], ]; } } diff --git a/solid/tests/Unit/Controller/ServerControllerTest.php b/solid/tests/Unit/Controller/ServerControllerTest.php new file mode 100644 index 00000000..b1104cbd --- /dev/null +++ b/solid/tests/Unit/Controller/ServerControllerTest.php @@ -0,0 +1,494 @@ +createMockConstructorParameters(); + + $parameters = array_slice($parameters, 0, $index); + + $this->expectException(\ArgumentCountError::class); + $message = vsprintf( + 'Too few arguments to function %s::%s, %s passed in %s on line %s and exactly %s expected', + [ + 'class' => preg_quote('OCA\Solid\Controller\ServerController', '/'), + 'method' => preg_quote('__construct()', '/'), + 'index' => $index, + 'file' => '.*', + 'line' => '\d+', + 'count' => 9, + + ] + ); + + $this->expectExceptionMessageMatches('/^' . $message . '$/'); + + new ServerController(...$parameters); + } + + /** + * @testdox ServerController should be instantiable with all required parameters + * + * @covers ::__construct + */ + public function testInstantiation() + { + $parameters = $this->createMockConstructorParameters(); + + $controller = new ServerController(...$parameters); + + $this->assertInstanceOf(ServerController::class, $controller); + } + + /** + * @testdox ServerController should return a 401 when asked to authorize without signed-in user + * + * @covers ::authorize + */ + public function testAuthorizeWithoutUser() + { + $parameters = $this->createMockConstructorParameters(); + + $controller = new ServerController(...$parameters); + + $expected = new JSONResponse('Authorization required', Http::STATUS_UNAUTHORIZED); + $actual = $controller->authorize(); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox ServerController should return a 400 when asked to authorize with a user but without valid token + * + * @covers ::authorize + */ + public function testAuthorizeWithoutValidToken() + { + $_GET['response_type'] = 'mock-response-type'; + + $parameters = $this->createMockConstructorParameters(); + + $this->mockUserManager->method('userExists')->willReturn(true); + + $controller = new ServerController(...$parameters); + + $actual = $controller->authorize(); + $expected = new JSONResponse('Bad request, does not contain valid token', Http::STATUS_BAD_REQUEST); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox ServerController should return a 302 redirect when asked to authorize client that has not been approved + * + * @covers ::authorize + */ + public function testAuthorizeWithoutApprovedClient() + { + $_GET['client_id'] = self::MOCK_CLIENT_ID; + $_GET['nonce'] = 'mock-nonce'; + // JWT with empty payload, HS256 encoded, created with `private.key` from fixtures + $_GET['request'] = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.8VKCTiBegJPuPIZlp0wbV0Sbdn5BS6TE5DCx6oYNc5o'; + $_GET['response_type'] = 'mock-response-type'; + + $_SERVER['REQUEST_URI'] = 'mock uri'; + + $parameters = $this->createMockConstructorParameters(); + + $this->mockConfig->method('getUserValue')->willReturnArgument(3); + + $this->mockUserManager->method('userExists')->willReturn(true); + + $controller = new ServerController(...$parameters); + + $actual = $controller->authorize(); + $expected = new JSONResponse('Approval required', Http::STATUS_FOUND, ['Location' => '']); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox ServerController should return a 400 when asked to authorize a client that sends an incorrect redirect URI + * + * @covers ::authorize + */ + public function testAuthorizeWithInvalidRedirectUri() + { + $_GET['client_id'] = self::MOCK_CLIENT_ID; + $_GET['redirect_uri'] = 'https://some.other.client/redirect'; + + $clientData = json_encode(['client_name' => 'Mock Client', 'redirect_uris' => ['https://mock.client/redirect']]); + + $parameters = $this->createMockConstructorParameters($clientData); + + $this->mockConfig->method('getUserValue') + ->with(self::MOCK_USER_ID, Application::APP_ID, 'allowedClients', '[]') + ->willReturn(json_encode([self::MOCK_CLIENT_ID])); + + $this->mockUserManager->method('userExists')->willReturn(true); + + $controller = new ServerController(...$parameters); + + $response = $controller->authorize(); + + $expected = [ + 'data' => vsprintf($controller::ERROR_UNREGISTERED_URI, [$_GET['redirect_uri']]), + 'headers' => [ + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Content-Security-Policy' => "default-src 'none';base-uri 'none';manifest-src 'self';frame-ancestors 'none'", + 'Content-Type' => 'application/json; charset=utf-8', + 'Feature-Policy' => "autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone 'none';payment 'none'", + 'X-Robots-Tag' => 'noindex, nofollow', + ], + 'status' => Http::STATUS_BAD_REQUEST, + ]; + + $actual = [ + 'data' => $response->getData(), + 'headers' => $response->getHeaders(), + 'status' => $response->getStatus(), + ]; + + // Not comparing time-sensitive data + unset($actual['headers']['X-Request-Id']); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox ServerController should return a 302 redirect when asked to authorize client that has been approved + * + * @covers ::authorize + */ + public function testAuthorize() + { + $_GET['client_id'] = self::MOCK_CLIENT_ID; + $_GET['nonce'] = 'mock-nonce'; + // JWT with empty payload, HS256 encoded, created with `private.key` from fixtures + $_GET['request'] = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.8VKCTiBegJPuPIZlp0wbV0Sbdn5BS6TE5DCx6oYNc5o'; + $_GET['response_type'] = 'mock-response-type'; + $_GET['redirect_uri'] = 'https://mock.client/redirect'; + + $_SERVER['REQUEST_URI'] = 'https://mock.server'; + $_SERVER['REQUEST_URI']; + + $clientData = json_encode(['client_name' => 'Mock Client', 'redirect_uris' => ['https://mock.client/redirect']]); + + $parameters = $this->createMockConstructorParameters($clientData); + + $this->mockConfig->method('getUserValue') + ->with(self::MOCK_USER_ID, Application::APP_ID, 'allowedClients', '[]') + ->willReturn(json_encode([self::MOCK_CLIENT_ID])); + + $this->mockUserManager->method('userExists')->willReturn(true); + + $controller = new ServerController(...$parameters); + + $response = $controller->authorize(); + + $expected = [ + 'data' => 'ok', + 'headers' => [ + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Content-Security-Policy' => "default-src 'none';base-uri 'none';manifest-src 'self';frame-ancestors 'none'", + 'Content-Type' => 'application/json; charset=utf-8', + 'Feature-Policy' => "autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone 'none';payment 'none'", + 'X-Robots-Tag' => 'noindex, nofollow', + ], + 'status' => Http::STATUS_FOUND, + ]; + + $actual = [ + 'data' => $response->getData(), + 'headers' => $response->getHeaders(), + 'status' => $response->getStatus(), + ]; + + $location = $actual['headers']['Location'] ?? ''; + + // Not comparing time-sensitive data + unset($actual['headers']['X-Request-Id'], $actual['headers']['Location']); + + $this->assertEquals($expected, $actual); + + // @TODO: Move $location assert to a separate test + $url = parse_url($location); + + parse_str($url['fragment'], $url['fragment']); + + unset($url['fragment']['access_token'], $url['fragment']['id_token']); + + $this->assertEquals([ + 'scheme' => 'https', + 'host' => 'mock.client', + 'path' => '/redirect', + 'fragment' => [ + 'token_type' => 'Bearer', + 'expires_in' => '3600', + ], + ], $url); + } + + /** + * @testdox ServerController should return a 400 when asked to register without valid client data + * + * @covers ::register + */ + public function testRegisterWithoutRedirectUris() + { + $parameters = $this->createMockConstructorParameters(); + + $controller = new ServerController(...$parameters); + + $actual = $controller->register(); + + $this->assertEquals( + new JSONResponse('Missing redirect URIs', Http::STATUS_BAD_REQUEST), + $actual + ); + } + + /** + * @testdox ServerController should return a 200 with client data when asked to register with valid redirect URIs + * + * @covers ::register + */ + public function testRegisterWithRedirectUris() + { + $parameters = $this->createMockConstructorParameters(); + + $this->mockURLGenerator->method('getBaseUrl') + ->willReturn('https://mock.server'); + + $controller = new ServerController(...$parameters); + + self::$clientData = json_encode(['redirect_uris' => ['https://mock.client/redirect']]); + + $response = $controller->register(); + + $actual = [ + 'data' => $response->getData(), + 'headers' => $response->getHeaders(), + 'status' => $response->getStatus(), + ]; + + // Not comparing time-sensitive data + unset($actual['data']['client_id_issued_at'], $actual['headers']['X-Request-Id']); + + $this->assertEquals([ + 'data' => [ + 'application_type' => 'web', + 'client_id' => 'f4a2d00f7602948a97ff409d7a581ec2', + 'grant_types' => ['implicit'], + 'id_token_signed_response_alg' => 'RS256', + 'redirect_uris' => ['https://mock.client/redirect'], + 'registration_access_token' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL21vY2suc2VydmVyIiwiYXVkIjoiZjRhMmQwMGY3NjAyOTQ4YTk3ZmY0MDlkN2E1ODFlYzIiLCJzdWIiOiJmNGEyZDAwZjc2MDI5NDhhOTdmZjQwOWQ3YTU4MWVjMiJ9.AfOi9YW70rL0EKn4_dvhkyu02iI4yGYV-Xh8hQ9RbHBUnvcXROFfQzn-OL-R3kV3nn8tknmpG-r_8Ouoo7O_Sjo8Hx1QSFfeqjJGOgB8HbXV7WN2spOMicSB-68EyftqfTGH0ksyPyJaNSTbkdIqtawsDaSKUVqTmziEo4IrE5anwDLZrtSUcS0A4KVrOAkJmgYGiC4MC0NMYXeBRxgkr1_h7GN4hekAXs9-5XwRH1mwswUVRL-6prx0IYpPNURFNqkS2NU83xNf-vONThOdLVkADVy-l3PCHT3E1sRdkklCHLjhWiZo7NcMlB0WdS-APnZYCi5hLEr5-jwNI2sxoA', + 'registration_client_uri' => '', + 'response_types' => ['id_token token'], + 'token_endpoint_auth_method' => 'client_secret_basic', + ], + 'headers' => [ + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Content-Security-Policy' => "default-src 'none';base-uri 'none';manifest-src 'self';frame-ancestors 'none'", + 'Feature-Policy' => "autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone 'none';payment 'none'", + 'X-Robots-Tag' => 'noindex, nofollow', + 'Content-Type' => 'application/json; charset=utf-8', + ], + 'status' => Http::STATUS_OK, + ], $actual); + } + + /** + * @testdox ServerController should consume Post, Server, and Session variables when generating a token + * + * @covers ::token + */ + public function testToken() + { + $_POST['client_id'] = self::MOCK_CLIENT_ID; + $_POST['code'] = ''; + $_SERVER['HTTP_DPOP'] = 'mock dpop'; + $_SESSION['nonce'] = 'mock nonce'; + + $parameters = $this->createMockConstructorParameters(); + + // @FIXME: Use actual TokenGenerator when we know how to make a valid 'code' for the test + $mockTokenGenerator = $this->createMock(\Pdsinterop\Solid\Auth\TokenGenerator::class); + $mockTokenGenerator->method('getCodeInfo')->willReturn(['user_id' => self::MOCK_USER_ID]); + $mockTokenGenerator->expects($this->once()) + ->method('addIdTokenToResponse') + ->with( + $this->isInstanceOf(Response::class), + $_POST['client_id'], + self::MOCK_USER_ID, + $_SESSION['nonce'], + self::$privateKey, + $_SERVER['HTTP_DPOP'], + ) + ->willReturn(new Response('php://memory', Http::STATUS_IM_A_TEAPOT, [ + 'Content-Type' => 'mock application type' + ])); + + $controller = new ServerController(...$parameters); + + $reflectionObject = new \ReflectionObject($controller); + $reflectionProperty = $reflectionObject->getProperty('tokenGenerator'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($controller, $mockTokenGenerator); + + $tokenResponse = $controller->token(); + + $expected = [ + 'data' => "I'm a teapot", + 'headers' => [ + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Content-Security-Policy' => "default-src 'none';base-uri 'none';manifest-src 'self';frame-ancestors 'none'", + 'Feature-Policy' => "autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone 'none';payment 'none'", + 'X-Robots-Tag' => 'noindex, nofollow', + 'Content-Type' => 'application/json; charset=utf-8', + ], + 'status' => Http::STATUS_IM_A_TEAPOT, + ]; + + $actual = [ + 'data' => $tokenResponse->getData(), + 'headers' => $tokenResponse->getHeaders(), + 'status' => $tokenResponse->getStatus(), + ]; + unset($actual['headers']['X-Request-Id']); + + + $this->assertEquals($expected, $actual); + } + + ////////////////////////////// MOCKS AND STUBS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + public function createMockConfig($clientData): IConfig|MockObject + { + $this->mockConfig = $this->createMock(IConfig::class); + + $this->mockConfig->method('getAppValue')->willReturnMap([ + [Application::APP_ID, 'client-' . self::MOCK_CLIENT_ID, '{}', 'return' => $clientData], + [Application::APP_ID, 'client-d6d7896757f61ac4c397d914053180ff', '{}', 'return' => $clientData], + [Application::APP_ID, 'client-', '{}', 'return' => $clientData], + [Application::APP_ID, 'profileData', '', 'return' => ''], + [Application::APP_ID, 'encryptionKey', '', 'return' => 'mock encryption key'], + [Application::APP_ID, 'privateKey', '', 'return' => self::$privateKey], + // Client ID from register() with https://mock.client + [Application::APP_ID, 'client-f4a2d00f7602948a97ff409d7a581ec2', '{}', 'return' => $clientData], + ]); + + return $this->mockConfig; + } + + public function createMockConstructorParameters($clientData = '{}'): array + { + $parameters = [ + 'mock appname', + $this->createMock(IRequest::class), + $this->createMock(ISession::class), + $this->createMockUserManager(), + $this->createMockUrlGenerator(), + self::MOCK_USER_ID, + $this->createMockConfig($clientData), + $this->createMock(UserService::class), + $this->createMock(IDBConnection::class), + ]; + + return $parameters; + } + + public function createMockUrlGenerator(): IURLGenerator|MockObject + { + $this->mockURLGenerator = $this->createMock(IURLGenerator::class); + + return $this->mockURLGenerator; + } + + public function createMockUserManager(): IUserManager|MockObject + { + $this->mockUserManager = $this->createMock(IUserManager::class); + + return $this->mockUserManager; + } + + /////////////////////////////// DATAPROVIDERS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + public static function provideConstructorParameterIndex() + { + return [ + 'appName' => [0], + 'request' => [1], + 'session' => [2], + 'userManager' => [3], + 'urlGenerator' => [4], + 'userId' => [5], + 'config' => [6], + 'userService' => [7], + 'connection' => [8], + ]; + } +} diff --git a/solid/tests/fixtures/keys/private.key b/solid/tests/fixtures/keys/private.key new file mode 100644 index 00000000..94793713 --- /dev/null +++ b/solid/tests/fixtures/keys/private.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAvqb0htUFZaZ+z5rn7cHWg0VzsSoVnusbtJvwWtHfD0T0s6Hb +OqzE5h2fgdGbB49HRtc21SNHx6jeEStGv03yyqYkLUKrJJSg+ksrL+pT3Nd0h25q +sx7YUoPPxnm6sbd3XTg5efCb2yyV2dOoAegUPjK46Ra6PqUvmICQWDsjnv0VJIx+ +TdDWmKY2xElk0T6CVNMD08OZVTHPwJgpGdRZyCK/SSmrvmAZ6K3ocKySJdKgYriR +bVMdx9NsczRkYU9b7tUpPmLu3IvsLboTbfRN23Y70Gx3Z8fuI1FRn23sEuQSIRW+ +NsAi7l+AEdJ7MdYn0xSY6YMNJ0/aGXi55gagQwIDAQABAoIBAQCz8CNNtnPXkqKR +EmTfk1kAoGYmyc+KI+AMQDlDnlzmrnA9sf+Vi0Zy4XaQMeId6m6dP7Yyx4+Rs6GT +lsK4/7qs5M20If4hEl40nQlvubvY7UjAIch2sh/9EQbjDjTUUpJH2y70FdEjtRrh +cdBZrE6evYSkCZ1STtlzF7QkcfyWqilTHEntrHRaM3N+B6F74Yi5g6VyGE9uqKEM +EuGDHVSXizdUjauTTVEa4o7pxTh+eTIdQsfRewer7iuxFPo2vBNOTU2O/obNUsVK +mgmGM4QDjurgXLL2XPr0dVVo3eiFvIdmtZgGVyLfL/vUXH7bwUIfkV6qWyRmdBiY +Dfsm8BJBAoGBAOGebDUVnP3NgFacWVYrtvBXcH2Q6X1W6JEAxctDDsnjchTdyG9E +zcsMVM/gFKXIDF5VeNoSt2pwCTBL6K0oPC31c01clActbHStaJWOOCuifzrvmu4n +X51TNGoKggbbSVx1UTifKte2t6SPRaZ26EqVrmO44fGkA3ip6TRYnSFzAoGBANhT +J47EieRWiNflq9XqDAZ1fZzo3AHB+b+pO4r8GZr3Dw0ShCAnQXv7Gb2JAJvE3UrC +Aq5r3yZMM7nI+n/OT06+UcJ3/vDGAPx9trNrpWkwmcWBmoBfp86vDRhT0kEIiKbO +wLYMmSNLHNkmQQdBX2ytnsRxRyCWtQmm09bzOJHxAoGBAKEB/nSPnP5elfS5FOPy +xFWWANgK/yWMTOGV7JFWpIocvz/22d/V+QqrHSdP4UxBi9oSIvF1I+FYXKZTtZNE +wFWH8SXHKHhKyTgmvBjmal1xVFyJu0WzYX+TbjcykoI0IZFSw4ilxdw1L67G88yM +1M7NLKtLuCpKgpOspZjOmCvTAoGAGji6KswYCt2SaNkmIx/jpUTInSR8xpnEtD7H +QOmeEPKxmFwON/eKMIUXcaoRsNAEIvOxb4MT4YiLHJIIC0XuxxS6xF/XP0hBBloW +s1jxC/cgLJixKa5uoNcHN1OxwMBQECgvo+GTDnwkWw4QA9kgwAOroxQ4EvMxrqHS +O9Pvn4ECgYA7xr/3Sz8n+BhgOdABW0m91P144rK9QDYiaClSxAha1KiFunmAy3pB +Uxdl4yTCTA9yKIH7X3bShDXnj+RmEZ+SkwzpPuKvAE8ZkZQuXv41anFrZYkR2PZy +oYiERqXgH5yS/mkDeXRFx1nWsVxjoLWfd/Vi7Lr43cuYFy4UjqXZdg== +-----END RSA PRIVATE KEY-----