From ccbe90951fb75b3527eaaad404e6abb6ed09ca8c Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 3 Mar 2021 06:11:13 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- app/finders/admin/plans_finder.rb | 24 +++ app/helpers/avatars_helper.rb | 29 +-- app/models/concerns/avatarable.rb | 8 + ...tion-settings-package-file-size-limits.yml | 5 + .../development/avatar_cache_for_email.yml | 8 + doc/api/api_resources.md | 1 + doc/api/plan_limits.md | 81 ++++++++ doc/api/settings.md | 5 + lib/api/admin/plan_limits.rb | 57 ++++++ lib/api/api.rb | 1 + lib/api/entities/plan_limit.rb | 14 ++ lib/gitlab/avatar_cache.rb | 86 +++++++++ .../participants_autocomplete_spec.rb | 1 + spec/finders/admin/plans_finder_spec.rb | 54 ++++++ spec/helpers/avatars_helper_spec.rb | 67 +++++-- spec/lib/api/entities/plan_limit_spec.rb | 24 +++ spec/lib/gitlab/avatar_cache_spec.rb | 101 ++++++++++ spec/models/user_spec.rb | 32 ++++ spec/requests/api/admin/plan_limits_spec.rb | 177 ++++++++++++++++++ 19 files changed, 744 insertions(+), 31 deletions(-) create mode 100644 app/finders/admin/plans_finder.rb create mode 100644 changelogs/unreleased/feat-api-application-settings-package-file-size-limits.yml create mode 100644 config/feature_flags/development/avatar_cache_for_email.yml create mode 100644 doc/api/plan_limits.md create mode 100644 lib/api/admin/plan_limits.rb create mode 100644 lib/api/entities/plan_limit.rb create mode 100644 lib/gitlab/avatar_cache.rb create mode 100644 spec/finders/admin/plans_finder_spec.rb create mode 100644 spec/lib/api/entities/plan_limit_spec.rb create mode 100644 spec/lib/gitlab/avatar_cache_spec.rb create mode 100644 spec/requests/api/admin/plan_limits_spec.rb diff --git a/app/finders/admin/plans_finder.rb b/app/finders/admin/plans_finder.rb new file mode 100644 index 00000000000..5ca4b61b25e --- /dev/null +++ b/app/finders/admin/plans_finder.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Admin + class PlansFinder + attr_reader :params + + def initialize(params = {}) + @params = params + end + + def execute + plans = Plan.all + by_name(plans) + end + + private + + def by_name(plans) + return plans unless params[:name] + + Plan.find_by(name: params[:name]) # rubocop: disable CodeReuse/ActiveRecord + end + end +end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 5457f96d506..8d22bda279f 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -22,11 +22,14 @@ module AvatarsHelper end def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true) - user = User.find_by_any_email(email) - if user - avatar_icon_for_user(user, size, scale, only_path: only_path) + return gravatar_icon(email, size, scale) if email.nil? + + if Feature.enabled?(:avatar_cache_for_email, @current_user, type: :development) + Gitlab::AvatarCache.by_email(email, size, scale, only_path) do + avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path: only_path) + end else - gravatar_icon(email, size, scale) + avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path: only_path) end end @@ -101,19 +104,23 @@ module AvatarsHelper private - def user_avatar_url_for(only_path: true, **options) - return options[:url] if options[:url] - - email = options[:user_email] - user = options.key?(:user) ? options[:user] : User.find_by_any_email(email) + def avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path:) + user = User.find_by_any_email(email) if user - avatar_icon_for_user(user, options[:size], only_path: only_path) + avatar_icon_for_user(user, size, scale, only_path: only_path) else - gravatar_icon(email, options[:size]) + gravatar_icon(email, size, scale) end end + def user_avatar_url_for(only_path: true, **options) + return options[:url] if options[:url] + return avatar_icon_for_user(options[:user], options[:size], only_path: only_path) if options[:user] + + avatar_icon_for_email(options[:user_email], options[:size], only_path: only_path) + end + def source_icon(source, options = {}) avatar_url = source.try(:avatar_url) diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index d342b526677..c106c08c04a 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -20,6 +20,7 @@ module Avatarable mount_uploader :avatar, AvatarUploader after_initialize :add_avatar_to_batch + after_commit :clear_avatar_caches end module ShadowMethods @@ -127,4 +128,11 @@ module Avatarable def avatar_mounter strong_memoize(:avatar_mounter) { _mounter(:avatar) } end + + def clear_avatar_caches + return unless respond_to?(:verified_emails) && verified_emails.any? && avatar_changed? + return unless Feature.enabled?(:avatar_cache_for_email, self, type: :development) + + Gitlab::AvatarCache.delete_by_email(*verified_emails) + end end diff --git a/changelogs/unreleased/feat-api-application-settings-package-file-size-limits.yml b/changelogs/unreleased/feat-api-application-settings-package-file-size-limits.yml new file mode 100644 index 00000000000..f07fd8210f3 --- /dev/null +++ b/changelogs/unreleased/feat-api-application-settings-package-file-size-limits.yml @@ -0,0 +1,5 @@ +--- +title: Add API endpoint /application/plan_limits for package file size limits +merge_request: 54232 +author: Jonas Wälter @wwwjon +type: added diff --git a/config/feature_flags/development/avatar_cache_for_email.yml b/config/feature_flags/development/avatar_cache_for_email.yml new file mode 100644 index 00000000000..d0285b5bb0f --- /dev/null +++ b/config/feature_flags/development/avatar_cache_for_email.yml @@ -0,0 +1,8 @@ +--- +name: avatar_cache_for_email +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55184 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323185 +milestone: '13.10' +type: development +group: group::source code +default_enabled: false diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md index 73552c8e6c3..b14d28d6ec0 100644 --- a/doc/api/api_resources.md +++ b/doc/api/api_resources.md @@ -155,6 +155,7 @@ The following API resources are available outside of project and group contexts | [Namespaces](namespaces.md) | `/namespaces` | | [Notification settings](notification_settings.md) | `/notification_settings` (also available for groups and projects) | | [Pages domains](pages_domains.md) | `/pages/domains` (also available for projects) | +| [Plan limits](plan_limits.md) | `/application/plan_limits` | | [Personal access tokens](personal_access_tokens.md) | `/personal_access_tokens` | | [Projects](projects.md) | `/users/:id/projects` (also available for projects) | | [Project repository storage moves](project_repository_storage_moves.md) **(FREE SELF)** | `/project_repository_storage_moves` | diff --git a/doc/api/plan_limits.md b/doc/api/plan_limits.md new file mode 100644 index 00000000000..105cf13f9de --- /dev/null +++ b/doc/api/plan_limits.md @@ -0,0 +1,81 @@ +--- +stage: Manage +group: Access +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +--- + +# Plan limits API **(FREE)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54232) in GitLab 13.10. + +The plan limits API allows you to maintain the application limits for the existing subscription plans. + +The existing plans depend on the GitLab edition. In the Community Edition, only the plan `default` +is available. In the Enterprise Edition, additional plans are available as well. + +NOTE: +Administrator access is required to use this API. + +## Get current plan limits + +List the current limits of a plan on the GitLab instance. + +```plaintext +GET /application/plan_limits +``` + +| Attribute | Type | Required | Description | +| --------------------------------- | ------- | -------- | ----------- | +| `plan_name` | string | no | Name of the plan to get the limits from. Default: `default`. | + +```shell +curl --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/application/plan_limits" +``` + +Example response: + +```json +{ + "conan_max_file_size": 3221225472, + "generic_packages_max_file_size": 5368709120, + "maven_max_file_size": 3221225472, + "npm_max_file_size": 524288000, + "nuget_max_file_size": 524288000, + "pypi_max_file_size": 3221225472 +} +``` + +## Change plan limits + +Modify the limits of a plan on the GitLab instance. + +```plaintext +PUT /application/plan_limits +``` + +| Attribute | Type | Required | Description | +| --------------------------------- | ------- | -------- | ----------- | +| `plan_name` | string | yes | Name of the plan to update. | +| `conan_max_file_size` | integer | no | Maximum Conan package file size in bytes. | +| `generic_packages_max_file_size` | integer | no | Maximum generic package file size in bytes. | +| `maven_max_file_size` | integer | no | Maximum Maven package file size in bytes. | +| `npm_max_file_size` | integer | no | Maximum NPM package file size in bytes. | +| `nuget_max_file_size` | integer | no | Maximum NuGet package file size in bytes. | +| `pypi_max_file_size` | integer | no | Maximum PyPI package file size in bytes. | + +```shell +curl --request PUT --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/application/plan_limits?plan_name=default&conan_max_file_size=3221225472" +``` + +Example response: + +```json +{ + "conan_max_file_size": 3221225472, + "generic_packages_max_file_size": 5368709120, + "maven_max_file_size": 3221225472, + "npm_max_file_size": 524288000, + "nuget_max_file_size": 524288000, + "pypi_max_file_size": 3221225472 +} +``` diff --git a/doc/api/settings.md b/doc/api/settings.md index 8a9ee3e72d1..91cbbeaf50a 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -400,3 +400,8 @@ listed in the descriptions of the relevant settings. | `version_check_enabled` | boolean | no | Let GitLab inform you when an update is available. | | `web_ide_clientside_preview_enabled` | boolean | no | Live Preview (allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview). | | `wiki_page_max_content_bytes` | integer | no | Maximum wiki page content size in **bytes**. Default: 52428800 Bytes (50 MB). The minimum value is 1024 bytes. | + +### Package Registry: Package file size limits + +The package file size limits are not part of the Application settings API. +Instead, these settings can be accessed using the [Plan limits API](plan_limits.md). diff --git a/lib/api/admin/plan_limits.rb b/lib/api/admin/plan_limits.rb new file mode 100644 index 00000000000..92f7d3dce0d --- /dev/null +++ b/lib/api/admin/plan_limits.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module API + module Admin + class PlanLimits < ::API::Base + before { authenticated_as_admin! } + + feature_category :not_owned + + helpers do + def current_plan(name) + plan = ::Admin::PlansFinder.new({ name: name }).execute + + not_found!('Plan') unless plan + plan + end + end + + desc 'Get current plan limits' do + success Entities::PlanLimit + end + params do + optional :plan_name, type: String, values: Plan.all_plans, default: Plan::DEFAULT, desc: 'Name of the plan' + end + get "application/plan_limits" do + params = declared_params(include_missing: false) + plan = current_plan(params.delete(:plan_name)) + + present plan.actual_limits, with: Entities::PlanLimit + end + + desc 'Modify plan limits' do + success Entities::PlanLimit + end + params do + requires :plan_name, type: String, values: Plan.all_plans, desc: 'Name of the plan' + + optional :conan_max_file_size, type: Integer, desc: 'Maximum Conan package file size in bytes' + optional :generic_packages_max_file_size, type: Integer, desc: 'Maximum generic package file size in bytes' + optional :maven_max_file_size, type: Integer, desc: 'Maximum Maven package file size in bytes' + optional :npm_max_file_size, type: Integer, desc: 'Maximum NPM package file size in bytes' + optional :nuget_max_file_size, type: Integer, desc: 'Maximum NuGet package file size in bytes' + optional :pypi_max_file_size, type: Integer, desc: 'Maximum PyPI package file size in bytes' + end + put "application/plan_limits" do + params = declared_params(include_missing: false) + plan = current_plan(params.delete(:plan_name)) + + if plan.actual_limits.update(params) + present plan.actual_limits, with: Entities::PlanLimit + else + render_validation_error!(plan.actual_limits) + end + end + end + end +end diff --git a/lib/api/api.rb b/lib/api/api.rb index bf4b031cb9f..7888c1b861a 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -169,6 +169,7 @@ module API mount ::API::AccessRequests mount ::API::Admin::Ci::Variables mount ::API::Admin::InstanceClusters + mount ::API::Admin::PlanLimits mount ::API::Admin::Sidekiq mount ::API::Appearance mount ::API::Applications diff --git a/lib/api/entities/plan_limit.rb b/lib/api/entities/plan_limit.rb new file mode 100644 index 00000000000..40e8b348c18 --- /dev/null +++ b/lib/api/entities/plan_limit.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module API + module Entities + class PlanLimit < Grape::Entity + expose :conan_max_file_size + expose :generic_packages_max_file_size + expose :maven_max_file_size + expose :npm_max_file_size + expose :nuget_max_file_size + expose :pypi_max_file_size + end + end +end diff --git a/lib/gitlab/avatar_cache.rb b/lib/gitlab/avatar_cache.rb new file mode 100644 index 00000000000..30c8e089061 --- /dev/null +++ b/lib/gitlab/avatar_cache.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Gitlab + class AvatarCache + class << self + # Increment this if a breaking change requires + # immediate cache expiry of all avatar caches. + # + # @return [Integer] + VERSION = 1 + + # @return [Symbol] + BASE_KEY = :avatar_cache + + # @return [ActiveSupport::Duration] + DEFAULT_EXPIRY = 7.days + + # Look up cached avatar data by email address. + # This accepts a block to provide the value to be + # cached in the event nothing is found. + # + # Multiple calls in the same request will be served from the + # request store. + # + # @param email [String] + # @param additional_keys [*Object] all must respond to `#to_s` + # @param expires_in [ActiveSupport::Duration, Integer] + # @yield [email, *additional_keys] yields the supplied params back to the block + # @return [String] + def by_email(email, *additional_keys, expires_in: DEFAULT_EXPIRY) + key = email_key(email) + subkey = additional_keys.join(":") + + Gitlab::SafeRequestStore.fetch([key, subkey]) do + with do |redis| + # Look for existing cache value + cached = redis.hget(key, subkey) + + # Return the cached entry if set + break cached unless cached.nil? + + # Otherwise, call the block to get the value + to_cache = yield(email, *additional_keys).to_s + + # Set it in the cache + redis.hset(key, subkey, to_cache) + + # Update the expiry time + redis.expire(key, expires_in) + + # Return this new value + break to_cache + end + end + end + + # Remove one or more emails from the cache + # + # @param emails [String] one or more emails to delete + # @return [Integer] the number of keys deleted + def delete_by_email(*emails) + return 0 if emails.empty? + + with do |redis| + keys = emails.map { |email| email_key(email) } + + Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + redis.unlink(*keys) + end + end + end + + private + + # @param email [String] + # @return [String] + def email_key(email) + "#{BASE_KEY}:v#{VERSION}:#{email}" + end + + def with(&blk) + Gitlab::Redis::Cache.with(&blk) # rubocop:disable CodeReuse/ActiveRecord + end + end + end +end diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb index d6f23b21d65..b22778012a8 100644 --- a/spec/features/participants_autocomplete_spec.rb +++ b/spec/features/participants_autocomplete_spec.rb @@ -85,6 +85,7 @@ RSpec.describe 'Member autocomplete', :js do let(:note) { create(:note_on_commit, project: project, commit_id: project.commit.id) } before do + allow(User).to receive(:find_by_any_email).and_call_original allow(User).to receive(:find_by_any_email) .with(noteable.author_email.downcase, confirmed: true).and_return(author) diff --git a/spec/finders/admin/plans_finder_spec.rb b/spec/finders/admin/plans_finder_spec.rb new file mode 100644 index 00000000000..9ea5944147c --- /dev/null +++ b/spec/finders/admin/plans_finder_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::PlansFinder do + let_it_be(:plan1) { create(:plan, name: 'plan1') } + let_it_be(:plan2) { create(:plan, name: 'plan2') } + + describe '#execute' do + context 'with no params' do + it 'returns all plans' do + found = described_class.new.execute + + expect(found).to match_array([plan1, plan2]) + end + end + + context 'with missing name in params' do + before do + @params = { title: 'plan2' } + end + + it 'returns all plans' do + found = described_class.new(@params).execute + + expect(found).to match_array([plan1, plan2]) + end + end + + context 'with existing name in params' do + before do + @params = { name: 'plan2' } + end + + it 'returns the plan' do + found = described_class.new(@params).execute + + expect(found).to match(plan2) + end + end + + context 'with non-existing name in params' do + before do + @params = { name: 'non-existing-plan' } + end + + it 'returns nil' do + found = described_class.new(@params).execute + + expect(found).to be_nil + end + end + end +end diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb index 9e18ab34c1f..7fcd5ae880a 100644 --- a/spec/helpers/avatars_helper_spec.rb +++ b/spec/helpers/avatars_helper_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe AvatarsHelper do include UploadHelpers - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } describe '#project_icon & #group_icon' do shared_examples 'resource with a default avatar' do |source_type| @@ -89,33 +89,60 @@ RSpec.describe AvatarsHelper do end end - describe '#avatar_icon_for_email' do + describe '#avatar_icon_for_email', :clean_gitlab_redis_cache do let(:user) { create(:user, avatar: File.open(uploaded_image_temp_path)) } - context 'using an email' do - context 'when there is a matching user' do - it 'returns a relative URL for the avatar' do - expect(helper.avatar_icon_for_email(user.email).to_s) - .to eq(user.avatar.url) + subject { helper.avatar_icon_for_email(user.email).to_s } + + shared_examples "returns avatar for email" do + context 'using an email' do + context 'when there is a matching user' do + it 'returns a relative URL for the avatar' do + expect(subject).to eq(user.avatar.url) + end + end + + context 'when no user exists for the email' do + it 'calls gravatar_icon' do + expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2) + + helper.avatar_icon_for_email('foo@example.com', 20, 2) + end + end + + context 'without an email passed' do + it 'calls gravatar_icon' do + expect(helper).to receive(:gravatar_icon).with(nil, 20, 2) + expect(User).not_to receive(:find_by_any_email) + + helper.avatar_icon_for_email(nil, 20, 2) + end end end + end - context 'when no user exists for the email' do - it 'calls gravatar_icon' do - expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2) - - helper.avatar_icon_for_email('foo@example.com', 20, 2) - end + context "when :avatar_cache_for_email flag is enabled" do + before do + stub_feature_flags(avatar_cache_for_email: true) end - context 'without an email passed' do - it 'calls gravatar_icon' do - expect(helper).to receive(:gravatar_icon).with(nil, 20, 2) + it_behaves_like "returns avatar for email" - helper.avatar_icon_for_email(nil, 20, 2) - end + it "caches the request" do + expect(User).to receive(:find_by_any_email).once.and_call_original + + expect(helper.avatar_icon_for_email(user.email).to_s).to eq(user.avatar.url) + expect(helper.avatar_icon_for_email(user.email).to_s).to eq(user.avatar.url) end end + + context "when :avatar_cache_for_email flag is disabled" do + before do + stub_feature_flags(avatar_cache_for_email: false) + end + + it_behaves_like "returns avatar for email" + end end describe '#avatar_icon_for_user' do @@ -346,7 +373,7 @@ RSpec.describe AvatarsHelper do is_expected.to eq tag( :img, alt: "#{options[:user_name]}'s avatar", - src: avatar_icon_for_email(options[:user_email], 16), + src: helper.avatar_icon_for_email(options[:user_email], 16), data: { container: 'body' }, class: "avatar s16 has-tooltip", title: options[:user_name] @@ -379,7 +406,7 @@ RSpec.describe AvatarsHelper do is_expected.to eq tag( :img, alt: "#{user_with_avatar.username}'s avatar", - src: avatar_icon_for_email(user_with_avatar.email, 16, only_path: false), + src: helper.avatar_icon_for_email(user_with_avatar.email, 16, only_path: false), data: { container: 'body' }, class: "avatar s16 has-tooltip", title: user_with_avatar.username diff --git a/spec/lib/api/entities/plan_limit_spec.rb b/spec/lib/api/entities/plan_limit_spec.rb new file mode 100644 index 00000000000..ee42c67f9b6 --- /dev/null +++ b/spec/lib/api/entities/plan_limit_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Entities::PlanLimit do + let(:plan_limits) { create(:plan_limits) } + + subject { described_class.new(plan_limits).as_json } + + it 'exposes correct attributes' do + expect(subject).to include( + :conan_max_file_size, + :generic_packages_max_file_size, + :maven_max_file_size, + :npm_max_file_size, + :nuget_max_file_size, + :pypi_max_file_size + ) + end + + it 'does not expose id and plan_id' do + expect(subject).not_to include(:id, :plan_id) + end +end diff --git a/spec/lib/gitlab/avatar_cache_spec.rb b/spec/lib/gitlab/avatar_cache_spec.rb new file mode 100644 index 00000000000..ffe6f81b6e7 --- /dev/null +++ b/spec/lib/gitlab/avatar_cache_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Gitlab::AvatarCache, :clean_gitlab_redis_cache do + def with(&blk) + Gitlab::Redis::Cache.with(&blk) # rubocop:disable CodeReuse/ActiveRecord + end + + def read(key, subkey) + with do |redis| + redis.hget(key, subkey) + end + end + + let(:thing) { double("thing", avatar_path: avatar_path) } + let(:avatar_path) { "/avatars/my_fancy_avatar.png" } + let(:key) { described_class.send(:email_key, "foo@bar.com") } + + let(:perform_fetch) do + described_class.by_email("foo@bar.com", 20, 2, true) do + thing.avatar_path + end + end + + describe "#by_email" do + it "writes a new value into the cache" do + expect(read(key, "20:2:true")).to eq(nil) + + perform_fetch + + expect(read(key, "20:2:true")).to eq(avatar_path) + end + + it "finds the cached value and doesn't execute the block" do + expect(thing).to receive(:avatar_path).once + + described_class.by_email("foo@bar.com", 20, 2, true) do + thing.avatar_path + end + + described_class.by_email("foo@bar.com", 20, 2, true) do + thing.avatar_path + end + end + + it "finds the cached value in the request store and doesn't execute the block" do + expect(thing).to receive(:avatar_path).once + + Gitlab::WithRequestStore.with_request_store do + described_class.by_email("foo@bar.com", 20, 2, true) do + thing.avatar_path + end + + described_class.by_email("foo@bar.com", 20, 2, true) do + thing.avatar_path + end + + expect(Gitlab::SafeRequestStore.read([key, "20:2:true"])).to eq(avatar_path) + end + end + end + + describe "#delete_by_email" do + subject { described_class.delete_by_email(*emails) } + + before do + perform_fetch + end + + context "no emails, somehow" do + let(:emails) { [] } + + it { is_expected.to eq(0) } + end + + context "single email" do + let(:emails) { "foo@bar.com" } + + it "removes the email" do + expect(read(key, "20:2:true")).to eq(avatar_path) + + expect(subject).to eq(1) + + expect(read(key, "20:2:true")).to eq(nil) + end + end + + context "multiple emails" do + let(:emails) { ["foo@bar.com", "missing@baz.com"] } + + it "removes the emails it finds" do + expect(read(key, "20:2:true")).to eq(avatar_path) + + expect(subject).to eq(1) + + expect(read(key, "20:2:true")).to eq(nil) + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d2693a32bdf..799b85edf3c 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2499,6 +2499,38 @@ RSpec.describe User do end end + describe "#clear_avatar_caches" do + let(:user) { create(:user) } + + context "when :avatar_cache_for_email flag is enabled" do + before do + stub_feature_flags(avatar_cache_for_email: true) + end + + it "clears the avatar cache when saving" do + allow(user).to receive(:avatar_changed?).and_return(true) + + expect(Gitlab::AvatarCache).to receive(:delete_by_email).with(*user.verified_emails) + + user.update(avatar: fixture_file_upload('spec/fixtures/dk.png')) + end + end + + context "when :avatar_cache_for_email flag is disabled" do + before do + stub_feature_flags(avatar_cache_for_email: false) + end + + it "doesn't attempt to clear the avatar cache" do + allow(user).to receive(:avatar_changed?).and_return(true) + + expect(Gitlab::AvatarCache).not_to receive(:delete_by_email) + + user.update(avatar: fixture_file_upload('spec/fixtures/dk.png')) + end + end + end + describe '#accept_pending_invitations!' do let(:user) { create(:user, email: 'user@email.com') } let!(:project_member_invite) { create(:project_member, :invited, invite_email: user.email) } diff --git a/spec/requests/api/admin/plan_limits_spec.rb b/spec/requests/api/admin/plan_limits_spec.rb new file mode 100644 index 00000000000..6bc133f67c0 --- /dev/null +++ b/spec/requests/api/admin/plan_limits_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do + let_it_be(:user) { create(:user) } + let_it_be(:admin) { create(:admin) } + let_it_be(:plan) { create(:plan, name: 'default') } + + describe 'GET /application/plan_limits' do + context 'as a non-admin user' do + it 'returns 403' do + get api('/application/plan_limits', user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'as an admin user' do + context 'no params' do + it 'returns plan limits' do + get api('/application/plan_limits', admin) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Hash + expect(json_response['conan_max_file_size']).to eq(Plan.default.actual_limits.conan_max_file_size) + expect(json_response['generic_packages_max_file_size']).to eq(Plan.default.actual_limits.generic_packages_max_file_size) + expect(json_response['maven_max_file_size']).to eq(Plan.default.actual_limits.maven_max_file_size) + expect(json_response['npm_max_file_size']).to eq(Plan.default.actual_limits.npm_max_file_size) + expect(json_response['nuget_max_file_size']).to eq(Plan.default.actual_limits.nuget_max_file_size) + expect(json_response['pypi_max_file_size']).to eq(Plan.default.actual_limits.pypi_max_file_size) + end + end + + context 'correct plan name in params' do + before do + @params = { plan_name: 'default' } + end + + it 'returns plan limits' do + get api('/application/plan_limits', admin), params: @params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Hash + expect(json_response['conan_max_file_size']).to eq(Plan.default.actual_limits.conan_max_file_size) + expect(json_response['generic_packages_max_file_size']).to eq(Plan.default.actual_limits.generic_packages_max_file_size) + expect(json_response['maven_max_file_size']).to eq(Plan.default.actual_limits.maven_max_file_size) + expect(json_response['npm_max_file_size']).to eq(Plan.default.actual_limits.npm_max_file_size) + expect(json_response['nuget_max_file_size']).to eq(Plan.default.actual_limits.nuget_max_file_size) + expect(json_response['pypi_max_file_size']).to eq(Plan.default.actual_limits.pypi_max_file_size) + end + end + + context 'invalid plan name in params' do + before do + @params = { plan_name: 'my-plan' } + end + + it 'returns validation error' do + get api('/application/plan_limits', admin), params: @params + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('plan_name does not have a valid value') + end + end + end + end + + describe 'PUT /application/plan_limits' do + context 'as a non-admin user' do + it 'returns 403' do + put api('/application/plan_limits', user), params: { plan_name: 'default' } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'as an admin user' do + context 'correct params' do + it 'updates multiple plan limits' do + put api('/application/plan_limits', admin), params: { + 'plan_name': 'default', + 'conan_max_file_size': 10, + 'generic_packages_max_file_size': 20, + 'maven_max_file_size': 30, + 'npm_max_file_size': 40, + 'nuget_max_file_size': 50, + 'pypi_max_file_size': 60 + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Hash + expect(json_response['conan_max_file_size']).to eq(10) + expect(json_response['generic_packages_max_file_size']).to eq(20) + expect(json_response['maven_max_file_size']).to eq(30) + expect(json_response['npm_max_file_size']).to eq(40) + expect(json_response['nuget_max_file_size']).to eq(50) + expect(json_response['pypi_max_file_size']).to eq(60) + end + + it 'updates single plan limits' do + put api('/application/plan_limits', admin), params: { + 'plan_name': 'default', + 'maven_max_file_size': 100 + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Hash + expect(json_response['maven_max_file_size']).to eq(100) + end + end + + context 'empty params' do + it 'fails to update plan limits' do + put api('/application/plan_limits', admin), params: {} + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to match('plan_name is missing') + end + end + + context 'params with wrong type' do + it 'fails to update plan limits' do + put api('/application/plan_limits', admin), params: { + 'plan_name': 'default', + 'conan_max_file_size': 'a', + 'generic_packages_max_file_size': 'b', + 'maven_max_file_size': 'c', + 'npm_max_file_size': 'd', + 'nuget_max_file_size': 'e', + 'pypi_max_file_size': 'f' + } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to include( + 'conan_max_file_size is invalid', + 'generic_packages_max_file_size is invalid', + 'maven_max_file_size is invalid', + 'generic_packages_max_file_size is invalid', + 'npm_max_file_size is invalid', + 'nuget_max_file_size is invalid', + 'pypi_max_file_size is invalid' + ) + end + end + + context 'missing plan_name in params' do + it 'fails to update plan limits' do + put api('/application/plan_limits', admin), params: { 'conan_max_file_size': 0 } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to match('plan_name is missing') + end + end + + context 'additional undeclared params' do + before do + Plan.default.actual_limits.update!({ 'golang_max_file_size': 1000 }) + end + + it 'updates only declared plan limits' do + put api('/application/plan_limits', admin), params: { + 'plan_name': 'default', + 'pypi_max_file_size': 200, + 'golang_max_file_size': 999 + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Hash + expect(json_response['pypi_max_file_size']).to eq(200) + expect(json_response['golang_max_file_size']).to be_nil + expect(Plan.default.actual_limits.golang_max_file_size).to eq(1000) + end + end + end + end +end