Commit d6dcd4af authored by Alan (Maciej) Paruszewski's avatar Alan (Maciej) Paruszewski Committed by Heinrich Lee Yu

Add vulnerabilityGrades to GraphQL API

This change adds new field to Group and InstanceSecurityDashboard types,
with this change user is able to fetch list of projects grouped by
security grade and fetch count for each group.
parent 3c6ee540
...@@ -5623,6 +5623,11 @@ type Group { ...@@ -5623,6 +5623,11 @@ type Group {
startDate: ISO8601Date! startDate: ISO8601Date!
): VulnerabilitiesCountByDayAndSeverityConnection ): VulnerabilitiesCountByDayAndSeverityConnection
"""
Represents vulnerable project counts for each grade
"""
vulnerabilityGrades: [VulnerableProjectsByGrade!]!
""" """
Vulnerability scanners reported on the project vulnerabilties of the group and its subgroups Vulnerability scanners reported on the project vulnerabilties of the group and its subgroups
""" """
...@@ -5776,6 +5781,11 @@ type InstanceSecurityDashboard { ...@@ -5776,6 +5781,11 @@ type InstanceSecurityDashboard {
last: Int last: Int
): ProjectConnection! ): ProjectConnection!
"""
Represents vulnerable project counts for each grade
"""
vulnerabilityGrades: [VulnerableProjectsByGrade!]!
""" """
Vulnerability scanners reported on the vulnerabilties from projects selected in Instance Security Dashboard Vulnerability scanners reported on the vulnerabilties from projects selected in Instance Security Dashboard
""" """
...@@ -14989,6 +14999,17 @@ type VulnerabilityEdge { ...@@ -14989,6 +14999,17 @@ type VulnerabilityEdge {
node: Vulnerability node: Vulnerability
} }
"""
The grade of the vulnerable project
"""
enum VulnerabilityGrade {
A
B
C
D
F
}
""" """
Represents a vulnerability identifier. Represents a vulnerability identifier.
""" """
...@@ -15429,4 +15450,44 @@ type VulnerablePackage { ...@@ -15429,4 +15450,44 @@ type VulnerablePackage {
The name of the vulnerable package The name of the vulnerable package
""" """
name: String name: String
}
"""
Represents vulnerability letter grades with associated projects
"""
type VulnerableProjectsByGrade {
"""
Number of projects within this grade
"""
count: Int!
"""
Grade based on the highest severity vulnerability present
"""
grade: VulnerabilityGrade!
"""
Projects within this grade
"""
projects(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): ProjectConnection!
} }
\ No newline at end of file
...@@ -15452,6 +15452,32 @@ ...@@ -15452,6 +15452,32 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "vulnerabilityGrades",
"description": "Represents vulnerable project counts for each grade",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "VulnerableProjectsByGrade",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "vulnerabilityScanners", "name": "vulnerabilityScanners",
"description": "Vulnerability scanners reported on the project vulnerabilties of the group and its subgroups", "description": "Vulnerability scanners reported on the project vulnerabilties of the group and its subgroups",
...@@ -15904,6 +15930,32 @@ ...@@ -15904,6 +15930,32 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "vulnerabilityGrades",
"description": "Represents vulnerable project counts for each grade",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "VulnerableProjectsByGrade",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "vulnerabilityScanners", "name": "vulnerabilityScanners",
"description": "Vulnerability scanners reported on the vulnerabilties from projects selected in Instance Security Dashboard", "description": "Vulnerability scanners reported on the vulnerabilties from projects selected in Instance Security Dashboard",
...@@ -44169,6 +44221,47 @@ ...@@ -44169,6 +44221,47 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "ENUM",
"name": "VulnerabilityGrade",
"description": "The grade of the vulnerable project",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "A",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "B",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "C",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "D",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "F",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "VulnerabilityIdentifier", "name": "VulnerabilityIdentifier",
...@@ -45527,6 +45620,112 @@ ...@@ -45527,6 +45620,112 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "VulnerableProjectsByGrade",
"description": "Represents vulnerability letter grades with associated projects",
"fields": [
{
"name": "count",
"description": "Number of projects within this grade",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "grade",
"description": "Grade based on the highest severity vulnerability present",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "VulnerabilityGrade",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "projects",
"description": "Projects within this grade",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ProjectConnection",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "__Directive", "name": "__Directive",
...@@ -852,6 +852,7 @@ Autogenerated return type of EpicTreeReorder ...@@ -852,6 +852,7 @@ Autogenerated return type of EpicTreeReorder
| `twoFactorGracePeriod` | Int | Time before two-factor authentication is enforced | | `twoFactorGracePeriod` | Int | Time before two-factor authentication is enforced |
| `userPermissions` | GroupPermissions! | Permissions for the current user on the resource | | `userPermissions` | GroupPermissions! | Permissions for the current user on the resource |
| `visibility` | String | Visibility of the namespace | | `visibility` | String | Visibility of the namespace |
| `vulnerabilityGrades` | VulnerableProjectsByGrade! => Array | Represents vulnerable project counts for each grade |
| `webUrl` | String! | Web URL of the group | | `webUrl` | String! | Web URL of the group |
## GroupMember ## GroupMember
...@@ -874,6 +875,12 @@ Represents a Group Member ...@@ -874,6 +875,12 @@ Represents a Group Member
| --- | ---- | ---------- | | --- | ---- | ---------- |
| `readGroup` | Boolean! | Indicates the user can perform `read_group` on this resource | | `readGroup` | Boolean! | Indicates the user can perform `read_group` on this resource |
## InstanceSecurityDashboard
| Name | Type | Description |
| --- | ---- | ---------- |
| `vulnerabilityGrades` | VulnerableProjectsByGrade! => Array | Represents vulnerable project counts for each grade |
## Issue ## Issue
| Name | Type | Description | | Name | Type | Description |
...@@ -2386,3 +2393,12 @@ Represents a vulnerable package. Used in vulnerability dependency data ...@@ -2386,3 +2393,12 @@ Represents a vulnerable package. Used in vulnerability dependency data
| Name | Type | Description | | Name | Type | Description |
| --- | ---- | ---------- | | --- | ---- | ---------- |
| `name` | String | The name of the vulnerable package | | `name` | String | The name of the vulnerable package |
## VulnerableProjectsByGrade
Represents vulnerability letter grades with associated projects
| Name | Type | Description |
| --- | ---- | ---------- |
| `count` | Int! | Number of projects within this grade |
| `grade` | VulnerabilityGrade! | Grade based on the highest severity vulnerability present |
...@@ -7,6 +7,7 @@ module EE ...@@ -7,6 +7,7 @@ module EE
prepended do prepended do
lazy_resolve ::Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate, :epic_aggregate lazy_resolve ::Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate, :epic_aggregate
lazy_resolve ::Gitlab::Graphql::Aggregations::Issues::LazyBlockAggregate, :block_aggregate lazy_resolve ::Gitlab::Graphql::Aggregations::Issues::LazyBlockAggregate, :block_aggregate
lazy_resolve ::Gitlab::Graphql::Aggregations::VulnerabilityStatistics::LazyAggregate, :aggregate
end end
end end
end end
...@@ -47,6 +47,14 @@ module EE ...@@ -47,6 +47,14 @@ module EE
null: true, null: true,
description: 'Number of vulnerabilities per severity level, per day, for the projects in the group and its subgroups', description: 'Number of vulnerabilities per severity level, per day, for the projects in the group and its subgroups',
resolver: ::Resolvers::VulnerabilitiesHistoryResolver resolver: ::Resolvers::VulnerabilitiesHistoryResolver
field :vulnerability_grades,
[::Types::VulnerableProjectsByGradeType],
null: false,
description: 'Represents vulnerable project counts for each grade',
resolve: -> (obj, _args, ctx) {
::Gitlab::Graphql::Aggregations::VulnerabilityStatistics::LazyAggregate.new(ctx, obj)
}
end end
end end
end end
......
...@@ -17,5 +17,16 @@ module Types ...@@ -17,5 +17,16 @@ module Types
null: true, null: true,
description: 'Vulnerability scanners reported on the vulnerabilties from projects selected in Instance Security Dashboard', description: 'Vulnerability scanners reported on the vulnerabilties from projects selected in Instance Security Dashboard',
resolver: ::Resolvers::Vulnerabilities::ScannersResolver resolver: ::Resolvers::Vulnerabilities::ScannersResolver
field :vulnerability_grades,
[Types::VulnerableProjectsByGradeType],
null: false,
description: 'Represents vulnerable project counts for each grade',
resolve: -> (obj, _args, ctx) {
::Gitlab::Graphql::Aggregations::VulnerabilityStatistics::LazyAggregate.new(
ctx,
::InstanceSecurityDashboard.new(ctx[:current_user])
)
}
end end
end end
# frozen_string_literal: true
module Types
class VulnerabilityGradeEnum < BaseEnum
graphql_name 'VulnerabilityGrade'
description 'The grade of the vulnerable project'
::Vulnerabilities::Statistic.letter_grades.keys.each do |grade|
value grade.to_s.upcase, value: grade.to_s
end
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class VulnerableProjectsByGradeType < BaseObject
graphql_name 'VulnerableProjectsByGrade'
description 'Represents vulnerability letter grades with associated projects'
field :grade, Types::VulnerabilityGradeEnum, null: false,
description: "Grade based on the highest severity vulnerability present"
field :count, GraphQL::INT_TYPE, null: false,
description: 'Number of projects within this grade',
complexity: 5
field :projects, Types::ProjectType.connection_type, null: false,
description: 'Projects within this grade',
authorize: :read_project,
complexity: 5
end
# rubocop: enable Graphql/AuthorizeTypes
end
...@@ -149,6 +149,7 @@ module EE ...@@ -149,6 +149,7 @@ module EE
scope :with_deleting_user, -> { includes(:deleting_user) } scope :with_deleting_user, -> { includes(:deleting_user) }
scope :with_compliance_framework_settings, -> { preload(:compliance_framework_setting) } scope :with_compliance_framework_settings, -> { preload(:compliance_framework_setting) }
scope :has_vulnerabilities, -> { joins(:vulnerabilities).group(:id) } scope :has_vulnerabilities, -> { joins(:vulnerabilities).group(:id) }
scope :has_vulnerability_statistics, -> { joins(:vulnerability_statistic) }
scope :with_group_saml_provider, -> { preload(group: :saml_provider) } scope :with_group_saml_provider, -> { preload(group: :saml_provider) }
......
# frozen_string_literal: true
module Vulnerabilities
class ProjectsGrade
attr_reader :vulnerable, :grade, :project_ids
# project_ids can contain IDs from projects that do not belong to vulnerable, they will be filtered out in `projects` method
def initialize(vulnerable, letter_grade, project_ids = [])
@vulnerable = vulnerable
@grade = letter_grade
@project_ids = project_ids
end
delegate :count, to: :projects
def projects
return vulnerable.projects.none if project_ids.blank?
vulnerable.projects.where(id: project_ids)
end
def self.grades_for(vulnerables)
::Vulnerabilities::Statistic
.for_project(vulnerables.map(&:projects).reduce(&:or))
.group(:letter_grade)
.select(:letter_grade, 'array_agg(project_id) project_ids')
.then do |statistics|
vulnerables.each_with_object({}) do |vulnerable, hash|
hash[vulnerable] = statistics.map { |statistic| new(vulnerable, statistic.letter_grade, statistic.project_ids) }
end
end
end
end
end
...@@ -18,6 +18,8 @@ module Vulnerabilities ...@@ -18,6 +18,8 @@ module Vulnerabilities
before_save :assign_letter_grade before_save :assign_letter_grade
scope :for_project, ->(project) { where(project_id: project) }
class << self class << self
# Takes an object which responds to `#[]` method call # Takes an object which responds to `#[]` method call
# like an instance of ActiveRecord::Base or a Hash and # like an instance of ActiveRecord::Base or a Hash and
......
---
title: Add vulnerabilityGrades to GraphQL API
merge_request: 36861
author:
type: added
# frozen_string_literal: true
module Gitlab
module Graphql
module Aggregations
module VulnerabilityStatistics
class LazyAggregate
attr_reader :vulnerable, :lazy_state
def initialize(query_ctx, vulnerable)
@vulnerable = vulnerable.respond_to?(:sync) ? vulnerable.sync : vulnerable
# Initialize the loading state for this query,
# or get the previously-initiated state
@lazy_state = query_ctx[:lazy_aggregate] ||= {
pending_vulnerables: Set.new,
loaded_objects: {}
}
# Register this ID to be loaded later:
@lazy_state[:pending_vulnerables] << vulnerable
end
# Return the loaded record, hitting the database if needed
def aggregate
# Check if the record was already loaded
if @lazy_state[:pending_vulnerables].present?
load_records_into_loaded_objects
end
@lazy_state[:loaded_objects][@vulnerable]
end
private
def load_records_into_loaded_objects
# The record hasn't been loaded yet, so
# hit the database with all pending IDs to prevent N+1
pending_vulnerables = @lazy_state[:pending_vulnerables].to_a
grades = ::Vulnerabilities::ProjectsGrade.grades_for(pending_vulnerables)
pending_vulnerables.each do |vulnerable|
@lazy_state[:loaded_objects][vulnerable] = grades[vulnerable]
end
@lazy_state[:pending_vulnerables].clear
end
end
end
end
end
end
...@@ -3,6 +3,26 @@ ...@@ -3,6 +3,26 @@
FactoryBot.define do FactoryBot.define do
factory :vulnerability_statistic, class: 'Vulnerabilities::Statistic' do factory :vulnerability_statistic, class: 'Vulnerabilities::Statistic' do
project project
letter_grade { :a }
trait :a do
info { 1 }
end
trait :b do
low { 1 }
end
trait :c do
medium { 1 }
end
trait :d do
high { 1 }
unknown { 1 }
end
trait :f do
critical { 1 }
end
end end
end end
...@@ -15,6 +15,7 @@ RSpec.describe GitlabSchema.types['Group'] do ...@@ -15,6 +15,7 @@ RSpec.describe GitlabSchema.types['Group'] do
it { expect(described_class).to have_graphql_field(:vulnerabilities) } it { expect(described_class).to have_graphql_field(:vulnerabilities) }
it { expect(described_class).to have_graphql_field(:vulnerability_scanners) } it { expect(described_class).to have_graphql_field(:vulnerability_scanners) }
it { expect(described_class).to have_graphql_field(:vulnerabilities_count_by_day_and_severity) } it { expect(described_class).to have_graphql_field(:vulnerabilities_count_by_day_and_severity) }
it { expect(described_class).to have_graphql_field(:vulnerability_grades) }
describe 'timelogs field' do describe 'timelogs field' do
subject { described_class.fields['timelogs'] } subject { described_class.fields['timelogs'] }
......
...@@ -8,7 +8,7 @@ RSpec.describe GitlabSchema.types['InstanceSecurityDashboard'] do ...@@ -8,7 +8,7 @@ RSpec.describe GitlabSchema.types['InstanceSecurityDashboard'] do
let_it_be(:user) { create(:user, security_dashboard_projects: [project]) } let_it_be(:user) { create(:user, security_dashboard_projects: [project]) }
let(:fields) do let(:fields) do
%i[projects vulnerability_scanners] %i[projects vulnerability_scanners vulnerability_grades]
end end
before do before do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityGrade'] do
it 'exposes all vulnerability grades' do
expect(described_class.values.keys).to contain_exactly(*%w[A B C D F])
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerableProjectsByGrade'] do
let(:fields) { %w(grade count projects).freeze }
specify { expect(described_class).to have_graphql_fields(fields) }
specify { expect(described_class.graphql_name).to eq('VulnerableProjectsByGrade') }
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Graphql::Aggregations::VulnerabilityStatistics::LazyAggregate do
let(:query_ctx) do
{}
end
let(:vulnerable) { create(:group) }
let(:blocks_vulnerable) { 18 }
let(:blocking_vulnerable) { 38 }
describe '#initialize' do
it 'adds the vulnerable to the lazy state' do
subject = described_class.new(query_ctx, vulnerable)
expect(subject.lazy_state[:pending_vulnerables]).to match [vulnerable]
expect(subject.vulnerable).to match vulnerable
end
end
describe '#aggregate' do
subject { described_class.new(query_ctx, vulnerable) }
before do
subject.instance_variable_set(:@lazy_state, fake_state)
end
context 'if the record has already been loaded' do
let(:fake_state) do
{ pending_vulnerables: Set.new, loaded_objects: { vulnerable => [::Vulnerabilities::ProjectsGrade.new(vulnerable, 'a', [])] } }
end
it 'does not make the query again' do
expect(::Vulnerabilities::ProjectsGrade).not_to receive(:grades_for)
subject.aggregate
end
end
context 'if the record has not been loaded' do
let(:other_vulnerable) { create(:group) }
let(:fake_state) do
{ pending_vulnerables: Set.new([vulnerable, other_vulnerable]), loaded_objects: {} }
end
let(:fake_data) do
{
vulnerable => [::Vulnerabilities::ProjectsGrade.new(vulnerable, 'a', [])],
other_vulnerable => [::Vulnerabilities::ProjectsGrade.new(other_vulnerable, 'b', [])]
}
end
before do
allow(::Vulnerabilities::ProjectsGrade).to receive(:grades_for).and_return(fake_data)
end
it 'makes the query' do
expect(::Vulnerabilities::ProjectsGrade).to receive(:grades_for).with([vulnerable, other_vulnerable])
subject.aggregate
end
it 'clears the pending IDs' do
subject.aggregate
expect(subject.lazy_state[:pending_vulnerables]).to be_empty
end
end
end
end
...@@ -217,6 +217,19 @@ RSpec.describe Project do ...@@ -217,6 +217,19 @@ RSpec.describe Project do
it { is_expected.to contain_exactly(project_1) } it { is_expected.to contain_exactly(project_1) }
end end
describe '.has_vulnerability_statistics' do
let_it_be(:project_1) { create(:project) }
let_it_be(:project_2) { create(:project) }
before do
create(:vulnerability_statistic, project: project_1)
end
subject { described_class.has_vulnerability_statistics }
it { is_expected.to contain_exactly(project_1) }
end
end end
describe 'validations' do describe 'validations' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Vulnerabilities::ProjectsGrade do
let_it_be(:group) { create(:group) }
let_it_be(:project_1) { create(:project, group: group) }
let_it_be(:project_2) { create(:project, group: group) }
let_it_be(:project_3) { create(:project, group: group) }
let_it_be(:project_4) { create(:project, group: group) }
let_it_be(:project_5) { create(:project, group: group) }
let_it_be(:vulnerability_statistic_1) { create(:vulnerability_statistic, :a, project: project_1) }
let_it_be(:vulnerability_statistic_2) { create(:vulnerability_statistic, :b, project: project_2) }
let_it_be(:vulnerability_statistic_3) { create(:vulnerability_statistic, :b, project: project_3) }
let_it_be(:vulnerability_statistic_4) { create(:vulnerability_statistic, :c, project: project_4) }
let_it_be(:vulnerability_statistic_5) { create(:vulnerability_statistic, :f, project: project_5) }
describe '.grades_for' do
let(:compare_key) { ->(projects_grade) { [projects_grade.grade, projects_grade.project_ids] } }
subject(:projects_grades) { described_class.grades_for([vulnerable]) }
context 'when the given vulnerable is a Group' do
let(:vulnerable) { group }
let(:expected_projects_grades) do
{
vulnerable => [
described_class.new(vulnerable, 'a', [project_1.id]),
described_class.new(vulnerable, 'b', [project_2.id, project_3.id]),
described_class.new(vulnerable, 'c', [project_4.id]),
described_class.new(vulnerable, 'f', [project_5.id])
]
}
end
it 'returns the letter grades for given vulnerable' do
expect(projects_grades[vulnerable].map(&compare_key)).to match_array(expected_projects_grades[vulnerable].map(&compare_key))
end
end
context 'when the given vulnerable is an InstanceSecurityDashboard' do
let(:user) { create(:user) }
let(:vulnerable) { InstanceSecurityDashboard.new(user) }
let(:expected_projects_grades) do
{
vulnerable => [described_class.new(vulnerable, 'a', [project_1.id])]
}
end
before do
project_1.add_developer(user)
user.security_dashboard_projects << project_1
end
it 'returns the letter grades for given vulnerable' do
expect(projects_grades[vulnerable].map(&compare_key)).to match_array(expected_projects_grades[vulnerable].map(&compare_key))
end
end
end
describe '#grade' do
::Vulnerabilities::Statistic.letter_grades.each do |letter|
subject(:grade) { projects_grade.grade }
context "when providing letter value of #{letter}" do
let(:projects_grade) { described_class.new(nil, letter) }
it { is_expected.to eq(letter) }
end
end
end
describe '#projects' do
let(:projects_grade) { described_class.new(group, 1, [project_3.id, project_4.id]) }
let(:expected_projects) { [project_3, project_4] }
subject(:projects) { projects_grade.projects }
it { is_expected.to eq(expected_projects) }
end
describe '#count' do
let(:projects_grade) { described_class.new(group, 1, [project_3.id, project_4.id]) }
subject(:projects) { projects_grade.count }
it { is_expected.to eq 2 }
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment