diff --git a/app/events/repositories/repository_created_event.rb b/app/events/repositories/repository_created_event.rb new file mode 100644 index 0000000000000000000000000000000000000000..3718404f7fba8c7b55ec658ecf780d839dcd8d61 --- /dev/null +++ b/app/events/repositories/repository_created_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Repositories + class RepositoryCreatedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'container_id' => { 'type' => 'integer' }, + 'container_type' => { 'type' => 'string' } + }, + 'required' => %w[container_id container_type] + } + end + end +end diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index bc155bf7809aca72162b1214e8a96026eb2117e7..c5bf067ecc749339cf37f022758b2210b3cb8f07 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -162,6 +162,11 @@ def after_repository_change_head ::Repositories::DefaultBranchChangedEvent.new(data: { container_id: id, container_type: self.class.name })) end + def after_create_repository + Gitlab::EventStore.publish( + ::Repositories::RepositoryCreatedEvent.new(data: { container_id: id, container_type: self.class.name })) + end + def after_change_head_branch_does_not_exist(branch) # No-op (by default) end diff --git a/app/models/repository.rb b/app/models/repository.rb index 5b39a219da64116ab405a920cb328e44cf22b6b5..7fd32751cb1afe4c3426bb71da5fd7e2bdc861a5 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -502,6 +502,8 @@ def after_create expire_status_cache repository_event(:create_repository) + + container.after_create_repository if container.respond_to?(:after_create_repository) end # Runs code just before a repository is deleted. diff --git a/ee/app/services/security/project_tracked_contexts/update_default_service.rb b/ee/app/services/security/project_tracked_contexts/update_default_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..293fa1963d6f42b0ed78ce244b55b53d35658311 --- /dev/null +++ b/ee/app/services/security/project_tracked_contexts/update_default_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Security + module ProjectTrackedContexts + class UpdateDefaultService < ::BaseService + def initialize(project, current_user = nil) + super + end + + def execute + return error_no_default_branch unless project.default_branch.present? + + default_context = find_or_initialize_default_context + + return success_response(default_context) if default_context.context_name == project.default_branch + + default_context.context_name = project.default_branch + + return error_response(default_context) unless default_context.save + + success_response(default_context) + end + + private + + def find_or_initialize_default_context + # Find existing default context or build a new one + project.security_project_tracked_contexts.default_branch.first_or_initialize do |context| + context.context_type = :branch + context.is_default = true + context.state = ::Security::ProjectTrackedContext::STATES[:tracked] + end + end + + def success_response(tracked_context) + ServiceResponse.success( + payload: { + tracked_context: tracked_context + } + ) + end + + def error_response(tracked_context) + ServiceResponse.error( + message: tracked_context.errors.full_messages.join(', '), + payload: { tracked_context: tracked_context } + ) + end + + def error_no_default_branch + ServiceResponse.error( + message: 'Project does not have a default branch' + ) + end + end + end +end diff --git a/ee/app/workers/security/project_tracked_contexts/update_default_worker.rb b/ee/app/workers/security/project_tracked_contexts/update_default_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..88baf0ae7ba8ebfd18317372c7598503c760dd73 --- /dev/null +++ b/ee/app/workers/security/project_tracked_contexts/update_default_worker.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Security + module ProjectTrackedContexts + class UpdateDefaultWorker + include ApplicationWorker + include Gitlab::EventStore::Subscriber + + data_consistency :sticky + feature_category :vulnerability_management + idempotent! + + def handle_event(event) + container_id = event.data[:container_id] + container_type = event.data[:container_type] + + return unless container_type == 'Project' + + Project.find_by_id(container_id).try do |project| + result = Security::ProjectTrackedContexts::UpdateDefaultService.new(project).execute + + log_error(project, result.errors) if result.error? + end + end + + private + + def log_error(project, errors) + Gitlab::AppLogger.warn( + message: "Failed to update default tracked context for project: #{errors.join(',')}", + project_id: project.id, + project_path: project.full_path + ) + end + end + end +end diff --git a/ee/lib/ee/gitlab/event_store.rb b/ee/lib/ee/gitlab/event_store.rb index 6ea9f521add355f6f87f400bab6293b28a0385b8..5522a3e11515e7aed0cbd783447cbf7def2f50d3 100644 --- a/ee/lib/ee/gitlab/event_store.rb +++ b/ee/lib/ee/gitlab/event_store.rb @@ -67,6 +67,10 @@ def configure!(store) ::Gitlab::CurrentSettings.enable_member_promotion_management? } store.subscribe ::Security::CreateDefaultTrackedContextWorker, to: ::Projects::ProjectCreatedEvent + store.subscribe ::Security::ProjectTrackedContexts::UpdateDefaultWorker, + to: ::Repositories::DefaultBranchChangedEvent + store.subscribe ::Security::ProjectTrackedContexts::UpdateDefaultWorker, + to: ::Repositories::RepositoryCreatedEvent register_threat_insights_subscribers(store) register_security_policy_subscribers(store) diff --git a/ee/spec/services/security/project_tracked_contexts/update_default_service_spec.rb b/ee/spec/services/security/project_tracked_contexts/update_default_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..175f1d60f35115f65ffb9d4ec2f2bc307e363fdc --- /dev/null +++ b/ee/spec/services/security/project_tracked_contexts/update_default_service_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::ProjectTrackedContexts::UpdateDefaultService, feature_category: :vulnerability_management do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + + subject(:service) { described_class.new(project, user) } + + describe '#execute' do + context 'when project has a default branch' do + before do + allow(project).to receive(:default_branch).and_return('main') + end + + context 'when default tracked context does not exist' do + it 'creates a new default tracked context' do + expect { service.execute }.to change { Security::ProjectTrackedContext.count }.by(1) + + result = service.execute + expect(result).to be_success + + tracked_context = result.payload[:tracked_context] + expect(tracked_context.project).to eq(project) + expect(tracked_context.context_name).to eq('main') + expect(tracked_context.context_type).to eq('branch') + expect(tracked_context.is_default).to be true + expect(tracked_context).to be_tracked + end + end + + context 'when default tracked context exists with matching name' do + let!(:existing_context) do + create(:security_project_tracked_context, + project: project, + context_name: 'main', + context_type: :branch, + is_default: true, + state: :tracked) + end + + it 'does not update the context' do + expect { service.execute }.not_to change { existing_context.reload.updated_at } + + result = service.execute + expect(result).to be_success + expect(result.payload[:tracked_context]).to eq(existing_context) + end + end + + context 'when default tracked context exists with different name' do + let!(:existing_context) do + create(:security_project_tracked_context, + project: project, + context_name: 'master', + context_type: :branch, + is_default: true, + state: :tracked) + end + + it 'updates the context name to match the default branch' do + result = service.execute + + expect(result).to be_success + expect(existing_context.reload.context_name).to eq('main') + expect(result.payload[:tracked_context]).to eq(existing_context) + end + end + + context 'when update fails due to validation error' do + let!(:existing_context) do + create(:security_project_tracked_context, + project: project, + context_name: 'master', + context_type: :branch, + is_default: true, + state: :tracked) + end + + before do + # Create a context with the target name to cause uniqueness validation error + create(:security_project_tracked_context, + project: project, + context_name: 'main', + context_type: :branch, + is_default: false, + state: :tracked) + end + + it 'returns an error' do + result = service.execute + + expect(result).to be_error + expect(result.message).to include('Context name has already been taken') + end + end + end + + context 'when project does not have a default branch' do + before do + allow(project).to receive(:default_branch).and_return(nil) + end + + it 'returns an error' do + result = service.execute + + expect(result).to be_error + expect(result.message).to eq('Project does not have a default branch') + end + + it 'does not create a tracked context' do + expect { service.execute }.not_to change { Security::ProjectTrackedContext.count } + end + end + end +end diff --git a/ee/spec/workers/security/project_tracked_contexts/update_default_worker_spec.rb b/ee/spec/workers/security/project_tracked_contexts/update_default_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..869c4ae16b3230be9bad315145459ecd476bdbad --- /dev/null +++ b/ee/spec/workers/security/project_tracked_contexts/update_default_worker_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::ProjectTrackedContexts::UpdateDefaultWorker, feature_category: :vulnerability_management do + let_it_be(:project) { create(:project, :repository) } + + describe '#handle_event' do + shared_examples 'updates default tracked context' do + subject(:handle_event) { described_class.new.handle_event(event) } + + context 'when project exists' do + before do + allow(project).to receive(:default_branch).and_return('main') + end + + it 'calls UpdateDefaultService' do + expect(Security::ProjectTrackedContexts::UpdateDefaultService) + .to receive(:new).with(project).and_call_original + + handle_event + end + + context 'when UpdateDefaultService succeeds' do + it 'does not log any warnings' do + expect(Gitlab::AppLogger).not_to receive(:warn) + + handle_event + end + end + + context 'when UpdateDefaultService fails' do + let(:service_result) do + ServiceResponse.error( + message: 'Some error', + payload: { + tracked_context: build(:security_project_tracked_context) + } + ) + end + + before do + allow_next_instance_of(Security::ProjectTrackedContexts::UpdateDefaultService) do |service| + allow(service).to receive(:execute).and_return(service_result) + end + end + + it 'logs a warning' do + expect(Gitlab::AppLogger).to receive(:warn).with( + message: "Failed to update default tracked context for project: Some error", + project_id: project.id, + project_path: project.full_path + ) + + handle_event + end + end + end + + context 'when project does not exist' do + let(:event_data) { event.data.merge(container_id: non_existing_record_id) } + let(:event) { event_class.new(data: event_data) } + + it 'does not raise an error' do + expect { handle_event }.not_to raise_error + end + + it 'does not call UpdateDefaultService' do + expect(Security::ProjectTrackedContexts::UpdateDefaultService).not_to receive(:new) + + handle_event + end + end + + context 'when container_type is not Project' do + let(:event_data) { event.data.merge(container_type: 'Snippet') } + let(:event) { event_class.new(data: event_data) } + + it 'does not call UpdateDefaultService' do + expect(Security::ProjectTrackedContexts::UpdateDefaultService).not_to receive(:new) + + handle_event + end + end + end + + context 'when handling DefaultBranchChangedEvent' do + let(:event_class) { Repositories::DefaultBranchChangedEvent } + let(:event) do + event_class.new(data: { + container_id: project.id, + container_type: 'Project' + }) + end + + it_behaves_like 'updates default tracked context' + + include_examples 'an idempotent worker' do + let(:job_args) { [event] } + end + end + + context 'when handling RepositoryCreatedEvent' do + let(:event_class) { Repositories::RepositoryCreatedEvent } + let(:event) do + event_class.new(data: { + container_id: project.id, + container_type: 'Project' + }) + end + + it_behaves_like 'updates default tracked context' + + include_examples 'an idempotent worker' do + let(:job_args) { [event] } + end + end + end + + describe 'event subscription' do + it 'is subscribed to events' do + expect(described_class.ancestors).to include(Gitlab::EventStore::Subscriber) + end + end +end diff --git a/spec/events/repositories/repository_created_event_spec.rb b/spec/events/repositories/repository_created_event_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f68f2db7a4d3e0fb5f6b9dc0990665734eb41f80 --- /dev/null +++ b/spec/events/repositories/repository_created_event_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Repositories::RepositoryCreatedEvent, feature_category: :source_code_management do + it_behaves_like 'a GitLab event store event' + + it 'has the correct schema' do + expect(event.schema).to eq({ + 'type' => 'object', + 'properties' => { + 'container_id' => { 'type' => 'integer' }, + 'container_type' => { 'type' => 'string' } + }, + 'required' => %w[container_id container_type] + }) + end + + def event + described_class.new(data: { + container_id: 1, + container_type: 'Project' + }) + end +end