From e7a619b518d07488c1dd182ee2bbdb4eff4c3c80 Mon Sep 17 00:00:00 2001 From: GitLab Duo Date: Tue, 2 Sep 2025 18:09:07 +0000 Subject: [PATCH] Duo Workflow: Resolve issue #424508 --- ee/lib/ee/api/projects.rb | 2 - ee/spec/requests/api/projects_spec.rb | 262 ++++++++++++++++++ spec/models/project_feature_available_spec.rb | 221 +++++++++++++++ 3 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 spec/models/project_feature_available_spec.rb diff --git a/ee/lib/ee/api/projects.rb b/ee/lib/ee/api/projects.rb index 3cadab41c07c49..2a6ae412ccf44b 100644 --- a/ee/lib/ee/api/projects.rb +++ b/ee/lib/ee/api/projects.rb @@ -124,8 +124,6 @@ def verify_issuable_default_templates_attrs!(project, attrs) end def verify_merge_pipelines_attrs!(project, attrs) - return if can?(current_user, :admin_project, project) - attrs.delete(:merge_pipelines_enabled) unless project.feature_available?(:merge_pipelines) attrs.delete(:merge_trains_enabled) unless project.feature_available?(:merge_trains) attrs.delete(:merge_trains_skip_train_allowed) unless project.feature_available?(:merge_trains) diff --git a/ee/spec/requests/api/projects_spec.rb b/ee/spec/requests/api/projects_spec.rb index dc0337a1027591..d26c1e11efabff 100644 --- a/ee/spec/requests/api/projects_spec.rb +++ b/ee/spec/requests/api/projects_spec.rb @@ -63,6 +63,49 @@ expect(response).to have_gitlab_http_status(:ok) end + context 'duo_remote_flows_enabled attribute in project list' do + let_it_be(:project_with_duo_flows) { create(:project, :private, namespace: user.namespace, duo_remote_flows_enabled: true) } + let_it_be(:project_without_duo_flows) { create(:project, :private, namespace: user.namespace, duo_remote_flows_enabled: false) } + + context 'when ai_workflows license is available' do + before do + stub_licensed_features(ai_workflows: true) + end + + it 'includes duo_remote_flows_enabled attribute for all projects' do + get api('/projects', user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + + duo_flows_project = json_response.find { |p| p['id'] == project_with_duo_flows.id } + no_duo_flows_project = json_response.find { |p| p['id'] == project_without_duo_flows.id } + + expect(duo_flows_project['duo_remote_flows_enabled']).to eq true + expect(no_duo_flows_project['duo_remote_flows_enabled']).to eq false + end + end + + context 'when ai_workflows license is not available' do + before do + stub_licensed_features(ai_workflows: false) + end + + it 'does not include duo_remote_flows_enabled attribute' do + get api('/projects', user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + + duo_flows_project = json_response.find { |p| p['id'] == project_with_duo_flows.id } + no_duo_flows_project = json_response.find { |p| p['id'] == project_without_duo_flows.id } + + expect(duo_flows_project['duo_remote_flows_enabled']).to be_nil + expect(no_duo_flows_project['duo_remote_flows_enabled']).to be_nil + end + end + end + context 'when there are several projects owned by groups' do let_it_be(:admin) { create(:admin) } @@ -466,6 +509,64 @@ end end + context 'duo_remote_flows_enabled attribute' do + before do + project.add_maintainer(user) + end + + context 'when ai_workflows license is available' do + before do + stub_licensed_features(ai_workflows: true) + end + + it 'returns duo_remote_flows_enabled flag' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to have_key 'duo_remote_flows_enabled' + end + + context 'when duo_remote_flows_enabled is true' do + before do + project.update!(duo_remote_flows_enabled: true) + end + + it 'returns true' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['duo_remote_flows_enabled']).to eq true + end + end + + context 'when duo_remote_flows_enabled is false' do + before do + project.update!(duo_remote_flows_enabled: false) + end + + it 'returns false' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['duo_remote_flows_enabled']).to eq false + end + end + end + + context 'when ai_workflows license is not available' do + before do + stub_licensed_features(ai_workflows: false) + end + + it 'does not return duo_remote_flows_enabled flag' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['duo_remote_flows_enabled']).to be_nil + end + end + end + context 'when protected_environments is available' do before do stub_licensed_features(protected_environments: true) @@ -677,6 +778,42 @@ it_behaves_like 'creates projects with templates' end end + + context 'with duo_remote_flows_enabled' do + let(:project_params) { { name: 'admin-duo-flows-project', duo_remote_flows_enabled: true } } + + context 'when ai_workflows license is available' do + before do + stub_licensed_features(ai_workflows: true) + end + + it 'creates project with duo_remote_flows_enabled set to true' do + post api("/projects/user/#{user.id}", admin, admin_mode: true), params: project_params + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['duo_remote_flows_enabled']).to eq true + + created_project = Project.find(json_response['id']) + expect(created_project.duo_remote_flows_enabled).to eq true + end + end + + context 'when ai_workflows license is not available' do + before do + stub_licensed_features(ai_workflows: false) + end + + it 'creates project but ignores duo_remote_flows_enabled setting' do + post api("/projects/user/#{user.id}", admin, admin_mode: true), params: project_params + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['duo_remote_flows_enabled']).to be_nil + + created_project = Project.find(json_response['id']) + expect(created_project.duo_remote_flows_enabled).to eq false + end + end + end end describe 'POST /projects' do @@ -785,6 +922,60 @@ expect(json_response['requirements_access_level']).to eq(project_params[:requirements_access_level]) end end + + context 'with duo_remote_flows_enabled' do + let(:project_params) { { name: 'duo-flows-project', duo_remote_flows_enabled: true } } + + context 'when ai_workflows license is available' do + before do + stub_licensed_features(ai_workflows: true) + end + + it 'creates project with duo_remote_flows_enabled set to true' do + post api('/projects', user), params: project_params + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['duo_remote_flows_enabled']).to eq true + + created_project = Project.find(json_response['id']) + expect(created_project.duo_remote_flows_enabled).to eq true + end + end + + context 'when ai_workflows license is not available' do + before do + stub_licensed_features(ai_workflows: false) + end + + it 'creates project but ignores duo_remote_flows_enabled setting' do + post api('/projects', user), params: project_params + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['duo_remote_flows_enabled']).to be_nil + + created_project = Project.find(json_response['id']) + expect(created_project.duo_remote_flows_enabled).to eq false + end + end + + context 'when setting duo_remote_flows_enabled to false' do + let(:project_params) { { name: 'duo-flows-project-disabled', duo_remote_flows_enabled: false } } + + before do + stub_licensed_features(ai_workflows: true) + end + + it 'creates project with duo_remote_flows_enabled set to false' do + post api('/projects', user), params: project_params + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['duo_remote_flows_enabled']).to eq false + + created_project = Project.find(json_response['id']) + expect(created_project.duo_remote_flows_enabled).to eq false + end + end + end end describe 'GET projects/:id/audit_events' do @@ -1992,6 +2183,77 @@ end end + context 'when setting duo_remote_flows_enabled to false' do + let(:project_params) { { duo_remote_flows_enabled: false } } + + before do + project.update!(duo_remote_flows_enabled: true) + stub_licensed_features(ai_workflows: true) + end + + it 'disables the feature' do + expect { subject }.to change { project.reload.duo_remote_flows_enabled }.from(true).to(false) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['duo_remote_flows_enabled']).to eq false + end + end + + context 'when user does not have permission to update duo_remote_flows_enabled' do + let(:developer_user) { create(:user) } + let(:project_params) { { duo_remote_flows_enabled: true } } + + before do + project.add_developer(developer_user) + stub_licensed_features(ai_workflows: true) + end + + it 'does not update the value' do + expect do + put api("/projects/#{project.id}", developer_user), params: project_params + end.not_to change { project.reload.duo_remote_flows_enabled } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when updating duo_remote_flows_enabled with invalid values' do + let(:project_params) { { duo_remote_flows_enabled: 'invalid' } } + + before do + stub_licensed_features(ai_workflows: true) + end + + it 'returns a bad request error' do + put api("/projects/#{project.id}", user), params: project_params + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to include('duo_remote_flows_enabled') + end + end + + context 'when updating duo_remote_flows_enabled along with other attributes' do + let(:project_params) do + { + duo_remote_flows_enabled: true, + description: 'Updated description with Duo Remote Flows enabled' + } + end + + before do + stub_licensed_features(ai_workflows: true) + end + + it 'updates both attributes successfully' do + expect { subject }.to change { project.reload.duo_remote_flows_enabled }.from(false).to(true) + .and change { project.reload.description }.to('Updated description with Duo Remote Flows enabled') + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['duo_remote_flows_enabled']).to eq true + expect(json_response['description']).to eq 'Updated description with Duo Remote Flows enabled' + end + end + context 'updating web_based_commit_signing_enabled' do using RSpec::Parameterized::TableSyntax diff --git a/spec/models/project_feature_available_spec.rb b/spec/models/project_feature_available_spec.rb new file mode 100644 index 00000000000000..2567726f227330 --- /dev/null +++ b/spec/models/project_feature_available_spec.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project#feature_available?', feature_category: :groups_and_projects do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + describe 'feature availability logic' do + context 'when feature is a ProjectFeature feature' do + it 'delegates to project_feature.feature_available?' do + expect(project.project_feature).to receive(:feature_available?).with(:issues, user) + project.feature_available?(:issues, user) + end + + it 'returns false when project_feature is nil' do + allow(project).to receive(:project_feature).and_return(nil) + expect(project.feature_available?(:issues, user)).to be false + end + + context 'with different access levels' do + ProjectFeature::FEATURES.each do |feature| + context "for #{feature} feature" do + it 'returns true when enabled' do + project.project_feature.update!("#{feature}_access_level" => ProjectFeature::ENABLED) + expect(project.feature_available?(feature, user)).to be true + end + + it 'returns false when disabled' do + project.project_feature.update!("#{feature}_access_level" => ProjectFeature::DISABLED) + expect(project.feature_available?(feature, user)).to be false + end + + context 'when private' do + before do + project.project_feature.update!("#{feature}_access_level" => ProjectFeature::PRIVATE) + end + + it 'returns false for non-members' do + expect(project.feature_available?(feature, user)).to be false + end + + it 'returns true for project members' do + project.add_developer(user) + expect(project.feature_available?(feature, user)).to be true + end + + it 'returns true for users who can read all resources' do + allow(user).to receive(:can_read_all_resources?).and_return(true) + expect(project.feature_available?(feature, user)).to be true + end + end + + context 'when public' do + it 'returns true for anyone' do + project.project_feature.update!("#{feature}_access_level" => ProjectFeature::PUBLIC) + expect(project.feature_available?(feature, user)).to be true + expect(project.feature_available?(feature, nil)).to be true + end + end + end + end + end + end + + context 'when feature is not a ProjectFeature feature' do + it 'delegates to licensed_feature_available? in EE' do + # In CE, this should return false for non-ProjectFeature features + expect(project.feature_available?(:some_ee_feature, user)).to be false + end + end + end + + describe 'specific feature methods' do + ProjectFeature::FEATURES.each do |feature| + describe "##{feature}_enabled?" do + it 'returns true when feature is enabled' do + project.project_feature.update!("#{feature}_access_level" => ProjectFeature::ENABLED) + expect(project.public_send("#{feature}_enabled?")).to be true + end + + it 'returns false when feature is disabled' do + project.project_feature.update!("#{feature}_access_level" => ProjectFeature::DISABLED) + expect(project.public_send("#{feature}_enabled?")).to be false + end + + it 'returns true when feature is private' do + project.project_feature.update!("#{feature}_access_level" => ProjectFeature::PRIVATE) + expect(project.public_send("#{feature}_enabled?")).to be true + end + + it 'returns true when feature is public' do + project.project_feature.update!("#{feature}_access_level" => ProjectFeature::PUBLIC) + expect(project.public_send("#{feature}_enabled?")).to be true + end + end + end + end + + describe 'edge cases' do + context 'when project_feature is missing' do + before do + project.project_feature.destroy! + project.reload + end + + it 'returns false for ProjectFeature features' do + expect(project.feature_available?(:issues, user)).to be false + end + + it 'still returns false for non-ProjectFeature features' do + expect(project.feature_available?(:some_ee_feature, user)).to be false + end + end + + context 'with nil user' do + it 'works correctly for public features' do + project.project_feature.update!(issues_access_level: ProjectFeature::PUBLIC) + expect(project.feature_available?(:issues, nil)).to be true + end + + it 'works correctly for enabled features' do + project.project_feature.update!(issues_access_level: ProjectFeature::ENABLED) + expect(project.feature_available?(:issues, nil)).to be true + end + + it 'works correctly for private features' do + project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE) + expect(project.feature_available?(:issues, nil)).to be false + end + + it 'works correctly for disabled features' do + project.project_feature.update!(issues_access_level: ProjectFeature::DISABLED) + expect(project.feature_available?(:issues, nil)).to be false + end + end + end + + describe 'integration with project visibility' do + context 'when project is private' do + let(:private_project) { create(:project, :private) } + + it 'respects feature access levels' do + private_project.project_feature.update!(issues_access_level: ProjectFeature::ENABLED) + expect(private_project.feature_available?(:issues, user)).to be true + end + end + + context 'when project is public' do + let(:public_project) { create(:project, :public) } + + it 'respects feature access levels' do + public_project.project_feature.update!(issues_access_level: ProjectFeature::DISABLED) + expect(public_project.feature_available?(:issues, user)).to be false + end + end + end + + describe 'minimum access level requirements' do + let(:guest_user) { create(:user) } + let(:reporter_user) { create(:user) } + + before do + project.add_guest(guest_user) + project.add_reporter(reporter_user) + end + + context 'for features requiring reporter access' do + ProjectFeature::PRIVATE_FEATURES_MIN_ACCESS_LEVEL.each do |feature, min_level| + next unless min_level == Gitlab::Access::REPORTER + + context "for #{feature}" do + before do + project.project_feature.update!("#{feature}_access_level" => ProjectFeature::PRIVATE) + end + + it 'denies access to guest users' do + expect(project.feature_available?(feature, guest_user)).to be false + end + + it 'allows access to reporter users' do + expect(project.feature_available?(feature, reporter_user)).to be true + end + end + end + end + + context 'for features requiring guest access' do + features_requiring_guest = ProjectFeature::FEATURES - ProjectFeature::PRIVATE_FEATURES_MIN_ACCESS_LEVEL.keys + + features_requiring_guest.each do |feature| + context "for #{feature}" do + before do + project.project_feature.update!("#{feature}_access_level" => ProjectFeature::PRIVATE) + end + + it 'allows access to guest users' do + expect(project.feature_available?(feature, guest_user)).to be true + end + end + end + end + end + + describe 'special cases for repository feature in private projects' do + let(:private_project) { create(:project, :private) } + let(:guest_user) { create(:user) } + let(:reporter_user) { create(:user) } + + before do + private_project.add_guest(guest_user) + private_project.add_reporter(reporter_user) + private_project.project_feature.update!(repository_access_level: ProjectFeature::PRIVATE) + end + + it 'requires reporter access for repository in private projects' do + expect(private_project.feature_available?(:repository, guest_user)).to be false + expect(private_project.feature_available?(:repository, reporter_user)).to be true + end + end +end \ No newline at end of file -- GitLab