From 1badc95c1aa4fd60520eeaefa95771e66b75ee79 Mon Sep 17 00:00:00 2001
From: Alishan Ladhani <aladhani@gitlab.com>
Date: Mon, 13 Dec 2021 14:40:20 -0500
Subject: [PATCH] Add Deployment Approvals API endpoint

Part of the deployment approvals MVC
---
 doc/api/deployments.md                        | 35 ++++++++
 ee/lib/api/entities/deployments/approval.rb   | 12 +++
 ee/lib/ee/api/deployments.rb                  | 37 +++++++++
 .../public_api/v4/deployment_approval.json    | 19 +++++
 .../api/entities/deployments/approval_spec.rb | 13 +++
 ee/spec/requests/api/deployments_spec.rb      | 80 +++++++++++++++++++
 lib/api/deployments.rb                        |  2 +
 7 files changed, 198 insertions(+)
 create mode 100644 ee/lib/api/entities/deployments/approval.rb
 create mode 100644 ee/lib/ee/api/deployments.rb
 create mode 100644 ee/spec/fixtures/api/schemas/public_api/v4/deployment_approval.json
 create mode 100644 ee/spec/lib/api/entities/deployments/approval_spec.rb

diff --git a/doc/api/deployments.md b/doc/api/deployments.md
index 253bc76737b3ddf2..19656098f4b6d5c0 100644
--- a/doc/api/deployments.md
+++ b/doc/api/deployments.md
@@ -375,3 +375,38 @@ It supports the same parameters as the [Merge Requests API](merge_requests.md#li
 ```shell
 curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments/42/merge_requests"
 ```
+
+## Approve or Reject a blocked Deployment
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/343864) in GitLab 14.7 [with a flag](../administration/feature_flags.md) named `deployment_approvals`. Disabled by default. This feature is not ready for production use.
+
+```plaintext
+POST /projects/:id/deployments/:deployment_id/approval
+```
+
+| Attribute       | Type           | Required | Description                                                                                                     |
+|-----------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------|
+| `id`            | integer/string | yes      | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user. |
+| `deployment_id` | integer        | yes      | The ID of the deployment.                                                                                       |
+| `status`        | string         | yes      | The status of the approval (either `approved` or `rejected`).                                                   |
+
+```shell
+curl --data "status=approved" \
+     --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments/1/approval"
+```
+
+Example response:
+
+```json
+{
+  "user": {
+    "name": "Administrator",
+    "username": "root",
+    "id": 1,
+    "state": "active",
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+    "web_url": "http://localhost:3000/root"
+  },
+  "status": "approved"
+}
+```
diff --git a/ee/lib/api/entities/deployments/approval.rb b/ee/lib/api/entities/deployments/approval.rb
new file mode 100644
index 0000000000000000..102e237f568fcdf3
--- /dev/null
+++ b/ee/lib/api/entities/deployments/approval.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module API
+  module Entities
+    module Deployments
+      class Approval < Grape::Entity
+        expose :user, using: Entities::UserBasic
+        expose :status
+      end
+    end
+  end
+end
diff --git a/ee/lib/ee/api/deployments.rb b/ee/lib/ee/api/deployments.rb
new file mode 100644
index 0000000000000000..c75b752421eb0027
--- /dev/null
+++ b/ee/lib/ee/api/deployments.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module EE
+  module API
+    module Deployments
+      extend ActiveSupport::Concern
+
+      prepended do
+        params do
+          requires :id, type: String, desc: 'The project ID'
+        end
+        resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+          desc 'Approve or reject a blocked deployment' do
+            detail 'This feature is gated behind the :deployment_approvals feature flag.'
+            success ::API::Entities::Deployments::Approval
+          end
+          params do
+            requires :deployment_id, type: String, desc: 'The Deployment ID'
+            requires :status, type: String, values: ::Deployments::Approval.statuses.keys
+          end
+          post ':id/deployments/:deployment_id/approval' do
+            deployment = user_project.deployments.find(params[:deployment_id])
+
+            result = ::Deployments::ApprovalService.new(user_project, current_user)
+                                                   .execute(deployment, params[:status])
+
+            if result[:status] == :success
+              present(result[:approval], with: ::API::Entities::Deployments::Approval, current_user: current_user)
+            else
+              render_api_error!(result[:message], 400)
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/ee/spec/fixtures/api/schemas/public_api/v4/deployment_approval.json b/ee/spec/fixtures/api/schemas/public_api/v4/deployment_approval.json
new file mode 100644
index 0000000000000000..517454d1709dc82c
--- /dev/null
+++ b/ee/spec/fixtures/api/schemas/public_api/v4/deployment_approval.json
@@ -0,0 +1,19 @@
+{
+  "type": "object",
+  "required": [
+    "status",
+    "user"
+  ],
+  "properties": {
+    "status": {
+      "type": "string"
+    },
+    "user": {
+      "type": "object",
+      "items": {
+        "$ref": "../../../../../../../spec/fixtures/api/schemas/public_api/v4/user/basic.json"
+      }
+    }
+  },
+  "additionalProperties": false
+}
diff --git a/ee/spec/lib/api/entities/deployments/approval_spec.rb b/ee/spec/lib/api/entities/deployments/approval_spec.rb
new file mode 100644
index 0000000000000000..3c8dd1f07bc59045
--- /dev/null
+++ b/ee/spec/lib/api/entities/deployments/approval_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::Deployments::Approval do
+  subject { described_class.new(approval).as_json }
+
+  let(:approval) { build(:deployment_approval) }
+
+  it 'exposes correct attributes' do
+    expect(subject.keys).to contain_exactly(:user, :status)
+  end
+end
diff --git a/ee/spec/requests/api/deployments_spec.rb b/ee/spec/requests/api/deployments_spec.rb
index c5aa328cf1f03836..ef80a845631b290e 100644
--- a/ee/spec/requests/api/deployments_spec.rb
+++ b/ee/spec/requests/api/deployments_spec.rb
@@ -180,4 +180,84 @@
       end
     end
   end
+
+  describe 'POST /projects/:id/deployments/:deployment_id/approval' do
+    shared_examples_for 'not created' do |approval_status: 'approved', response_status:, message:|
+      it 'does not create an approval' do
+        expect { post(api(path, user), params: { status: approval_status }) }.not_to change { Deployments::Approval.count }
+
+        expect(response).to have_gitlab_http_status(response_status)
+        expect(response.body).to include(message)
+      end
+    end
+
+    let(:deployment) { create(:deployment, :blocked, project: project, environment: environment, deployable: create(:ci_build, :manual, project: project)) }
+    let(:path) { "/projects/#{project.id}/deployments/#{deployment.id}/approval" }
+
+    before do
+      create(:protected_environment, :maintainers_can_deploy, project: environment.project, name: environment.name, required_approval_count: 1)
+    end
+
+    context 'when user is authorized to read project' do
+      before do
+        project.add_developer(user)
+      end
+
+      context 'and Protected Environments feature is available' do
+        before do
+          stub_licensed_features(protected_environments: true)
+        end
+
+        context 'and user is authorized to update deployment' do
+          before do
+            project.add_maintainer(user)
+          end
+
+          it 'creates an approval' do
+            expect { post(api(path, user), params: { status: 'approved' }) }.to change { Deployments::Approval.count }.by(1)
+
+            expect(response).to have_gitlab_http_status(:success)
+            expect(response).to match_response_schema('public_api/v4/deployment_approval', dir: 'ee')
+            expect(json_response['status']).to eq('approved')
+            expect(json_response.dig('user', 'id')).to eq(user.id)
+          end
+        end
+
+        context 'and user is not authorized to update deployment' do
+          include_examples 'not created', response_status: :bad_request, message: 'You do not have permission to approve or reject this deployment'
+        end
+
+        context 'with an invalid status' do
+          include_examples 'not created', approval_status: 'foo', response_status: :bad_request, message: 'status does not have a valid value'
+        end
+
+        context 'with a deployment that does not belong to the project' do
+          let(:other_project) { create(:project, :repository) }
+
+          let(:user) { other_project.creator }
+          let(:path) { "/projects/#{other_project.id}/deployments/#{deployment.id}/approval" }
+
+          include_examples 'not created', response_status: :not_found, message: '404 Not found'
+        end
+
+        context 'with a deployment that does not exist' do
+          let(:path) { "/projects/#{project.id}/deployments/0/approval" }
+
+          include_examples 'not created', response_status: :not_found, message: '404 Not found'
+        end
+      end
+
+      context 'when Protected Environments feature is not available' do
+        before do
+          stub_licensed_features(protected_environments: false)
+        end
+
+        include_examples 'not created', response_status: :bad_request, message: 'This environment is not protected'
+      end
+    end
+
+    context 'when user is not authorized to read project' do
+      include_examples 'not created', response_status: :not_found, message: '404 Project Not Found'
+    end
+  end
 end
diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb
index 80a50ded5227353a..486ff5d89bc8e286 100644
--- a/lib/api/deployments.rb
+++ b/lib/api/deployments.rb
@@ -165,3 +165,5 @@ class Deployments < ::API::Base
     end
   end
 end
+
+API::Deployments.prepend_mod
-- 
GitLab