Commit b6c27bee authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '33896-project-selection-instance-security-dashboard' into 'master'

Implement project management for the instance security dashboard

See merge request gitlab-org/gitlab!20335
parents b943c07d 5f26a25f
import Vue from 'vue';
import createRouter from 'ee/security_dashboard/store/router';
import projectSelector from 'ee/security_dashboard/store/plugins/project_selector';
import syncWithRouter from 'ee/security_dashboard/store/plugins/sync_with_router';
import createStore from 'ee/security_dashboard/store';
import InstanceSecurityDashboard from 'ee/security_dashboard/components/instance_security_dashboard.vue';
if (gon.features && gon.features.securityDashboard) {
document.addEventListener('DOMContentLoaded', () => {
const el = document.querySelector('#js-security');
const {
dashboardDocumentation,
emptyStateSvgPath,
emptyDashboardStateSvgPath,
projectAddEndpoint,
projectListEndpoint,
vulnerabilitiesCountEndpoint,
vulnerabilitiesEndpoint,
vulnerabilitiesHistoryEndpoint,
vulnerabilityFeedbackHelpPath,
} = el.dataset;
const router = createRouter();
const store = createStore({ plugins: [projectSelector, syncWithRouter(router)] });
return new Vue({
el,
router,
store,
components: {
InstanceSecurityDashboard,
},
render(createElement) {
return createElement(InstanceSecurityDashboard, {
props: {
dashboardDocumentation,
emptyStateSvgPath,
emptyDashboardStateSvgPath,
projectAddEndpoint,
projectListEndpoint,
vulnerabilitiesCountEndpoint,
vulnerabilitiesEndpoint,
vulnerabilitiesHistoryEndpoint,
vulnerabilityFeedbackHelpPath,
},
});
},
});
});
}
import Vue from 'vue';
import createStore from 'ee/security_dashboard/store';
import router from 'ee/security_dashboard/store/router';
import DashboardComponent from 'ee/security_dashboard/components/app.vue';
if (gon.features && gon.features.securityDashboard) {
document.addEventListener(
'DOMContentLoaded',
() =>
new Vue({
el: '#js-security',
store: createStore(),
router,
components: {
DashboardComponent,
},
render(createElement) {
return createElement(DashboardComponent, {
props: {},
});
},
}),
);
}
......@@ -54,7 +54,7 @@ export default {
state.searchCount += 1;
},
[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, results) {
state.projectSearchResults = results;
state.projectSearchResults = results.data;
state.messages.noResults = state.projectSearchResults.length === 0;
state.messages.searchError = false;
......
......@@ -16,14 +16,14 @@ class OperationsController < ApplicationController
def list
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
projects = load_projects(current_user)
projects = load_projects
render json: { projects: serialize_as_json(projects) }
end
def environments_list
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
projects = load_environments_projects(current_user)
projects = load_environments_projects
render json: { projects: serialize_as_json_for_environments(projects) }
end
......@@ -31,7 +31,7 @@ class OperationsController < ApplicationController
def create
project_ids = params['project_ids']
result = add_projects(current_user, project_ids)
result = add_projects(project_ids)
render json: {
added: result.added_project_ids,
......@@ -43,7 +43,7 @@ class OperationsController < ApplicationController
def destroy
project_id = params['project_id']
if remove_project(current_user, project_id)
if remove_project(project_id)
head :ok
else
head :no_content
......@@ -60,19 +60,23 @@ class OperationsController < ApplicationController
render_404 unless Feature.enabled?(:environments_dashboard, current_user, default_enabled: true)
end
def load_projects(current_user)
def load_projects
Dashboard::Operations::ListService.new(current_user).execute
end
def load_environments_projects(current_user)
def load_environments_projects
Dashboard::Environments::ListService.new(current_user).execute
end
def add_projects(current_user, project_ids)
UsersOpsDashboardProjects::CreateService.new(current_user).execute(project_ids)
def add_projects(project_ids)
Dashboard::Projects::CreateService.new(
current_user,
current_user.ops_dashboard_projects,
feature: :operations_dashboard
).execute(project_ids)
end
def remove_project(current_user, project_id)
def remove_project(project_id)
UsersOpsDashboardProjects::DestroyService.new(current_user).execute(project_id)
end
......
......@@ -2,8 +2,5 @@
module Security
class DashboardController < ::Security::ApplicationController
def show
head :ok
end
end
end
......@@ -2,16 +2,58 @@
module Security
class ProjectsController < ::Security::ApplicationController
POLLING_INTERVAL = 120_000
def index
head :ok
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
render json: {
projects: ::Security::ProjectSerializer.new.represent(
current_user.security_dashboard_projects
).as_json
}
end
def create
head :ok
result = add_projects
render json: {
added: result.added_project_ids,
duplicate: result.duplicate_project_ids,
invalid: result.invalid_project_ids
}
end
def destroy
head :ok
if !remove_project.zero?
head :ok
else
head :no_content
end
end
private
def add_projects
Dashboard::Projects::CreateService.new(
current_user,
current_user.security_dashboard_projects,
feature: :security_dashboard
).execute(project_ids)
end
def remove_project
current_user
.users_security_dashboard_projects
.delete_by_project_id(project_id)
end
def project_ids
params.fetch(:project_ids, [])
end
def project_id
params[:id]
end
end
end
......@@ -11,6 +11,10 @@ class UsersSecurityDashboardProject < ApplicationRecord
validates :project_id, uniqueness: { scope: [:user_id] }
validate :per_user_projects_limit
def self.delete_by_project_id(project_id)
where(project_id: project_id).delete_all
end
private
def per_user_projects_limit
......
# frozen_string_literal: true
module Security
class ProjectEntity < API::Entities::BasicProjectDetails
include RequestAwareEntity
expose :remove_path do |project|
security_project_path(id: project.id)
end
end
end
# frozen_string_literal: true
module Security
class ProjectSerializer < BaseSerializer
entity ::Security::ProjectEntity
end
end
......@@ -19,8 +19,8 @@ module Dashboard
# rubocop: disable CodeReuse/ActiveRecord
def load_projects(user)
projects = ::Dashboard::Operations::ProjectsService
.new(user)
projects = ::Dashboard::Projects::ListService
.new(user, feature: :operations_dashboard)
.execute(user.ops_dashboard_projects, limit: MAX_NUM_PROJECTS)
ActiveRecord::Associations::Preloader.new.preload(projects, [
......
......@@ -25,8 +25,8 @@ module Dashboard
def load_projects(user)
projects = user.ops_dashboard_projects
ProjectsService
.new(user)
Dashboard::Projects::ListService
.new(user, feature: :operations_dashboard)
.execute(projects, include_unavailable: true)
.to_a # 1 query
end
......
# frozen_string_literal: true
module Dashboard
module Projects
class CreateService
Result = Struct.new(:added_project_ids, :invalid_project_ids, :duplicate_project_ids)
def initialize(user, projects_relation, feature:)
@user = user
@projects_relation = projects_relation
@feature = feature
end
def execute(project_ids)
projects_to_add = load_projects(project_ids)
invalid = find_invalid_ids(projects_to_add, project_ids)
added, duplicate = add_projects(projects_to_add)
Result.new(added.map(&:id), invalid, duplicate.map(&:id))
end
private
attr_reader :feature,
:projects_relation,
:user
def load_projects(project_ids)
Dashboard::Projects::ListService.new(user, feature: feature).execute(project_ids)
end
def find_invalid_ids(projects_to_add, project_ids)
found_ids = projects_to_add.map(&:id).map(&:to_s)
project_ids.map(&:to_s) - found_ids
end
def add_projects(projects)
projects.partition(&method(:add_project))
end
def add_project(project)
projects_relation << project
true
rescue ActiveRecord::RecordInvalid
false
end
end
end
end
# frozen_string_literal: true
module Dashboard
module Operations
class ProjectsService
def initialize(user)
module Projects
class ListService
def initialize(user, feature:)
@user = user
@feature = feature
end
def execute(project_ids, include_unavailable: false, limit: nil)
return [] unless License.feature_available?(:operations_dashboard)
return [] unless License.feature_available?(feature)
projects = find_projects(user, project_ids).to_a
projects = find_projects(project_ids)
projects = available_projects(projects) unless include_unavailable
projects = limit ? projects.first(limit) : projects
......@@ -19,21 +20,27 @@ module Dashboard
private
attr_reader :user, :project_ids
attr_reader :user, :feature
def available_projects(projects)
projects.select { |project| project.feature_available?(:operations_dashboard) }
projects.select { |project| project.feature_available?(feature) }
end
def find_projects(user, project_ids)
def find_projects(project_ids)
ProjectsFinder.new(
current_user: user,
project_ids_relation: project_ids,
params: {
min_access_level: ProjectMember::DEVELOPER
}
params: projects_finder_params
).execute
end
def projects_finder_params
return {} if user.can?(:read_all_resources)
{
min_access_level: ProjectMember::DEVELOPER
}
end
end
end
end
# frozen_string_literal: true
module UsersOpsDashboardProjects
class CreateService < UsersOpsDashboardProjects::BaseService
Result = Struct.new(:added_project_ids, :invalid_project_ids, :duplicate_project_ids)
def execute(project_ids)
projects_to_add = load_projects(user, project_ids)
invalid = find_invalid_ids(projects_to_add, project_ids)
added, duplicate = add_projects(projects_to_add, user)
Result.new(added.map(&:id), invalid, duplicate.map(&:id))
end
private
def load_projects(current_user, project_ids)
Dashboard::Operations::ProjectsService.new(current_user).execute(project_ids)
end
def find_invalid_ids(projects_to_add, project_ids)
by_string_id = projects_to_add.index_by { |project| project.id.to_s }
project_ids.reject { |id| by_string_id.key?(id.to_s) }
end
def add_projects(projects, user)
projects.partition { |project| add_project(project, user) }
end
def add_project(project, user)
user.ops_dashboard_projects << project
true
rescue ActiveRecord::RecordInvalid
false
end
end
end
- page_title _('Security Dashboard')
- @hide_breadcrumbs = true
#js-security{ data: { vulnerabilities_endpoint: '/groups/gitlab-org/-/security/vulnerabilities',
vulnerabilities_count_endpoint: '/groups/gitlab-org/-/security/vulnerabilities/summary',
vulnerabilities_history_endpoint: '/groups/gitlab-org/-/security/vulnerabilities/history',
project_add_endpoint: security_projects_path,
project_list_endpoint: security_projects_path,
vulnerability_feedback_help_path: help_page_path('user/application_security/index', anchor: 'interacting-with-the-vulnerabilities'),
empty_state_svg_path: image_path('illustrations/operations-dashboard_empty.svg'),
empty_dashboard_state_svg_path: image_path('illustrations/security-dashboard-empty-state.svg'),
dashboard_documentation: help_page_path('user/application_security/security_dashboard/index', anchor: 'instance-security-dashboard') } }
- page_title _('Security Dashboard')
- @hide_breadcrumbs = true
#js-security
......@@ -3,12 +3,45 @@
require 'spec_helper'
describe Security::ProjectsController do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
describe 'GET #index' do
it_behaves_like Security::ApplicationController do
let(:security_application_controller_child_action) do
get :index
end
end
context 'with an authenticated user' do
before do
stub_licensed_features(security_dashboard: true)
user.security_dashboard_projects << project
sign_in(user)
end
it "returns the current user's security dashboard projects" do
get :index
aggregate_failures 'expect successful response containing the project with a remove path' do
expect(response).to have_gitlab_http_status(200)
expect(json_response['projects'].count).to be(1)
dashboard_project = json_response['projects'].first
expect(dashboard_project['id']).to be(project.id)
expect(dashboard_project['remove_path']).to eq(security_project_path(id: project.id))
end
end
it 'sets a polling interval header' do
get :index
expect(response).to have_gitlab_http_status(200)
expect(response.headers['Poll-Interval']).to eq('120000')
end
end
end
describe 'POST #create' do
......@@ -17,12 +50,142 @@ describe Security::ProjectsController do
post :create
end
end
context 'with an authenticated user' do
let(:params) { { project_ids: [project.id] } }
subject { post :create, params: params }
before do
stub_licensed_features(security_dashboard: true)
project.add_developer(user)
sign_in(user)
end
it "adds the given projects to the current user's security dashboard" do
subject
aggregate_failures 'expect successful response and project added to dashboard' do
expect(response).to have_gitlab_http_status(200)
expect(user.reload.security_dashboard_projects).to contain_exactly(project)
expect(json_response).to eq({
'added' => [project.id],
'duplicate' => [],
'invalid' => []
})
end
end
context 'when given a project that is already added to the dashboard' do
it 'does not add the same project twice and returns the duplicate IDs in the response' do
user.security_dashboard_projects << project
subject
aggregate_failures 'expect successful response and no duplicate project added to dashboard' do
expect(response).to have_gitlab_http_status(200)
expect(user.reload.security_dashboard_projects.count).to be(1)
expect(json_response).to eq({
'added' => [],
'duplicate' => [project.id],
'invalid' => []
})
end
end
end
context 'when given an invalid project ID' do
let(:params) { { project_ids: [-1] } }
it 'does not error and includes them in the response' do
subject
aggregate_failures 'expect successful response and no project added to dashboard' do
expect(response).to have_gitlab_http_status(200)
expect(user.reload.security_dashboard_projects).to be_empty
expect(json_response).to eq({
'added' => [],
'duplicate' => [],
'invalid' => ['-1']
})
end
end
end
end
context 'with an authenticated auditor' do
it 'allows them to add projects to the dashboard' do
stub_licensed_features(security_dashboard: true)
auditor = create(:auditor)
sign_in(auditor)
post :create, params: { project_ids: [project.id] }
aggregate_failures 'expect successful response and project added to dashboard' do
expect(response).to have_gitlab_http_status(200)
expect(auditor.reload.security_dashboard_projects).to contain_exactly(project)
end
end
end
end
describe 'DELETE #destroy' do
it_behaves_like Security::ApplicationController do
let(:security_application_controller_child_action) do
delete :destroy, params: { id: 1 }
context 'with an authenticated user' do
before do
stub_licensed_features(security_dashboard: true)
user.security_dashboard_projects << project
sign_in(user)
end
subject { delete :destroy, params: { id: project.id } }
context 'and the instance does not have an Ultimate license' do
it '404s' do
stub_licensed_features(security_dashboard: false)
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'and the security dashboard feature is disabled' do
it '404s' do
stub_feature_flags(security_dashboard: false)
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
it "removes the project from the current user's security dashboard" do
subject
aggregate_failures 'expect successful response and project removed from dashboard' do
expect(response).to have_gitlab_http_status(200)
expect(user.reload.security_dashboard_projects).to be_empty
end
end
context "when given a project not on the current user's security dashboard" do
it 'does nothing and returns 204' do
delete :destroy, params: { id: -1 }
expect(response).to have_gitlab_http_status(204)
end
end
end
context 'when the user is not authenticated' do
it 'redirects the user to the sign in page' do
delete :destroy, params: { id: project.id }
expect(response).to redirect_to(new_user_session_path)
end
end
end
......
......@@ -4,13 +4,14 @@ require 'spec_helper'
describe ProjectsFinder do
describe '#execute' do
let(:finder) { described_class.new(params: params) }
let(:finder) { described_class.new(current_user: user, params: params, project_ids_relation: project_ids_relation) }
let(:user) { create(:user) }
subject { finder.execute }
describe 'filter by plans' do
let(:params) { { plans: plans } }
let(:project_ids_relation) { nil }
let!(:gold_project) { create_project(:gold_plan) }
let!(:gold_project2) { create_project(:gold_plan) }
let!(:silver_project) { create_project(:silver_plan) }
......
......@@ -215,49 +215,59 @@ describe('projectsSelector mutations', () => {
describe('RECEIVE_SEARCH_RESULTS_SUCCESS', () => {
it('sets "projectSearchResults" to be the payload', () => {
const payload = [];
const payload = { data: [{ id: 1, name: 'test-project' }] };
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, payload);
expect(state.projectSearchResults).toBe(payload);
expect(state.projectSearchResults).toBe(payload.data);
});
it('sets "messages.noResults" to be false if the payload is not empty', () => {
const payload = { data: [{ id: 1, name: 'test-project' }] };
state.messages.noResults = true;
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, ['']);
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, payload);
expect(state.messages.noResults).toBe(false);
});
it('sets "messages.searchError" to be false', () => {
const payload = { data: [{ id: 1, name: 'test-project' }] };
state.messages.searchError = true;
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, ['']);
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, payload);
expect(state.messages.searchError).toBe(false);
});
it('sets "messages.minimumQuery" to be false', () => {
const payload = { data: [{ id: 1, name: 'test-project' }] };
state.messages.minimumQuery = true;
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, ['']);
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, payload);
expect(state.messages.minimumQuery).toBe(false);
});
it('decrements "searchCount" by one', () => {
const payload = { data: [{ id: 1, name: 'test-project' }] };
state.searchCount = 1;
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, ['']);
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, payload);
expect(state.searchCount).toBe(0);
});
it('does not decrement "searchCount" into negative', () => {
const payload = { data: [{ id: 1, name: 'test-project' }] };
state.searchCount = 0;
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, ['']);
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, payload);
expect(state.searchCount).toBe(0);
});
......
......@@ -37,4 +37,22 @@ describe UsersSecurityDashboardProject do
end
end
end
describe '.delete_by_project_id' do
it 'deletes all entries for the given project ID' do
project = create(:project)
dashboard_project = create(:users_security_dashboard_project, project: project)
result = described_class.delete_by_project_id(project.id)
expect(result).to be(1)
expect { dashboard_project.reload }.to raise_exception(ActiveRecord::UnknownPrimaryKey)
end
context 'when there is no record with the given project ID' do
it 'fails silently' do
expect(described_class.delete_by_project_id(-1)).to be_zero
end
end
end
end
......@@ -10,11 +10,11 @@ describe Dashboard::Operations::ListService do
let!(:user) { create(:user) }
describe '#execute' do
let(:projects_service) { double(Dashboard::Operations::ProjectsService) }
let(:projects_service) { double(Dashboard::Projects::ListService) }
before do
allow(Dashboard::Operations::ProjectsService)
.to receive(:new).with(user).and_return(projects_service)
allow(Dashboard::Projects::ListService)
.to receive(:new).with(user, feature: :operations_dashboard).and_return(projects_service)
end
shared_examples 'no projects' do
......
......@@ -2,18 +2,18 @@
require 'spec_helper'
describe UsersOpsDashboardProjects::CreateService do
describe Dashboard::Projects::CreateService do
let(:user) { create(:user) }
let(:service) { described_class.new(user) }
let(:service) { described_class.new(user, user.ops_dashboard_projects, feature: :operations_dashboard) }
let(:project) { create(:project) }
describe '#execute' do
let(:projects_service) { double(Dashboard::Operations::ProjectsService) }
let(:projects_service) { double(Dashboard::Projects::ListService) }
let(:result) { service.execute(input) }
before do
allow(Dashboard::Operations::ProjectsService)
.to receive(:new).with(user).and_return(projects_service)
allow(Dashboard::Projects::ListService)
.to receive(:new).with(user, feature: :operations_dashboard).and_return(projects_service)
allow(projects_service)
.to receive(:execute).with(input).and_return(output)
end
......@@ -63,7 +63,7 @@ describe UsersOpsDashboardProjects::CreateService do
let(:output) { [] }
it 'does not add invalid project ids' do
expect(result).to eq(expected_result(invalid_project_ids: input))
expect(result).to eq(expected_result(invalid_project_ids: input.map(&:to_s)))
end
end
end
......@@ -75,7 +75,7 @@ describe UsersOpsDashboardProjects::CreateService do
invalid_project_ids: [],
duplicate_project_ids: []
)
UsersOpsDashboardProjects::CreateService::Result.new(
described_class::Result.new(
added_project_ids, invalid_project_ids, duplicate_project_ids
)
end
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Dashboard::Operations::ProjectsService do
describe Dashboard::Projects::ListService do
PUBLIC = Gitlab::VisibilityLevel::PUBLIC
PRIVATE = Gitlab::VisibilityLevel::PRIVATE
......@@ -11,7 +11,7 @@ describe Dashboard::Operations::ProjectsService do
let(:user) { create(:user) }
let(:project) { create(:project, namespace: namespace, visibility_level: PRIVATE) }
let(:namespace) { create(:namespace, visibility_level: PRIVATE) }
let(:service) { described_class.new(user) }
let(:service) { described_class.new(user, feature: :operations_dashboard) }
describe '#execute' do
let(:result) { service.execute(projects) }
......@@ -155,5 +155,12 @@ describe Dashboard::Operations::ProjectsService do
end
end
end
context 'when the user is an auditor' do
let(:projects) { [project] }
let(:user) { create(:auditor) }
it_behaves_like 'project found'
end
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