diff options
author | Rémy Coutable <remy@rymai.me> | 2016-06-23 17:14:31 +0200 |
---|---|---|
committer | Rémy Coutable <remy@rymai.me> | 2016-08-10 19:07:05 +0200 |
commit | 29850364eccccc3ce7305f6706cea1d5d073de2e (patch) | |
tree | bbf98c0a621676b7b3f3e58b4618905923ed1454 | |
parent | b1aac0382c406b3856db90e15df8b2a9ea7ff6cd (diff) | |
download | gitlab-ce-29850364eccccc3ce7305f6706cea1d5d073de2e.tar.gz |
New AccessRequests API endpoints for Group & Project
Also, mutualize AccessRequests and Members endpoints for Group &
Project.
New API documentation for the AccessRequests endpoints.
Signed-off-by: Rémy Coutable <remy@rymai.me>
-rw-r--r-- | CHANGELOG | 1 | ||||
-rw-r--r-- | app/models/members/project_member.rb | 1 | ||||
-rw-r--r-- | app/models/project.rb | 4 | ||||
-rw-r--r-- | app/services/members/destroy_service.rb | 5 | ||||
-rw-r--r-- | doc/api/README.md | 6 | ||||
-rw-r--r-- | doc/api/access_requests.md | 147 | ||||
-rw-r--r-- | doc/api/groups.md | 82 | ||||
-rw-r--r-- | doc/api/members.md | 182 | ||||
-rw-r--r-- | doc/api/projects.md | 90 | ||||
-rw-r--r-- | lib/api/access_requests.rb | 91 | ||||
-rw-r--r-- | lib/api/api.rb | 8 | ||||
-rw-r--r-- | lib/api/entities.rb | 24 | ||||
-rw-r--r-- | lib/api/group_members.rb | 87 | ||||
-rw-r--r-- | lib/api/helpers.rb | 25 | ||||
-rw-r--r-- | lib/api/helpers/members_helpers.rb | 13 | ||||
-rw-r--r-- | lib/api/members.rb | 124 | ||||
-rw-r--r-- | lib/api/project_members.rb | 110 | ||||
-rw-r--r-- | spec/models/member_spec.rb | 2 | ||||
-rw-r--r-- | spec/models/members/project_member_spec.rb | 3 | ||||
-rw-r--r-- | spec/requests/api/access_requests_spec.rb | 246 | ||||
-rw-r--r-- | spec/requests/api/group_members_spec.rb | 10 | ||||
-rw-r--r-- | spec/requests/api/members_spec.rb | 312 | ||||
-rw-r--r-- | spec/requests/api/project_members_spec.rb | 31 | ||||
-rw-r--r-- | spec/support/api/members_shared_examples.rb | 11 |
24 files changed, 1189 insertions, 426 deletions
diff --git a/CHANGELOG b/CHANGELOG index 88588059a35..898a21d221a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,7 @@ v 8.11.0 (unreleased) - Improve diff performance by eliminating redundant checks for text blobs - Convert switch icon into icon font (ClemMakesApps) - API: Endpoints for enabling and disabling deploy keys + - API: List access requests, request access, approve, and deny access requests to a project or a group. !4833 - Use long options for curl examples in documentation !5703 (winniehell) - Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell) - Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell) diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index f176feddbad..18e97c969d7 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -8,6 +8,7 @@ class ProjectMember < Member # Make sure project member points only to project as it source default_value_for :source_type, SOURCE_TYPE validates_format_of :source_type, with: /\AProject\z/ + validates :access_level, inclusion: { in: Gitlab::Access.values } default_scope { where(source_type: SOURCE_TYPE) } scope :in_project, ->(project) { where(source_id: project.id) } diff --git a/app/models/project.rb b/app/models/project.rb index 3b1a53edc75..e0b28160937 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -999,6 +999,10 @@ class Project < ActiveRecord::Base project_members.find_by(user_id: user) end + def add_user(user, access_level, current_user = nil) + team.add_user(user, access_level, current_user) + end + def default_branch @default_branch ||= repository.root_ref if repository.exists? end diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index 15358f80208..9e3f6af628d 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -2,8 +2,9 @@ module Members class DestroyService < BaseService attr_accessor :member, :current_user - def initialize(member, user) - @member, @current_user = member, user + def initialize(member, current_user) + @member = member + @current_user = current_user end def execute diff --git a/doc/api/README.md b/doc/api/README.md index a357af3831d..f3117815c7c 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -16,6 +16,8 @@ following locations: - [Commits](commits.md) - [Deploy Keys](deploy_keys.md) - [Groups](groups.md) +- [Group Access Requests](access_requests.md) +- [Group Members](members.md) - [Issues](issues.md) - [Keys](keys.md) - [Labels](labels.md) @@ -25,6 +27,8 @@ following locations: - [Namespaces](namespaces.md) - [Notes](notes.md) (comments) - [Projects](projects.md) including setting Webhooks +- [Project Access Requests](access_requests.md) +- [Project Members](members.md) - [Project Snippets](project_snippets.md) - [Repositories](repositories.md) - [Repository Files](repository_files.md) @@ -154,7 +158,7 @@ be returned with status code `403`: ```json { - "message": "403 Forbidden: Must be admin to use sudo" + "message": "403 Forbidden - Must be admin to use sudo" } ``` diff --git a/doc/api/access_requests.md b/doc/api/access_requests.md new file mode 100644 index 00000000000..261585af282 --- /dev/null +++ b/doc/api/access_requests.md @@ -0,0 +1,147 @@ +# Group and project access requests + + >**Note:** This feature was introduced in GitLab 8.11 + + **Valid access levels** + + The access levels are defined in the `Gitlab::Access` module. Currently, these levels are recognized: + +``` +10 => Guest access +20 => Reporter access +30 => Developer access +40 => Master access +50 => Owner access # Only valid for groups +``` + +## List access requests for a group or project + +Gets a list of access requests viewable by the authenticated user. + +Returns `200` if the request succeeds. + +``` +GET /groups/:id/access_requests +GET /projects/:id/access_requests +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/access_requests +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/access_requests +``` + +Example response: + +```json +[ + { + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "requested_at": "2012-10-22T14:13:35Z" + }, + { + "id": 2, + "username": "john_doe", + "name": "John Doe", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "requested_at": "2012-10-22T14:13:35Z" + } +] +``` + +## Request access to a group or project + +Requests access for the authenticated user to a group or project. + +Returns `201` if the request succeeds. + +``` +POST /groups/:id/access_requests +POST /projects/:id/access_requests +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | + +```bash +curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/access_requests +curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/access_requests +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "requested_at": "2012-10-22T14:13:35Z" +} +``` + +## Approve an access request + +Approves an access request for the given user. + +Returns `201` if the request succeeds. + +``` +PUT /groups/:id/access_requests/:user_id/approve +PUT /projects/:id/access_requests/:user_id/approve +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the access requester | +| `access_level` | integer | no | A valid access level (defaults: `30`, developer access level) | + +```bash +curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/access_requests/:user_id/approve?access_level=20 +curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/access_requests/:user_id/approve?access_level=20 +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 20 +} +``` + +## Deny an access request + +Denies an access request for the given user. + +Returns `200` if the request succeeds. + +``` +DELETE /groups/:id/access_requests/:user_id +DELETE /projects/:id/access_requests/:user_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the access requester | + +```bash +curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/access_requests/:user_id +curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/access_requests/:user_id +``` diff --git a/doc/api/groups.md b/doc/api/groups.md index fd665e967a9..a898387eaa2 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -417,87 +417,7 @@ GET /groups?search=foobar ## Group members -**Group access levels** - -The group access levels are defined in the `Gitlab::Access` module. Currently, these levels are recognized: - -``` -GUEST = 10 -REPORTER = 20 -DEVELOPER = 30 -MASTER = 40 -OWNER = 50 -``` - -### List group members - -Get a list of group members viewable by the authenticated user. - -``` -GET /groups/:id/members -``` - -```json -[ - { - "id": 1, - "username": "raymond_smith", - "name": "Raymond Smith", - "state": "active", - "created_at": "2012-10-22T14:13:35Z", - "access_level": 30 - }, - { - "id": 2, - "username": "john_doe", - "name": "John Doe", - "state": "active", - "created_at": "2012-10-22T14:13:35Z", - "access_level": 30 - } -] -``` - -### Add group member - -Adds a user to the list of group members. - -``` -POST /groups/:id/members -``` - -Parameters: - -- `id` (required) - The ID or path of a group -- `user_id` (required) - The ID of a user to add -- `access_level` (required) - Project access level - -### Edit group team member - -Updates a group team member to a specified access level. - -``` -PUT /groups/:id/members/:user_id -``` - -Parameters: - -- `id` (required) - The ID of a group -- `user_id` (required) - The ID of a group member -- `access_level` (required) - Project access level - -### Remove user team member - -Removes user from user team. - -``` -DELETE /groups/:id/members/:user_id -``` - -Parameters: - -- `id` (required) - The ID or path of a user group -- `user_id` (required) - The ID of a group member +Please consult the [Group Members](members.md) documentation. ## Namespaces in groups diff --git a/doc/api/members.md b/doc/api/members.md new file mode 100644 index 00000000000..4cfc27c300b --- /dev/null +++ b/doc/api/members.md @@ -0,0 +1,182 @@ +# Group and project members + +**Valid access levels** + +The access levels are defined in the `Gitlab::Access` module. Currently, these levels are recognized: + +``` +10 => Guest access +20 => Reporter access +30 => Developer access +40 => Master access +50 => Owner access # Only valid for groups +``` + +## List all members of a group or project + +Gets a list of group or project members viewable by the authenticated user. + +Returns `200` if the request succeeds. + +``` +GET /groups/:id/members +GET /projects/:id/members +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `query` | string | no | A query string to search for members | + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/members +``` + +Example response: + +```json +[ + { + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 30 + }, + { + "id": 2, + "username": "john_doe", + "name": "John Doe", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 30 + } +] +``` + +## Get a member of a group or project + +Gets a member of a group or project. + +Returns `200` if the request succeeds. + +``` +GET /groups/:id/members/:user_id +GET /projects/:id/members/:user_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the member | + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members/:user_id +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/members/:user_id +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 30 +} +``` + +## Add a member to a group or project + +Adds a member to a group or project. + +Returns `201` if the request succeeds. + +``` +POST /groups/:id/members +POST /projects/:id/members +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the new member | +| `access_level` | integer | yes | A valid access level | + +```bash +curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members/:user_id?access_level=30 +curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/members/:user_id?access_level=30 +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 30 +} +``` + +## Edit a member of a group or project + +Updates a member of a group or project. + +Returns `200` if the request succeeds. + +``` +PUT /groups/:id/members/:user_id +PUT /projects/:id/members/:user_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the member | +| `access_level` | integer | yes | A valid access level | + +```bash +curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members/:user_id?access_level=40 +curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/members/:user_id?access_level=40 +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 40 +} +``` + +## Remove a member from a group or project + +Removes a user from a group or project. + +Returns `200` if the request succeeds. + +``` +DELETE /groups/:id/members/:user_id +DELETE /projects/:id/members/:user_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the member | + +```bash +curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members/:user_id +curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/members/:user_id +``` diff --git a/doc/api/projects.md b/doc/api/projects.md index 727cb44f335..37d97b2db44 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -858,95 +858,9 @@ Parameters: In Markdown contexts, the link is automatically expanded when the format in `markdown` is used. -## Team members +## Project members -### List project team members - -Get a list of a project's team members. - -``` -GET /projects/:id/members -``` - -Parameters: - -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `query` (optional) - Query string to search for members - -### Get project team member - -Gets a project team member. - -``` -GET /projects/:id/members/:user_id -``` - -Parameters: - -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `user_id` (required) - The ID of a user - -```json -{ - "id": 1, - "username": "john_smith", - "email": "john@example.com", - "name": "John Smith", - "state": "active", - "created_at": "2012-05-23T08:00:58Z", - "access_level": 40 -} -``` - -### Add project team member - -Adds a user to a project team. This is an idempotent method and can be called multiple times -with the same parameters. Adding team membership to a user that is already a member does not -affect the existing membership. - -``` -POST /projects/:id/members -``` - -Parameters: - -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `user_id` (required) - The ID of a user to add -- `access_level` (required) - Project access level - -### Edit project team member - -Updates a project team member to a specified access level. - -``` -PUT /projects/:id/members/:user_id -``` - -Parameters: - -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `user_id` (required) - The ID of a team member -- `access_level` (required) - Project access level - -### Remove project team member - -Removes a user from a project team. - -``` -DELETE /projects/:id/members/:user_id -``` - -Parameters: - -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `user_id` (required) - The ID of a team member - -This method removes the project member if the user has the proper access rights to do so. -It returns a status code 403 if the member does not have the proper rights to perform this action. -In all other cases this method is idempotent and revoking team membership for a user who is not -currently a team member is considered success. -Please note that the returned JSON currently differs slightly. Thus you should not -rely on the returned JSON structure. +Please consult the [Project Members](members.md) documentation. ### Share project with group diff --git a/lib/api/access_requests.rb b/lib/api/access_requests.rb new file mode 100644 index 00000000000..9c41d8aaa3e --- /dev/null +++ b/lib/api/access_requests.rb @@ -0,0 +1,91 @@ +module API + class AccessRequests < Grape::API + before { authenticate! } + + helpers ::API::Helpers::MembersHelpers + + %w[group project].each do |source_type| + resource source_type.pluralize do + # Get a list of group/project access requests viewable by the authenticated user. + # + # Parameters: + # id (required) - The group/project ID + # + # Example Request: + # GET /groups/:id/access_requests + # GET /projects/:id/access_requests + get ":id/access_requests" do + source = find_source(source_type, params[:id]) + authorize_admin_source!(source_type, source) + + access_requesters = source.requesters + users = Kaminari.paginate_array(access_requesters.map(&:user)) + + present paginate(users), with: Entities::AccessRequester, source: source + end + + # Request access to the group/project + # + # Parameters: + # id (required) - The group/project ID + # + # Example Request: + # POST /groups/:id/access_requests + # POST /projects/:id/access_requests + post ":id/access_requests" do + source = find_source(source_type, params[:id]) + access_requester = source.request_access(current_user) + + if access_requester.persisted? + present access_requester.user, with: Entities::AccessRequester, access_requester: access_requester + else + render_validation_error!(access_requester) + end + end + + # Approve a group/project access request + # + # Parameters: + # id (required) - The group/project ID + # user_id (required) - The user ID of the access requester + # access_level (optional) - Access level + # + # Example Request: + # PUT /groups/:id/access_requests/:user_id/approve + # PUT /projects/:id/access_requests/:user_id/approve + put ':id/access_requests/:user_id/approve' do + required_attributes! [:user_id] + source = find_source(source_type, params[:id]) + authorize_admin_source!(source_type, source) + + member = source.requesters.find_by!(user_id: params[:user_id]) + if params[:access_level] + member.update(access_level: params[:access_level]) + end + member.accept_request + + status :created + present member.user, with: Entities::Member, member: member + end + + # Deny a group/project access request + # + # Parameters: + # id (required) - The group/project ID + # user_id (required) - The user ID of the access requester + # + # Example Request: + # DELETE /groups/:id/access_requests/:user_id + # DELETE /projects/:id/access_requests/:user_id + delete ":id/access_requests/:user_id" do + required_attributes! [:user_id] + source = find_source(source_type, params[:id]) + + access_requester = source.requesters.find_by!(user_id: params[:user_id]) + + ::Members::DestroyService.new(access_requester, current_user).execute + end + end + end + end +end diff --git a/lib/api/api.rb b/lib/api/api.rb index 6cd4a853dbe..d43af3f24e9 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -3,6 +3,10 @@ module API include APIGuard version 'v3', using: :path + rescue_from Gitlab::Access::AccessDeniedError do + rack_response({ 'message' => '403 Forbidden' }.to_json, 403) + end + rescue_from ActiveRecord::RecordNotFound do rack_response({ 'message' => '404 Not found' }.to_json, 404) end @@ -32,6 +36,7 @@ module API # Ensure the namespace is right, otherwise we might load Grape::API::Helpers helpers ::API::Helpers + mount ::API::AccessRequests mount ::API::AwardEmoji mount ::API::Branches mount ::API::Builds @@ -40,19 +45,18 @@ module API mount ::API::DeployKeys mount ::API::Environments mount ::API::Files - mount ::API::GroupMembers mount ::API::Groups mount ::API::Internal mount ::API::Issues mount ::API::Keys mount ::API::Labels mount ::API::LicenseTemplates + mount ::API::Members mount ::API::MergeRequests mount ::API::Milestones mount ::API::Namespaces mount ::API::Notes mount ::API::ProjectHooks - mount ::API::ProjectMembers mount ::API::ProjectSnippets mount ::API::Projects mount ::API::Repositories diff --git a/lib/api/entities.rb b/lib/api/entities.rb index e5b00dc45a5..c5ff4557b4a 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -91,9 +91,17 @@ module API end end - class ProjectMember < UserBasic + class Member < UserBasic expose :access_level do |user, options| - options[:project].project_members.find_by(user_id: user.id).access_level + member = options[:member] || options[:source].members.find_by(user_id: user.id) + member.access_level + end + end + + class AccessRequester < UserBasic + expose :requested_at do |user, options| + access_requester = options[:access_requester] || options[:source].requesters.find_by(user_id: user.id) + access_requester.requested_at end end @@ -108,12 +116,6 @@ module API expose :shared_projects, using: Entities::Project end - class GroupMember < UserBasic - expose :access_level do |user, options| - options[:group].group_members.find_by(user_id: user.id).access_level - end - end - class RepoBranch < Grape::Entity expose :name @@ -325,7 +327,7 @@ module API expose :id, :path, :kind end - class Member < Grape::Entity + class MemberAccess < Grape::Entity expose :access_level expose :notification_level do |member, options| if member.notification_setting @@ -334,10 +336,10 @@ module API end end - class ProjectAccess < Member + class ProjectAccess < MemberAccess end - class GroupAccess < Member + class GroupAccess < MemberAccess end class ProjectService < Grape::Entity diff --git a/lib/api/group_members.rb b/lib/api/group_members.rb deleted file mode 100644 index dbe5bb08d3f..00000000000 --- a/lib/api/group_members.rb +++ /dev/null @@ -1,87 +0,0 @@ -module API - class GroupMembers < Grape::API - before { authenticate! } - - resource :groups do - # Get a list of group members viewable by the authenticated user. - # - # Example Request: - # GET /groups/:id/members - get ":id/members" do - group = find_group(params[:id]) - users = group.users - present users, with: Entities::GroupMember, group: group - end - - # Add a user to the list of group members - # - # Parameters: - # id (required) - group id - # user_id (required) - the users id - # access_level (required) - Project access level - # Example Request: - # POST /groups/:id/members - post ":id/members" do - group = find_group(params[:id]) - authorize! :admin_group, group - required_attributes! [:user_id, :access_level] - - unless validate_access_level?(params[:access_level]) - render_api_error!("Wrong access level", 422) - end - - if group.group_members.find_by(user_id: params[:user_id]) - render_api_error!("Already exists", 409) - end - - group.add_users([params[:user_id]], params[:access_level], current_user) - member = group.group_members.find_by(user_id: params[:user_id]) - present member.user, with: Entities::GroupMember, group: group - end - - # Update group member - # - # Parameters: - # id (required) - The ID of a group - # user_id (required) - The ID of a group member - # access_level (required) - Project access level - # Example Request: - # PUT /groups/:id/members/:user_id - put ':id/members/:user_id' do - group = find_group(params[:id]) - authorize! :admin_group, group - required_attributes! [:access_level] - - group_member = group.group_members.find_by(user_id: params[:user_id]) - not_found!('User can not be found') if group_member.nil? - - if group_member.update_attributes(access_level: params[:access_level]) - @member = group_member.user - present @member, with: Entities::GroupMember, group: group - else - handle_member_errors group_member.errors - end - end - - # Remove member. - # - # Parameters: - # id (required) - group id - # user_id (required) - the users id - # - # Example Request: - # DELETE /groups/:id/members/:user_id - delete ":id/members/:user_id" do - group = find_group(params[:id]) - authorize! :admin_group, group - member = group.group_members.find_by(user_id: params[:user_id]) - - if member.nil? - render_api_error!("404 Not Found - user_id:#{params[:user_id]} not a member of group #{group.name}", 404) - else - member.destroy - end - end - end - end -end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 130509cdad6..f06c262fd4c 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -28,7 +28,7 @@ module API # If the sudo is the current user do nothing if identifier && !(@current_user.id == identifier || @current_user.username == identifier) - render_api_error!('403 Forbidden: Must be admin to use sudo', 403) unless @current_user.is_admin? + forbidden!('Must be admin to use sudo') unless @current_user.is_admin? @current_user = User.by_username_or_id(identifier) not_found!("No user id or username for: #{identifier}") if @current_user.nil? end @@ -47,18 +47,18 @@ module API end end + # Deprecated def user_project @project ||= find_project(params[:id]) - @project || not_found!("Project") end def find_project(id) project = Project.find_with_namespace(id) || Project.find_by(id: id) - if project && can?(current_user, :read_project, project) + if can?(current_user, :read_project, project) project else - nil + not_found!('Project') end end @@ -89,11 +89,7 @@ module API end def find_group(id) - begin - group = Group.find(id) - rescue ActiveRecord::RecordNotFound - group = Group.find_by!(path: id) - end + group = Group.find_by(path: id) || Group.find_by(id: id) if can?(current_user, :read_group, group) group @@ -135,7 +131,7 @@ module API end def authorize!(action, subject) - forbidden! unless abilities.allowed?(current_user, action, subject) + forbidden! unless can?(current_user, action, subject) end def authorize_push_project @@ -197,10 +193,6 @@ module API errors end - def validate_access_level?(level) - Gitlab::Access.options_with_owner.values.include? level.to_i - end - # Checks the occurrences of datetime attributes, each attribute if present in the params hash must be in ISO 8601 # format (YYYY-MM-DDTHH:MM:SSZ) or a Bad Request error is invoked. # @@ -411,11 +403,6 @@ module API File.read(Gitlab.config.gitlab_shell.secret_file).chomp end - def handle_member_errors(errors) - error!(errors[:access_level], 422) if errors[:access_level].any? - not_found!(errors) - end - def send_git_blob(repository, blob) env['api.format'] = :txt content_type 'text/plain' diff --git a/lib/api/helpers/members_helpers.rb b/lib/api/helpers/members_helpers.rb new file mode 100644 index 00000000000..90114f6f667 --- /dev/null +++ b/lib/api/helpers/members_helpers.rb @@ -0,0 +1,13 @@ +module API + module Helpers + module MembersHelpers + def find_source(source_type, id) + public_send("find_#{source_type}", id) + end + + def authorize_admin_source!(source_type, source) + authorize! :"admin_#{source_type}", source + end + end + end +end diff --git a/lib/api/members.rb b/lib/api/members.rb new file mode 100644 index 00000000000..56f8b1ca391 --- /dev/null +++ b/lib/api/members.rb @@ -0,0 +1,124 @@ +module API + class Members < Grape::API + before { authenticate! } + + helpers ::API::Helpers::MembersHelpers + + %w[group project].each do |source_type| + resource source_type.pluralize do + # Get a list of group/project members viewable by the authenticated user. + # + # Parameters: + # id (required) - The group/project ID + # query - Query string + # + # Example Request: + # GET /groups/:id/members + # GET /projects/:id/members + get ":id/members" do + source = find_source(source_type, params[:id]) + + members = source.members + members = members.joins(:user).merge(User.search(params[:query])) if params[:query] + users = Kaminari.paginate_array(members.map(&:user)) + + present paginate(users), with: Entities::Member, source: source + end + + # Get a group/project member + # + # Parameters: + # id (required) - The group/project ID + # user_id (required) - The user ID of the member + # + # Example Request: + # GET /groups/:id/members/:user_id + # GET /projects/:id/members/:user_id + get ":id/members/:user_id" do + source = find_source(source_type, params[:id]) + + members = source.members + member = members.find_by!(user_id: params[:user_id]) + + present member.user, with: Entities::Member, member: member + end + + # Add a new group/project member + # + # Parameters: + # id (required) - The group/project ID + # user_id (required) - The user ID of the new member + # access_level (required) - A valid access level + # + # Example Request: + # POST /groups/:id/members + # POST /projects/:id/members + post ":id/members" do + source = find_source(source_type, params[:id]) + authorize_admin_source!(source_type, source) + required_attributes! [:user_id, :access_level] + + access_requester = source.requesters.find_by(user_id: params[:user_id]) + if access_requester + # We pass current_user = access_requester so that the requester doesn't + # receive a "access denied" email + ::Members::DestroyService.new(access_requester, access_requester.user).execute + end + + conflict!('Member already exists') if source.members.exists?(user_id: params[:user_id]) + + source.add_user(params[:user_id], params[:access_level], current_user) + member = source.members.find_by(user_id: params[:user_id]) + if member + present member.user, with: Entities::Member, member: member + else + render_api_error!('400 Bad Request', 400) + end + end + + # Update a group/project member + # + # Parameters: + # id (required) - The group/project ID + # user_id (required) - The user ID of the member + # access_level (required) - A valid access level + # + # Example Request: + # PUT /groups/:id/members/:user_id + # PUT /projects/:id/members/:user_id + put ":id/members/:user_id" do + source = find_source(source_type, params[:id]) + authorize_admin_source!(source_type, source) + required_attributes! [:user_id, :access_level] + + member = source.members.find_by!(user_id: params[:user_id]) + + if member.update_attributes(access_level: params[:access_level]) + present member.user, with: Entities::Member, member: member + else + render_validation_error!(member) + end + end + + # Remove a group/project member + # + # Parameters: + # id (required) - The group/project ID + # user_id (required) - The user ID of the member + # + # Example Request: + # DELETE /groups/:id/members/:user_id + # DELETE /projects/:id/members/:user_id + delete ":id/members/:user_id" do + source = find_source(source_type, params[:id]) + required_attributes! [:user_id] + + member = source.members.find_by!(user_id: params[:user_id]) + + ::Members::DestroyService.new(member, current_user).execute + status :no_content + end + end + end + end +end diff --git a/lib/api/project_members.rb b/lib/api/project_members.rb deleted file mode 100644 index 6a0b3e7d134..00000000000 --- a/lib/api/project_members.rb +++ /dev/null @@ -1,110 +0,0 @@ -module API - # Projects members API - class ProjectMembers < Grape::API - before { authenticate! } - - resource :projects do - # Get a project team members - # - # Parameters: - # id (required) - The ID of a project - # query - Query string - # Example Request: - # GET /projects/:id/members - get ":id/members" do - if params[:query].present? - @members = paginate user_project.users.where("username LIKE ?", "%#{params[:query]}%") - else - @members = paginate user_project.users - end - present @members, with: Entities::ProjectMember, project: user_project - end - - # Get a project team members - # - # Parameters: - # id (required) - The ID of a project - # user_id (required) - The ID of a user - # Example Request: - # GET /projects/:id/members/:user_id - get ":id/members/:user_id" do - @member = user_project.users.find params[:user_id] - present @member, with: Entities::ProjectMember, project: user_project - end - - # Add a new project team member - # - # Parameters: - # id (required) - The ID of a project - # user_id (required) - The ID of a user - # access_level (required) - Project access level - # Example Request: - # POST /projects/:id/members - post ":id/members" do - authorize! :admin_project, user_project - required_attributes! [:user_id, :access_level] - - # either the user is already a team member or a new one - project_member = user_project.project_member(params[:user_id]) - if project_member.nil? - project_member = user_project.project_members.new( - user_id: params[:user_id], - access_level: params[:access_level] - ) - end - - if project_member.save - @member = project_member.user - present @member, with: Entities::ProjectMember, project: user_project - else - handle_member_errors project_member.errors - end - end - - # Update project team member - # - # Parameters: - # id (required) - The ID of a project - # user_id (required) - The ID of a team member - # access_level (required) - Project access level - # Example Request: - # PUT /projects/:id/members/:user_id - put ":id/members/:user_id" do - authorize! :admin_project, user_project - required_attributes! [:access_level] - - project_member = user_project.project_members.find_by(user_id: params[:user_id]) - not_found!("User can not be found") if project_member.nil? - - if project_member.update_attributes(access_level: params[:access_level]) - @member = project_member.user - present @member, with: Entities::ProjectMember, project: user_project - else - handle_member_errors project_member.errors - end - end - - # Remove a team member from project - # - # Parameters: - # id (required) - The ID of a project - # user_id (required) - The ID of a team member - # Example Request: - # DELETE /projects/:id/members/:user_id - delete ":id/members/:user_id" do - project_member = user_project.project_members.find_by(user_id: params[:user_id]) - - unless current_user.can?(:admin_project, user_project) || - current_user.can?(:destroy_project_member, project_member) - forbidden! - end - - if project_member.nil? - { message: "Access revoked", id: params[:user_id].to_i } - else - project_member.destroy - end - end - end - end -end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 44cd3c08718..2277f4e13bf 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -10,7 +10,7 @@ describe Member, models: true do it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:source) } - it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) } + it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.all_values) } it_behaves_like 'an object with email-formated attributes', :invite_email do subject { build(:project_member) } diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 28673de3189..913d74645a7 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -27,6 +27,7 @@ describe ProjectMember, models: true do describe 'validations' do it { is_expected.to allow_value('Project').for(:source_type) } it { is_expected.not_to allow_value('project').for(:source_type) } + it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) } end describe 'modules' do @@ -40,7 +41,7 @@ describe ProjectMember, models: true do end describe "#destroy" do - let(:owner) { create(:project_member, access_level: ProjectMember::OWNER) } + let(:owner) { create(:project_member, access_level: ProjectMember::MASTER) } let(:project) { owner.project } let(:master) { create(:project_member, project: project) } diff --git a/spec/requests/api/access_requests_spec.rb b/spec/requests/api/access_requests_spec.rb new file mode 100644 index 00000000000..d78494b76fa --- /dev/null +++ b/spec/requests/api/access_requests_spec.rb @@ -0,0 +1,246 @@ +require 'spec_helper' + +describe API::AccessRequests, api: true do + include ApiHelpers + + let(:master) { create(:user) } + let(:developer) { create(:user) } + let(:access_requester) { create(:user) } + let(:stranger) { create(:user) } + + let(:project) do + project = create(:project, :public, creator_id: master.id, namespace: master.namespace) + project.team << [developer, :developer] + project.team << [master, :master] + project.request_access(access_requester) + project + end + + let(:group) do + group = create(:group, :public) + group.add_developer(developer) + group.add_owner(master) + group.request_access(access_requester) + group + end + + shared_examples 'GET /:sources/:id/access_requests' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { get api("/#{source_type.pluralize}/#{source.id}/access_requests", stranger) } + end + + context 'when authenticated as a non-master/owner' do + %i[developer access_requester stranger].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + get api("/#{source_type.pluralize}/#{source.id}/access_requests", user) + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + it 'returns access requesters' do + get api("/#{source_type.pluralize}/#{source.id}/access_requests", master) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + end + end + end + + shared_examples 'POST /:sources/:id/access_requests' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { post api("/#{source_type.pluralize}/#{source.id}/access_requests", stranger) } + end + + context 'when authenticated as a member' do + %i[developer master].each do |type| + context "as a #{type}" do + it 'returns 400' do + expect do + user = public_send(type) + post api("/#{source_type.pluralize}/#{source.id}/access_requests", user) + + expect(response).to have_http_status(400) + end.not_to change { source.requesters.count } + end + end + end + end + + context 'when authenticated as an access requester' do + it 'returns 400' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/access_requests", access_requester) + + expect(response).to have_http_status(400) + end.not_to change { source.requesters.count } + end + end + + context 'when authenticated as a stranger' do + it 'returns 201' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/access_requests", stranger) + + expect(response).to have_http_status(201) + end.to change { source.requesters.count }.by(1) + + # User attributes + expect(json_response['id']).to eq(stranger.id) + expect(json_response['name']).to eq(stranger.name) + expect(json_response['username']).to eq(stranger.username) + expect(json_response['state']).to eq(stranger.state) + expect(json_response['avatar_url']).to eq(stranger.avatar_url) + expect(json_response['web_url']).to eq(Gitlab::Routing.url_helpers.user_url(stranger)) + + # Member attributes + expect(json_response['requested_at']).to be_present + end + end + end + end + + shared_examples 'PUT /:sources/:id/access_requests/:user_id/approve' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { put api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}/approve", stranger) } + end + + context 'when authenticated as a non-master/owner' do + %i[developer access_requester stranger].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + put api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}/approve", user) + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + it 'returns 201' do + expect do + put api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}/approve", master), + access_level: Member::MASTER + + expect(response).to have_http_status(201) + end.to change { source.members.count }.by(1) + # User attributes + expect(json_response['id']).to eq(access_requester.id) + expect(json_response['name']).to eq(access_requester.name) + expect(json_response['username']).to eq(access_requester.username) + expect(json_response['state']).to eq(access_requester.state) + expect(json_response['avatar_url']).to eq(access_requester.avatar_url) + expect(json_response['web_url']).to eq(Gitlab::Routing.url_helpers.user_url(access_requester)) + + # Member attributes + expect(json_response['access_level']).to eq(Member::MASTER) + end + + context 'user_id does not match an existing access requester' do + it 'returns 404' do + expect do + put api("/#{source_type.pluralize}/#{source.id}/access_requests/#{stranger.id}/approve", master) + + expect(response).to have_http_status(404) + end.not_to change { source.members.count } + end + end + end + end + end + + shared_examples 'DELETE /:sources/:id/access_requests/:user_id' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}", stranger) } + end + + context 'when authenticated as a non-master/owner' do + %i[developer stranger].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}", user) + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as the access requester' do + it 'returns 200' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}", access_requester) + + expect(response).to have_http_status(200) + end.to change { source.requesters.count }.by(-1) + end + end + + context 'when authenticated as a master/owner' do + it 'returns 200' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}", master) + + expect(response).to have_http_status(200) + end.to change { source.requesters.count }.by(-1) + end + + context 'user_id does not match an existing access requester' do + it 'returns 404' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{stranger.id}", master) + + expect(response).to have_http_status(404) + end.not_to change { source.requesters.count } + end + end + end + end + end + + it_behaves_like 'GET /:sources/:id/access_requests', 'project' do + let(:source) { project } + end + + it_behaves_like 'GET /:sources/:id/access_requests', 'group' do + let(:source) { group } + end + + it_behaves_like 'POST /:sources/:id/access_requests', 'project' do + let(:source) { project } + end + + it_behaves_like 'POST /:sources/:id/access_requests', 'group' do + let(:source) { group } + end + + it_behaves_like 'PUT /:sources/:id/access_requests/:user_id/approve', 'project' do + let(:source) { project } + end + + it_behaves_like 'PUT /:sources/:id/access_requests/:user_id/approve', 'group' do + let(:source) { group } + end + + it_behaves_like 'DELETE /:sources/:id/access_requests/:user_id', 'project' do + let(:source) { project } + end + + it_behaves_like 'DELETE /:sources/:id/access_requests/:user_id', 'group' do + let(:source) { group } + end +end diff --git a/spec/requests/api/group_members_spec.rb b/spec/requests/api/group_members_spec.rb index 8bd6a8062ae..cdb3d7ddf4a 100644 --- a/spec/requests/api/group_members_spec.rb +++ b/spec/requests/api/group_members_spec.rb @@ -96,9 +96,9 @@ describe API::API, api: true do expect(response).to have_http_status(400) end - it "returns a 422 error when access level is not known" do + it "returns a 400 error when access level is not known" do post api("/groups/#{group_no_members.id}/members", owner), user_id: master.id, access_level: 1234 - expect(response).to have_http_status(422) + expect(response).to have_http_status(400) end end end @@ -156,12 +156,12 @@ describe API::API, api: true do expect(response).to have_http_status(400) end - it 'returns a 422 error when access level is not known' do + it 'returns a 400 error when access level is not known' do put( api("/groups/#{group_with_members.id}/members/#{master.id}", owner), access_level: 1234 ) - expect(response).to have_http_status(422) + expect(response).to have_http_status(400) end end end @@ -182,7 +182,7 @@ describe API::API, api: true do delete api("/groups/#{group_with_members.id}/members/#{guest.id}", owner) end.to change { group_with_members.members.count }.by(-1) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) end it "returns a 404 error when user id is not known" do diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb new file mode 100644 index 00000000000..b11a16487ac --- /dev/null +++ b/spec/requests/api/members_spec.rb @@ -0,0 +1,312 @@ +require 'spec_helper' + +describe API::Members, api: true do + include ApiHelpers + + let(:master) { create(:user) } + let(:developer) { create(:user) } + let(:access_requester) { create(:user) } + let(:stranger) { create(:user) } + + let(:project) do + project = create(:project, :public, creator_id: master.id, namespace: master.namespace) + project.team << [developer, :developer] + project.team << [master, :master] + project.request_access(access_requester) + project + end + + let!(:group) do + group = create(:group, :public) + group.add_developer(developer) + group.add_owner(master) + group.request_access(access_requester) + group + end + + shared_examples 'GET /:sources/:id/members' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { get api("/#{source_type.pluralize}/#{source.id}/members", stranger) } + end + + context 'when authenticated as a non-member' do + %i[access_requester stranger].each do |type| + context "as a #{type}" do + it 'returns 200' do + user = public_send(type) + get api("/#{source_type.pluralize}/#{source.id}/members", user) + + expect(response).to have_http_status(200) + expect(json_response.size).to eq(2) + end + end + end + end + + it 'finds members with query string' do + get api("/#{source_type.pluralize}/#{source.id}/members", developer), query: master.username + + expect(response).to have_http_status(200) + expect(json_response.count).to eq(1) + expect(json_response.first['username']).to eq(master.username) + end + end + end + + shared_examples 'GET /:sources/:id/members/:user_id' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { get api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", stranger) } + end + + context 'when authenticated as a non-member' do + %i[access_requester stranger].each do |type| + context "as a #{type}" do + it 'returns 200' do + user = public_send(type) + get api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", user) + + expect(response).to have_http_status(200) + # User attributes + expect(json_response['id']).to eq(developer.id) + expect(json_response['name']).to eq(developer.name) + expect(json_response['username']).to eq(developer.username) + expect(json_response['state']).to eq(developer.state) + expect(json_response['avatar_url']).to eq(developer.avatar_url) + expect(json_response['web_url']).to eq(Gitlab::Routing.url_helpers.user_url(developer)) + + # Member attributes + expect(json_response['access_level']).to eq(Member::DEVELOPER) + end + end + end + end + end + end + + shared_examples 'POST /:sources/:id/members' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { post api("/#{source_type.pluralize}/#{source.id}/members", stranger) } + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger developer].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + post api("/#{source_type.pluralize}/#{source.id}/members", user) + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + context 'and new member is already a requester' do + it 'transforms the requester into a proper member' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + user_id: access_requester.id, access_level: Member::MASTER + + expect(response).to have_http_status(201) + end.to change { source.members.count }.by(1) + expect(source.requesters.count).to eq(0) + expect(json_response['id']).to eq(access_requester.id) + expect(json_response['access_level']).to eq(Member::MASTER) + end + end + + it 'creates a new member' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + user_id: stranger.id, access_level: Member::DEVELOPER + + expect(response).to have_http_status(201) + end.to change { source.members.count }.by(1) + expect(json_response['id']).to eq(stranger.id) + expect(json_response['access_level']).to eq(Member::DEVELOPER) + end + end + + it "returns #{source_type == 'project' ? 201 : 409} if member already exists" do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + user_id: master.id, access_level: Member::MASTER + + expect(response).to have_http_status(409) + end + + it 'returns 400 when user_id is not given' do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + access_level: Member::MASTER + + expect(response).to have_http_status(400) + end + + it 'returns 400 when access_level is not given' do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + user_id: stranger.id + + expect(response).to have_http_status(400) + end + + it 'returns 400 when access_level is not valid' do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + user_id: stranger.id, access_level: 1234 + + expect(response).to have_http_status(400) + end + end + end + + shared_examples 'PUT /:sources/:id/members/:user_id' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", stranger) } + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger developer].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", user) + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + it 'updates the member' do + put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master), + access_level: Member::MASTER + + expect(response).to have_http_status(200) + expect(json_response['id']).to eq(developer.id) + expect(json_response['access_level']).to eq(Member::MASTER) + end + end + + it 'returns 409 if member does not exist' do + put api("/#{source_type.pluralize}/#{source.id}/members/123", master), + access_level: Member::MASTER + + expect(response).to have_http_status(404) + end + + it 'returns 400 when access_level is not given' do + put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master) + + expect(response).to have_http_status(400) + end + + it 'returns 400 when access level is not valid' do + put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master), + access_level: 1234 + + expect(response).to have_http_status(400) + end + end + end + + shared_examples 'DELETE /:sources/:id/members/:user_id' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", stranger) } + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", user) + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as a member and deleting themself' do + it 'deletes the member' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", developer) + + expect(response).to have_http_status(204) + end.to change { source.members.count }.by(-1) + end + end + + context 'when authenticated as a master/owner' do + context 'and member is a requester' do + it 'returns 404' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/members/#{access_requester.id}", master) + + expect(response).to have_http_status(404) + end.not_to change { source.requesters.count } + end + end + + it 'deletes the member' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master) + + expect(response).to have_http_status(204) + end.to change { source.members.count }.by(-1) + end + end + + it 'returns 409 if member does not exist' do + delete api("/#{source_type.pluralize}/#{source.id}/members/123", master) + + expect(response).to have_http_status(404) + end + end + end + + it_behaves_like 'GET /:sources/:id/members', 'project' do + let(:source) { project } + end + + it_behaves_like 'GET /:sources/:id/members', 'group' do + let(:source) { group } + end + + it_behaves_like 'GET /:sources/:id/members/:user_id', 'project' do + let(:source) { project } + end + + it_behaves_like 'GET /:sources/:id/members/:user_id', 'group' do + let(:source) { group } + end + + it_behaves_like 'POST /:sources/:id/members', 'project' do + let(:source) { project } + end + + it_behaves_like 'POST /:sources/:id/members', 'group' do + let(:source) { group } + end + + it_behaves_like 'PUT /:sources/:id/members/:user_id', 'project' do + let(:source) { project } + end + + it_behaves_like 'PUT /:sources/:id/members/:user_id', 'group' do + let(:source) { group } + end + + it_behaves_like 'DELETE /:sources/:id/members/:user_id', 'project' do + let(:source) { project } + end + + it_behaves_like 'DELETE /:sources/:id/members/:user_id', 'group' do + let(:source) { group } + end +end diff --git a/spec/requests/api/project_members_spec.rb b/spec/requests/api/project_members_spec.rb index 13cc0d81ac8..195d5bb3f8f 100644 --- a/spec/requests/api/project_members_spec.rb +++ b/spec/requests/api/project_members_spec.rb @@ -62,7 +62,7 @@ describe API::API, api: true do expect(json_response['access_level']).to eq(ProjectMember::DEVELOPER) end - it "returns a 201 status if user is already project member" do + it "returns a 409 status if user is already project member" do post api("/projects/#{project.id}/members", user), user_id: user2.id, access_level: ProjectMember::DEVELOPER @@ -70,9 +70,7 @@ describe API::API, api: true do post api("/projects/#{project.id}/members", user), user_id: user2.id, access_level: ProjectMember::DEVELOPER end.not_to change { ProjectMember.count } - expect(response).to have_http_status(201) - expect(json_response['username']).to eq(user2.username) - expect(json_response['access_level']).to eq(ProjectMember::DEVELOPER) + expect(response).to have_http_status(409) end it "returns a 400 error when user id is not given" do @@ -85,9 +83,9 @@ describe API::API, api: true do expect(response).to have_http_status(400) end - it "returns a 422 error when access level is not known" do + it "returns a 400 error when access level is not known" do post api("/projects/#{project.id}/members", user), user_id: user2.id, access_level: 1234 - expect(response).to have_http_status(422) + expect(response).to have_http_status(400) end end @@ -111,9 +109,9 @@ describe API::API, api: true do expect(response).to have_http_status(400) end - it "returns a 422 error when access level is not known" do + it "returns a 400 error when access level is not known" do put api("/projects/#{project.id}/members/#{user3.id}", user), access_level: 123 - expect(response).to have_http_status(422) + expect(response).to have_http_status(400) end end @@ -129,27 +127,25 @@ describe API::API, api: true do end.to change { ProjectMember.count }.by(-1) end - it "returns 200 if team member is not part of a project" do + it "returns 404 if team member is not part of a project" do delete api("/projects/#{project.id}/members/#{user3.id}", user) expect do delete api("/projects/#{project.id}/members/#{user3.id}", user) end.not_to change { ProjectMember.count } - expect(response).to have_http_status(200) + expect(response).to have_http_status(404) end - it "returns 200 if team member already removed" do + it "returns 404 if team member already removed" do delete api("/projects/#{project.id}/members/#{user3.id}", user) delete api("/projects/#{project.id}/members/#{user3.id}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(404) end - it "returns 200 OK when the user was not member" do + it "returns 404 when the user was not member" do expect do delete api("/projects/#{project.id}/members/1000000", user) end.to change { ProjectMember.count }.by(0) - expect(response).to have_http_status(200) - expect(json_response['id']).to eq(1000000) - expect(json_response['message']).to eq('Access revoked') + expect(response).to have_http_status(404) end context 'when the user is not an admin or owner' do @@ -158,8 +154,7 @@ describe API::API, api: true do delete api("/projects/#{project.id}/members/#{user3.id}", user3) end.to change { ProjectMember.count }.by(-1) - expect(response).to have_http_status(200) - expect(json_response['id']).to eq(project_member2.id) + expect(response).to have_http_status(204) end end end diff --git a/spec/support/api/members_shared_examples.rb b/spec/support/api/members_shared_examples.rb new file mode 100644 index 00000000000..dab71a35a55 --- /dev/null +++ b/spec/support/api/members_shared_examples.rb @@ -0,0 +1,11 @@ +shared_examples 'a 404 response when source is private' do + before do + source.update_column(:visibility_level, Gitlab::VisibilityLevel::PRIVATE) + end + + it 'returns 404' do + route + + expect(response).to have_http_status(404) + end +end |