Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-04-08 00:09:11 +00:00
parent 9bc5f183df
commit b89bcf56ec
67 changed files with 895 additions and 210 deletions

View file

@ -488,7 +488,7 @@ gem 'flipper', '~> 0.17.1'
gem 'flipper-active_record', '~> 0.17.1'
gem 'flipper-active_support_cache_store', '~> 0.17.1'
gem 'unleash', '~> 0.1.5'
gem 'gitlab-experiment', '~> 0.5.2'
gem 'gitlab-experiment', '~> 0.5.3'
# Structured logging
gem 'lograge', '~> 0.5'

View file

@ -443,7 +443,7 @@ GEM
numerizer (~> 0.2)
gitlab-dangerfiles (1.1.1)
danger-gitlab
gitlab-experiment (0.5.2)
gitlab-experiment (0.5.3)
activesupport (>= 3.0)
scientist (~> 1.6, >= 1.6.0)
gitlab-fog-azure-rm (1.0.1)
@ -1424,7 +1424,7 @@ DEPENDENCIES
github-markup (~> 1.7.0)
gitlab-chronic (~> 0.10.5)
gitlab-dangerfiles (~> 1.1.1)
gitlab-experiment (~> 0.5.2)
gitlab-experiment (~> 0.5.3)
gitlab-fog-azure-rm (~> 1.0.1)
gitlab-fog-google (~> 1.13)
gitlab-labkit (~> 0.16.2)

View file

@ -793,10 +793,22 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => {
commit(types.VIEW_DIFF_FILE, fileHash);
};
export const setFileByFile = ({ commit }, { fileByFile }) => {
export const setFileByFile = ({ state, commit }, { fileByFile }) => {
const fileViewMode = fileByFile ? DIFF_VIEW_FILE_BY_FILE : DIFF_VIEW_ALL_FILES;
commit(types.SET_FILE_BY_FILE, fileByFile);
Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode);
return axios
.put(state.endpointUpdateUser, {
view_diffs_file_by_file: fileByFile,
})
.then(() => {
// https://gitlab.com/gitlab-org/gitlab/-/issues/326961
// We can't even do a simple console warning here because
// the pipeline will fail. However, the issue above will
// eventually handle errors appropriately.
// console.warn('Saving the file-by-fil user preference failed.');
});
};
export function reviewFile({ commit, state }, { file, reviewed = true }) {

View file

@ -1,8 +1,9 @@
<script>
/* eslint-disable vue/no-v-html */
import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { experiment } from '~/experimentation/utils';
import { __, s__ } from '~/locale';
import { NEW_REPO_EXPERIMENT } from '../constants';
import blankProjectIllustration from '../illustrations/blank-project.svg';
import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg';
import createFromTemplateIllustration from '../illustrations/create-from-template.svg';
@ -13,8 +14,10 @@ import WelcomePage from './welcome.vue';
const BLANK_PANEL = 'blank_project';
const CI_CD_PANEL = 'cicd_for_external_repo';
const LAST_ACTIVE_TAB_KEY = 'new_project_last_active_tab';
const PANELS = [
{
key: 'blank',
name: BLANK_PANEL,
selector: '#blank-project-pane',
title: s__('ProjectsNew|Create blank project'),
@ -24,6 +27,7 @@ const PANELS = [
illustration: blankProjectIllustration,
},
{
key: 'template',
name: 'create_from_template',
selector: '#create-from-template-pane',
title: s__('ProjectsNew|Create from template'),
@ -33,6 +37,7 @@ const PANELS = [
illustration: createFromTemplateIllustration,
},
{
key: 'import',
name: 'import_project',
selector: '#import-project-pane',
title: s__('ProjectsNew|Import project'),
@ -42,6 +47,7 @@ const PANELS = [
illustration: importProjectIllustration,
},
{
key: 'ci',
name: CI_CD_PANEL,
selector: '#ci-cd-project-pane',
title: s__('ProjectsNew|Run CI/CD for external repository'),
@ -86,11 +92,27 @@ export default {
computed: {
availablePanels() {
const PANEL_TITLES = experiment(NEW_REPO_EXPERIMENT, {
use: () => ({
blank: s__('ProjectsNew|Create blank project'),
import: s__('ProjectsNew|Import project'),
}),
try: () => ({
blank: s__('ProjectsNew|Create blank project/repository'),
import: s__('ProjectsNew|Import project/repository'),
}),
});
const updatedPanels = PANELS.map(({ key, title, ...el }) => ({
...el,
title: PANEL_TITLES[key] !== undefined ? PANEL_TITLES[key] : title,
}));
if (this.isCiCdAvailable) {
return PANELS;
return updatedPanels;
}
return PANELS.filter((p) => p.name !== CI_CD_PANEL);
return updatedPanels.filter((p) => p.name !== CI_CD_PANEL);
},
activePanel() {

View file

@ -1,9 +1,10 @@
<script>
/* eslint-disable vue/no-v-html */
import Tracking from '~/tracking';
import { NEW_REPO_EXPERIMENT } from '../constants';
import NewProjectPushTipPopover from './new_project_push_tip_popover.vue';
const trackingMixin = Tracking.mixin(gon.tracking_data);
const trackingMixin = Tracking.mixin({ ...gon.tracking_data, experiment: NEW_REPO_EXPERIMENT });
export default {
components: {

View file

@ -0,0 +1 @@
export const NEW_REPO_EXPERIMENT = 'new_repo';

View file

@ -74,6 +74,7 @@ class ProjectsController < Projects::ApplicationController
@project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute
if @project.saved?
experiment(:new_repo, user: current_user).track(:project_created)
experiment(:new_project_readme, actor: current_user).track(
:created,
property: active_new_project_tab,

View file

@ -75,7 +75,7 @@ module Emails
end
def ssh_key_expired_email(user, fingerprints)
return unless user && user.active?
return unless user&.active?
@user = user
@fingerprints = fingerprints
@ -86,6 +86,18 @@ module Emails
end
end
def ssh_key_expiring_soon_email(user, fingerprints)
return unless user&.active?
@user = user
@fingerprints = fingerprints
@target_url = profile_keys_url
Gitlab::I18n.with_locale(@user.preferred_language) do
mail(to: @user.notification_email, subject: subject(_("Your SSH key is expiring soon.")))
end
end
def unknown_sign_in_email(user, ip, time)
@user = user
@ip = ip

View file

@ -44,6 +44,7 @@ class Key < ApplicationRecord
scope :for_user, -> (user) { where(user: user) }
scope :order_last_used_at_desc, -> { reorder(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) }
scope :expired_today_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') = CURRENT_DATE AND expiry_notification_delivered_at IS NULL"]) }
scope :expiring_soon_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') > CURRENT_DATE AND date(expires_at AT TIME ZONE 'UTC') < ? AND before_expiry_notification_delivered_at IS NULL", DAYS_TO_EXPIRE.days.from_now.to_date]) }
def self.regular_keys
where(type: ['Key', nil])

View file

@ -11,14 +11,14 @@ class Service < ApplicationRecord
include EachBatch
SERVICE_NAMES = %w[
asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord
asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord
drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack
].freeze
PROJECT_SPECIFIC_SERVICE_NAMES = %w[
jenkins
datadog jenkins
].freeze
# Fake services to help with local development.

View file

@ -104,6 +104,7 @@ class User < ApplicationRecord
# Profile
has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :expired_today_and_unnotified_keys, -> { expired_today_and_not_notified }, class_name: 'Key'
has_many :expiring_soon_and_unnotified_keys, -> { expiring_soon_and_not_notified }, class_name: 'Key'
has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :group_deploy_keys
has_many :gpg_keys
@ -402,6 +403,14 @@ class User < ApplicationRecord
.where('keys.user_id = users.id')
.expired_today_and_not_notified)
end
scope :with_ssh_key_expiring_soon, -> do
includes(:expiring_soon_and_unnotified_keys)
.where('EXISTS (?)',
::Key
.select(1)
.where('keys.user_id = users.id')
.expiring_soon_and_not_notified)
end
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) }

View file

@ -114,6 +114,36 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
presenter(merge_request).api_unapprove_path
end
expose :test_reports_path do |merge_request|
if merge_request.has_test_reports?
test_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
end
end
expose :accessibility_report_path do |merge_request|
if merge_request.has_accessibility_reports?
accessibility_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
end
end
expose :codequality_reports_path do |merge_request|
if merge_request.has_codequality_reports?
codequality_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
end
end
expose :terraform_reports_path do |merge_request|
if merge_request.has_terraform_reports?
terraform_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
end
end
expose :exposed_artifacts_path do |merge_request|
if merge_request.has_exposed_artifacts?
exposed_artifacts_project_merge_request_path(merge_request.project, merge_request, format: :json)
end
end
expose :blob_path do
expose :head_path, if: -> (mr, _) { mr.source_branch_sha } do |merge_request|
project_blob_path(merge_request.project, merge_request.source_branch_sha)

View file

@ -76,36 +76,6 @@ class MergeRequestPollWidgetEntity < Grape::Entity
presenter(merge_request).cancel_auto_merge_path
end
expose :test_reports_path do |merge_request|
if merge_request.has_test_reports?
test_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
end
end
expose :accessibility_report_path do |merge_request|
if merge_request.has_accessibility_reports?
accessibility_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
end
end
expose :codequality_reports_path do |merge_request|
if merge_request.has_codequality_reports?
codequality_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
end
end
expose :terraform_reports_path do |merge_request|
if merge_request.has_terraform_reports?
terraform_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
end
end
expose :exposed_artifacts_path do |merge_request|
if merge_request.has_exposed_artifacts?
exposed_artifacts_project_merge_request_path(merge_request.project, merge_request, format: :json)
end
end
expose :create_issue_to_resolve_discussions_path do |merge_request|
presenter(merge_request).create_issue_to_resolve_discussions_path
end

View file

@ -2,17 +2,38 @@
module Keys
class ExpiryNotificationService < ::Keys::BaseService
attr_accessor :keys
attr_accessor :keys, :expiring_soon
def initialize(user, params)
@keys = params[:keys]
@expiring_soon = params[:expiring_soon]
super
end
def execute
return unless user.can?(:receive_notifications)
return unless allowed?
if expiring_soon
trigger_expiring_soon_notification
else
trigger_expired_notification
end
end
private
def allowed?
user.can?(:receive_notifications)
end
def trigger_expiring_soon_notification
notification_service.ssh_key_expiring_soon(user, keys.map(&:fingerprint))
keys.update_all(before_expiry_notification_delivered_at: Time.current.utc)
end
def trigger_expired_notification
notification_service.ssh_key_expired(user, keys.map(&:fingerprint))
keys.update_all(expiry_notification_delivered_at: Time.current.utc)

View file

@ -86,6 +86,13 @@ class NotificationService
mailer.ssh_key_expired_email(user, fingerprints).deliver_later
end
# Notify the user when at least one of their ssh key is expiring soon
def ssh_key_expiring_soon(user, fingerprints)
return unless user.can?(:receive_notifications)
mailer.ssh_key_expiring_soon_email(user, fingerprints).deliver_later
end
# Notify a user when a previously unknown IP or device is used to
# sign in to their account
def unknown_sign_in(user, ip, time)

View file

@ -2,7 +2,7 @@
.omniauth-container.gl-mt-5
%label.label-bold.d-block
Sign in with
= _('Sign in with')
- providers = enabled_button_based_providers
.d-flex.justify-content-between.flex-wrap
- providers.each do |provider|
@ -17,4 +17,4 @@
%label
= check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
%span
Remember me
= _('Remember me')

View file

@ -52,6 +52,8 @@
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
-# Rendering this above Gon, to use in JS later
= render 'layouts/header/new_repo_experiment'
= Gon::Base.render_data(nonce: content_security_policy_nonce)
= javascript_include_tag locale_path unless I18n.locale == :en

View file

@ -1,4 +1,4 @@
%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown", track_value: "" } }
%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } }
= link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", id: "js-onboarding-new-project-link", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do
= sprite_icon('plus-square')
= sprite_icon('chevron-down', css_class: 'caret-down')
@ -37,8 +37,7 @@
= render 'layouts/header/project_invite_members_new_dropdown_item'
%li.divider
%li.dropdown-bold-header GitLab
- if current_user.can_create_project?
%li= link_to _('New project'), new_project_path, class: 'qa-global-new-project-link'
= content_for :new_repo_experiment
- if current_user.can_create_group?
%li= link_to _('New group'), new_group_path
- if current_user.can?(:create_snippet)

View file

@ -0,0 +1,7 @@
- content_for :new_repo_experiment do
- if current_user&.can_create_project?
- experiment(:new_repo, user: current_user) do |e|
- e.use do
%li= link_to _('New project'), new_project_path, class: 'qa-global-new-project-link', data: { track_experiment: 'new_repo', track_event: 'click_link', track_label: 'plus_menu_dropdown' }
- e.try do
%li= link_to _('New project/repository'), new_project_path, class: 'qa-global-new-project-link', data: { track_experiment: 'new_repo', track_event: 'click_link', track_label: 'plus_menu_dropdown' }

View file

@ -2,7 +2,7 @@
-# https://gitlab.com/gitlab-org/gitlab-foss/issues/49713 for more information.
%ul.list-unstyled.navbar-sub-nav
- if dashboard_nav_link?(:projects)
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown", data: { track_label: "projects_dropdown", track_event: "click_dropdown" } }) do
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown", data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } }) do
%button{ type: 'button', data: { toggle: "dropdown" } }
= _('Projects')
= sprite_icon('chevron-down', css_class: 'caret-down')

View file

@ -11,14 +11,21 @@
= nav_link(path: 'projects#trending') do
= link_to explore_root_path, data: { track_label: "projects_dropdown_explore_projects", track_event: "click_link" } do
= _('Explore projects')
= nav_link(path: 'projects/new#blank_project',
html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' },
data: { track_label: "projects_dropdown_blank_project", track_event: "click_link" }) do
= link_to new_project_path(anchor: 'blank_project') do
- experiment(:new_repo, user: current_user) do |e|
- e.use do
= nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do
= link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", track_experiment: "new_repo" } do
= _('Create blank project')
= nav_link(path: 'projects/new#import_project') do
= link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link" } do
= link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", track_experiment: "new_repo" } do
= _('Import project')
- e.try do
= nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do
= link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", track_experiment: "new_repo" } do
= _('Create blank project/repository')
= nav_link(path: 'projects/new#import_project') do
= link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", track_experiment: "new_repo" } do
= _('Import project/repository')
= nav_link(path: 'projects/new#create_from_template') do
= link_to new_project_path(anchor: 'create_from_template'), data: { track_label: "projects_dropdown_create_from_template", track_event: "click_link" } do
= _('Create from template')

View file

@ -0,0 +1,9 @@
<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
<%= _('Your SSH keys with the following fingerprints are scheduled to expire soon:') %>
<% @fingerprints.each do |fingerprint| %>
- <%= fingerprint %>
<% end %>
<%= _('You can create a new one or check them in your SSH keys settings %{ssh_key_link}.') % { ssh_key_link: @target_url } %>

View file

@ -0,0 +1,13 @@
%p
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
%p
= _('Your SSH keys with the following fingerprints are scheduled to expire soon:')
%table
%tbody
- @fingerprints.each do |fingerprint|
%tr
%td= fingerprint
%p
- ssh_key_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
= html_escape(_('You can create a new one or check them in your %{ssh_key_link_start}SSH keys%{ssh_key_link_end} settings.')) % { ssh_key_link_start: ssh_key_link_start, ssh_key_link_end: '</a>'.html_safe }

View file

@ -17,8 +17,9 @@
- else
= _('Project access token creation is disabled in this group. You can still use and manage existing tokens.')
%p
- if current_user.can?(:admin_group, @project.group)
- group_settings_link = edit_group_path(@project.group)
- root_group = @project.group.root_ancestor
- if current_user.can?(:admin_group, root_group)
- group_settings_link = edit_group_path(root_group)
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link }
= _('You can enable project access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }

View file

@ -451,6 +451,14 @@
:weight: 1
:idempotent: true
:tags: []
- :name: cronjob:ssh_keys_expiring_soon_notification
:feature_category: :compliance_management
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: cronjob:stuck_ci_jobs
:feature_category: :continuous_integration
:has_external_dependencies:

View file

@ -17,7 +17,7 @@ module SshKeys
keys = user.expired_today_and_unnotified_keys
Keys::ExpiryNotificationService.new(user, { keys: keys }).execute
Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: false }).execute
end
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
module SshKeys
class ExpiringSoonNotificationWorker
include ApplicationWorker
include CronjobQueue
feature_category :compliance_management
idempotent!
def perform
return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml)
User.with_ssh_key_expiring_soon.find_each do |user|
with_context(user: user) do
Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expiring soon ssh key(s)"
keys = user.expiring_soon_and_unnotified_keys
Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: true }).execute
end
end
end
end
end

View file

@ -0,0 +1,5 @@
---
title: Partial index optimization for namespaces id
merge_request: 58220
author:
type: performance

View file

@ -0,0 +1,5 @@
---
title: Externalize strings in shared/_omniauth_box.html.haml
merge_request: 58281
author: nuwe1
type: other

View file

@ -0,0 +1,5 @@
---
title: User notification when SSH key is set to expire soon
merge_request: 58171
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Move CI related paths to cached MR widget
merge_request: 58711
author:
type: performance

View file

@ -0,0 +1,5 @@
---
title: Fix project access token creation group settings link
merge_request: 58686
author:
type: fixed

View file

@ -0,0 +1,5 @@
---
title: Sync single-file mode user preference when changed from the MR cog menu checkbox
merge_request: 55931
author:
type: changed

View file

@ -0,0 +1,8 @@
---
name: new_repo
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55818
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/285153
milestone: '13.11'
type: experiment
group: group::adoption
default_enabled: false

View file

@ -569,6 +569,9 @@ Settings.cron_jobs['ssh_keys_expired_notification_worker']['job_class'] = 'SshKe
Settings.cron_jobs['namespaces_in_product_marketing_emails_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['namespaces_in_product_marketing_emails_worker']['cron'] ||= '0 9 * * *'
Settings.cron_jobs['namespaces_in_product_marketing_emails_worker']['job_class'] = 'Namespaces::InProductMarketingEmailsWorker'
Settings.cron_jobs['ssh_keys_expiring_soon_notification_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['ssh_keys_expiring_soon_notification_worker']['cron'] ||= '0 1 * * *'
Settings.cron_jobs['ssh_keys_expiring_soon_notification_worker']['job_class'] = 'SshKeys::ExpiringSoonNotificationWorker'
Gitlab.com do
Settings.cron_jobs['batched_background_migrations_worker'] ||= Settingslogic.new({})

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddBeforeExpiryNotificationDeliveredToKeys < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :keys, :before_expiry_notification_delivered_at, :datetime_with_timezone
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class AddIndexToKeysOnExpiresAtAndBeforeExpiryNotificationUndelivered < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'idx_keys_expires_at_and_before_expiry_notification_undelivered'
disable_ddl_transaction!
def up
add_concurrent_index :keys,
"date(timezone('UTC', expires_at)), before_expiry_notification_delivered_at",
where: 'before_expiry_notification_delivered_at IS NULL', name: INDEX_NAME
end
def down
remove_concurrent_index_by_name(:keys, INDEX_NAME)
end
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class CreateNamespacesIdParentIdPartialIndex < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
NAME = 'index_namespaces_id_parent_id_is_null'
disable_ddl_transaction!
def up
add_concurrent_index :namespaces, :id, where: 'parent_id IS NULL', name: NAME
end
def down
remove_concurrent_index :namespaces, :id, name: NAME
end
end

View file

@ -0,0 +1 @@
07d527134f776dbed2199f1717c34b3a6c41caadcaa3c50e6e5866f2cfad31b0

View file

@ -0,0 +1 @@
1cd4799ed7df41bfb9d96a7d18faaa9cbb2dc03f2a804c2bc3c1a6bba15d6d3d

View file

@ -0,0 +1 @@
d29f002f88440a10674b251791fa027cb0ae1c1b0c4fd776a2078e3c94160f17

View file

@ -13969,7 +13969,8 @@ CREATE TABLE keys (
last_used_at timestamp without time zone,
fingerprint_sha256 bytea,
expires_at timestamp with time zone,
expiry_notification_delivered_at timestamp with time zone
expiry_notification_delivered_at timestamp with time zone,
before_expiry_notification_delivered_at timestamp with time zone
);
CREATE SEQUENCE keys_id_seq
@ -21729,6 +21730,8 @@ CREATE INDEX idx_jira_connect_subscriptions_on_installation_id ON jira_connect_s
CREATE UNIQUE INDEX idx_jira_connect_subscriptions_on_installation_id_namespace_id ON jira_connect_subscriptions USING btree (jira_connect_installation_id, namespace_id);
CREATE INDEX idx_keys_expires_at_and_before_expiry_notification_undelivered ON keys USING btree (date(timezone('UTC'::text, expires_at)), before_expiry_notification_delivered_at) WHERE (before_expiry_notification_delivered_at IS NULL);
CREATE INDEX idx_members_created_at_user_id_invite_token ON members USING btree (created_at) WHERE ((invite_token IS NOT NULL) AND (user_id IS NULL));
CREATE INDEX idx_merge_requests_on_id_and_merge_jid ON merge_requests USING btree (id, merge_jid) WHERE ((merge_jid IS NOT NULL) AND (state_id = 4));
@ -23161,6 +23164,8 @@ CREATE UNIQUE INDEX index_namespace_root_storage_statistics_on_namespace_id ON n
CREATE UNIQUE INDEX index_namespace_statistics_on_namespace_id ON namespace_statistics USING btree (namespace_id);
CREATE INDEX index_namespaces_id_parent_id_is_null ON namespaces USING btree (id) WHERE (parent_id IS NULL);
CREATE INDEX index_namespaces_on_created_at ON namespaces USING btree (created_at);
CREATE INDEX index_namespaces_on_custom_project_templates_group_id_and_type ON namespaces USING btree (custom_project_templates_group_id, type) WHERE (custom_project_templates_group_id IS NOT NULL);

View file

@ -412,7 +412,7 @@ in the regression issue as fixes are addressed.
In order to track things that can be improved in the GitLab codebase,
we use the ~"technical debt" label in the [GitLab issue tracker](https://gitlab.com/gitlab-org/gitlab/-/issues).
For missed user experience requirements, we use the ~"UX debt" label.
We use the ~"UX debt" label when we choose to deviate from the MVC, in a way that harms the user experience.
These labels should be added to issues that describe things that can be improved,
shortcuts that have been taken, features that need additional attention, and all

View file

@ -9956,6 +9956,30 @@ Status: `implemented`
Tiers: `premium`, `ultimate`
### `redis_hll_counters.epics_usage.g_project_management_epic_users_changing_labels_monthly`
Count of MAU chaging the epic lables
[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_28d/20210312195730_g_project_management_epic_labels_monthly.yml)
Group: `group::product planning`
Status: `implemented`
Tiers: `premium`, `ultimate`
### `redis_hll_counters.epics_usage.g_project_management_epic_users_changing_labels_weekly`
Count of WAU chaging the epic lables
[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_7d/20210312195849_g_project_management_epic_labels_weekly.yml)
Group: `group::product planning`
Status: `implemented`
Tiers: `premium`, `ultimate`
### `redis_hll_counters.epics_usage.g_project_management_issue_promoted_to_epic_monthly`
Count of MAU promoting issues to epics

View file

@ -218,7 +218,8 @@ To use SSH with GitLab, copy your public key to your GitLab account.
The expiration date is informational only, and does not prevent you from using
the key. However, administrators can view expiration dates and
use them for guidance when [deleting keys](../user/admin_area/credentials_inventory.md#delete-a-users-ssh-key).
GitLab checks all SSH keys at 02:00 AM UTC every day. It emails an expiration notice for all SSH keys that expire on the current date. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/322637) in GitLab 13.11.)
- GitLab checks all SSH keys at 02:00 AM UTC every day. It emails an expiration notice for all SSH keys that expire on the current date. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/322637) in GitLab 13.11.)
- GitLab checks all SSH keys at 01:00 AM UTC every day. It emails an expiration notice for all SSH keys that are scheduled to expire seven days from now. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/322637) in GitLab 13.11.)
1. Select **Add key**.
## Verify that you can connect

View file

@ -11,6 +11,9 @@ GitLab provides a simple tool to administrators for emailing all users, or users
a chosen group or project, right from the Admin Area. Users receive the email
at their primary email address.
For information about email notifications originating from GitLab, read
[GitLab notification emails](../user/profile/notifications.md).
## Use-cases
- Notify your users about a new project, a new feature, or a new product launch.

View file

@ -1143,6 +1143,22 @@ Profiles:
UnicodeFuzzing: true
```
## Troubleshooting
### Failed to start scanner session (version header not found)
The API Fuzzing engine outputs an error message when it cannot establish a connection with the scanner application component. The error message is shown in the job output window of the `apifuzzer_fuzz` job. A common cause of this issue is changing the `FUZZAPI_API` variable from its default.
**Error message**
- In [GitLab 13.11 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/323939), `Failed to start scanner session (version header not found).`
- In GitLab 13.10 and earlier, `API Security version header not found. Are you sure that you are connecting to the API Security server?`.
**Solution**
- Remove the `FUZZAPI_API` variable from the `.gitlab-ci.yml` file. The value will be inherited from the API Fuzzing CI/CD template. We recommend this method instead of manually setting a value.
- If removing the variable is not possible, check to see if this value has changed in the latest version of the [API Fuzzing CI/CD template](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Security/API-Fuzzing.gitlab-ci.yml). If so, update the value in the `.gitlab-ci.yml` file.
<!--
### Target Container

View file

@ -11,6 +11,9 @@ GitLab Notifications allow you to stay informed about what's happening in GitLab
enabled, you can receive updates about activity in issues, merge requests, epics, and designs.
Notifications are sent via email.
For the tool that enables GitLab administrators to send messages to users, read
[Email from GitLab](../../tools/email.md).
## Receiving notifications
You receive notifications for one of the following reasons:

View file

@ -122,3 +122,9 @@
redis_slot: project_management
aggregation: daily
feature_flag: track_epics_activity
- name: g_project_management_epic_users_changing_labels
category: epics_usage
redis_slot: project_management
aggregation: daily
feature_flag: track_epics_activity

View file

@ -8948,6 +8948,9 @@ msgstr ""
msgid "Create blank project"
msgstr ""
msgid "Create blank project/repository"
msgstr ""
msgid "Create branch"
msgstr ""
@ -13805,6 +13808,9 @@ msgstr ""
msgid "From the Kubernetes cluster details view, applications list, install GitLab Runner."
msgstr ""
msgid "Full"
msgstr ""
msgid "Full name"
msgstr ""
@ -14012,6 +14018,9 @@ msgstr ""
msgid "Geo|%{name} is scheduled for re-verify"
msgstr ""
msgid "Geo|%{timeAgoStr} (%{pendingEvents} events)"
msgstr ""
msgid "Geo|%{title} checksum progress"
msgstr ""
@ -14075,6 +14084,9 @@ msgstr ""
msgid "Geo|Geo Status"
msgstr ""
msgid "Geo|Geo nodes are paused using a command run on the node"
msgstr ""
msgid "Geo|Geo sites"
msgstr ""
@ -14228,6 +14240,9 @@ msgstr ""
msgid "Geo|Secondary site"
msgstr ""
msgid "Geo|Selective (%{syncLabel})"
msgstr ""
msgid "Geo|Status"
msgstr ""
@ -15982,6 +15997,9 @@ msgstr ""
msgid "Import project members"
msgstr ""
msgid "Import project/repository"
msgstr ""
msgid "Import projects from Bitbucket"
msgstr ""
@ -20920,6 +20938,9 @@ msgstr ""
msgid "New project"
msgstr ""
msgid "New project/repository"
msgstr ""
msgid "New release"
msgstr ""
@ -22580,6 +22601,9 @@ msgstr ""
msgid "Pause replication"
msgstr ""
msgid "Paused"
msgstr ""
msgid "Paused runners don't accept new jobs"
msgstr ""
@ -24794,6 +24818,9 @@ msgstr ""
msgid "ProjectsNew|Create blank project"
msgstr ""
msgid "ProjectsNew|Create blank project/repository"
msgstr ""
msgid "ProjectsNew|Create from template"
msgstr ""
@ -24812,6 +24839,9 @@ msgstr ""
msgid "ProjectsNew|Import project"
msgstr ""
msgid "ProjectsNew|Import project/repository"
msgstr ""
msgid "ProjectsNew|Initialize repository with a README"
msgstr ""
@ -25755,6 +25785,9 @@ msgstr ""
msgid "Remediations"
msgstr ""
msgid "Remember me"
msgstr ""
msgid "Remind later"
msgstr ""
@ -28542,6 +28575,9 @@ msgstr ""
msgid "Sign in via 2FA code"
msgstr ""
msgid "Sign in with"
msgstr ""
msgid "Sign in with Single Sign-On"
msgstr ""
@ -35610,12 +35646,18 @@ msgstr ""
msgid "Your SSH key has expired"
msgstr ""
msgid "Your SSH key is expiring soon."
msgstr ""
msgid "Your SSH key was deleted"
msgstr ""
msgid "Your SSH keys (%{count})"
msgstr ""
msgid "Your SSH keys with the following fingerprints are scheduled to expire soon:"
msgstr ""
msgid "Your SSH keys with the following fingerprints has expired:"
msgstr ""

View file

@ -5,7 +5,7 @@ module QA
module Dashboard
module Snippet
class Index < Page::Base
view 'app/views/layouts/header/_new_dropdown.haml' do
view 'app/views/layouts/header/_new_dropdown.html.haml' do
element :new_menu_toggle
element :global_new_snippet_link
end

View file

@ -22,7 +22,7 @@ module QA
element :file_tree_table
end
view 'app/views/layouts/header/_new_dropdown.haml' do
view 'app/views/layouts/header/_new_dropdown.html.haml' do
element :new_menu_toggle
element :new_issue_link, "link_to _('New issue'), new_project_issue_path(@project)" # rubocop:disable QA/ElementWithPattern
end

View file

@ -448,6 +448,12 @@ RSpec.describe ProjectsController do
post :create, params: { project: project_params }
end
it 'tracks a created event for the new_repo experiment', :experiment do
expect(experiment(:new_repo, :candidate)).to track(:project_created).on_next_instance
post :create, params: { project: project_params }
end
end
describe 'POST #archive' do

View file

@ -12,6 +12,72 @@ RSpec.describe 'New project', :js do
sign_in(user)
end
context 'new repo experiment', :experiment do
it 'when in control renders "project"' do
stub_experiments(new_repo: :control)
visit new_project_path
find('li.header-new.dropdown').click
page.within('li.header-new.dropdown') do
expect(page).to have_selector('a', text: 'New project')
expect(page).to have_no_selector('a', text: 'New project/repository')
end
expect(page).to have_selector('.blank-state-title', text: 'Create blank project')
expect(page).to have_no_selector('.blank-state-title', text: 'Create blank project/repository')
end
it 'when in candidate renders "project/repository"' do
stub_experiments(new_repo: :candidate)
visit new_project_path
find('li.header-new.dropdown').click
page.within('li.header-new.dropdown') do
expect(page).to have_selector('a', text: 'New project/repository')
end
expect(page).to have_selector('.blank-state-title', text: 'Create blank project/repository')
end
context 'with combined_menu feature disabled' do
before do
stub_feature_flags(combined_menu: false)
end
it 'when in control it renders "project" in the new projects dropdown' do
stub_experiments(new_repo: :control)
visit new_project_path
find('#nav-projects-dropdown').click
page.within('#nav-projects-dropdown') do
expect(page).to have_selector('a', text: 'Create blank project')
expect(page).to have_selector('a', text: 'Import project')
expect(page).to have_no_selector('a', text: 'Create blank project/repository')
expect(page).to have_no_selector('a', text: 'Import project/repository')
end
end
it 'when in candidate it renders "project/repository" in the new projects dropdown' do
stub_experiments(new_repo: :candidate)
visit new_project_path
find('#nav-projects-dropdown').click
page.within('#nav-projects-dropdown') do
expect(page).to have_selector('a', text: 'Create blank project/repository')
expect(page).to have_selector('a', text: 'Import project/repository')
end
end
end
end
it 'shows a message if multiple levels are restricted' do
Gitlab::CurrentSettings.update!(
restricted_visibility_levels: [Gitlab::VisibilityLevel::PRIVATE, Gitlab::VisibilityLevel::INTERNAL]

View file

@ -116,6 +116,22 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do
end
end
context 'with nested groups' do
let(:subgroup) { create(:group, parent: group) }
context 'when user is not a top level group owner' do
before do
subgroup.add_owner(user)
end
it 'does not show group settings link' do
visit project_settings_access_tokens_path(project)
expect(page).not_to have_link('group settings', href: edit_group_path(group))
end
end
end
context 'when user is a group owner' do
before do
group.add_owner(user)

View file

@ -1507,19 +1507,42 @@ describe('DiffsStoreActions', () => {
});
describe('setFileByFile', () => {
const updateUserEndpoint = 'user/prefs';
let putSpy;
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
putSpy = jest.spyOn(axios, 'put');
mock.onPut(updateUserEndpoint).reply(200, {});
});
afterEach(() => {
mock.restore();
});
it.each`
value
${true}
${false}
`('commits SET_FILE_BY_FILE with the new value $value', ({ value }) => {
return testAction(
`(
'commits SET_FILE_BY_FILE and persists the File-by-File user preference with the new value $value',
async ({ value }) => {
await testAction(
setFileByFile,
{ fileByFile: value },
{ viewDiffsFileByFile: null },
{
viewDiffsFileByFile: null,
endpointUpdateUser: updateUserEndpoint,
},
[{ type: types.SET_FILE_BY_FILE, payload: value }],
[],
);
});
expect(putSpy).toHaveBeenCalledWith(updateUserEndpoint, { view_diffs_file_by_file: value });
},
);
});
describe('reviewFile', () => {

View file

@ -1,5 +1,6 @@
import { GlBreadcrumb } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { assignGitlabExperiment } from 'helpers/experimentation_helper';
import App from '~/projects/experiment_new_project_creation/components/app.vue';
import LegacyContainer from '~/projects/experiment_new_project_creation/components/legacy_container.vue';
import WelcomePage from '~/projects/experiment_new_project_creation/components/welcome.vue';
@ -17,6 +18,34 @@ describe('Experimental new project creation app', () => {
wrapper = null;
});
const findWelcomePage = () => wrapper.findComponent(WelcomePage);
const findPanel = (panelName) =>
findWelcomePage()
.props()
.panels.find((p) => p.name === panelName);
describe('new_repo experiment', () => {
describe('when in the candidate variant', () => {
assignGitlabExperiment('new_repo', 'candidate');
it('has "repository" in the panel title', () => {
createComponent();
expect(findPanel('blank_project').title).toBe('Create blank project/repository');
});
});
describe('when in the control variant', () => {
assignGitlabExperiment('new_repo', 'control');
it('has "project" in the panel title', () => {
createComponent();
expect(findPanel('blank_project').title).toBe('Create blank project');
});
});
});
describe('with empty hash', () => {
beforeEach(() => {
createComponent();

View file

@ -1,8 +1,13 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { mockTracking } from 'helpers/tracking_helper';
import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
import { getExperimentData } from '~/experimentation/utils';
import NewProjectPushTipPopover from '~/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue';
import WelcomePage from '~/projects/experiment_new_project_creation/components/welcome.vue';
jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() }));
describe('Welcome page', () => {
let wrapper;
let trackingSpy;
@ -14,6 +19,7 @@ describe('Welcome page', () => {
beforeEach(() => {
trackingSpy = mockTracking('_category_', document, jest.spyOn);
trackingSpy.mockImplementation(() => {});
getExperimentData.mockReturnValue(undefined);
});
afterEach(() => {
@ -22,14 +28,35 @@ describe('Welcome page', () => {
wrapper = null;
});
it('tracks link clicks', () => {
it('tracks link clicks', async () => {
createComponent({ panels: [{ name: 'test', href: '#' }] });
wrapper.find('a').trigger('click');
const link = wrapper.find('a');
link.trigger('click');
await nextTick();
return wrapper.vm.$nextTick().then(() => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { label: 'test' });
});
});
it('adds new_repo experiment data if in experiment', async () => {
const mockExperimentData = 'data';
getExperimentData.mockReturnValue(mockExperimentData);
createComponent({ panels: [{ name: 'test', href: '#' }] });
const link = wrapper.find('a');
link.trigger('click');
await nextTick();
return wrapper.vm.$nextTick().then(() => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', {
label: 'test',
context: {
data: mockExperimentData,
schema: TRACKING_CONTEXT_SCHEMA,
},
});
});
});
it('renders new project push tip popover', () => {
createComponent({ panels: [{ name: 'test', href: '#' }] });

View file

@ -212,52 +212,101 @@ RSpec.describe Emails::Profile do
end
end
describe 'notification email for expired ssh key' do
let_it_be(:user) { create(:user) }
describe 'SSH key notification' do
let_it_be_with_reload(:user) { create(:user) }
let_it_be(:fingerprints) { ["aa:bb:cc:dd:ee:zz"] }
context 'when valid' do
subject { Notify.ssh_key_expired_email(user, fingerprints) }
shared_examples 'is sent to the user' do
it { is_expected.to deliver_to user.email }
end
shared_examples 'has the correct subject' do |subject_text|
it { is_expected.to have_subject subject_text }
end
shared_examples 'has the correct body text' do |body_text|
it { is_expected.to have_body_text body_text }
end
shared_examples 'includes a link to ssh key page' do
it { is_expected.to have_body_text /#{profile_keys_url}/ }
end
shared_examples 'includes the email reason' do
it { is_expected.to have_body_text /You're receiving this email because of your account on localhost/ }
end
shared_examples 'valid use case' do
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
it_behaves_like 'a user cannot unsubscribe through footer link'
it 'is sent to the user' do
is_expected.to deliver_to user.email
it_behaves_like 'is sent to the user'
it_behaves_like 'includes a link to ssh key page'
it_behaves_like 'includes the email reason'
end
it 'has the correct subject' do
is_expected.to have_subject /Your SSH key has expired/
shared_examples 'does not send email' do
it do
expect { subject }.not_to change { ActionMailer::Base.deliveries.count }
end
end
it 'mentions the ssh keu has expired' do
is_expected.to have_body_text /Your SSH keys with the following fingerprints has expired/
shared_context 'block user' do
before do
user.block!
end
end
it 'includes a link to ssh key page' do
is_expected.to have_body_text /#{profile_keys_url}/
end
context 'notification email for expired ssh key' do
context 'when valid' do
subject { Notify.ssh_key_expired_email(user, fingerprints) }
it 'includes the email reason' do
is_expected.to have_body_text /You're receiving this email because of your account on localhost/
end
include_examples 'valid use case'
it_behaves_like 'has the correct subject', /Your SSH key has expired/
it_behaves_like 'has the correct body text', /Your SSH keys with the following fingerprints has expired/
end
context 'when invalid' do
context 'when user does not exist' do
it do
expect { Notify.ssh_key_expired_email(nil) }.not_to change { ActionMailer::Base.deliveries.count }
end
subject { Notify.ssh_key_expired_email(nil, fingerprints) }
it_behaves_like 'does not send email'
end
context 'when user is not active' do
before do
user.block!
subject { Notify.ssh_key_expired_email(user, fingerprints) }
include_context 'block user'
it_behaves_like 'does not send email'
end
end
end
it do
expect { Notify.ssh_key_expired_email(user) }.not_to change { ActionMailer::Base.deliveries.count }
context 'notification email for expiring ssh key' do
context 'when valid' do
subject { Notify.ssh_key_expiring_soon_email(user, fingerprints) }
include_examples 'valid use case'
it_behaves_like 'has the correct subject', /Your SSH key is expiring soon/
it_behaves_like 'has the correct body text', /Your SSH keys with the following fingerprints are scheduled to expire soon/
end
context 'when invalid' do
context 'when user does not exist' do
subject { Notify.ssh_key_expiring_soon_email(nil, fingerprints) }
it_behaves_like 'does not send email'
end
context 'when user is not active' do
subject { Notify.ssh_key_expiring_soon_email(user, fingerprints) }
include_context 'block user'
it_behaves_like 'does not send email'
end
end
end

View file

@ -76,17 +76,27 @@ RSpec.describe Key, :mailer do
end
end
describe '.expired_today_and_not_notified' do
context 'expiration scopes' do
let_it_be(:user) { create(:user) }
let_it_be(:expired_today_not_notified) { create(:key, expires_at: Time.current, user: user) }
let_it_be(:expired_today_already_notified) { create(:key, expires_at: Time.current, user: user, expiry_notification_delivered_at: Time.current) }
let_it_be(:expired_yesterday) { create(:key, expires_at: 1.day.ago, user: user) }
let_it_be(:future_expiry) { create(:key, expires_at: 1.day.from_now, user: user) }
let_it_be(:expiring_soon_unotified) { create(:key, expires_at: 3.days.from_now, user: user) }
let_it_be(:expiring_soon_notified) { create(:key, expires_at: 4.days.from_now, user: user, before_expiry_notification_delivered_at: Time.current) }
let_it_be(:future_expiry) { create(:key, expires_at: 1.month.from_now, user: user) }
it 'returns tokens that have expired today' do
describe '.expired_today_and_not_notified' do
it 'returns keys that expire today' do
expect(described_class.expired_today_and_not_notified).to contain_exactly(expired_today_not_notified)
end
end
describe '.expiring_soon_and_not_notified' do
it 'returns keys that will expire soon' do
expect(described_class.expiring_soon_and_not_notified).to contain_exactly(expiring_soon_unotified)
end
end
end
end
context "validation of uniqueness (based on fingerprint uniqueness)" do

View file

@ -1001,18 +1001,27 @@ RSpec.describe User do
end
end
describe '.with_ssh_key_expired_today' do
context 'SSH key expiration scopes' do
let_it_be(:user1) { create(:user) }
let_it_be(:expired_today_not_notified) { create(:key, expires_at: Time.current, user: user1) }
let_it_be(:user2) { create(:user) }
let_it_be(:expired_today_not_notified) { create(:key, expires_at: Time.current, user: user1) }
let_it_be(:expired_today_already_notified) { create(:key, expires_at: Time.current, user: user2, expiry_notification_delivered_at: Time.current) }
let_it_be(:expiring_soon_not_notified) { create(:key, expires_at: 2.days.from_now, user: user2) }
let_it_be(:expiring_soon_notified) { create(:key, expires_at: 2.days.from_now, user: user1, before_expiry_notification_delivered_at: Time.current) }
it 'returns users whose token has expired today' do
describe '.with_ssh_key_expired_today' do
it 'returns users whose key has expired today' do
expect(described_class.with_ssh_key_expired_today).to contain_exactly(user1)
end
end
describe '.with_ssh_key_expiring_soon' do
it 'returns users whose keys will expire soon' do
expect(described_class.with_ssh_key_expiring_soon).to contain_exactly(user2)
end
end
end
describe '.active_without_ghosts' do
let_it_be(:user1) { create(:user, :external) }
let_it_be(:user2) { create(:user, state: 'blocked') }

View file

@ -313,4 +313,38 @@ RSpec.describe MergeRequestPollCachedWidgetEntity do
end
end
end
describe 'ci related paths' do
using RSpec::Parameterized::TableSyntax
where(:path_field, :method_for_existence_check) do
:terraform_reports_path | :has_terraform_reports?
:accessibility_report_path | :has_accessibility_reports?
:exposed_artifacts_path | :has_exposed_artifacts?
:test_reports_path | :has_test_reports?
:codequality_reports_path | :has_codequality_reports?
end
with_them do
context 'when merge request has reports' do
before do
allow(resource).to receive(method_for_existence_check).and_return(true)
end
it 'set the path to poll data' do
expect(subject[path_field]).to be_present
end
end
context 'when merge request has no reports' do
before do
allow(resource).to receive(method_for_existence_check).and_return(false)
end
it 'does not set reports path' do
expect(subject[path_field]).to be_nil
end
end
end
end
end

View file

@ -87,72 +87,6 @@ RSpec.describe MergeRequestPollWidgetEntity do
end
end
describe 'terraform_reports_path' do
context 'when merge request has terraform reports' do
before do
allow(resource).to receive(:has_terraform_reports?).and_return(true)
end
it 'set the path to poll data' do
expect(subject[:terraform_reports_path]).to be_present
end
end
context 'when merge request has no terraform reports' do
before do
allow(resource).to receive(:has_terraform_reports?).and_return(false)
end
it 'set the path to poll data' do
expect(subject[:terraform_reports_path]).to be_nil
end
end
end
describe 'accessibility_report_path' do
context 'when merge request has accessibility reports' do
before do
allow(resource).to receive(:has_accessibility_reports?).and_return(true)
end
it 'set the path to poll data' do
expect(subject[:accessibility_report_path]).to be_present
end
end
context 'when merge request has no accessibility reports' do
before do
allow(resource).to receive(:has_accessibility_reports?).and_return(false)
end
it 'set the path to poll data' do
expect(subject[:accessibility_report_path]).to be_nil
end
end
end
describe 'exposed_artifacts_path' do
context 'when merge request has exposed artifacts' do
before do
expect(resource).to receive(:has_exposed_artifacts?).and_return(true)
end
it 'set the path to poll data' do
expect(subject[:exposed_artifacts_path]).to be_present
end
end
context 'when merge request has no exposed artifacts' do
before do
expect(resource).to receive(:has_exposed_artifacts?).and_return(false)
end
it 'set the path to poll data' do
expect(subject[:exposed_artifacts_path]).to be_nil
end
end
end
describe 'auto merge' do
before do
project.add_maintainer(user)

View file

@ -4,48 +4,93 @@ require 'spec_helper'
RSpec.describe Keys::ExpiryNotificationService do
let_it_be_with_reload(:user) { create(:user) }
let_it_be_with_reload(:expired_key) { create(:key, expires_at: Time.current, user: user) }
let(:params) { { keys: keys } }
let(:params) { { keys: user.keys, expiring_soon: expiring_soon } }
subject { described_class.new(user, params) }
context 'with expired key', :mailer do
let(:keys) { user.keys }
it 'sends a notification' do
shared_examples 'sends a notification' do
it do
perform_enqueued_jobs do
subject.execute
end
should_email(user)
end
end
it 'uses notification service to send email to the user' do
shared_examples 'uses notification service to send email to the user' do |notification_method|
it do
expect_next_instance_of(NotificationService) do |notification_service|
expect(notification_service).to receive(:ssh_key_expired).with(expired_key.user, [expired_key.fingerprint])
expect(notification_service).to receive(notification_method).with(key.user, [key.fingerprint])
end
subject.execute
end
it 'updates notified column' do
expect { subject.execute }.to change { expired_key.reload.expiry_notification_delivered_at }
end
context 'when user does not have permission to receive notification' do
before do
user.block!
end
it 'does not send notification' do
shared_examples 'does not send notification' do
it do
perform_enqueued_jobs do
subject.execute
end
should_not_email(user)
end
end
shared_context 'block user' do
before do
user.block!
end
end
context 'with key expiring today', :mailer do
let_it_be_with_reload(:key) { create(:key, expires_at: Time.current, user: user) }
let(:expiring_soon) { false }
context 'when user has permission to receive notification' do
it_behaves_like 'sends a notification'
it_behaves_like 'uses notification service to send email to the user', :ssh_key_expired
it 'updates notified column' do
expect { subject.execute }.to change { key.reload.expiry_notification_delivered_at }
end
end
context 'when user does NOT have permission to receive notification' do
include_context 'block user'
it_behaves_like 'does not send notification'
it 'does not update notified column' do
expect { subject.execute }.not_to change { expired_key.reload.expiry_notification_delivered_at }
expect { subject.execute }.not_to change { key.reload.expiry_notification_delivered_at }
end
end
end
context 'with key expiring soon', :mailer do
let_it_be_with_reload(:key) { create(:key, expires_at: 3.days.from_now, user: user) }
let(:expiring_soon) { true }
context 'when user has permission to receive notification' do
it_behaves_like 'sends a notification'
it_behaves_like 'uses notification service to send email to the user', :ssh_key_expiring_soon
it 'updates notified column' do
expect { subject.execute }.to change { key.reload.before_expiry_notification_delivered_at }
end
end
context 'when user does NOT have permission to receive notification' do
include_context 'block user'
it_behaves_like 'does not send notification'
it 'does not update notified column' do
expect { subject.execute }.not_to change { key.reload.before_expiry_notification_delivered_at }
end
end
end

View file

@ -288,11 +288,19 @@ RSpec.describe NotificationService, :mailer do
end
end
end
end
describe '#ssh_key_expired' do
let_it_be(:user) { create(:user) }
describe 'SSH Keys' do
let_it_be_with_reload(:user) { create(:user) }
let_it_be(:fingerprints) { ["aa:bb:cc:dd:ee:zz"] }
shared_context 'block user' do
before do
user.block!
end
end
describe '#ssh_key_expired' do
subject { notification.ssh_key_expired(user, fingerprints) }
it 'sends email to the token owner' do
@ -300,15 +308,29 @@ RSpec.describe NotificationService, :mailer do
end
context 'when user is not allowed to receive notifications' do
before do
user.block!
end
include_context 'block user'
it 'does not send email to the token owner' do
expect { subject }.not_to have_enqueued_email(user, fingerprints, mail: "ssh_key_expired_email")
end
end
end
describe '#ssh_key_expiring_soon' do
subject { notification.ssh_key_expiring_soon(user, fingerprints) }
it 'sends email to the token owner' do
expect { subject }.to have_enqueued_email(user, fingerprints, mail: "ssh_key_expiring_soon_email")
end
context 'when user is not allowed to receive notifications' do
include_context 'block user'
it 'does not send email to the token owner' do
expect { subject }.not_to have_enqueued_email(user, fingerprints, mail: "ssh_key_expiring_soon_email")
end
end
end
end
describe '#unknown_sign_in' do

View file

@ -163,6 +163,7 @@ RSpec.describe 'layouts/header/_new_dropdown' do
end
it 'has a "New project" link' do
render('layouts/header/new_repo_experiment')
render
expect(rendered).to have_link('New project', href: new_project_path)

View file

@ -0,0 +1,66 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe SshKeys::ExpiringSoonNotificationWorker, type: :worker do
subject(:worker) { described_class.new }
it 'uses a cronjob queue' do
expect(worker.sidekiq_options_hash).to include(
'queue' => 'cronjob:ssh_keys_expiring_soon_notification',
'queue_namespace' => :cronjob
)
end
describe '#perform' do
let_it_be(:user) { create(:user) }
context 'with key expiring soon' do
let_it_be_with_reload(:expiring_soon) { create(:key, expires_at: 6.days.from_now, user: user) }
it 'invoke the notification service' do
expect_next_instance_of(Keys::ExpiryNotificationService) do |expiry_service|
expect(expiry_service).to receive(:execute)
end
worker.perform
end
it 'updates notified column' do
expect { worker.perform }.to change { expiring_soon.reload.before_expiry_notification_delivered_at }
end
include_examples 'an idempotent worker' do
subject do
perform_multiple(worker: worker)
end
end
context 'when feature is not enabled' do
before do
stub_feature_flags(ssh_key_expiration_email_notification: false)
end
it 'does not update notified column' do
expect { worker.perform }.not_to change { expiring_soon.reload.before_expiry_notification_delivered_at }
end
end
end
context 'when key has expired in the past' do
let_it_be(:expired_past) { create(:key, expires_at: 1.day.ago, user: user) }
it 'does not update notified column' do
expect { worker.perform }.not_to change { expired_past.reload.before_expiry_notification_delivered_at }
end
end
context 'when key is not expiring soon' do
let_it_be(:expires_future) { create(:key, expires_at: 8.days.from_now, user: user) }
it 'does not update notified column' do
expect { worker.perform }.not_to change { expires_future.reload.before_expiry_notification_delivered_at }
end
end
end
end