From 761788697414a4cc82d1583f46e0d9d00bfb8f5c Mon Sep 17 00:00:00 2001
From: nicolasdular <ndular@gitlab.com>
Date: Tue, 23 Jun 2020 17:23:59 +0200
Subject: [PATCH] Add Namespace API to set additionally purchased storage

This exposes the params `additional_purchased_storage_size` and
`additional_purchased_storage_ends_on` in our Namespace API and
the possibility to set them for admin users.

This API will be used by customer portal when additional storage
packages get bought
---
 app/policies/base_policy.rb                   |  2 +
 ee/app/models/ee/namespace.rb                 | 10 ++++
 ee/app/models/namespace_limit.rb              |  7 +++
 ee/app/policies/ee/group_policy.rb            |  2 +
 ee/app/policies/ee/namespace_policy.rb        |  2 +
 ...asdular-namespace-storage-purchase-api.yml |  5 ++
 ee/lib/ee/api/entities/namespace.rb           |  7 ++-
 ee/lib/ee/api/namespaces.rb                   |  2 +
 ee/spec/models/ee/namespace_limit_spec.rb     |  7 +++
 ee/spec/models/ee/namespace_spec.rb           | 14 +++++
 ee/spec/policies/group_policy_spec.rb         |  2 +
 ee/spec/policies/namespace_policy_spec.rb     |  2 +
 ee/spec/requests/api/namespaces_spec.rb       | 54 +++++++++++++++----
 .../namespace_policy_shared_examples.rb       | 34 ++++++++++++
 14 files changed, 137 insertions(+), 13 deletions(-)
 create mode 100644 ee/app/models/namespace_limit.rb
 create mode 100644 ee/changelogs/unreleased/nicolasdular-namespace-storage-purchase-api.yml
 create mode 100644 ee/spec/models/ee/namespace_limit_spec.rb
 create mode 100644 spec/support/shared_examples/policies/namespace_policy_shared_examples.rb

diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index f41d69c7a41f35..13d732e4eddd5e 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -58,6 +58,8 @@ class BasePolicy < DeclarativePolicy::Base
   rule { admin }.enable :read_all_resources
 
   rule { default }.enable :read_cross_project
+
+  condition(:is_gitlab_com) { ::Gitlab.dev_env_or_com? }
 end
 
 BasePolicy.prepend_if_ee('EE::BasePolicy')
diff --git a/ee/app/models/ee/namespace.rb b/ee/app/models/ee/namespace.rb
index eb8218fe626b5e..00e07c12f5cebf 100644
--- a/ee/app/models/ee/namespace.rb
+++ b/ee/app/models/ee/namespace.rb
@@ -26,10 +26,12 @@ module Namespace
       attr_writer :root_ancestor
 
       has_one :namespace_statistics
+      has_one :namespace_limit, inverse_of: :namespace
       has_one :gitlab_subscription, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
       has_one :elasticsearch_indexed_namespace
 
       accepts_nested_attributes_for :gitlab_subscription
+      accepts_nested_attributes_for :namespace_limit
 
       scope :include_gitlab_subscription, -> { includes(:gitlab_subscription) }
       scope :join_gitlab_subscription, -> { joins("LEFT OUTER JOIN gitlab_subscriptions ON gitlab_subscriptions.namespace_id=namespaces.id") }
@@ -53,6 +55,10 @@ module Namespace
       delegate :shared_runners_minutes, :shared_runners_seconds, :shared_runners_seconds_last_reset,
         :extra_shared_runners_minutes, to: :namespace_statistics, allow_nil: true
 
+      delegate :additional_purchased_storage_size, :additional_purchased_storage_size=,
+        :additional_purchased_storage_ends_on, :additional_purchased_storage_ends_on=,
+        to: :namespace_limit, allow_nil: true
+
       delegate :email, to: :owner, allow_nil: true, prefix: true
 
       # Opportunistically clear the +file_template_project_id+ if invalid
@@ -72,6 +78,10 @@ module Namespace
       before_save :clear_feature_available_cache
     end
 
+    def namespace_limit
+      super.presence || build_namespace_limit
+    end
+
     class_methods do
       extend ::Gitlab::Utils::Override
 
diff --git a/ee/app/models/namespace_limit.rb b/ee/app/models/namespace_limit.rb
new file mode 100644
index 00000000000000..dfffbf44a1fc6d
--- /dev/null
+++ b/ee/app/models/namespace_limit.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class NamespaceLimit < ApplicationRecord
+  self.primary_key = :namespace_id
+
+  belongs_to :namespace, inverse_of: :namespace_limit
+end
diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb
index 3c205f0dc1015f..8df2b98142991d 100644
--- a/ee/app/policies/ee/group_policy.rb
+++ b/ee/app/policies/ee/group_policy.rb
@@ -256,6 +256,8 @@ module GroupPolicy
       rule { ~reject_unsigned_commits_available }.prevent :change_reject_unsigned_commits
 
       rule { can?(:maintainer_access) & push_rules_available }.enable :change_push_rules
+
+      rule { admin & is_gitlab_com }.enable :update_subscription_limit
     end
 
     override :lookup_access_level!
diff --git a/ee/app/policies/ee/namespace_policy.rb b/ee/app/policies/ee/namespace_policy.rb
index c5dddc0649444f..1743407fef62dc 100644
--- a/ee/app/policies/ee/namespace_policy.rb
+++ b/ee/app/policies/ee/namespace_policy.rb
@@ -8,6 +8,8 @@ module NamespacePolicy
       rule { owner | admin }.policy do
         enable :create_jira_connect_subscription
       end
+
+      rule { admin & is_gitlab_com }.enable :update_subscription_limit
     end
   end
 end
diff --git a/ee/changelogs/unreleased/nicolasdular-namespace-storage-purchase-api.yml b/ee/changelogs/unreleased/nicolasdular-namespace-storage-purchase-api.yml
new file mode 100644
index 00000000000000..ef61f9023aac60
--- /dev/null
+++ b/ee/changelogs/unreleased/nicolasdular-namespace-storage-purchase-api.yml
@@ -0,0 +1,5 @@
+---
+title: Add Namespace API to set additionally purchased storage
+merge_request: 35257
+author:
+type: added
diff --git a/ee/lib/ee/api/entities/namespace.rb b/ee/lib/ee/api/entities/namespace.rb
index 86981ec6b97d89..e18237112dd04e 100644
--- a/ee/lib/ee/api/entities/namespace.rb
+++ b/ee/lib/ee/api/entities/namespace.rb
@@ -7,10 +7,13 @@ module Namespace
         extend ActiveSupport::Concern
 
         prepended do
+          can_update_limits = ->(namespace, opts) { ::Ability.allowed?(opts[:current_user], :update_subscription_limit, namespace) }
           can_admin_namespace = ->(namespace, opts) { ::Ability.allowed?(opts[:current_user], :admin_namespace, namespace) }
 
-          expose :shared_runners_minutes_limit, if: ->(_, options) { options[:current_user]&.admin? }
-          expose :extra_shared_runners_minutes_limit, if: ->(_, options) { options[:current_user]&.admin? }
+          expose :shared_runners_minutes_limit, if: can_update_limits
+          expose :extra_shared_runners_minutes_limit, if: can_update_limits
+          expose :additional_purchased_storage_size, if: can_update_limits
+          expose :additional_purchased_storage_ends_on, if: can_update_limits
           expose :billable_members_count do |namespace, options|
             namespace.billable_members_count(options[:requested_hosted_plan])
           end
diff --git a/ee/lib/ee/api/namespaces.rb b/ee/lib/ee/api/namespaces.rb
index 3ed9b6104b7727..11576a60e315d2 100644
--- a/ee/lib/ee/api/namespaces.rb
+++ b/ee/lib/ee/api/namespaces.rb
@@ -53,6 +53,8 @@ def update_namespace(namespace)
           params do
             optional :shared_runners_minutes_limit, type: Integer, desc: "Pipeline minutes quota for this namespace"
             optional :extra_shared_runners_minutes_limit, type: Integer, desc: "Extra pipeline minutes for this namespace"
+            optional :additional_purchased_storage_size, type: Integer, desc: "Additional storage size for this namespace"
+            optional :additional_purchased_storage_ends_on, type: Date, desc: "End of subscription of the additional purchased storage"
           end
           put ':id' do
             authenticated_as_admin!
diff --git a/ee/spec/models/ee/namespace_limit_spec.rb b/ee/spec/models/ee/namespace_limit_spec.rb
new file mode 100644
index 00000000000000..4e97883f1930b7
--- /dev/null
+++ b/ee/spec/models/ee/namespace_limit_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe NamespaceLimit do
+  it { is_expected.to belong_to(:namespace) }
+end
diff --git a/ee/spec/models/ee/namespace_spec.rb b/ee/spec/models/ee/namespace_spec.rb
index 97e762cc3243fa..6819a6d4632e9b 100644
--- a/ee/spec/models/ee/namespace_spec.rb
+++ b/ee/spec/models/ee/namespace_spec.rb
@@ -13,6 +13,7 @@
   let!(:gold_plan) { create(:gold_plan) }
 
   it { is_expected.to have_one(:namespace_statistics) }
+  it { is_expected.to have_one(:namespace_limit) }
   it { is_expected.to have_one(:gitlab_subscription).dependent(:destroy) }
   it { is_expected.to have_one(:elasticsearch_indexed_namespace) }
 
@@ -24,6 +25,10 @@
   it { is_expected.to delegate_method(:trial_ends_on).to(:gitlab_subscription) }
   it { is_expected.to delegate_method(:upgradable?).to(:gitlab_subscription) }
   it { is_expected.to delegate_method(:email).to(:owner).with_prefix.allow_nil }
+  it { is_expected.to delegate_method(:additional_purchased_storage_size).to(:namespace_limit) }
+  it { is_expected.to delegate_method(:additional_purchased_storage_size=).to(:namespace_limit).with_arguments(:args) }
+  it { is_expected.to delegate_method(:additional_purchased_storage_ends_on).to(:namespace_limit) }
+  it { is_expected.to delegate_method(:additional_purchased_storage_ends_on=).to(:namespace_limit).with_arguments(:args) }
 
   shared_examples 'plan helper' do |namespace_plan|
     let(:namespace) { create(:namespace_with_plan, plan: "#{plan_name}_plan") }
@@ -1462,4 +1467,13 @@ def stub_minutes_used_and_limit(minutes_used, limit)
       end
     end
   end
+
+  describe 'ensure namespace limit' do
+    it 'has namespace limit upon namespace initialization' do
+      namespace = build(:namespace)
+
+      expect(namespace.namespace_limit).to be_present
+      expect(namespace.namespace_limit).not_to be_persisted
+    end
+  end
 end
diff --git a/ee/spec/policies/group_policy_spec.rb b/ee/spec/policies/group_policy_spec.rb
index 1ea67a4350d579..f2726047304c1f 100644
--- a/ee/spec/policies/group_policy_spec.rb
+++ b/ee/spec/policies/group_policy_spec.rb
@@ -1086,4 +1086,6 @@ def set_access_level(access_level)
       end
     end
   end
+
+  it_behaves_like 'update namespace limit policy'
 end
diff --git a/ee/spec/policies/namespace_policy_spec.rb b/ee/spec/policies/namespace_policy_spec.rb
index 83a943c278ad59..00e77a4f5463e2 100644
--- a/ee/spec/policies/namespace_policy_spec.rb
+++ b/ee/spec/policies/namespace_policy_spec.rb
@@ -48,4 +48,6 @@
       it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
     end
   end
+
+  it_behaves_like 'update namespace limit policy'
 end
diff --git a/ee/spec/requests/api/namespaces_spec.rb b/ee/spec/requests/api/namespaces_spec.rb
index 4069b653c48a5d..7ecabe83d8e04b 100644
--- a/ee/spec/requests/api/namespaces_spec.rb
+++ b/ee/spec/requests/api/namespaces_spec.rb
@@ -11,6 +11,10 @@
 
   describe "GET /namespaces" do
     context "when authenticated as admin" do
+      before do
+        allow(Gitlab).to receive(:com?).and_return(true)
+      end
+
       it "returns correct attributes" do
         get api("/namespaces", admin)
 
@@ -23,12 +27,14 @@
                                                                  'parent_id', 'members_count_with_descendants',
                                                                  'plan', 'shared_runners_minutes_limit',
                                                                  'avatar_url', 'web_url', 'trial_ends_on', 'trial',
-                                                                 'extra_shared_runners_minutes_limit', 'billable_members_count')
+                                                                 'extra_shared_runners_minutes_limit', 'billable_members_count',
+                                                                 'additional_purchased_storage_size', 'additional_purchased_storage_ends_on')
 
         expect(user_kind_json_response.keys).to contain_exactly('id', 'kind', 'name', 'path', 'full_path',
                                                                 'parent_id', 'plan', 'shared_runners_minutes_limit',
                                                                 'avatar_url', 'web_url', 'trial_ends_on', 'trial',
-                                                                'extra_shared_runners_minutes_limit', 'billable_members_count')
+                                                                'extra_shared_runners_minutes_limit', 'billable_members_count',
+                                                                'additional_purchased_storage_size', 'additional_purchased_storage_ends_on')
       end
     end
 
@@ -111,27 +117,43 @@
   end
 
   describe 'PUT /namespaces/:id' do
+    let(:params) do
+      {
+        shared_runners_minutes_limit: 9001,
+        additional_purchased_storage_size: 10_000,
+        additional_purchased_storage_ends_on: Date.today.to_s
+      }
+    end
+
+    before do
+      allow(Gitlab).to receive(:com?).and_return(true)
+    end
+
     context 'when authenticated as admin' do
       it 'updates namespace using full_path when full_path contains dots' do
-        put api("/namespaces/#{group1.full_path}", admin), params: { shared_runners_minutes_limit: 9001 }
+        put api("/namespaces/#{group1.full_path}", admin), params: params
 
         aggregate_failures do
           expect(response).to have_gitlab_http_status(:ok)
-          expect(json_response['shared_runners_minutes_limit']).to eq(9001)
+          expect(json_response['shared_runners_minutes_limit']).to eq(params[:shared_runners_minutes_limit])
+          expect(json_response['additional_purchased_storage_size']).to eq(params[:additional_purchased_storage_size])
+          expect(json_response['additional_purchased_storage_ends_on']).to eq(params[:additional_purchased_storage_ends_on])
         end
       end
 
       it 'updates namespace using id' do
-        put api("/namespaces/#{group1.id}", admin), params: { shared_runners_minutes_limit: 9001 }
+        put api("/namespaces/#{group1.id}", admin), params: params
 
         expect(response).to have_gitlab_http_status(:ok)
-        expect(json_response['shared_runners_minutes_limit']).to eq(9001)
+        expect(json_response['shared_runners_minutes_limit']).to eq(params[:shared_runners_minutes_limit])
+        expect(json_response['additional_purchased_storage_size']).to eq(params[:additional_purchased_storage_size])
+        expect(json_response['additional_purchased_storage_ends_on']).to eq(params[:additional_purchased_storage_ends_on])
       end
     end
 
     context 'when not authenticated as admin' do
       it 'retuns 403' do
-        put api("/namespaces/#{group1.id}", user), params: { shared_runners_minutes_limit: 9001 }
+        put api("/namespaces/#{group1.id}", user), params: params
 
         expect(response).to have_gitlab_http_status(:forbidden)
       end
@@ -139,7 +161,7 @@
 
     context 'when namespace not found' do
       it 'returns 404' do
-        put api("/namespaces/#{non_existing_record_id}", admin), params: { shared_runners_minutes_limit: 9001 }
+        put api("/namespaces/#{non_existing_record_id}", admin), params: params
 
         expect(response).to have_gitlab_http_status(:not_found)
         expect(json_response).to eq('message' => '404 Namespace Not Found')
@@ -147,10 +169,20 @@
     end
 
     context 'when invalid params' do
-      it 'returns validation error' do
-        put api("/namespaces/#{group1.id}", admin), params: { shared_runners_minutes_limit: 'unknown' }
+      where(:attr) do
+        [
+          :shared_runners_minutes_limit,
+          :additional_purchased_storage_size,
+          :additional_purchased_storage_ends_on
+        ]
+      end
 
-        expect(response).to have_gitlab_http_status(:bad_request)
+      with_them do
+        it "returns validation error for #{attr}" do
+          put api("/namespaces/#{group1.id}", admin), params: Hash[attr, 'unknown']
+
+          expect(response).to have_gitlab_http_status(:bad_request)
+        end
       end
     end
 
diff --git a/spec/support/shared_examples/policies/namespace_policy_shared_examples.rb b/spec/support/shared_examples/policies/namespace_policy_shared_examples.rb
new file mode 100644
index 00000000000000..ddec1ba5e3d10f
--- /dev/null
+++ b/spec/support/shared_examples/policies/namespace_policy_shared_examples.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'update namespace limit policy' do
+  describe 'update_subscription_limit' do
+    using RSpec::Parameterized::TableSyntax
+
+    let(:policy) { :update_subscription_limit }
+
+    where(:role, :is_com, :allowed) do
+      :user  | true | false
+      :owner | true | false
+      :admin | true  | true
+      :user  | false | false
+      :owner | false | false
+      :admin | false | false
+    end
+
+    with_them do
+      let(:current_user) { build_stubbed(role) }
+
+      before do
+        allow(Gitlab).to receive(:com?).and_return(is_com)
+      end
+
+      context 'when admin mode enabled', :enable_admin_mode do
+        it { is_expected.to(allowed ? be_allowed(policy) : be_disallowed(policy)) }
+      end
+
+      context 'when admin mode disabled' do
+        it { is_expected.to be_disallowed(policy) }
+      end
+    end
+  end
+end
-- 
GitLab