Commit 462cc755 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '213623-add-vulnerability-grades-to-graphql-api' into 'master'

Add vulnerabilityGrades to GraphQL API

See merge request gitlab-org/gitlab!36861
parents 93796580 d6dcd4af
...@@ -5663,6 +5663,11 @@ type Group { ...@@ -5663,6 +5663,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
""" """
...@@ -5816,6 +5821,11 @@ type InstanceSecurityDashboard { ...@@ -5816,6 +5821,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
""" """
...@@ -15040,6 +15050,17 @@ type VulnerabilityEdge { ...@@ -15040,6 +15050,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.
""" """
...@@ -15480,4 +15501,44 @@ type VulnerablePackage { ...@@ -15480,4 +15501,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
...@@ -15568,6 +15568,32 @@ ...@@ -15568,6 +15568,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",
...@@ -16020,6 +16046,32 @@ ...@@ -16020,6 +16046,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",
...@@ -44348,6 +44400,47 @@ ...@@ -44348,6 +44400,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",
...@@ -45706,6 +45799,112 @@ ...@@ -45706,6 +45799,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",
...@@ -862,6 +862,7 @@ Autogenerated return type of EpicTreeReorder ...@@ -862,6 +862,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
...@@ -884,6 +885,12 @@ Represents a Group Member ...@@ -884,6 +885,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 |
...@@ -2395,3 +2402,12 @@ Represents a vulnerable package. Used in vulnerability dependency data ...@@ -2395,3 +2402,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