diff --git a/app/Http/Controllers/Api/OAuth2/OAuth2UserApiController.php b/app/Http/Controllers/Api/OAuth2/OAuth2UserApiController.php index 06abfdd6..3f4ac9cd 100644 --- a/app/Http/Controllers/Api/OAuth2/OAuth2UserApiController.php +++ b/app/Http/Controllers/Api/OAuth2/OAuth2UserApiController.php @@ -20,6 +20,7 @@ use App\Http\Exceptions\HTTP403ForbiddenException; use App\Http\Utils\HTMLCleaner; use App\ModelSerializers\SerializerRegistry; +use App\ModelSerializers\SerializerUtils; use Auth\Repositories\IUserRepository; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request as LaravelRequest; @@ -640,6 +641,27 @@ public function userInfo() required: true, schema: new OA\Schema(type: 'integer') ), + new OA\Parameter( + name: 'expand', + description: 'Expand relations: groups', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'fields', + description: 'Comma-separated list of scalar fields to return, e.g. first_name,last_name,pic,public_profile_allow_chat_with_me', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'relations', + description: 'Comma-separated list of relations to include (supported: groups)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), ], responses: [ new OA\Response( @@ -663,22 +685,19 @@ public function userInfo() )] public function get($id) { - try { + return $this->processRequest(function () use ($id) { $user = $this->repository->getById(intval($id)); if (is_null($user)) { throw new EntityNotFoundException(); } - return $this->ok(SerializerRegistry::getInstance()->getSerializer($user, SerializerRegistry::SerializerType_Private)->serialize()); - } catch (ValidationException $ex1) { - Log::warning($ex1); - return $this->error412($ex1->getMessages()); - } catch (EntityNotFoundException $ex2) { - Log::warning($ex2); - return $this->error404(['message' => $ex2->getMessage()]); - } catch (Exception $ex) { - Log::error($ex); - return $this->error500($ex); - } + return $this->ok(SerializerRegistry::getInstance() + ->getSerializer($user, SerializerRegistry::SerializerType_Private) + ->serialize( + SerializerUtils::getExpand(), + SerializerUtils::getFields(), + SerializerUtils::getRelations() + )); + }); } /** @@ -716,6 +735,20 @@ public function get($id) required: false, schema: new OA\Schema(type: 'string') ), + new OA\Parameter( + name: 'fields', + description: 'Comma-separated list of scalar fields to return, e.g. first_name,last_name,pic,public_profile_allow_chat_with_me', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'relations', + description: 'Comma-separated list of relations to include (supported: groups)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), ], responses: [ new OA\Response( @@ -747,7 +780,9 @@ public function getV2($id) return $this->ok(SerializerRegistry::getInstance() ->getSerializer($user, SerializerRegistry::SerializerType_Private) ->serialize( - Request::input("expand", '') + SerializerUtils::getExpand(), + SerializerUtils::getFields(), + SerializerUtils::getRelations() )); }); } diff --git a/app/ModelSerializers/Auth/UserSerializer.php b/app/ModelSerializers/Auth/UserSerializer.php index 9c29d113..dd4ef935 100644 --- a/app/ModelSerializers/Auth/UserSerializer.php +++ b/app/ModelSerializers/Auth/UserSerializer.php @@ -46,6 +46,10 @@ final class PublicUserSerializer extends BaseUserSerializer final class PrivateUserSerializer extends BaseUserSerializer { + protected static $allowed_relations = [ + 'groups', + ]; + protected static $array_mappings = [ 'Email' => 'email:json_string', 'Identifier' => 'identifier:json_string', @@ -96,15 +100,19 @@ public function serialize($expand = null, array $fields = [], array $relations = $user = $this->object; if (!$user instanceof User) return []; + if (!count($relations)) $relations = $this->getAllowedRelations(); + $values = parent::serialize($expand, $fields, $relations, $params); - $groups = []; - foreach ($user->getGroups() as $group) { - if (!$group instanceof Group) continue; - $groups[] = $group->getSlug(); - } + if (in_array('groups', $relations)) { + $groups = []; + foreach ($user->getGroups() as $group) { + if (!$group instanceof Group) continue; + $groups[] = $group->getSlug(); + } - $values['groups'] = $groups; + $values['groups'] = $groups; + } if (!empty($expand)) { $exp_expand = explode(',', $expand); diff --git a/tests/OAuth2UserApiTest.php b/tests/OAuth2UserApiTest.php index a0633ba1..0f24ac88 100644 --- a/tests/OAuth2UserApiTest.php +++ b/tests/OAuth2UserApiTest.php @@ -95,6 +95,128 @@ public function testGetUserByIdV1(){ $this->assertNotNull($user); } + public function testGetUserByIdV1WithFieldsPassthrough(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'fields' => 'public_profile_allow_chat_with_me,first_name,last_name,pic' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@get", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + // no `relations` override sent -> the default relation ('groups') still applies. + $this->assertEqualsCanonicalizing( + ['public_profile_allow_chat_with_me', 'first_name', 'last_name', 'pic', 'groups'], + array_keys($payload) + ); + } + + public function testGetUserByIdV1WithFieldsAndRelationsNone(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'fields' => 'public_profile_allow_chat_with_me,first_name,last_name,pic', + // 'none' is an arbitrary non-matching relation name used only to prove the override + // suppresses the default -- there is no dedicated empty-relations syntax for this endpoint. + 'relations' => 'none' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@get", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + $this->assertEqualsCanonicalizing( + ['public_profile_allow_chat_with_me', 'first_name', 'last_name', 'pic'], + array_keys($payload) + ); + } + + public function testGetUserByIdV1NotFoundStillReturns404(){ + $params = [ + 'id' => PHP_INT_MAX, + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@get", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token)); + + $this->assertResponseStatus(404); + $payload = json_decode($response->getContent(), true); + $this->assertArrayHasKey('message', $payload); + } + + public function testGetUserByIdV1WithNoParamsReturnsSameShapeAsBefore(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + // No 'fields', 'relations', or 'expand' at all -- proves the fields/relations + // passthrough is purely additive and does not change the v1 response for a + // caller that sends none of the new params. + $params = [ + 'id' => $user->getId(), + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@get", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + // Full private field set, unchanged from before this PR -- id/timestamps/PII/groups all present. + $this->assertEqualsCanonicalizing( + [ + 'active', 'address1', 'address2', 'bio', 'birthday', 'city', 'company', + 'country_iso_code', 'created_at', 'email', 'email_verified', 'first_name', + 'gender', 'gender_specify', 'github_user', 'groups', 'id', 'identifier', + 'irc', 'job_title', 'language', 'last_login_date', 'last_name', + 'linked_in_profile', 'phone_number', 'pic', 'post_code', + 'public_profile_allow_chat_with_me', 'public_profile_show_bio', + 'public_profile_show_email', 'public_profile_show_fullname', + 'public_profile_show_photo', 'public_profile_show_social_media_info', + 'public_profile_show_telephone_number', 'second_email', 'spam_type', + 'state', 'statement_of_interest', 'third_email', 'twitter_name', + 'updated_at', 'wechat_user', + ], + array_keys($payload) + ); + } + public function testGetUserByIdV2(){ $repo = EntityManager::getRepository(User::class); $user = $repo->getAll()[0]; @@ -119,6 +241,166 @@ public function testGetUserByIdV2(){ $this->assertNotNull($user); } + public function testGetUserByIdV2WithNoParamsReturnsSameShapeAsBefore(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + // No 'fields', 'relations', or 'expand' at all -- proves the fields/relations + // passthrough is purely additive and does not change the response for a caller + // that sends none of the new params. + $params = [ + 'id' => $user->getId(), + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@getV2", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token_service_app_type)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + // Full private field set, unchanged from before this plan -- id/timestamps/PII/groups all present. + $this->assertEqualsCanonicalizing( + [ + 'active', 'address1', 'address2', 'bio', 'birthday', 'city', 'company', + 'country_iso_code', 'created_at', 'email', 'email_verified', 'first_name', + 'gender', 'gender_specify', 'github_user', 'groups', 'id', 'identifier', + 'irc', 'job_title', 'language', 'last_login_date', 'last_name', + 'linked_in_profile', 'phone_number', 'pic', 'post_code', + 'public_profile_allow_chat_with_me', 'public_profile_show_bio', + 'public_profile_show_email', 'public_profile_show_fullname', + 'public_profile_show_photo', 'public_profile_show_social_media_info', + 'public_profile_show_telephone_number', 'second_email', 'spam_type', + 'state', 'statement_of_interest', 'third_email', 'twitter_name', + 'updated_at', 'wechat_user', + ], + array_keys($payload) + ); + } + + public function testGetUserByIdV2WithFieldsPassthrough(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'fields' => 'public_profile_allow_chat_with_me,first_name,last_name,pic' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@getV2", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token_service_app_type)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + // no `relations` override sent -> the default relation ('groups') still applies. + $this->assertEqualsCanonicalizing( + ['public_profile_allow_chat_with_me', 'first_name', 'last_name', 'pic', 'groups'], + array_keys($payload) + ); + } + + public function testGetUserByIdV2WithFieldsAndRelationsNone(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'fields' => 'public_profile_allow_chat_with_me,first_name,last_name,pic', + // 'none' is an arbitrary non-matching relation name used only to prove the override + // suppresses the default -- there is no dedicated empty-relations syntax for this endpoint. + 'relations' => 'none' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@getV2", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token_service_app_type)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + $this->assertEqualsCanonicalizing( + ['public_profile_allow_chat_with_me', 'first_name', 'last_name', 'pic'], + array_keys($payload) + ); + } + + public function testGetUserByIdV2ExpandOverridesRelationsNone(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'fields' => 'public_profile_allow_chat_with_me,first_name,last_name,pic', + // same non-matching sentinel as testGetUserByIdV2WithFieldsAndRelationsNone -- proves + // `expand` still forces `groups` back in even though `relations` omitted it. + 'relations' => 'none', + 'expand' => 'groups' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@getV2", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token_service_app_type)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + $this->assertArrayHasKey('groups', $payload); + $this->assertIsArray($payload['groups']); + } + + public function testGetUserByIdV2RelationsGroupsWithExpand(){ + $repo = EntityManager::getRepository(User::class); + $user = $repo->getAll()[0]; + + $params = [ + 'id' => $user->getId(), + 'relations' => 'groups', + 'expand' => 'groups' + ]; + + $response = $this->action( + "GET", + "Api\OAuth2\OAuth2UserApiController@getV2", + $params, + [], + [], + [], + array("HTTP_Authorization" => " Bearer " .$this->access_token_service_app_type)); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $payload = json_decode($content, true); + $this->assertNotNull($payload); + $this->assertArrayHasKey('groups', $payload); + $this->assertIsArray($payload['groups']); + } + public function testGetInfoCORS(){ $response = $this->action("OPTIONS", "Api\OAuth2\OAuth2UserApiController@me", [], diff --git a/tests/UserLoginTurnstileTest.php b/tests/UserLoginTurnstileTest.php index f13b3db8..571db04f 100644 --- a/tests/UserLoginTurnstileTest.php +++ b/tests/UserLoginTurnstileTest.php @@ -35,8 +35,8 @@ final class UserLoginTurnstileTest extends BrowserKitTestCase // Matches ServerConfigurationService::DefaultMaxFailedLoginAttempts2ShowCaptcha private const CAPTCHA_THRESHOLD = 3; - private string $testEmail; - private string $testPassword; + private ?string $testEmail = null; + private ?string $testPassword = null; protected function prepareForTests(): void {