Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
6d5f18a3c1
commit
40b78ea2b6
35 changed files with 697 additions and 240 deletions
|
@ -111,7 +111,6 @@ linters:
|
|||
- Layout/EmptyLineAfterGuardClause
|
||||
- Layout/LeadingCommentSpace
|
||||
- Layout/SpaceAfterColon
|
||||
- Layout/SpaceAfterComma
|
||||
- Layout/SpaceAroundOperators
|
||||
- Layout/SpaceBeforeBlockBraces
|
||||
- Layout/SpaceBeforeComma
|
||||
|
|
|
@ -750,7 +750,6 @@ RSpec/EmptyLineAfterFinalLetItBe:
|
|||
- ee/spec/support/shared_examples/graphql/mutations/set_multiple_assignees_shared_examples.rb
|
||||
- ee/spec/support/shared_examples/quick_actions/issue/status_page_quick_actions_shared_examples.rb
|
||||
- ee/spec/support/shared_examples/services/search_notes_shared_examples.rb
|
||||
- ee/spec/tasks/gitlab/license_rake_spec.rb
|
||||
- ee/spec/views/admin/application_settings/_elasticsearch_form.html.haml_spec.rb
|
||||
- ee/spec/views/groups/_compliance_frameworks.html.haml_spec.rb
|
||||
- ee/spec/views/groups/edit.html.haml_spec.rb
|
||||
|
|
|
@ -48,7 +48,7 @@ export default {
|
|||
}"
|
||||
>
|
||||
<gl-link
|
||||
v-gl-tooltip
|
||||
v-gl-tooltip:tooltip-container.left
|
||||
:href="job.status.details_path"
|
||||
:title="tooltipText"
|
||||
class="js-job-link gl-display-flex gl-align-items-center"
|
||||
|
|
|
@ -16,8 +16,8 @@ class Groups::LabelsController < Groups::ApplicationController
|
|||
format.html do
|
||||
# at group level we do not want to list project labels,
|
||||
# we only want `only_group_labels = false` when pulling labels for label filter dropdowns, fetched through json
|
||||
@labels = available_labels(params.merge(only_group_labels: true)).includes(:group).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord
|
||||
@labels.each { |label| label.lazy_subscription(current_user) } # preload subscriptions
|
||||
@labels = available_labels(params.merge(only_group_labels: true)).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord
|
||||
Preloaders::LabelsPreloader.new(@labels, current_user).preload_all
|
||||
end
|
||||
format.json do
|
||||
render json: LabelSerializer.new.represent_appearance(available_labels)
|
||||
|
|
|
@ -17,11 +17,14 @@ class Projects::LabelsController < Projects::ApplicationController
|
|||
feature_category :issue_tracking
|
||||
|
||||
def index
|
||||
@prioritized_labels = @available_labels.prioritized(@project)
|
||||
@labels = @available_labels.unprioritized(@project).page(params[:page])
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.html do
|
||||
@prioritized_labels = @available_labels.prioritized(@project)
|
||||
@labels = @available_labels.unprioritized(@project).page(params[:page])
|
||||
# preload group, project, and subscription data
|
||||
Preloaders::LabelsPreloader.new(@prioritized_labels, current_user, @project).preload_all
|
||||
Preloaders::LabelsPreloader.new(@labels, current_user, @project).preload_all
|
||||
end
|
||||
format.json do
|
||||
render json: LabelSerializer.new.represent_appearance(@available_labels)
|
||||
end
|
||||
|
|
|
@ -5,6 +5,7 @@ module Resolvers
|
|||
module Users
|
||||
class SnippetsResolver < BaseResolver
|
||||
include ResolvesSnippets
|
||||
include Gitlab::Allowable
|
||||
|
||||
alias_method :user, :object
|
||||
|
||||
|
@ -14,6 +15,12 @@ module Resolvers
|
|||
|
||||
private
|
||||
|
||||
def resolve_snippets(_args)
|
||||
return Snippet.none unless Ability.allowed?(current_user, :read_user_profile, user)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def snippet_finder_params(args)
|
||||
super.merge(author: user)
|
||||
end
|
||||
|
|
32
app/models/preloaders/labels_preloader.rb
Normal file
32
app/models/preloaders/labels_preloader.rb
Normal file
|
@ -0,0 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Preloaders
|
||||
# This class preloads the `project`, `group`, and subscription associations for the given
|
||||
# labels, user, and project (if provided). A Label can be of type ProjectLabel or GroupLabel
|
||||
# and the preloader supports both.
|
||||
#
|
||||
# Usage:
|
||||
# labels = Label.where(...)
|
||||
# Preloaders::LabelsPreloader.new(labels, current_user, @project).preload_all
|
||||
# labels.first.project # won't fire any query
|
||||
class LabelsPreloader
|
||||
attr_reader :labels, :user, :project
|
||||
|
||||
def initialize(labels, user, project = nil)
|
||||
@labels, @user, @project = labels, user, project
|
||||
end
|
||||
|
||||
def preload_all
|
||||
preloader = ActiveRecord::Associations::Preloader.new
|
||||
|
||||
preloader.preload(labels.select {|l| l.is_a? ProjectLabel }, { project: [:project_feature, namespace: :route] })
|
||||
preloader.preload(labels.select {|l| l.is_a? GroupLabel }, { group: :route })
|
||||
labels.each do |label|
|
||||
label.lazy_subscription(user)
|
||||
label.lazy_subscription(user, project) if project.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Preloaders::LabelsPreloader.prepend_if_ee('EE::Preloaders::LabelsPreloader')
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SystemHooksService
|
||||
BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember, Group, ProjectMember].freeze
|
||||
BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember, Group, ProjectMember, User].freeze
|
||||
|
||||
def execute_hooks_for(model, event)
|
||||
data = build_event_data(model, event)
|
||||
|
@ -47,15 +47,6 @@ class SystemHooksService
|
|||
if event == :rename || event == :transfer
|
||||
data[:old_path_with_namespace] = model.old_path_with_namespace
|
||||
end
|
||||
when User
|
||||
data.merge!(user_data(model))
|
||||
|
||||
case event
|
||||
when :rename
|
||||
data[:old_username] = model.username_before_last_save
|
||||
when :failed_login
|
||||
data[:state] = model.state
|
||||
end
|
||||
end
|
||||
|
||||
data
|
||||
|
@ -79,15 +70,6 @@ class SystemHooksService
|
|||
}
|
||||
end
|
||||
|
||||
def user_data(model)
|
||||
{
|
||||
name: model.name,
|
||||
email: model.email,
|
||||
user_id: model.id,
|
||||
username: model.username
|
||||
}
|
||||
end
|
||||
|
||||
def builder_driven_event_data_available?(model)
|
||||
model.class.in?(BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES)
|
||||
end
|
||||
|
@ -100,10 +82,10 @@ class SystemHooksService
|
|||
Gitlab::HookData::GroupBuilder
|
||||
when ProjectMember
|
||||
Gitlab::HookData::ProjectMemberBuilder
|
||||
when User
|
||||
Gitlab::HookData::UserBuilder
|
||||
end
|
||||
|
||||
builder_class.new(model).build(event)
|
||||
end
|
||||
end
|
||||
|
||||
SystemHooksService.prepend_if_ee('EE::SystemHooksService')
|
||||
|
|
|
@ -5,4 +5,4 @@
|
|||
= render 'shared/ref_switcher', destination: 'graphs'
|
||||
= link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn gl-button btn-default'
|
||||
|
||||
.js-contributors-graph{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json),'data-project-branch': current_ref }
|
||||
.js-contributors-graph{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json), 'data-project-branch': current_ref }
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
%h5 Request headers:
|
||||
%pre
|
||||
- hook_log.request_headers.each do |k,v|
|
||||
- hook_log.request_headers.each do |k, v|
|
||||
<strong>#{k}:</strong> #{v}
|
||||
%br
|
||||
|
||||
|
@ -34,7 +34,7 @@
|
|||
#{Gitlab::Json.pretty_generate(hook_log.request_data)}
|
||||
%h5 Response headers:
|
||||
%pre
|
||||
- hook_log.response_headers.each do |k,v|
|
||||
- hook_log.response_headers.each do |k, v|
|
||||
<strong>#{k}:</strong> #{v}
|
||||
%br
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Reduce queries on projects labels controller
|
||||
merge_request: 57864
|
||||
author:
|
||||
type: performance
|
5
changelogs/unreleased/326009-remove-unused-index.yml
Normal file
5
changelogs/unreleased/326009-remove-unused-index.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Drop unused mirror_data index
|
||||
merge_request: 58349
|
||||
author:
|
||||
type: performance
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Avoid listing snippets through GraphQL when user profile is private
|
||||
merge_request: 58739
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: cached_sidebar_open_epics_count
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58064
|
||||
rollout_issue_url:
|
||||
milestone: '13.11'
|
||||
type: development
|
||||
group: group::product planning
|
||||
default_enabled: true
|
|
@ -0,0 +1,30 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# See https://docs.gitlab.com/ee/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class RemoveIndexMirrorDataOnNextExecutionAndRetryCount < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
INDEX_NAME = 'index_mirror_data_on_next_execution_and_retry_count'
|
||||
|
||||
def up
|
||||
remove_concurrent_index(
|
||||
:project_mirror_data,
|
||||
%i[next_execution_timestamp retry_count],
|
||||
name: INDEX_NAME
|
||||
)
|
||||
end
|
||||
|
||||
def down
|
||||
add_concurrent_index(
|
||||
:project_mirror_data,
|
||||
%i[next_execution_timestamp retry_count],
|
||||
name: INDEX_NAME
|
||||
)
|
||||
end
|
||||
end
|
1
db/schema_migrations/20210401134455
Normal file
1
db/schema_migrations/20210401134455
Normal file
|
@ -0,0 +1 @@
|
|||
3420d83bf8a1f44e69960849efa25525883f17a2776ae3ce28db855cd550ca8e
|
|
@ -23140,8 +23140,6 @@ CREATE INDEX index_milestones_on_title_trigram ON milestones USING gin (title gi
|
|||
|
||||
CREATE INDEX index_mirror_data_non_scheduled_or_started ON project_mirror_data USING btree (next_execution_timestamp, retry_count) WHERE ((status)::text <> ALL ('{scheduled,started}'::text[]));
|
||||
|
||||
CREATE INDEX index_mirror_data_on_next_execution_and_retry_count ON project_mirror_data USING btree (next_execution_timestamp, retry_count);
|
||||
|
||||
CREATE UNIQUE INDEX index_mr_blocks_on_blocking_and_blocked_mr_ids ON merge_request_blocks USING btree (blocking_merge_request_id, blocked_merge_request_id);
|
||||
|
||||
CREATE INDEX index_mr_cleanup_schedules_timestamps ON merge_request_cleanup_schedules USING btree (scheduled_at) WHERE (completed_at IS NULL);
|
||||
|
|
|
@ -772,113 +772,7 @@ argument :title, GraphQL::STRING_TYPE,
|
|||
|
||||
## Authorization
|
||||
|
||||
Authorizations can be applied to both types and fields using the same
|
||||
abilities as in the Rails app.
|
||||
|
||||
If the:
|
||||
|
||||
- Currently authenticated user fails the authorization, the authorized
|
||||
resource is returned as `null`.
|
||||
- Resource is part of a collection, the collection is filtered to
|
||||
exclude the objects that the user's authorization checks failed against.
|
||||
|
||||
Also see [authorizing resources in a mutation](#authorizing-resources).
|
||||
|
||||
NOTE:
|
||||
Try to load only what the currently authenticated user is allowed to
|
||||
view with our existing finders first, without relying on authorization
|
||||
to filter the records. This minimizes database queries and unnecessary
|
||||
authorization checks of the loaded records.
|
||||
|
||||
### Type authorization
|
||||
|
||||
Authorize a type by passing an ability to the `authorize` method. All
|
||||
fields with the same type is authorized by checking that the
|
||||
currently authenticated user has the required ability.
|
||||
|
||||
For example, the following authorization ensures that the currently
|
||||
authenticated user can only see projects that they have the
|
||||
`read_project` ability for (so long as the project is returned in a
|
||||
field that uses `Types::ProjectType`):
|
||||
|
||||
```ruby
|
||||
module Types
|
||||
class ProjectType < BaseObject
|
||||
authorize :read_project
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
You can also authorize against multiple abilities, in which case all of
|
||||
the ability checks must pass.
|
||||
|
||||
For example, the following authorization ensures that the currently
|
||||
authenticated user must have `read_project` and `another_ability`
|
||||
abilities to see a project:
|
||||
|
||||
```ruby
|
||||
module Types
|
||||
class ProjectType < BaseObject
|
||||
authorize [:read_project, :another_ability]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Field authorization
|
||||
|
||||
Fields can be authorized with the `authorize` option.
|
||||
|
||||
For example, the following authorization ensures that the currently
|
||||
authenticated user must have the `owner_access` ability to see the
|
||||
project:
|
||||
|
||||
```ruby
|
||||
module Types
|
||||
class MyType < BaseObject
|
||||
field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver, authorize: :owner_access
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Fields can also be authorized against multiple abilities, in which case
|
||||
all of ability checks must pass. This requires explicitly
|
||||
passing a block to `field`:
|
||||
|
||||
```ruby
|
||||
module Types
|
||||
class MyType < BaseObject
|
||||
field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver do
|
||||
authorize [:owner_access, :another_ability]
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
If the field's type already [has a particular
|
||||
authorization](#type-authorization) then there is no need to add that
|
||||
same authorization to the field.
|
||||
|
||||
### Type and Field authorizations together
|
||||
|
||||
Authorizations are cumulative, so where authorizations are defined on
|
||||
a field, and also on the field's type, then the currently authenticated
|
||||
user would need to pass all ability checks.
|
||||
|
||||
In the following simplified example the currently authenticated user
|
||||
would need both `first_permission` and `second_permission` abilities in
|
||||
order to see the author of the issue.
|
||||
|
||||
```ruby
|
||||
class UserType
|
||||
authorize :first_permission
|
||||
end
|
||||
```
|
||||
|
||||
```ruby
|
||||
class IssueType
|
||||
field :author, UserType, authorize: :second_permission
|
||||
end
|
||||
```
|
||||
See: [GraphQL Authorization](graphql_guide/authorization.md)
|
||||
|
||||
## Resolvers
|
||||
|
||||
|
|
|
@ -142,6 +142,14 @@ migration performing the scheduling. Otherwise the background migration would be
|
|||
scheduled multiple times on systems that are upgrading multiple patch releases at
|
||||
once.
|
||||
|
||||
When you start the second post-deployment migration, you should delete any
|
||||
previously queued jobs from the initial migration with the provided
|
||||
helper:
|
||||
|
||||
```ruby
|
||||
delete_queued_jobs('BackgroundMigrationClassName')
|
||||
```
|
||||
|
||||
## Cleaning Up
|
||||
|
||||
NOTE:
|
||||
|
|
223
doc/development/graphql_guide/authorization.md
Normal file
223
doc/development/graphql_guide/authorization.md
Normal file
|
@ -0,0 +1,223 @@
|
|||
---
|
||||
stage: none
|
||||
group: unassigned
|
||||
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
|
||||
---
|
||||
|
||||
# GraphQL Authorization
|
||||
|
||||
Authorizations can be applied in these places:
|
||||
|
||||
- Types:
|
||||
- Objects (all classes descending from `::Types::BaseObject`)
|
||||
- Enums (all classes descending from `::Types::BaseEnum`)
|
||||
- Resolvers:
|
||||
- Field resolvers (all classes descending from `::Types::BaseResolver`)
|
||||
- Mutations (all classes descending from `::Types::BaseMutation`)
|
||||
- Fields (all fields declared using the `field` DSL method)
|
||||
|
||||
Authorizations cannot be specified for abstract types (interfaces and
|
||||
unions). Abstract types delegate to their member types.
|
||||
Basic built in scalars (such as integers) do not have authorizations.
|
||||
|
||||
Our authorization system uses the same [`DeclarativePolicy`](../policies.md)
|
||||
system as throughout the rest of the application.
|
||||
|
||||
- For single values (such as `Query.project`), if the currently authenticated
|
||||
user fails the authorization, the field resolves to `null`.
|
||||
- For collections (such as `Project.issues`), the collection is filtered to
|
||||
exclude the objects that the user's authorization checks failed against. This
|
||||
process of filtering (also known as _redaction_) happens after pagination, so
|
||||
some pages may be smaller than the requested page size, due to redacted
|
||||
objects being removed.
|
||||
|
||||
Also see [authorizing resources in a mutation](../api_graphql_styleguide.md#authorizing-resources).
|
||||
|
||||
NOTE:
|
||||
The best practice is to load only what the currently authenticated user is allowed to
|
||||
view with our existing finders first, without relying on authorization
|
||||
to filter the records. This minimizes database queries and unnecessary
|
||||
authorization checks of the loaded records. It also avoids situations,
|
||||
such as short pages, which can expose the presence of confidential resources.
|
||||
|
||||
See [`authorization_spec.rb`](https://gitlab.com/gitlab-org/gitlab/blob/master/spec/graphql/features/authorization_spec.rb)
|
||||
for examples of all the authorization schemes discussed here.
|
||||
|
||||
## Type authorization
|
||||
|
||||
Authorize a type by passing an ability to the `authorize` method. All
|
||||
fields with the same type is authorized by checking that the
|
||||
currently authenticated user has the required ability.
|
||||
|
||||
For example, the following authorization ensures that the currently
|
||||
authenticated user can only see projects that they have the
|
||||
`read_project` ability for (so long as the project is returned in a
|
||||
field that uses `Types::ProjectType`):
|
||||
|
||||
```ruby
|
||||
module Types
|
||||
class ProjectType < BaseObject
|
||||
authorize :read_project
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
You can also authorize against multiple abilities, in which case all of
|
||||
the ability checks must pass.
|
||||
|
||||
For example, the following authorization ensures that the currently
|
||||
authenticated user must have `read_project` and `another_ability`
|
||||
abilities to see a project:
|
||||
|
||||
```ruby
|
||||
module Types
|
||||
class ProjectType < BaseObject
|
||||
authorize [:read_project, :another_ability]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Resolver authorization
|
||||
|
||||
Resolvers can have their own authorizations, which can be applied either to the
|
||||
parent object or to the resolved values.
|
||||
|
||||
An example of a resolver that authorizes against the parent is
|
||||
`Resolvers::BoardListsResolver`, which requires that the parent
|
||||
satisfy `:read_list` before it runs.
|
||||
|
||||
An example which authorizes against the resolved resource is
|
||||
`Resolvers::Ci::ConfigResolver`, which requires that the resolved value satisfy
|
||||
`:read_pipeline`.
|
||||
|
||||
To authorize against the parent, the resolver must _opt in_ (because this
|
||||
was not the default value initially), by declaring this with `authorizes_object!`:
|
||||
|
||||
```ruby
|
||||
module Resolvers
|
||||
class MyResolver < BaseResolver
|
||||
authorizes_object!
|
||||
|
||||
authorize :some_permission
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
To authorize against the resolved value, the resolver must apply the
|
||||
authorization at some point, typically by using `#authorized_find!(**args)`:
|
||||
|
||||
```ruby
|
||||
module Resolvers
|
||||
class MyResolver < BaseResolver
|
||||
authorize :some_permission
|
||||
|
||||
def resolve(**args)
|
||||
authorized_find!(**args) # calls find_object
|
||||
end
|
||||
|
||||
def find_object(id:)
|
||||
MyThing.find(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Of the two approaches, authorizing the object is more efficient, because it
|
||||
helps avoid unnecessary queries.
|
||||
|
||||
## Field authorization
|
||||
|
||||
Fields can be authorized with the `authorize` option.
|
||||
|
||||
Fields authorization is checked against the current object, and
|
||||
authorization happens _before_ resolution, which means that
|
||||
fields do not have access to the resolved resource. If you need to
|
||||
apply an authorization check to a field, you probably want to add
|
||||
authorization to the resolver, or ideally to the type.
|
||||
|
||||
For example, the following authorization ensures that the
|
||||
authenticated user must have administrator level access to the project
|
||||
to view the `secretName` field:
|
||||
|
||||
```ruby
|
||||
module Types
|
||||
class ProjectType < BaseObject
|
||||
field :secret_name, ::GraphQL::STRING_TYPE, null: true, authorize: :owner_access
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
In this example, we use field authorization (such as
|
||||
`Ability.allowed?(current_user, :read_transactions, bank_account)`) to avoid
|
||||
a more expensive query:
|
||||
|
||||
```ruby
|
||||
module Types
|
||||
class BankAccountType < BaseObject
|
||||
field :transactions, ::Types::TransactionType.connection_type, null: true,
|
||||
authorize: :read_transactions
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Field authorization is recommended for:
|
||||
|
||||
- Scalar fields (strings, booleans, or numbers) that should have different levels
|
||||
of access controls to other fields.
|
||||
- Object and collection fields where an access check can be applied to the
|
||||
parent to save the field resolution, and avoid individual policy checks
|
||||
on each resolved object.
|
||||
|
||||
Field authorization does not replace object level checks, unless the object
|
||||
precisely matches the access level of the parent project. For example, issues
|
||||
can be confidential, independent of the access level of the parent. Therefore,
|
||||
we should not use field authorization for `Project.issue`.
|
||||
|
||||
You can also authorize fields against multiple abilities. Pass the abilities
|
||||
as an array instead of as a single value:
|
||||
|
||||
```ruby
|
||||
module Types
|
||||
class MyType < BaseObject
|
||||
field :hidden_field, ::GraphQL::INT_TYPE,
|
||||
null: true,
|
||||
authorize: [:owner_access, :another_ability]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
The field authorization on `MyType.hiddenField` implies the following tests:
|
||||
|
||||
```ruby
|
||||
Ability.allowed?(current_user, :owner_access, object_of_my_type) &&
|
||||
Ability.allowed?(current_user, :another_ability, object_of_my_type)
|
||||
```
|
||||
|
||||
## Type and Field authorizations together
|
||||
|
||||
Authorizations are cumulative. In other words, the currently authenticated
|
||||
user may need to pass authorization requirements on both a field and a field's
|
||||
type.
|
||||
|
||||
In the following simplified example the currently authenticated user
|
||||
needs both `first_permission` on the user and `second_permission` on the
|
||||
issue to see the author of the issue.
|
||||
|
||||
```ruby
|
||||
class UserType
|
||||
authorize :first_permission
|
||||
end
|
||||
```
|
||||
|
||||
```ruby
|
||||
class IssueType
|
||||
field :author, UserType, authorize: :second_permission
|
||||
end
|
||||
```
|
||||
|
||||
The combination of the object authorization on `UserType` and the field authorization on `IssueType.author` implies the following tests:
|
||||
|
||||
```ruby
|
||||
Ability.allowed?(current_user, :second_permission, issue) &&
|
||||
Ability.allowed?(current_user, :first_permission, issue.author)
|
||||
```
|
|
@ -17,6 +17,7 @@ feedback, and suggestions.
|
|||
- [GraphQL API documentation style guide](../documentation/graphql_styleguide.md): documentation
|
||||
style guide for GraphQL.
|
||||
- [GraphQL API](../../api/graphql/index.md): user documentation for the GitLab GraphQL API.
|
||||
- [GraphQL authorization](authorization.md): guide to using authorization in GraphQL.
|
||||
- [GraphQL BatchLoader](batchloader.md): development documentation on the BatchLoader.
|
||||
- [GraphQL pagination](pagination.md): development documentation on pagination.
|
||||
- [GraphQL Pro](graphql_pro.md): information on our GraphQL Pro subscription.
|
||||
|
|
|
@ -497,6 +497,7 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF
|
|||
redis_slot: compliance
|
||||
expiry: 42 # 6 weeks
|
||||
aggregation: weekly
|
||||
feature_flag: usage_data_i_compliance_credential_inventory
|
||||
```
|
||||
|
||||
Keys:
|
||||
|
@ -528,7 +529,7 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF
|
|||
aggregation.
|
||||
- `aggregation`: may be set to a `:daily` or `:weekly` key. Defines how counting data is stored in Redis.
|
||||
Aggregation on a `daily` basis does not pull more fine grained data.
|
||||
- `feature_flag`: optional `default_enabled: :yaml`. If no feature flag is set then the tracking is enabled. For details, see our [GitLab internal Feature flags](../feature_flags/index.md) documentation. The feature flags are owned by the group adding the event tracking.
|
||||
- `feature_flag`: optional `default_enabled: :yaml`. If no feature flag is set then the tracking is enabled. One feature flag can be used for multiple events. For details, see our [GitLab internal Feature flags](../feature_flags/index.md) documentation. The feature flags are owned by the group adding the event tracking.
|
||||
|
||||
Use one of the following methods to track events:
|
||||
|
||||
|
@ -567,8 +568,6 @@ Use one of the following methods to track events:
|
|||
|
||||
1. Track event in API using `increment_unique_values(event_name, values)` helper method.
|
||||
|
||||
To be able to track the event, Usage Ping must be enabled and the event feature `usage_data_<event_name>` must be enabled.
|
||||
|
||||
Arguments:
|
||||
|
||||
- `event_name`: event name.
|
||||
|
@ -612,10 +611,6 @@ Use one of the following methods to track events:
|
|||
|
||||
API requests are protected by checking for a valid CSRF token.
|
||||
|
||||
To increment the values, the related feature `usage_data_<event_name>` should be
|
||||
set to `default_enabled: true`. For more information, see
|
||||
[Feature flags in development of GitLab](../feature_flags/index.md).
|
||||
|
||||
```plaintext
|
||||
POST /usage_data/increment_unique_users
|
||||
```
|
||||
|
@ -640,8 +635,6 @@ Use one of the following methods to track events:
|
|||
Usage Data API is behind `usage_data_api` feature flag which, as of GitLab 13.7, is
|
||||
now set to `default_enabled: true`.
|
||||
|
||||
Each event tracked using Usage Data API is behind a feature flag `usage_data_#{event_name}` which should be `default_enabled: true`
|
||||
|
||||
```javascript
|
||||
import api from '~/api';
|
||||
|
||||
|
|
|
@ -323,3 +323,36 @@ To remove a child epic from a parent epic:
|
|||
|
||||
1. Select the <kbd>x</kbd> button in the parent epic's list of epics.
|
||||
1. Select **Remove** in the **Remove epic** warning message.
|
||||
|
||||
## Cached epic count
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/299540) in GitLab 13.11.
|
||||
> - It's [deployed behind a feature flag](../../feature_flags.md), enabled by default.
|
||||
> - It's enabled on GitLab.com.
|
||||
> - It's recommended for production use.
|
||||
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-cached-epic-count).
|
||||
|
||||
WARNING:
|
||||
This feature might not be available to you. Check the **version history** note above for details.
|
||||
|
||||
In a group, the sidebar displays the total count of open epics and this value is cached if higher
|
||||
than 1000. The cached value is rounded to thousands (or millions) and updated every 24 hours.
|
||||
|
||||
### Enable or disable cached epic count **(PREMIUM SELF)**
|
||||
|
||||
Cached epic count in the left sidebar is under development but ready for production use. It is
|
||||
deployed behind a feature flag that is **enabled by default**.
|
||||
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
|
||||
can disable it.
|
||||
|
||||
To disable it:
|
||||
|
||||
```ruby
|
||||
Feature.disable(:cached_sidebar_open_epics_count)
|
||||
```
|
||||
|
||||
To enable it:
|
||||
|
||||
```ruby
|
||||
Feature.enable(:cached_sidebar_open_epics_count)
|
||||
```
|
||||
|
|
|
@ -22,13 +22,15 @@ module API
|
|||
users: Entities::UserBasic
|
||||
}.freeze
|
||||
|
||||
SCOPE_PRELOAD_METHOD = {
|
||||
merge_requests: :with_api_entity_associations,
|
||||
projects: :with_api_entity_associations,
|
||||
issues: :with_api_entity_associations,
|
||||
milestones: :with_api_entity_associations,
|
||||
commits: :with_api_commit_entity_associations
|
||||
}.freeze
|
||||
def scope_preload_method
|
||||
{
|
||||
merge_requests: :with_api_entity_associations,
|
||||
projects: :with_api_entity_associations,
|
||||
issues: :with_api_entity_associations,
|
||||
milestones: :with_api_entity_associations,
|
||||
commits: :with_api_commit_entity_associations
|
||||
}.freeze
|
||||
end
|
||||
|
||||
def search(additional_params = {})
|
||||
search_params = {
|
||||
|
@ -60,7 +62,7 @@ module API
|
|||
end
|
||||
|
||||
def preload_method
|
||||
SCOPE_PRELOAD_METHOD[params[:scope].to_sym]
|
||||
scope_preload_method[params[:scope].to_sym]
|
||||
end
|
||||
|
||||
def verify_search_scope!(resource:)
|
||||
|
|
|
@ -236,6 +236,14 @@ module Gitlab
|
|||
Gitlab::ApplicationContext.with_context(caller_id: self.class.to_s, &block)
|
||||
end
|
||||
|
||||
def delete_queued_jobs(class_name)
|
||||
Gitlab::BackgroundMigration.steal(class_name) do |job|
|
||||
job.delete
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def track_in_database(class_name, arguments)
|
||||
|
|
53
lib/gitlab/hook_data/user_builder.rb
Normal file
53
lib/gitlab/hook_data/user_builder.rb
Normal file
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module HookData
|
||||
class UserBuilder < BaseBuilder
|
||||
alias_method :user, :object
|
||||
|
||||
# Sample data
|
||||
# {
|
||||
# :created_at=>"2021-04-02T10:00:26Z",
|
||||
# :updated_at=>"2021-04-02T10:00:26Z",
|
||||
# :event_name=>"user_create",
|
||||
# :name=>"John Doe",
|
||||
# :email=>"john@example.com",
|
||||
# :user_id=>1,
|
||||
# :username=>"johndoe"
|
||||
# }
|
||||
|
||||
def build(event)
|
||||
[
|
||||
timestamps_data,
|
||||
event_data(event),
|
||||
user_data,
|
||||
event_specific_user_data(event)
|
||||
].reduce(:merge)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_data
|
||||
{
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
user_id: user.id,
|
||||
username: user.username
|
||||
}
|
||||
end
|
||||
|
||||
def event_specific_user_data(event)
|
||||
case event
|
||||
when :rename
|
||||
{ old_username: user.username_before_last_save }
|
||||
when :failed_login
|
||||
{ state: user.state }
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Gitlab::HookData::UserBuilder.prepend_if_ee('EE::Gitlab::HookData::UserBuilder')
|
|
@ -60,7 +60,7 @@ RSpec.describe Groups::LabelsController do
|
|||
create_list(:group_label, 3, group: group)
|
||||
|
||||
# some n+1 queries still exist
|
||||
expect { get :index, params: { group_id: group.to_param } }.not_to exceed_all_query_limit(control.count).with_threshold(12)
|
||||
expect { get :index, params: { group_id: group.to_param } }.not_to exceed_all_query_limit(control.count).with_threshold(10)
|
||||
expect(assigns(:labels).count).to eq(4)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -93,6 +93,26 @@ RSpec.describe Projects::LabelsController do
|
|||
end
|
||||
end
|
||||
|
||||
context 'with views rendered' do
|
||||
render_views
|
||||
|
||||
before do
|
||||
list_labels
|
||||
end
|
||||
|
||||
it 'avoids N+1 queries' do
|
||||
control = ActiveRecord::QueryRecorder.new(skip_cached: false) { list_labels }
|
||||
|
||||
create_list(:label, 3, project: project)
|
||||
create_list(:group_label, 3, group: group)
|
||||
|
||||
# some n+1 queries still exist
|
||||
# calls to get max project authorization access level
|
||||
expect { list_labels }.not_to exceed_all_query_limit(control.count).with_threshold(25)
|
||||
expect(assigns(:labels).count).to eq(10)
|
||||
end
|
||||
end
|
||||
|
||||
def list_labels
|
||||
get :index, params: { namespace_id: project.namespace.to_param, project_id: project }
|
||||
end
|
||||
|
|
|
@ -75,9 +75,19 @@ RSpec.describe Resolvers::Users::SnippetsResolver do
|
|||
end.to raise_error(GraphQL::CoercionError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user profile is private' do
|
||||
it 'does not return snippets for that user' do
|
||||
expect(resolve_snippets(obj: other_user)).to contain_exactly(other_personal_snippet, other_project_snippet)
|
||||
|
||||
other_user.update!(private_profile: true)
|
||||
|
||||
expect(resolve_snippets(obj: other_user)).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_snippets(args: {})
|
||||
resolve(described_class, args: args, ctx: { current_user: current_user }, obj: current_user)
|
||||
def resolve_snippets(args: {}, context_user: current_user, obj: current_user)
|
||||
resolve(described_class, args: args, ctx: { current_user: context_user }, obj: obj)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -515,64 +515,18 @@ RSpec.describe GroupsHelper do
|
|||
let_it_be(:current_user) { create(:user) }
|
||||
let_it_be(:group) { create(:group, name: 'group') }
|
||||
|
||||
subject { helper.cached_issuables_count(group, type: type) }
|
||||
|
||||
before do
|
||||
allow(helper).to receive(:current_user) { current_user }
|
||||
allow(count_service).to receive(:new).and_call_original
|
||||
end
|
||||
|
||||
shared_examples 'caching issuables count' do
|
||||
it 'calls the correct service class' do
|
||||
subject
|
||||
expect(count_service).to have_received(:new).with(group, current_user)
|
||||
end
|
||||
|
||||
it 'returns all digits for count value under 1000' do
|
||||
allow_next_instance_of(count_service) do |service|
|
||||
allow(service).to receive(:count).and_return(999)
|
||||
end
|
||||
|
||||
expect(subject).to eq('999')
|
||||
end
|
||||
|
||||
it 'returns truncated digits for count value over 1000' do
|
||||
allow_next_instance_of(count_service) do |service|
|
||||
allow(service).to receive(:count).and_return(2300)
|
||||
end
|
||||
|
||||
expect(subject).to eq('2.3k')
|
||||
end
|
||||
|
||||
it 'returns truncated digits for count value over 10000' do
|
||||
allow_next_instance_of(count_service) do |service|
|
||||
allow(service).to receive(:count).and_return(12560)
|
||||
end
|
||||
|
||||
expect(subject).to eq('12.6k')
|
||||
end
|
||||
|
||||
it 'returns truncated digits for count value over 100000' do
|
||||
allow_next_instance_of(count_service) do |service|
|
||||
allow(service).to receive(:count).and_return(112560)
|
||||
end
|
||||
|
||||
expect(subject).to eq('112.6k')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with issue type' do
|
||||
context 'with issues type' do
|
||||
let(:type) { :issues }
|
||||
let(:count_service) { Groups::OpenIssuesCountService }
|
||||
|
||||
it_behaves_like 'caching issuables count'
|
||||
it_behaves_like 'cached issuables count'
|
||||
end
|
||||
|
||||
context 'with merge request type' do
|
||||
context 'with merge requests type' do
|
||||
let(:type) { :merge_requests }
|
||||
let(:count_service) { Groups::MergeRequestsCountService }
|
||||
|
||||
it_behaves_like 'caching issuables count'
|
||||
it_behaves_like 'cached issuables count'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -431,4 +431,21 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
|
|||
model.bulk_migrate_in(10.minutes, [%w(Class hello world)])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#delete_queued_jobs' do
|
||||
let(:job1) { double }
|
||||
let(:job2) { double }
|
||||
|
||||
it 'deletes all queued jobs for the given background migration' do
|
||||
expect(Gitlab::BackgroundMigration).to receive(:steal).with('BackgroundMigrationClassName') do |&block|
|
||||
expect(block.call(job1)).to be(false)
|
||||
expect(block.call(job2)).to be(false)
|
||||
end
|
||||
|
||||
expect(job1).to receive(:delete)
|
||||
expect(job2).to receive(:delete)
|
||||
|
||||
model.delete_queued_jobs('BackgroundMigrationClassName')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
90
spec/lib/gitlab/hook_data/user_builder_spec.rb
Normal file
90
spec/lib/gitlab/hook_data/user_builder_spec.rb
Normal file
|
@ -0,0 +1,90 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::HookData::UserBuilder do
|
||||
let_it_be(:user) { create(:user, name: 'John Doe', username: 'johndoe', email: 'john@example.com') }
|
||||
|
||||
describe '#build' do
|
||||
let(:data) { described_class.new(user).build(event) }
|
||||
let(:event_name) { data[:event_name] }
|
||||
let(:attributes) do
|
||||
[
|
||||
:event_name, :created_at, :updated_at, :name, :email, :user_id, :username
|
||||
]
|
||||
end
|
||||
|
||||
context 'data' do
|
||||
shared_examples_for 'includes the required attributes' do
|
||||
it 'includes the required attributes' do
|
||||
expect(data).to include(*attributes)
|
||||
|
||||
expect(data[:name]).to eq('John Doe')
|
||||
expect(data[:email]).to eq('john@example.com')
|
||||
expect(data[:user_id]).to eq(user.id)
|
||||
expect(data[:username]).to eq('johndoe')
|
||||
expect(data[:created_at]).to eq(user.created_at.xmlschema)
|
||||
expect(data[:updated_at]).to eq(user.updated_at.xmlschema)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples_for 'does not include old username attributes' do
|
||||
it 'does not include old username attributes' do
|
||||
expect(data).not_to include(:old_username)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples_for 'does not include state attributes' do
|
||||
it 'does not include state attributes' do
|
||||
expect(data).not_to include(:state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'on create' do
|
||||
let(:event) { :create }
|
||||
|
||||
it { expect(event_name).to eq('user_create') }
|
||||
it_behaves_like 'includes the required attributes'
|
||||
it_behaves_like 'does not include old username attributes'
|
||||
it_behaves_like 'does not include state attributes'
|
||||
end
|
||||
|
||||
context 'on destroy' do
|
||||
let(:event) { :destroy }
|
||||
|
||||
it { expect(event_name).to eq('user_destroy') }
|
||||
it_behaves_like 'includes the required attributes'
|
||||
it_behaves_like 'does not include old username attributes'
|
||||
it_behaves_like 'does not include state attributes'
|
||||
end
|
||||
|
||||
context 'on rename' do
|
||||
let(:event) { :rename }
|
||||
|
||||
it { expect(event_name).to eq('user_rename') }
|
||||
it_behaves_like 'includes the required attributes'
|
||||
it_behaves_like 'does not include state attributes'
|
||||
|
||||
it 'includes old username details' do
|
||||
allow(user).to receive(:username_before_last_save).and_return('old-username')
|
||||
|
||||
expect(data[:old_username]).to eq(user.username_before_last_save)
|
||||
end
|
||||
end
|
||||
|
||||
context 'on failed_login' do
|
||||
let(:event) { :failed_login }
|
||||
|
||||
it { expect(event_name).to eq('user_failed_login') }
|
||||
it_behaves_like 'includes the required attributes'
|
||||
it_behaves_like 'does not include old username attributes'
|
||||
|
||||
it 'includes state details' do
|
||||
user.ldap_block!
|
||||
|
||||
expect(data[:state]).to eq('ldap_blocked')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
52
spec/models/preloaders/labels_preloader_spec.rb
Normal file
52
spec/models/preloaders/labels_preloader_spec.rb
Normal file
|
@ -0,0 +1,52 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Preloaders::LabelsPreloader do
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
shared_examples 'an efficient database query' do
|
||||
let(:subscriptions) { labels.each { |l| create(:subscription, subscribable: l, project: l.project, user: user) }}
|
||||
|
||||
it 'does not make n+1 queries' do
|
||||
first_label = labels_with_preloaded_data.first
|
||||
clean_labels = labels_with_preloaded_data
|
||||
|
||||
expect { access_data(clean_labels) }.to issue_same_number_of_queries_as { access_data([first_label]) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'project labels' do
|
||||
let_it_be(:projects) { create_list(:project, 3, :public, :repository) }
|
||||
let_it_be(:labels) { projects.each { |p| create(:label, project: p) } }
|
||||
|
||||
it_behaves_like 'an efficient database query'
|
||||
end
|
||||
|
||||
context 'group labels' do
|
||||
let_it_be(:groups) { create_list(:group, 3) }
|
||||
let_it_be(:labels) { groups.each { |g| create(:group_label, group: g) } }
|
||||
|
||||
it_behaves_like 'an efficient database query'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def labels_with_preloaded_data
|
||||
l = Label.where(id: labels.map(&:id))
|
||||
described_class.new(l, user).preload_all
|
||||
l
|
||||
end
|
||||
|
||||
def access_data(labels)
|
||||
labels.each do |label|
|
||||
if label.is_a?(ProjectLabel)
|
||||
label.project.project_feature
|
||||
label.lazy_subscription(user, label.project)
|
||||
elsif label.is_a?(GroupLabel)
|
||||
label.group.route
|
||||
label.lazy_subscription(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -113,37 +113,9 @@ RSpec.describe SystemHooksService do
|
|||
expect(data[:old_path]).to eq('old-path')
|
||||
end
|
||||
end
|
||||
|
||||
context 'user_rename' do
|
||||
it 'contains old and new username' do
|
||||
allow(user).to receive(:username_before_last_save).and_return('old-username')
|
||||
|
||||
data = event_data(user, :rename)
|
||||
|
||||
expect(data).to include(:event_name, :name, :created_at, :updated_at, :email, :user_id, :username, :old_username)
|
||||
expect(data[:username]).to eq(user.username)
|
||||
expect(data[:old_username]).to eq(user.username_before_last_save)
|
||||
end
|
||||
end
|
||||
|
||||
context 'user_failed_login' do
|
||||
it 'contains state of user' do
|
||||
user.ldap_block!
|
||||
|
||||
data = event_data(user, :failed_login)
|
||||
|
||||
expect(data).to include(:event_name, :name, :created_at, :updated_at, :email, :user_id, :username, :state)
|
||||
expect(data[:username]).to eq(user.username)
|
||||
expect(data[:state]).to eq('ldap_blocked')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'event names' do
|
||||
it { expect(event_name(user, :create)).to eq "user_create" }
|
||||
it { expect(event_name(user, :destroy)).to eq "user_destroy" }
|
||||
it { expect(event_name(user, :rename)).to eq 'user_rename' }
|
||||
it { expect(event_name(user, :failed_login)).to eq 'user_failed_login' }
|
||||
it { expect(event_name(project, :create)).to eq "project_create" }
|
||||
it { expect(event_name(project, :destroy)).to eq "project_destroy" }
|
||||
it { expect(event_name(project, :rename)).to eq "project_rename" }
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# This shared_example requires the following variables:
|
||||
# - current_user
|
||||
# - group
|
||||
# - type, the issuable type (ie :issues, :merge_requests)
|
||||
# - count_service, the Service used by the specified issuable type
|
||||
|
||||
RSpec.shared_examples 'cached issuables count' do
|
||||
subject { helper.cached_issuables_count(group, type: type) }
|
||||
|
||||
before do
|
||||
allow(helper).to receive(:current_user) { current_user }
|
||||
allow(count_service).to receive(:new).and_call_original
|
||||
end
|
||||
|
||||
it 'calls the correct service class' do
|
||||
subject
|
||||
expect(count_service).to have_received(:new).with(group, current_user)
|
||||
end
|
||||
|
||||
it 'returns all digits for count value under 1000' do
|
||||
allow_next_instance_of(count_service) do |service|
|
||||
allow(service).to receive(:count).and_return(999)
|
||||
end
|
||||
|
||||
expect(subject).to eq('999')
|
||||
end
|
||||
|
||||
it 'returns truncated digits for count value over 1000' do
|
||||
allow_next_instance_of(count_service) do |service|
|
||||
allow(service).to receive(:count).and_return(2300)
|
||||
end
|
||||
|
||||
expect(subject).to eq('2.3k')
|
||||
end
|
||||
|
||||
it 'returns truncated digits for count value over 10000' do
|
||||
allow_next_instance_of(count_service) do |service|
|
||||
allow(service).to receive(:count).and_return(12560)
|
||||
end
|
||||
|
||||
expect(subject).to eq('12.6k')
|
||||
end
|
||||
|
||||
it 'returns truncated digits for count value over 100000' do
|
||||
allow_next_instance_of(count_service) do |service|
|
||||
allow(service).to receive(:count).and_return(112560)
|
||||
end
|
||||
|
||||
expect(subject).to eq('112.6k')
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue