'.html_safe, deletion_date: @deletion_date }
diff --git a/app/views/notify/project_scheduled_for_deletion.text.erb b/app/views/notify/project_scheduled_for_deletion.text.erb
new file mode 100644
index 00000000000..f274df9af83
--- /dev/null
+++ b/app/views/notify/project_scheduled_for_deletion.text.erb
@@ -0,0 +1,7 @@
+<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
+
+<%= _('Your project %{project_name} has been marked for deletion and will be removed in %{days}.') % { project_name: @project.full_name, days: pluralize((@deletion_due_in_days / 1.day).to_i, _('day')) } %>
+
+<%= _('View your project: %{project_url}') % { project_url: project_url(@project) } %>
+
+<%= _('If this was a mistake, you can retain the project before %{deletion_date}: %{retention_url}') % { retention_url: inactive_dashboard_projects_url, deletion_date: @deletion_date } %>
diff --git a/config/feature_flags/gitlab_com_derisk/project_deletion_notification_email.yml b/config/feature_flags/gitlab_com_derisk/project_deletion_notification_email.yml
new file mode 100644
index 00000000000..ecbd3fa6896
--- /dev/null
+++ b/config/feature_flags/gitlab_com_derisk/project_deletion_notification_email.yml
@@ -0,0 +1,10 @@
+---
+name: project_deletion_notification_email
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/522883
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/184026
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/525979
+milestone: '17.11'
+group: group::authorization
+type: gitlab_com_derisk
+default_enabled: false
+
diff --git a/config/routes.rb b/config/routes.rb
index 03a3d5b2d19..8d1cec46764 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -181,7 +181,6 @@ InitializerConnections.raise_if_new_database_connection do
draw :gitlab_subscriptions
draw :phone_verification
draw :arkose
- draw :amazon_q
scope '/from_secondary/:geo_node_id' do
draw :git_http
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 2bb13b2a47c..38590610026 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -182,6 +182,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resources :work_items, only: [:index, :show], param: :iid
post :preview_markdown
+
+ post '/restore' => '/groups#restore', as: :restore
end
scope(
diff --git a/doc/administration/raketasks/_index.md b/doc/administration/raketasks/_index.md
index 4129aa3e7a8..0335a6de2fe 100644
--- a/doc/administration/raketasks/_index.md
+++ b/doc/administration/raketasks/_index.md
@@ -37,6 +37,7 @@ The following Rake tasks are available for use with GitLab:
| [Incoming email](incoming_email.md) | Incoming email-related tasks. |
| [Integrity checks](check.md) | Check the integrity of repositories, files, LDAP, and more. |
| [LDAP maintenance](ldap.md) | [LDAP](../../administration/auth/ldap/_index.md)-related tasks. |
+| [Password](password.md) | Password management tasks. |
| [Praefect Rake tasks](praefect.md) | [Praefect](../../administration/gitaly/praefect.md)-related tasks. |
| [Project import/export](project_import_export.md) | Prepare for [project exports and imports](../../user/project/settings/import_export.md). |
| [Sidekiq job migration](../sidekiq/sidekiq_job_migration.md) | Migrate Sidekiq jobs scheduled for future dates to a new queue. |
diff --git a/doc/administration/raketasks/password.md b/doc/administration/raketasks/password.md
new file mode 100644
index 00000000000..57aa4725d55
--- /dev/null
+++ b/doc/administration/raketasks/password.md
@@ -0,0 +1,34 @@
+---
+stage: Systems
+group: Distribution
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
+title: Maintenance Rake tasks
+---
+
+{{< details >}}
+
+- Tier: Free, Premium, Ultimate
+- Offering: GitLab Self-Managed
+
+{{< /details >}}
+
+GitLab provides Rake tasks for managing passwords.
+
+## Reset passwords
+
+To reset a password using a Rake task, see [reset user passwords](../../security/reset_user_password.md#use-a-rake-task).
+
+## Check password salt length
+
+Starting with GitLab 17.11, the salts of password hashes on FIPS instances
+are increased when a user signs in.
+
+You can check how many users need this migration:
+
+```shell
+# omnibus-gitlab
+sudo gitlab-rake gitlab:password:fips_check_salts:[true]
+
+# installation from source
+bundle exec rake gitlab:password:fips_check_salts:[true] RAILS_ENV=production
+```
diff --git a/doc/development/database/foreign_keys.md b/doc/development/database/foreign_keys.md
index da01b126497..0a9c3edcf8b 100644
--- a/doc/development/database/foreign_keys.md
+++ b/doc/development/database/foreign_keys.md
@@ -6,7 +6,7 @@ title: Foreign keys and associations
---
When adding an association to a model you must also add a foreign key. When
-adding a foreign key you must always add an [index](#indexes).
+adding a foreign key you must always add an [index](#indexes) first.
If the [index must be created async](adding_database_indexes.md#create-indexes-asynchronously)
due to duration reasons, you must avoid adding the foreign key until the index
@@ -158,10 +158,10 @@ this should be set to `CASCADE`.
When adding a foreign key in PostgreSQL the column is not indexed automatically,
thus you must also add a concurrent index. Indexes are required for all foreign
-keys and they must be added in the same or earlier migration than the migration
-adding the foreign key. Conversely, foreign keys must be removed in
-the same or earlier migration than the migration
-removing indexes supporting these foreign keys.
+keys and they must be added before the foreign key. This can mean that they are
+an earlier step in the same migration or they are added in an earlier migration
+than the migration adding the foreign key. For the same reasons, foreign keys
+must be removed before removing indexes supporting these foreign keys.
Without an index on the foreign key it forces Postgres to do a full table scan
every time a record is deleted from the referenced table. In the past this has
diff --git a/doc/development/feature_flags/_index.md b/doc/development/feature_flags/_index.md
index 20e2fec6373..a87c016ebfb 100644
--- a/doc/development/feature_flags/_index.md
+++ b/doc/development/feature_flags/_index.md
@@ -413,10 +413,13 @@ For this tool to automatically remove the usages of the feature flag in your cod
For example you can create a patch file for `config/feature_flags/beta/my_feature_flag.yml` using the following steps:
-1. Edit the code locally to remove the feature flag `my_feature_flag` usage assuming that the feature flag is already enabled and we are rolling forward
-1. Run `git diff > config/feature_flags/beta/my_feature_flag.patch`
-1. Undo the changes to the files where you removed the feature flag usage
-1. Commit this file `config/feature_flags/beta/my_feature_flag.patch` file to the branch where you are adding the feature flag
+1. Ensure you have a clean Git working directory.
+1. Delete `config/feature_flags/beta/my_feature_flag.yml`.
+1. Edit the code locally to remove any usage of `my_feature_flag` as though that the feature flag is already enabled and the feature is moving forward.
+1. Run `git diff > config/feature_flags/beta/my_feature_flag.patch`. If your feature flag is not a `beta` flag, ensure your patch file in the same directory as the YAML file that defines your feature flag.
+1. Undo the deletion of `config/feature_flags/beta/my_feature_flag.yml`
+1. Undo the changes to the files you ended to remove the feature flag usage
+1. Commit the patch file to the branch where you are adding the feature flag
Then in future the `gitlab-housekeeper` will automatically clean up your
feature flag for you by applying this patch.
diff --git a/doc/user/profile/notifications.md b/doc/user/profile/notifications.md
index 50ffd9d9d8b..d85a9616385 100644
--- a/doc/user/profile/notifications.md
+++ b/doc/user/profile/notifications.md
@@ -269,6 +269,8 @@ Learn how to [opt out of all emails from GitLab](#opt-out-of-all-gitlab-emails).
The following table presents the events that generate notifications for issues, merge requests, and
epics:
+
+
| Type | Event | Sent to |
|------|-------|---------|
| Epic | Closed | Subscribers and participants. |
@@ -276,7 +278,6 @@ epics:
| Epic | New note | Participants, Watchers, Subscribers, and Custom notification level with this event selected. Also anyone mentioned by username in the comment, with notification level "Mention" or higher. |
| Epic | Reopened | Subscribers and participants. |
| Issue | Closed | Subscribers and participants. |
-
| Issue | Due tomorrow. The notification is sent at 00:50 in the server's time zone (for GitLab.com this is UTC) for open issues with a due date of the next calendar day. | Participants and Custom notification level with this event selected. |
| Issue | Milestone changed | Subscribers and participants. |
| Issue | Milestone removed | Subscribers and participants. |
diff --git a/doc/user/work_items/custom_fields.md b/doc/user/work_items/custom_fields.md
index eff97432e01..909fad3e0ae 100644
--- a/doc/user/work_items/custom_fields.md
+++ b/doc/user/work_items/custom_fields.md
@@ -81,6 +81,7 @@ To create a custom field:
- In **Use on**, select the work item types where you want this field to be available.
- In **Options** (on single-select and multi-select fields), enter the possible select options.
A single-select or multi-select field can have at most 50 select options.
+ - Reorder options by dragging the grip icon ({{< icon name="grip" >}}) to the left of each option.
1. Select **Save**.
### Edit a custom field
diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb
index 5da734cf6c8..cccdd2f7c38 100644
--- a/lib/gitlab/auth.rb
+++ b/lib/gitlab/auth.rb
@@ -489,7 +489,8 @@ module Gitlab
def unavailable_scopes_for_resource(resource)
unavailable_ai_features_scopes +
- unavailable_observability_scopes_for_resource(resource)
+ unavailable_observability_scopes_for_resource(resource) +
+ unavailable_virtual_registry_scopes_for_resource(resource)
end
def unavailable_ai_features_scopes
@@ -503,6 +504,12 @@ module Gitlab
OBSERVABILITY_SCOPES
end
+ def unavailable_virtual_registry_scopes_for_resource(resource)
+ return VIRTUAL_REGISTRY_SCOPES if resource.is_a?(Project)
+
+ []
+ end
+
def non_admin_available_scopes
API_SCOPES + REPOSITORY_SCOPES + registry_scopes + virtual_registry_scopes + OBSERVABILITY_SCOPES + AI_FEATURES_SCOPES
end
diff --git a/lib/tasks/gitlab/password.rake b/lib/tasks/gitlab/password.rake
index a7b7aafc0c9..477bff52f79 100644
--- a/lib/tasks/gitlab/password.rake
+++ b/lib/tasks/gitlab/password.rake
@@ -28,5 +28,40 @@ namespace :gitlab do
puts "Password successfully updated for user with username #{username}."
end
+
+ desc "GitLab | Password | Check status of password salts on FIPS systems"
+ task :fips_check_salts, [:print_usernames] => :environment do |_, args|
+ abort Rainbow('This command is only available on FIPS instances').red unless Gitlab::FIPS.enabled?
+
+ message = "Active users with unmigrated salts:"
+ batch_size = 50
+ min_salt_len = 64
+ count_total = 0
+ count_unmigrated = 0
+
+ puts Rainbow(message) if args.print_usernames
+
+ User.active.each_batch(of: batch_size) do |user_batch|
+ user_batch.each do |user|
+ count_total += 1
+
+ begin
+ hash = user.encrypted_password
+ salt_len = Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512
+ .split_digest(hash)[:salt].length
+ rescue StandardError => e
+ puts("Error getting salt for user #{user.username}: #{e.message}")
+ next
+ end
+
+ if salt_len < min_salt_len
+ puts user.username if args.print_usernames
+ count_unmigrated += 1
+ end
+ end
+ end
+
+ puts Rainbow("#{message} #{count_unmigrated} out of #{count_total} total users")
+ end
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ab07b53aa12..ab2b48d5257 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -6418,9 +6418,6 @@ msgstr ""
msgid "AmazonQ|Amazon Q will be turned off for all groups, subgroups, and projects, even if they have previously enabled it."
msgstr ""
-msgid "AmazonQ|An error occurred. Please try again later."
-msgstr ""
-
msgid "AmazonQ|An unexpected error occurred while disconnecting Amazon Q. Please see the browser console log for more details."
msgstr ""
@@ -6433,9 +6430,6 @@ msgstr ""
msgid "AmazonQ|Are you sure? Removing the ARN will disconnect Amazon Q from GitLab and all related features will stop working."
msgstr ""
-msgid "AmazonQ|Ask GitLab Duo with Amazon Q to suggest a solution for this issue"
-msgstr ""
-
msgid "AmazonQ|Audience"
msgstr ""
@@ -6463,9 +6457,6 @@ msgstr ""
msgid "AmazonQ|Create an identity provider for this GitLab instance within AWS using the following values. %{helpStart}Learn more%{helpEnd}."
msgstr ""
-msgid "AmazonQ|Create fixes for review findings"
-msgstr ""
-
msgid "AmazonQ|Create unit tests for selected lines of code in Java or Python files"
msgstr ""
@@ -6514,9 +6505,6 @@ msgstr ""
msgid "AmazonQ|I'm creating unit tests for this merge request. I'll update this comment when I'm done."
msgstr ""
-msgid "AmazonQ|I'm generating a fix for this review finding. I'll update this comment when I'm done."
-msgstr ""
-
msgid "AmazonQ|I'm generating code for this issue. I'll update this comment and open a merge request when I'm done."
msgstr ""
@@ -6586,9 +6574,6 @@ msgstr ""
msgid "AmazonQ|Status"
msgstr ""
-msgid "AmazonQ|Suggest a fix"
-msgstr ""
-
msgid "AmazonQ|This field is required"
msgstr ""
@@ -6628,9 +6613,6 @@ msgstr ""
msgid "AmazonQ|dev"
msgstr ""
-msgid "AmazonQ|fix"
-msgstr ""
-
msgid "AmazonQ|review"
msgstr ""
@@ -31526,6 +31508,9 @@ msgstr ""
msgid "ImportProjects|All organizations"
msgstr ""
+msgid "ImportProjects|Are you sure you want to import the project to a personal namespace?"
+msgstr ""
+
msgid "ImportProjects|Blocked import URL: %{message}"
msgstr ""
@@ -31541,6 +31526,9 @@ msgstr ""
msgid "ImportProjects|Collaborated"
msgstr ""
+msgid "ImportProjects|Continue import"
+msgstr ""
+
msgid "ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}"
msgstr ""
@@ -31550,7 +31538,7 @@ msgstr ""
msgid "ImportProjects|Imported files will be kept. You can import this repository again later."
msgstr ""
-msgid "ImportProjects|Importing a project into a personal namespace results in all contributions being mapped to the same bot user. To map contributions to real users, import projects into a group instead."
+msgid "ImportProjects|Importing a project into a personal namespace results in all contributions being mapped to the same bot user and they cannot be reassigned. To map contributions to actual users, import the project to a group instead."
msgstr ""
msgid "ImportProjects|Importing the project failed"
@@ -64474,7 +64462,7 @@ msgstr ""
msgid "UsageQuota|Code packages and container images."
msgstr ""
-msgid "UsageQuota|Compute units usage is calculated based on instance runners duration with cost factors applied."
+msgid "UsageQuota|Compute minutes usage displays the hosted runner usage against the total available compute minutes."
msgstr ""
msgid "UsageQuota|Compute usage"
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index 3c577f899f7..d16bdd33530 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe GroupsController, :with_current_organization, factory_default: :keep, feature_category: :code_review_workflow do
include ExternalAuthorizationServiceHelpers
include AdminModeHelper
+ include NamespacesHelper
let_it_be(:group_organization) { current_organization }
let_it_be_with_refind(:group) { create_default(:group, :public, organization: group_organization) }
@@ -24,7 +25,6 @@ RSpec.describe GroupsController, :with_current_organization, factory_default: :k
before do
enable_admin_mode!(admin_with_admin_mode)
- stub_feature_flags(downtier_delayed_deletion: false)
end
shared_examples 'member with ability to create subgroups' do
@@ -527,42 +527,249 @@ RSpec.describe GroupsController, :with_current_organization, factory_default: :k
end
describe 'DELETE #destroy' do
- context 'as another user' do
- it 'returns 404' do
- sign_in(create(:user))
+ let(:format) { :html }
+ let(:params) { {} }
- delete :destroy, params: { id: group.to_param }
+ subject { delete :destroy, format: format, params: { id: group.to_param, **params } }
+
+ context 'when authenticated user can admin the group' do
+ let_it_be(:user) { owner }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'delayed deletion feature is available' do
+ context 'success' do
+ it 'marks the group for delayed deletion' do
+ expect { subject }.to change { group.reload.marked_for_deletion? }.from(false).to(true)
+ end
+
+ it 'does not immediately delete the group' do
+ Sidekiq::Testing.fake! do
+ expect { subject }.not_to change { GroupDestroyWorker.jobs.size }
+ end
+ end
+
+ context 'for a html request' do
+ it 'redirects to group path' do
+ subject
+
+ expect(response).to redirect_to(group_path(group))
+ end
+ end
+
+ context 'for a json request', :freeze_time do
+ let(:format) { :json }
+
+ it 'returns json with message' do
+ subject
+
+ # FIXME: Replace `group.marked_for_deletion_on` with `group` after https://gitlab.com/gitlab-org/gitlab/-/work_items/527085
+ expect(json_response['message'])
+ .to eq(
+ "'#{group.name}' has been scheduled for deletion and will be deleted on " \
+ "#{permanent_deletion_date_formatted(group.marked_for_deletion_on)}.")
+ end
+ end
+ end
+
+ context 'failure' do
+ before do
+ allow(::Groups::MarkForDeletionService).to receive_message_chain(:new, :execute).and_return({ status: :error, message: 'error' })
+ end
+
+ it 'does not mark the group for deletion' do
+ expect { subject }.not_to change { group.reload.marked_for_deletion? }.from(false)
+ end
+
+ context 'for a html request' do
+ it 'redirects to group edit page' do
+ subject
+
+ expect(response).to redirect_to(edit_group_path(group))
+ expect(flash[:alert]).to include 'error'
+ end
+ end
+
+ context 'for a json request' do
+ let(:format) { :json }
+
+ it 'returns json with message' do
+ subject
+
+ expect(json_response['message']).to eq("error")
+ end
+ end
+ end
+
+ context 'when group is already marked for deletion' do
+ before do
+ create(:group_deletion_schedule, group: group, marked_for_deletion_on: Date.current)
+ end
+
+ context 'when permanently_remove param is set' do
+ let(:params) { { permanently_remove: true } }
+
+ context 'for a html request' do
+ it 'deletes the group immediately and redirects to root path' do
+ expect(GroupDestroyWorker).to receive(:perform_async)
+
+ subject
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:toast]).to include "Group '#{group.name}' is being deleted."
+ end
+ end
+
+ context 'for a json request' do
+ let(:format) { :json }
+
+ it 'deletes the group immediately and returns json with message' do
+ expect(GroupDestroyWorker).to receive(:perform_async)
+
+ subject
+
+ expect(json_response['message']).to eq("Group '#{group.name}' is being deleted.")
+ end
+ end
+ end
+
+ context 'when permanently_remove param is not set' do
+ context 'for a html request' do
+ it 'redirects to edit path with error' do
+ subject
+
+ expect(response).to redirect_to(edit_group_path(group))
+ expect(flash[:alert]).to include "Group has been already marked for deletion"
+ end
+ end
+
+ context 'for a json request' do
+ let(:format) { :json }
+
+ it 'returns json with message' do
+ subject
+
+ expect(json_response['message']).to eq("Group has been already marked for deletion")
+ end
+ end
+ end
+ end
+ end
+
+ context 'delayed deletion feature is not available', :sidekiq_inline do
+ before do
+ stub_feature_flags(downtier_delayed_deletion: false)
+ end
+
+ context 'for a html request' do
+ it 'immediately schedules a group destroy and redirects to root page with alert about immediate deletion' do
+ Sidekiq::Testing.fake! do
+ expect { subject }.to change { GroupDestroyWorker.jobs.size }.by(1)
+ end
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:toast]).to include "Group '#{group.name}' is being deleted."
+ end
+ end
+
+ context 'for a json request' do
+ let(:format) { :json }
+
+ it 'immediately schedules a group destroy and returns json with message' do
+ Sidekiq::Testing.fake! do
+ expect { subject }.to change { GroupDestroyWorker.jobs.size }.by(1)
+ end
+
+ expect(json_response['message']).to eq("Group '#{group.name}' is being deleted.")
+ end
+ end
+ end
+ end
+
+ context 'when authenticated user cannot admin the group' do
+ before do
+ sign_in(create(:user))
+ end
+
+ it 'returns 404' do
+ subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
+ end
- context 'as the group owner' do
- let(:user) { create(:user) }
- let(:group) { create(:group) }
+ describe 'POST #restore' do
+ let_it_be(:group) do
+ create(:group_with_deletion_schedule,
+ marked_for_deletion_on: 1.day.ago,
+ deleting_user: user)
+ end
+ subject { post :restore, params: { group_id: group.to_param } }
+
+ context 'when authenticated user can admin the group' do
before do
group.add_owner(user)
sign_in(user)
end
- context 'for a html request' do
- it 'schedules a group destroy and redirects to the root path' do
- Sidekiq::Testing.fake! do
- expect { delete :destroy, params: { id: group.to_param } }.to change(GroupDestroyWorker.jobs, :size).by(1)
+ context 'when the delayed deletion feature is available' do
+ context 'when the restore succeeds' do
+ it 'restores the group' do
+ expect { subject }.to change { group.reload.marked_for_deletion? }.from(true).to(false)
+ end
+
+ it 'renders success notice upon restoring' do
+ subject
+
+ expect(response).to redirect_to(edit_group_path(group))
+ expect(flash[:notice]).to include "Group '#{group.name}' has been successfully restored."
+ end
+ end
+
+ context 'when the restore fails' do
+ before do
+ allow(::Groups::RestoreService).to receive_message_chain(:new, :execute).and_return({ status: :error, message: 'error' })
+ end
+
+ it 'does not restore the group' do
+ expect { subject }.not_to change { group.reload.marked_for_deletion? }.from(true)
+ end
+
+ it 'redirects to group edit page' do
+ subject
+
+ expect(response).to redirect_to(edit_group_path(group))
+ expect(flash[:alert]).to include 'error'
end
- expect(flash[:toast]).to eq(format(_("Group '%{group_name}' is being deleted."), group_name: group.full_name))
- expect(response).to redirect_to(root_path)
end
end
- context 'for a json request' do
- it 'schedules a group destroy and returns message' do
- Sidekiq::Testing.fake! do
- expect { delete :destroy, format: :json, params: { id: group.to_param } }.to change(GroupDestroyWorker.jobs, :size).by(1)
- end
- expect(Gitlab::Json.parse(response.body)).to eq({ 'message' => "Group '#{group.full_name}' is being deleted." })
+ context 'when delayed deletion feature is not available' do
+ before do
+ stub_feature_flags(downtier_delayed_deletion: false)
end
+
+ it 'returns 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when authenticated user cannot admin the group' do
+ before do
+ sign_in(create(:user))
+ end
+
+ it 'returns 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
end
end
end
diff --git a/spec/features/boards/sidebar_labels_spec.rb b/spec/features/boards/sidebar_labels_spec.rb
index 685a285b12b..5af08f232ca 100644
--- a/spec/features/boards/sidebar_labels_spec.rb
+++ b/spec/features/boards/sidebar_labels_spec.rb
@@ -242,11 +242,10 @@ RSpec.describe 'Project issue boards sidebar labels', :js, feature_category: :po
page.within(labels_widget) do
click_button 'Edit'
- wait_for_requests
-
- expect(page).to have_selector('.gl-new-dropdown-item-check-icon', count: 2)
- expect(page).to have_content(development.title)
- expect(page).to have_content(stretch.title)
+ # Selected labels are shown twice - once in a "Selected" section and once in the "All" section below
+ expect(page).to have_selector('.gl-new-dropdown-item-check-icon', count: 4)
+ expect(page).to have_content(development.title, count: 2)
+ expect(page).to have_content(stretch.title, count: 2)
end
end
diff --git a/spec/features/import/manifest_import_spec.rb b/spec/features/import/manifest_import_spec.rb
index 35c25966e87..175ae2fee8c 100644
--- a/spec/features/import/manifest_import_spec.rb
+++ b/spec/features/import/manifest_import_spec.rb
@@ -34,6 +34,7 @@ RSpec.describe 'Import multiple repositories by uploading a manifest file', :js,
page.within(second_row) do
click_on 'Import'
end
+ click_on 'Continue import'
wait_for_requests
diff --git a/spec/fixtures/api/schemas/entities/discussion.json b/spec/fixtures/api/schemas/entities/discussion.json
index 81e4f01f220..8d4dcb9f900 100644
--- a/spec/fixtures/api/schemas/entities/discussion.json
+++ b/spec/fixtures/api/schemas/entities/discussion.json
@@ -176,12 +176,6 @@
"path": {
"type": "string"
},
- "amazon_q_quick_actions_path": {
- "type": [
- "string",
- "null"
- ]
- },
"commands_changes": {
"type": "object",
"additionalProperties": true
diff --git a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
index e2bce360d14..430ccd181f2 100644
--- a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
+++ b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
@@ -1,4 +1,6 @@
-import createMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer';
+import createMarkdownDeserializer, {
+ transformQuickActions,
+} from '~/content_editor/services/gl_api_markdown_deserializer';
import MarkdownSerializer from '~/content_editor/services/markdown_serializer';
import { builders, tiptapEditor, doc, text } from '../serialization_utils';
@@ -22,6 +24,14 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => {
renderMarkdown = jest.fn();
});
+ describe('transformQuickActions', () => {
+ it('ensures at least 3 newlines after quick actions so that reference style links after the quick action are correctly parsed', () => {
+ expect(
+ transformQuickActions('Link to [GitLab][link]\n/confidential\n[link]: https://gitlab.com'),
+ ).toBe('Link to [GitLab][link]\n/confidential\n\n\n[link]: https://gitlab.com');
+ });
+ });
+
describe('when deserializing', () => {
let deserializer;
let result;
diff --git a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
index 47bee7dae0b..a1355706566 100644
--- a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
@@ -1,4 +1,4 @@
-import { GlBadge, GlButton } from '@gitlab/ui';
+import { GlBadge, GlButton, GlModal } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
@@ -45,6 +45,7 @@ describe('ProviderRepoTableRow', () => {
const findImportStatus = () => wrapper.findComponent(ImportStatus);
const findProviderLink = () => wrapper.findByTestId('provider-link');
const findMembershipsWarning = () => wrapper.findByTestId('memberships-warning');
+ const findGlModal = () => wrapper.findComponent(GlModal);
const findCancelButton = () => {
const buttons = wrapper
@@ -107,12 +108,44 @@ describe('ProviderRepoTableRow', () => {
it('shows memberships warning', () => {
expect(findMembershipsWarning().isVisible()).toBe(true);
});
+
+ it('shows modal with warning message when import button is clicked', async () => {
+ findImportButton().vm.$emit('click');
+ await nextTick();
+
+ const modal = findGlModal();
+ expect(modal.props('title')).toBe(
+ 'Are you sure you want to import the project to a personal namespace?',
+ );
+ expect(modal.text()).toContain(
+ 'Importing a project into a personal namespace results in all contributions being mapped to the same bot user and they cannot be reassigned. To map contributions to actual users, import the project to a group instead.',
+ );
+ });
+
+ it('triggers import when clicking modal primary button', async () => {
+ findImportButton().vm.$emit('click');
+ await nextTick();
+
+ findGlModal().vm.$emit('primary');
+
+ expect(fetchImport).toHaveBeenCalledWith(expect.anything(), {
+ repoId: repo.importSource.id,
+ optionalStages: {},
+ });
+ });
});
describe('when group namespace is selected as import target', () => {
it('does not show memberships warning', () => {
expect(findMembershipsWarning().isVisible()).toBe(false);
});
+
+ it('does not show modal when import button is clicked', async () => {
+ findImportButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findGlModal().exists()).toBe(false);
+ });
});
it('renders import button', () => {
diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js
index 45e48467706..340f8694df1 100644
--- a/spec/frontend/work_items/components/work_item_labels_spec.js
+++ b/spec/frontend/work_items/components/work_item_labels_spec.js
@@ -214,22 +214,6 @@ describe('WorkItemLabels component', () => {
expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(false);
});
- it('filters search results by title in frontend', async () => {
- createComponent({
- searchQueryHandler: jest.fn().mockResolvedValue(getProjectLabelsResponse(mockLabels)),
- });
-
- showDropdown();
- await findWorkItemSidebarDropdownWidget().vm.$emit('searchStarted', mockLabels[0].title);
-
- expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(true);
-
- await waitForPromises();
-
- expect(findWorkItemSidebarDropdownWidget().props('listItems')).toHaveLength(1);
- expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(false);
- });
-
it('emits error event if search query fails', async () => {
createComponent({ searchQueryHandler: errorHandler });
showDropdown();
@@ -336,25 +320,17 @@ describe('WorkItemLabels component', () => {
it('shows selected labels at top of list', async () => {
const [label1, label2, label3] = mockLabels;
- const label999 = {
- __typename: 'Label',
- id: 'gid://gitlab/Label/999',
- title: 'Label 999',
- description: 'Label not in the label query result',
- color: '#fff',
- textColor: '#000',
- };
createComponent({
workItemQueryHandler: workItemQuerySuccess,
updateWorkItemMutationHandler: jest.fn().mockResolvedValue(
updateWorkItemMutationResponseFactory({
- labels: [label1, label999],
+ labels: [label1, label3],
}),
),
});
- updateLabels([label1Id, label999.id]);
+ updateLabels([label1Id, label3Id]);
showDropdown();
@@ -362,10 +338,11 @@ describe('WorkItemLabels component', () => {
const selected = [
{ color: label1.color, text: label1.title, value: label1.id },
- { color: label999.color, text: label999.title, value: label999.id },
+ { color: label3.color, text: label3.title, value: label3.id },
];
const unselected = [
+ { color: label1.color, text: label1.title, value: label1.id },
{ color: label2.color, text: label2.title, value: label2.id },
{ color: label3.color, text: label3.title, value: label3.id },
];
@@ -376,6 +353,21 @@ describe('WorkItemLabels component', () => {
]);
});
+ it('does not update labels when no labels were added or removed', async () => {
+ createComponent({
+ workItemQueryHandler: workItemQueryWithLabelsHandler,
+ updateWorkItemMutationHandler: successRemoveAllLabelWorkItemMutationHandler,
+ });
+ await waitForPromises();
+
+ showDropdown();
+ findWorkItemSidebarDropdownWidget().vm.$emit('updateSelected', [label2Id, label3Id]);
+ findWorkItemSidebarDropdownWidget().vm.$emit('updateSelected', [label1Id, label2Id, label3Id]);
+ findWorkItemSidebarDropdownWidget().vm.$emit('updateValue', [label1Id, label2Id, label3Id]);
+
+ expect(successRemoveAllLabelWorkItemMutationHandler).not.toHaveBeenCalled();
+ });
+
describe('tracking', () => {
let trackingSpy;
diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb
index f9819d8345b..399e53dcc41 100644
--- a/spec/lib/gitlab/auth_spec.rb
+++ b/spec/lib/gitlab/auth_spec.rb
@@ -251,12 +251,28 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching, feature_cate
end
context 'when dependency proxy is enabled' do
+ let(:virtual_registry_scopes) { %i[read_virtual_registry write_virtual_registry] }
+
before do
stub_config(dependency_proxy: { enabled: true })
end
it 'contains all virtual registry related scopes' do
- expect(subject.virtual_registry_scopes).to eq %i[read_virtual_registry write_virtual_registry]
+ expect(subject.virtual_registry_scopes).to eq virtual_registry_scopes
+ end
+
+ context 'for a Project' do
+ it 'does not include virtual registry scopes' do
+ expect(subject.available_scopes_for(build_stubbed(:project))).to not_include(*virtual_registry_scopes)
+ end
+ end
+
+ %i[user group].each do |resource_type|
+ context "for a #{resource_type}" do
+ it 'includes the virtual registry scopes' do
+ expect(subject.available_scopes_for(build_stubbed(resource_type))).to include(*virtual_registry_scopes)
+ end
+ end
end
end
end
diff --git a/spec/mailers/emails/projects_spec.rb b/spec/mailers/emails/projects_spec.rb
index f39c2efd8a6..ebfc4cf608b 100644
--- a/spec/mailers/emails/projects_spec.rb
+++ b/spec/mailers/emails/projects_spec.rb
@@ -283,4 +283,29 @@ RSpec.describe Emails::Projects do
is_expected.to have_body_text("#{project.name} | Project export error")
end
end
+
+ describe '#project_scheduled_for_deletion' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:frozen_time) { Time.new(2023, 10, 15, 12, 0, 0) }
+ let_it_be(:project) { create(:project, marked_for_deletion_on: frozen_time) }
+
+ let(:deletion_adjourned_period) { 7 }
+ let(:deletion_date) { (frozen_time.to_date + deletion_adjourned_period.days).strftime('%B %-d, %Y') }
+
+ before do
+ stub_application_setting(deletion_adjourned_period: deletion_adjourned_period)
+ allow_next_instance_of(Project) do |instance|
+ allow(instance).to receive(:marked_for_deletion_on).and_return(frozen_time)
+ end
+ end
+
+ subject { Notify.project_scheduled_for_deletion(user.id, project.id) }
+
+ it 'has expected content', :aggregate_failures do
+ is_expected.to have_subject("#{project.name} | Project scheduled for deletion")
+ is_expected.to have_body_text(project.full_name)
+ is_expected.to have_body_text(deletion_adjourned_period.to_s)
+ is_expected.to have_body_text(deletion_date)
+ end
+ end
end
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 7d1efd287c1..4c3f52bf25c 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -4551,6 +4551,76 @@ RSpec.describe NotificationService, :mailer, feature_category: :team_planning do
end
end
+ describe 'project scheduled for deletion' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+
+ context 'when project emails are disabled' do
+ before do
+ allow(project).to receive(:emails_disabled?).and_return(true)
+ end
+
+ it 'does not send any emails' do
+ expect(Notify).not_to receive(:project_scheduled_for_deletion)
+
+ subject.project_scheduled_for_deletion(project)
+ end
+ end
+
+ context 'when project emails are enabled' do
+ before do
+ allow(project).to receive(:emails_disabled?).and_return(false)
+ end
+
+ context 'when user is owner' do
+ it 'sends email' do
+ expect(Notify).to receive(:project_scheduled_for_deletion).with(project.first_owner.id, project.id).and_call_original
+
+ subject.project_scheduled_for_deletion(project)
+ end
+
+ context 'when owner is blocked' do
+ it 'does not send email' do
+ project.owner.block!
+
+ expect(Notify).not_to receive(:project_scheduled_for_deletion)
+
+ subject.project_scheduled_for_deletion(project)
+ end
+ end
+ end
+
+ context 'when project has multiple owners' do
+ it 'sends email to all owners' do
+ project.add_owner(user)
+
+ expect(Notify).to receive(:project_scheduled_for_deletion).with(project.first_owner.id, project.id).and_call_original
+ expect(Notify).to receive(:project_scheduled_for_deletion).with(user.id, project.id).and_call_original
+
+ subject.project_scheduled_for_deletion(project)
+ end
+ end
+
+ context 'when project has no direct owners but belongs to a group with owners' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:group_owner) { create(:user) }
+
+ before do
+ group.add_owner(group_owner)
+ # Ensure project has no direct owners
+ project.members.owners.delete_all if project.members.owners.any?
+ end
+
+ it 'sends email to group owners' do
+ expect(Notify).to receive(:project_scheduled_for_deletion).with(group_owner.id, project.id).and_call_original
+
+ subject.project_scheduled_for_deletion(project)
+ end
+ end
+ end
+ end
+
def build_team(project)
@u_watcher = create_global_setting_for(create(:user), :watch)
@u_participating = create_global_setting_for(create(:user), :participating)
diff --git a/spec/services/projects/mark_for_deletion_service_spec.rb b/spec/services/projects/mark_for_deletion_service_spec.rb
index dc982f7866c..82b733a8ff9 100644
--- a/spec/services/projects/mark_for_deletion_service_spec.rb
+++ b/spec/services/projects/mark_for_deletion_service_spec.rb
@@ -11,8 +11,9 @@ RSpec.describe Projects::MarkForDeletionService, feature_category: :groups_and_p
let(:original_project_path) { project.path }
let(:original_project_name) { project.name }
let(:licensed) { false }
+ let(:service) { described_class.new(project, user) }
- subject(:result) { described_class.new(project, user).execute(licensed: licensed) }
+ subject(:result) { service.execute(licensed: licensed) }
context 'with downtier_delayed_deletion feature flag enabled' do
context 'when marking project for deletion' do
@@ -47,6 +48,12 @@ RSpec.describe Projects::MarkForDeletionService, feature_category: :groups_and_p
result
end
+
+ it 'sends project deletion notification' do
+ expect(service).to receive(:send_project_deletion_notification)
+
+ result
+ end
end
context 'when marking project for deletion once again' do
@@ -58,6 +65,22 @@ RSpec.describe Projects::MarkForDeletionService, feature_category: :groups_and_p
expect(result[:status]).to eq(:success)
expect(project.marked_for_deletion_at).to eq(marked_for_deletion_at.to_date)
end
+
+ it 'does not send project deletion notification' do
+ project.update!(marked_for_deletion_at: marked_for_deletion_at)
+
+ expect(service).not_to receive(:send_project_deletion_notification)
+
+ result
+ end
+
+ it 'does not send notification email' do
+ stub_feature_flags(project_deletion_notification_email: true)
+
+ expect(NotificationService).not_to receive(:new)
+
+ result
+ end
end
end
@@ -66,7 +89,8 @@ RSpec.describe Projects::MarkForDeletionService, feature_category: :groups_and_p
stub_feature_flags(downtier_delayed_deletion: false)
end
- it 'returns an error response' do
+ it 'returns an error response and does not send notification' do
+ expect(service).not_to receive(:send_project_deletion_notification)
expect(result).to eq(status: :error, message: 'Cannot mark project for deletion: feature not supported')
end
@@ -76,6 +100,88 @@ RSpec.describe Projects::MarkForDeletionService, feature_category: :groups_and_p
it 'is successful' do
expect(result[:status]).to eq(:success)
end
+
+ it 'sends project deletion notification' do
+ expect(service).to receive(:send_project_deletion_notification)
+
+ result
+ end
+ end
+ end
+
+ describe '#send_project_deletion_notification' do
+ context 'when all conditions are met' do
+ before do
+ stub_feature_flags(project_deletion_notification_email: true)
+ allow(project).to receive_messages(adjourned_deletion?: true, marked_for_deletion?: true)
+ end
+
+ it 'sends a notification email' do
+ expect_next_instance_of(NotificationService) do |service|
+ expect(service).to receive(:project_scheduled_for_deletion).with(project)
+ end
+
+ execute_send_project_deletion_notification
+ end
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(project_deletion_notification_email: false)
+ allow(project).to receive_messages(adjourned_deletion?: true, marked_for_deletion?: true)
+ end
+
+ it 'does not send a notification email' do
+ expect(NotificationService).not_to receive(:new)
+
+ execute_send_project_deletion_notification
+ end
+ end
+
+ context 'when feature flag is enabled for specific project' do
+ before do
+ stub_feature_flags(project_deletion_notification_email: project)
+ allow(project).to receive_messages(adjourned_deletion?: true, marked_for_deletion?: true)
+ end
+
+ it 'sends a notification email' do
+ expect_next_instance_of(NotificationService) do |service|
+ expect(service).to receive(:project_scheduled_for_deletion).with(project)
+ end
+
+ execute_send_project_deletion_notification
+ end
+ end
+
+ context 'when adjourned deletion is disabled' do
+ before do
+ stub_feature_flags(project_deletion_notification_email: true)
+ allow(project).to receive_messages(adjourned_deletion?: false, marked_for_deletion?: true)
+ end
+
+ it 'does not send a notification email' do
+ expect(NotificationService).not_to receive(:new)
+
+ execute_send_project_deletion_notification
+ end
+ end
+
+ context 'when project is not marked for deletion' do
+ before do
+ stub_feature_flags(project_deletion_notification_email: true)
+ allow(project).to receive_messages(adjourned_deletion?: true, marked_for_deletion?: false)
+ end
+
+ it 'does not send a notification email' do
+ expect(NotificationService).not_to receive(:new)
+
+ execute_send_project_deletion_notification
+ end
end
end
end
+
+def execute_send_project_deletion_notification
+ service = described_class.new(project, user)
+ service.send(:send_project_deletion_notification)
+end
diff --git a/spec/tasks/gitlab/password_rake_spec.rb b/spec/tasks/gitlab/password_rake_spec.rb
index 2b7056344d3..2c883452946 100644
--- a/spec/tasks/gitlab/password_rake_spec.rb
+++ b/spec/tasks/gitlab/password_rake_spec.rb
@@ -3,27 +3,29 @@
require 'spec_helper'
RSpec.describe 'gitlab:password rake tasks', :silence_stdout do
- let!(:user_1) { create(:user, username: 'foobar', password: User.random_password, password_automatically_set: true) }
- let(:password) { User.random_password }
-
- def stub_username(username)
- allow(Gitlab::TaskHelpers).to receive(:prompt).with('Enter username: ').and_return(username)
- end
-
- def stub_password(password, confirmation = nil)
- confirmation ||= password
- allow(Gitlab::TaskHelpers).to receive(:prompt_for_password).and_return(password)
- allow(Gitlab::TaskHelpers).to receive(:prompt_for_password).with('Confirm password: ').and_return(confirmation)
- end
-
- before do
+ before(:all) do
Rake.application.rake_require 'tasks/gitlab/password'
-
- stub_username('foobar')
- stub_password(password)
end
describe ':reset' do
+ let!(:user_1) { create(:user, username: 'foobar', password: User.random_password, password_automatically_set: true) }
+ let(:password) { User.random_password }
+
+ def stub_username(username)
+ allow(Gitlab::TaskHelpers).to receive(:prompt).with('Enter username: ').and_return(username)
+ end
+
+ def stub_password(password, confirmation = nil)
+ confirmation ||= password
+ allow(Gitlab::TaskHelpers).to receive(:prompt_for_password).and_return(password)
+ allow(Gitlab::TaskHelpers).to receive(:prompt_for_password).with('Confirm password: ').and_return(confirmation)
+ end
+
+ before do
+ stub_username('foobar')
+ stub_password(password)
+ end
+
context 'when all inputs are correct' do
it 'updates the password properly' do
expect(user_1.password_automatically_set?).to eq(true)
@@ -80,4 +82,69 @@ RSpec.describe 'gitlab:password rake tasks', :silence_stdout do
end
end
end
+
+ describe ":fips_check_salts" do
+ subject(:run_rake) { run_rake_task('gitlab:password:fips_check_salts') }
+
+ context 'without fips mode' do
+ it 'aborts' do
+ expect { run_rake }.to abort_execution
+ end
+ end
+
+ context 'in fips mode', :fips_mode do
+ def hash(salt_len)
+ Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512.digest(
+ User.random_password, 20_000, Devise.friendly_token(salt_len))
+ end
+
+ let!(:unmigrated_users) do
+ create(:user, username: 'user1', encrypted_password: hash(16))
+ create(:user, username: 'user2', encrypted_password: hash(16))
+ end
+
+ let!(:migrated_users) do
+ create(:user, username: 'user3', encrypted_password: hash(64))
+ create(:user, username: 'user4', encrypted_password: hash(64))
+ end
+
+ context 'with no extra argument' do
+ it 'only prints the user count' do
+ expect { run_rake }
+ .to output("Active users with unmigrated salts: 2 out of 4 total users\n")
+ .to_stdout
+ end
+ end
+
+ context 'with an error while inspecting a salt' do
+ before do
+ allow(Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512)
+ .to receive(:split_digest)
+ .and_raise(StandardError.new('test error'))
+ end
+
+ it 'prints an error message' do
+ expect { run_rake }
+ .to output(/Error getting salt for user user1: test error/)
+ .to_stdout
+ end
+ end
+
+ context 'with user printing enabled' do
+ subject(:run_rake) { run_rake_task('gitlab:password:fips_check_salts', "true") }
+
+ it 'prints the user names and user count' do
+ expect { run_rake }
+ .to output(
+ <<~MSG
+ Active users with unmigrated salts:
+ user1
+ user2
+ Active users with unmigrated salts: 2 out of 4 total users
+ MSG
+ ).to_stdout
+ end
+ end
+ end
+ end
end