Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/blob/blob_utils.js5
-rw-r--r--app/assets/javascripts/blob/viewer/index.js58
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js20
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue3
-rw-r--r--app/assets/javascripts/boards/models/list.js4
-rw-r--r--app/assets/stylesheets/framework/files.scss11
-rw-r--r--app/assets/stylesheets/highlight/common.scss9
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss5
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss5
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss6
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss5
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss5
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss5
-rw-r--r--app/controllers/application_controller.rb10
-rw-r--r--app/controllers/concerns/confirm_email_warning.rb7
-rw-r--r--app/controllers/concerns/uploads_actions.rb17
-rw-r--r--app/controllers/uploads_controller.rb2
-rw-r--r--app/finders/group_descendants_finder.rb2
-rw-r--r--app/graphql/mutations/todos/base.rb17
-rw-r--r--app/graphql/mutations/todos/mark_done.rb38
-rw-r--r--app/graphql/types/mutation_type.rb1
-rw-r--r--app/models/ci/pipeline.rb2
-rw-r--r--app/models/concerns/issuable.rb4
-rw-r--r--app/models/group.rb2
-rw-r--r--app/models/merge_request.rb2
-rw-r--r--app/models/project.rb2
-rw-r--r--app/models/todo.rb4
-rw-r--r--app/policies/todo_policy.rb1
-rw-r--r--app/services/ci/register_job_service.rb2
-rw-r--r--app/services/cohorts_service.rb2
-rw-r--r--app/views/shared/_file_highlight.html.haml2
-rw-r--r--changelogs/unreleased/26207-issue-board-loading-infinite-if-closing-the-closed-row.yml5
-rw-r--r--changelogs/unreleased/31914-graphql-todos-mark-todo-as-done-pd.yml5
-rw-r--r--changelogs/unreleased/id-conditional-check-mergeability.yml5
-rw-r--r--changelogs/unreleased/mfluharty-clickable-links-in-file-view.yml5
-rw-r--r--db/migrate/20191112115247_add_cached_markdown_version_to_vulnerabilities.rb9
-rw-r--r--db/post_migrate/20191112115317_change_vulnerabilities_title_html_to_nullable.rb9
-rw-r--r--db/schema.rb5
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql36
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json133
-rw-r--r--doc/api/graphql/reference/index.md8
-rw-r--r--lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb2
-rw-r--r--lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml2
-rw-r--r--lib/gitlab/database/migration_helpers.rb2
-rw-r--r--locale/gitlab.pot3
-rw-r--r--spec/controllers/application_controller_spec.rb14
-rw-r--r--spec/controllers/uploads_controller_spec.rb24
-rw-r--r--spec/features/projects/blobs/edit_spec.rb7
-rw-r--r--spec/graphql/mutations/todos/mark_done_spec.rb66
-rw-r--r--spec/javascripts/blob/viewer/index_spec.js49
-rw-r--r--spec/javascripts/boards/board_list_common_spec.js13
-rw-r--r--spec/javascripts/boards/board_list_spec.js250
-rw-r--r--spec/models/concerns/redactable_spec.rb38
-rw-r--r--spec/models/merge_request_spec.rb44
-rw-r--r--spec/models/todo_spec.rb13
-rw-r--r--spec/requests/api/graphql/mutations/todos/mark_done_spec.rb97
-rw-r--r--spec/requests/user_avatar_spec.rb36
-rw-r--r--spec/support/shared_examples/models/concerns/issuable_shared_examples.rb (renamed from spec/support/shared_examples/models/concern/issuable_shared_examples.rb)0
-rw-r--r--spec/support/shared_examples/models/concerns/redactable_shared_examples.rb39
59 files changed, 811 insertions, 366 deletions
diff --git a/app/assets/javascripts/blob/blob_utils.js b/app/assets/javascripts/blob/blob_utils.js
deleted file mode 100644
index 27fcc7f7b79..00000000000
--- a/app/assets/javascripts/blob/blob_utils.js
+++ /dev/null
@@ -1,5 +0,0 @@
-// capture anything starting with http:// or https://
-// up until a disallowed character or whitespace
-export const blobLinkRegex = /https?:\/\/[^"<>\\^`{|}\s]+/g;
-
-export default { blobLinkRegex };
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index f032c2f216b..07e4dde41d9 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -4,10 +4,6 @@ import Flash from '../../flash';
import { handleLocationHash } from '../../lib/utils/common_utils';
import axios from '../../lib/utils/axios_utils';
import { __ } from '~/locale';
-import { blobLinkRegex } from '~/blob/blob_utils';
-
-const SIMPLE_VIEWER_NAME = 'simple';
-const RICH_VIEWER_NAME = 'rich';
export default class BlobViewer {
constructor() {
@@ -25,7 +21,7 @@ export default class BlobViewer {
}
static initRichViewer() {
- const viewer = document.querySelector(`.blob-viewer[data-type="${RICH_VIEWER_NAME}"]`);
+ const viewer = document.querySelector('.blob-viewer[data-type="rich"]');
if (!viewer || !viewer.dataset.richType) return;
const initViewer = promise =>
@@ -65,12 +61,8 @@ export default class BlobViewer {
this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn');
this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn');
- this.simpleViewer = this.$fileHolder[0].querySelector(
- `.blob-viewer[data-type="${SIMPLE_VIEWER_NAME}"]`,
- );
- this.richViewer = this.$fileHolder[0].querySelector(
- `.blob-viewer[data-type="${RICH_VIEWER_NAME}"]`,
- );
+ this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]');
+ this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]');
this.initBindings();
@@ -79,10 +71,10 @@ export default class BlobViewer {
switchToInitialViewer() {
const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)');
- let initialViewerName = initialViewer.dataset.type;
+ let initialViewerName = initialViewer.getAttribute('data-type');
if (this.switcher && window.location.hash.indexOf('#L') === 0) {
- initialViewerName = SIMPLE_VIEWER_NAME;
+ initialViewerName = 'simple';
}
this.switchToViewer(initialViewerName);
@@ -99,41 +91,35 @@ export default class BlobViewer {
this.copySourceBtn.addEventListener('click', () => {
if (this.copySourceBtn.classList.contains('disabled')) return this.copySourceBtn.blur();
- return this.switchToViewer(SIMPLE_VIEWER_NAME);
+ return this.switchToViewer('simple');
});
}
}
- static linkifyURLs(viewer) {
- if (viewer.dataset.linkified) return;
-
- document.querySelectorAll('.js-blob-content .code .line').forEach(line => {
- // eslint-disable-next-line no-param-reassign
- line.innerHTML = line.innerHTML.replace(blobLinkRegex, '<a href="$&">$&</a>');
- });
-
- // eslint-disable-next-line no-param-reassign
- viewer.dataset.linkified = true;
- }
-
switchViewHandler(e) {
const target = e.currentTarget;
e.preventDefault();
- this.switchToViewer(target.dataset.viewer);
+ this.switchToViewer(target.getAttribute('data-viewer'));
}
toggleCopyButtonState() {
if (!this.copySourceBtn) return;
- if (this.simpleViewer.dataset.loaded) {
- this.copySourceBtn.dataset.title = __('Copy file contents');
+ if (this.simpleViewer.getAttribute('data-loaded')) {
+ this.copySourceBtn.setAttribute('title', __('Copy file contents'));
this.copySourceBtn.classList.remove('disabled');
} else if (this.activeViewer === this.simpleViewer) {
- this.copySourceBtn.dataset.title = __('Wait for the file to load to copy its contents');
+ this.copySourceBtn.setAttribute(
+ 'title',
+ __('Wait for the file to load to copy its contents'),
+ );
this.copySourceBtn.classList.add('disabled');
} else {
- this.copySourceBtn.dataset.title = __('Switch to the source to copy the file contents');
+ this.copySourceBtn.setAttribute(
+ 'title',
+ __('Switch to the source to copy the file contents'),
+ );
this.copySourceBtn.classList.add('disabled');
}
@@ -173,8 +159,6 @@ export default class BlobViewer {
this.$fileHolder.trigger('highlight:line');
handleLocationHash();
- if (name === SIMPLE_VIEWER_NAME) BlobViewer.linkifyURLs(viewer);
-
this.toggleCopyButtonState();
})
.catch(() => new Flash(__('Error loading viewer')));
@@ -182,17 +166,17 @@ export default class BlobViewer {
static loadViewer(viewerParam) {
const viewer = viewerParam;
- const { url, loaded, loading } = viewer.dataset;
+ const url = viewer.getAttribute('data-url');
- if (!url || loaded || loading) {
+ if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) {
return Promise.resolve(viewer);
}
- viewer.dataset.loading = true;
+ viewer.setAttribute('data-loading', 'true');
return axios.get(url).then(({ data }) => {
viewer.innerHTML = data.html;
- viewer.dataset.loaded = true;
+ viewer.setAttribute('data-loaded', 'true');
return viewer;
});
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 8561f650e8f..011898a5e7a 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -4,8 +4,7 @@ import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
-import { blobLinkRegex } from '~/blob/blob_utils';
-import TemplateSelectorMediator from '~/blob/file_template_mediator';
+import TemplateSelectorMediator from '../blob/file_template_mediator';
import getModeByFileExtension from '~/lib/utils/ace_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
@@ -18,7 +17,6 @@ export default class EditBlob {
this.initModePanesAndLinks();
this.initSoftWrap();
this.initFileSelectors();
- this.initBlobContentLinkClickability();
}
configureAceEditor() {
@@ -91,22 +89,6 @@ export default class EditBlob {
return this.editor.focus();
}
- initBlobContentLinkClickability() {
- this.editor.renderer.on('afterRender', () => {
- document.querySelectorAll('.ace_text-layer .ace_line > *').forEach(token => {
- if (token.dataset.linkified || !token.textContent.includes('http')) return;
-
- // eslint-disable-next-line no-param-reassign
- token.innerHTML = token.innerHTML.replace(
- blobLinkRegex,
- '<a target="_blank" href="$&">$&</a>',
- );
- // eslint-disable-next-line no-param-reassign
- token.dataset.linkified = true;
- });
- });
- }
-
initSoftWrap() {
this.isSoftWrapped = false;
this.$toggleButton = $('.soft-wrap-toggle');
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 1273fcc6a91..b8439bc8741 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -84,7 +84,8 @@ export default {
this.$nextTick(() => {
if (
this.scrollHeight() <= this.listHeight() &&
- this.list.issuesSize > this.list.issues.length
+ this.list.issuesSize > this.list.issues.length &&
+ this.list.isExpanded
) {
this.list.page += 1;
this.list.getIssues(false).catch(() => {
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 1e213c324eb..bb8c8e68297 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -50,8 +50,8 @@ class List {
this.page = 1;
this.loading = true;
this.loadingMore = false;
- this.issues = [];
- this.issuesSize = 0;
+ this.issues = obj.issues || [];
+ this.issuesSize = obj.issuesSize ? obj.issuesSize : 0;
this.defaultAvatar = defaultAvatar;
if (obj.label) {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index fc4944d731e..4938215b2e7 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -258,17 +258,6 @@
}
}
}
-
- .file-editor {
- .ace_underline {
- text-decoration: none;
- }
-
- .ace_line a {
- pointer-events: auto;
- color: inherit;
- }
- }
}
span.idiff {
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index 95f6fb8c333..bdeac7e97c0 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -29,12 +29,3 @@
color: $link;
}
}
-
-// Links to URLs, emails, or dependencies
-.code .line a {
- color: inherit;
-
- &:hover {
- text-decoration: underline;
- }
-}
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 16e6824baf8..cbce0ba3f1e 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -193,6 +193,11 @@ $dark-il: #de935f;
color: $dark-highlight-color !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $dark-na;
+ }
+
.hll { background-color: $dark-hll-bg; }
.c { color: $dark-c; } /* Comment */
.err { color: $dark-err; } /* Error */
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index cfbb7a1db94..1b61ffa37e3 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -193,6 +193,11 @@ $monokai-gi: #a6e22e;
color: $black !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $monokai-k;
+ }
+
.hll { background-color: $monokai-hll; }
.c { color: $monokai-c; } /* Comment */
.err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index a099563542d..a7ede266fb5 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -143,6 +143,12 @@
background-color: $white-normal;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $gl-text-color;
+ text-decoration: underline;
+ }
+
.hll { background-color: $white-light; }
.gd {
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index d74d5c6ebda..6569f3abc8b 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -196,6 +196,11 @@ $solarized-dark-il: #2aa198;
background-color: $solarized-dark-highlight !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $solarized-dark-kd;
+ }
+
/* Solarized Dark
For use with Jekyll and Pygments
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index d995c5bba1f..4e74a9ea50a 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -204,6 +204,11 @@ $solarized-light-il: #2aa198;
background-color: $solarized-light-highlight !important;
}
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $solarized-light-kd;
+ }
+
/* Solarized Light
For use with Jekyll and Pygments
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index c58cf89f0ca..973f94c63aa 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -209,6 +209,11 @@ span.highlight_word {
background-color: $white-highlight !important;
}
+// Links to URLs, emails, or dependencies
+.line a {
+ color: $white-nb;
+}
+
.hll { background-color: $white-hll-bg; }
.c { color: $white-c;
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index e4d878641b3..c85b192b34a 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -20,11 +20,11 @@ class ApplicationController < ActionController::Base
before_action :authenticate_user!, except: [:route_not_found]
before_action :enforce_terms!, if: :should_enforce_terms?
before_action :validate_user_service_ticket!
- before_action :check_password_expiration, if: :html_request?
+ before_action :check_password_expiration
before_action :ldap_security_check
before_action :sentry_context
before_action :default_headers
- before_action :add_gon_variables, if: :html_request?
+ before_action :add_gon_variables, unless: [:peek_request?, :json_request?]
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :devise_controller?
before_action :active_user_check, unless: :devise_controller?
@@ -455,8 +455,8 @@ class ApplicationController < ActionController::Base
response.headers['Page-Title'] = URI.escape(page_title('GitLab'))
end
- def html_request?
- request.format.html?
+ def peek_request?
+ request.path.start_with?('/-/peek')
end
def json_request?
@@ -466,7 +466,7 @@ class ApplicationController < ActionController::Base
def should_enforce_terms?
return false unless Gitlab::CurrentSettings.current_application_settings.enforce_terms
- html_request? && !devise_controller?
+ !(peek_request? || devise_controller?)
end
def set_usage_stats_consent_flag
diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb
index 32e1a46e580..86df0010665 100644
--- a/app/controllers/concerns/confirm_email_warning.rb
+++ b/app/controllers/concerns/confirm_email_warning.rb
@@ -4,18 +4,15 @@ module ConfirmEmailWarning
extend ActiveSupport::Concern
included do
- before_action :set_confirm_warning, if: :show_confirm_warning?
+ before_action :set_confirm_warning, if: -> { Feature.enabled?(:soft_email_confirmation) }
end
protected
- def show_confirm_warning?
- html_request? && request.get? && Feature.enabled?(:soft_email_confirmation)
- end
-
def set_confirm_warning
return unless current_user
return if current_user.confirmed?
+ return if peek_request? || json_request? || !request.get?
email = current_user.unconfirmed_email || current_user.email
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index 023c41821da..b87779c22d3 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -1,16 +1,11 @@
# frozen_string_literal: true
module UploadsActions
- extend ActiveSupport::Concern
include Gitlab::Utils::StrongMemoize
include SendFileUpload
UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze
- included do
- prepend_before_action :set_request_format_from_path_extension
- end
-
def create
uploader = UploadService.new(model, params[:file], uploader_class).execute
@@ -69,18 +64,6 @@ module UploadsActions
private
- # From ActionDispatch::Http::MimeNegotiation. We have an initializer that
- # monkey-patches this method out (so that repository paths don't guess a
- # format based on extension), but we do want this behaviour when serving
- # uploads.
- def set_request_format_from_path_extension
- path = request.headers['action_dispatch.original_path'] || request.headers['PATH_INFO']
-
- if match = path&.match(/\.(\w+)\z/)
- request.format = match.captures.first
- end
- end
-
def uploader_class
raise NotImplementedError
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index f39a2b81b54..635db386792 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -20,7 +20,7 @@ class UploadsController < ApplicationController
skip_before_action :authenticate_user!
before_action :upload_mount_satisfied?
- before_action :model
+ before_action :find_model
before_action :authorize_access!, only: [:show]
before_action :authorize_create_access!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index 4e489a9c930..1f6829a97d6 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -80,7 +80,7 @@ class GroupDescendantsFinder
if current_user
authorized_groups = GroupsFinder.new(current_user,
all_available: false)
- .execute.as('authorized')
+ .execute.arel.as('authorized')
authorized_to_user = groups_table.project(1).from(authorized_groups)
.where(authorized_groups[:id].eq(groups_table[:id]))
.exists
diff --git a/app/graphql/mutations/todos/base.rb b/app/graphql/mutations/todos/base.rb
new file mode 100644
index 00000000000..b6c7b320be1
--- /dev/null
+++ b/app/graphql/mutations/todos/base.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Todos
+ class Base < ::Mutations::BaseMutation
+ private
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id)
+ end
+
+ def to_global_id(id)
+ ::URI::GID.build(app: GlobalID.app, model_name: Todo.name, model_id: id, params: nil).to_s
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb
new file mode 100644
index 00000000000..5483708b5c6
--- /dev/null
+++ b/app/graphql/mutations/todos/mark_done.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Todos
+ class MarkDone < ::Mutations::Todos::Base
+ graphql_name 'TodoMarkDone'
+
+ authorize :update_todo
+
+ argument :id,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The global id of the todo to mark as done'
+
+ field :todo, Types::TodoType,
+ null: false,
+ description: 'The requested todo'
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def resolve(id:)
+ todo = authorized_find!(id: id)
+ mark_done(Todo.where(id: todo.id)) unless todo.done?
+
+ {
+ todo: todo.reset,
+ errors: errors_on_object(todo)
+ }
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def mark_done(todo)
+ TodoService.new.mark_todos_as_done(todo, current_user)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index c636bf0e31f..632e1fdc2df 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -16,6 +16,7 @@ module Types
mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true
mount_mutation Mutations::Notes::Update
mount_mutation Mutations::Notes::Destroy
+ mount_mutation Mutations::Todos::MarkDone
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 12926bc2379..f9840e13e03 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -405,7 +405,7 @@ module Ci
.where('stage=sg.stage').failed_but_allowed.to_sql
stages_with_statuses = CommitStatus.from(stages_query, :sg)
- .pluck('sg.stage', status_sql, "(#{warnings_sql})")
+ .pluck('sg.stage', Arel.sql(status_sql), Arel.sql("(#{warnings_sql})"))
stages_with_statuses.map do |stage|
Ci::LegacyStage.new(self, Hash[%i[name status warnings].zip(stage)])
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 796e6438a2c..01cd1e0224b 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -118,8 +118,8 @@ module Issuable
# rubocop:enable GitlabSecurity/SqlInjection
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
- scope :order_milestone_due_desc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC') }
- scope :order_milestone_due_asc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC') }
+ scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) }
+ scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) }
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
scope :any_label, -> { joins(:label_links).group(:id) }
diff --git a/app/models/group.rb b/app/models/group.rb
index 71d81289bf5..7496fee0b51 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -126,7 +126,7 @@ class Group < Namespace
def visible_to_user_arel(user)
groups_table = self.arel_table
- authorized_groups = user.authorized_groups.as('authorized')
+ authorized_groups = user.authorized_groups.arel.as('authorized')
groups_table.project(1)
.from(authorized_groups)
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 5e3cef0a52f..b85285978ab 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -796,6 +796,8 @@ class MergeRequest < ApplicationRecord
end
def check_mergeability
+ return if Feature.enabled?(:merge_requests_conditional_mergeability_check, default_enabled: true) && !recheck_merge_status?
+
MergeRequests::MergeabilityCheckService.new(self).execute(retry_lease: false)
end
# rubocop: enable CodeReuse/ServiceClass
diff --git a/app/models/project.rb b/app/models/project.rb
index bafde342157..9ee162df241 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -1918,7 +1918,7 @@ class Project < ApplicationRecord
end
def default_environment
- production_first = "(CASE WHEN name = 'production' THEN 0 ELSE 1 END), id ASC"
+ production_first = Arel.sql("(CASE WHEN name = 'production' THEN 0 ELSE 1 END), id ASC")
environments
.with_state(:available)
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 1927b54510e..4e48fb3b782 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -160,6 +160,10 @@ class Todo < ApplicationRecord
action == ASSIGNED
end
+ def done?
+ state == 'done'
+ end
+
def action_name
ACTION_NAMES[action]
end
diff --git a/app/policies/todo_policy.rb b/app/policies/todo_policy.rb
index f8644217f04..d01a046c343 100644
--- a/app/policies/todo_policy.rb
+++ b/app/policies/todo_policy.rb
@@ -7,4 +7,5 @@ class TodoPolicy < BasePolicy
end
rule { own_todo }.enable :read_todo
+ rule { own_todo }.enable :update_todo
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index ff140444c1c..30e2a66e04a 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -149,7 +149,7 @@ module Ci
# this returns builds that are ordered by number of running builds
# we prefer projects that don't use shared runners at all
joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id")
- .order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC')
+ .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC')
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb
index 97fbb70f350..dbbe89ef260 100644
--- a/app/services/cohorts_service.rb
+++ b/app/services/cohorts_service.rb
@@ -88,7 +88,7 @@ class CohortsService
User
.where('created_at > ?', MONTHS_INCLUDED.months.ago.end_of_month)
.group(created_at_month, last_activity_on_month)
- .reorder("#{created_at_month} ASC", "#{last_activity_on_month} ASC")
+ .reorder(Arel.sql("#{created_at_month} ASC, #{last_activity_on_month} ASC"))
.count
end
end
diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml
index 4e609f50993..d7e57fc0d01 100644
--- a/app/views/shared/_file_highlight.html.haml
+++ b/app/views/shared/_file_highlight.html.haml
@@ -10,7 +10,7 @@
%a.diff-line-num{ href: "#{link}#L#{i}", id: "L#{i}", 'data-line-number' => i }
= link_icon
= i
- .blob-content.js-blob-content{ data: { blob_id: blob.id } }
+ .blob-content{ data: { blob_id: blob.id } }
%pre.code.highlight
%code
= blob.present.highlight
diff --git a/changelogs/unreleased/26207-issue-board-loading-infinite-if-closing-the-closed-row.yml b/changelogs/unreleased/26207-issue-board-loading-infinite-if-closing-the-closed-row.yml
new file mode 100644
index 00000000000..f1b7e8a948e
--- /dev/null
+++ b/changelogs/unreleased/26207-issue-board-loading-infinite-if-closing-the-closed-row.yml
@@ -0,0 +1,5 @@
+---
+title: Fix closed board list loading issue
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/31914-graphql-todos-mark-todo-as-done-pd.yml b/changelogs/unreleased/31914-graphql-todos-mark-todo-as-done-pd.yml
new file mode 100644
index 00000000000..2a5a7a2ec5e
--- /dev/null
+++ b/changelogs/unreleased/31914-graphql-todos-mark-todo-as-done-pd.yml
@@ -0,0 +1,5 @@
+---
+title: Mark todo done by GraphQL API
+merge_request: 18581
+author:
+type: added
diff --git a/changelogs/unreleased/id-conditional-check-mergeability.yml b/changelogs/unreleased/id-conditional-check-mergeability.yml
new file mode 100644
index 00000000000..1b52c86df59
--- /dev/null
+++ b/changelogs/unreleased/id-conditional-check-mergeability.yml
@@ -0,0 +1,5 @@
+---
+title: Run check_mergeability only if merge status requires it
+merge_request: 19364
+author:
+type: performance
diff --git a/changelogs/unreleased/mfluharty-clickable-links-in-file-view.yml b/changelogs/unreleased/mfluharty-clickable-links-in-file-view.yml
deleted file mode 100644
index e6d42e10c19..00000000000
--- a/changelogs/unreleased/mfluharty-clickable-links-in-file-view.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Make URLs in blob viewer and blob editor into clickable links
-merge_request: 18305
-author:
-type: added
diff --git a/db/migrate/20191112115247_add_cached_markdown_version_to_vulnerabilities.rb b/db/migrate/20191112115247_add_cached_markdown_version_to_vulnerabilities.rb
new file mode 100644
index 00000000000..b0c513737e8
--- /dev/null
+++ b/db/migrate/20191112115247_add_cached_markdown_version_to_vulnerabilities.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddCachedMarkdownVersionToVulnerabilities < ActiveRecord::Migration[5.2]
+ DOWNTIME = false
+
+ def change
+ add_column :vulnerabilities, :cached_markdown_version, :integer
+ end
+end
diff --git a/db/post_migrate/20191112115317_change_vulnerabilities_title_html_to_nullable.rb b/db/post_migrate/20191112115317_change_vulnerabilities_title_html_to_nullable.rb
new file mode 100644
index 00000000000..6e0f3247410
--- /dev/null
+++ b/db/post_migrate/20191112115317_change_vulnerabilities_title_html_to_nullable.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class ChangeVulnerabilitiesTitleHtmlToNullable < ActiveRecord::Migration[5.2]
+ DOWNTIME = false
+
+ def change
+ change_column_null :vulnerabilities, :title_html, true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index b5991904302..0c816d4764e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2019_11_11_115431) do
+ActiveRecord::Schema.define(version: 2019_11_12_115317) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
@@ -3928,7 +3928,7 @@ ActiveRecord::Schema.define(version: 2019_11_11_115431) do
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.string "title", limit: 255, null: false
- t.text "title_html", null: false
+ t.text "title_html"
t.text "description"
t.text "description_html"
t.bigint "start_date_sourcing_milestone_id"
@@ -3941,6 +3941,7 @@ ActiveRecord::Schema.define(version: 2019_11_11_115431) do
t.integer "confidence", limit: 2, null: false
t.boolean "confidence_overridden", default: false
t.integer "report_type", limit: 2, null: false
+ t.integer "cached_markdown_version"
t.index ["author_id"], name: "index_vulnerabilities_on_author_id"
t.index ["closed_by_id"], name: "index_vulnerabilities_on_closed_by_id"
t.index ["due_date_sourcing_milestone_id"], name: "index_vulnerabilities_on_due_date_sourcing_milestone_id"
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index a74ce3491d3..d7dd5774365 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -3413,6 +3413,7 @@ type Mutation {
mergeRequestSetMilestone(input: MergeRequestSetMilestoneInput!): MergeRequestSetMilestonePayload
mergeRequestSetWip(input: MergeRequestSetWipInput!): MergeRequestSetWipPayload
removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload
+ todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload
toggleAwardEmoji(input: ToggleAwardEmojiInput!): ToggleAwardEmojiPayload
updateEpic(input: UpdateEpicInput!): UpdateEpicPayload
updateNote(input: UpdateNoteInput!): UpdateNotePayload
@@ -4826,6 +4827,41 @@ type TodoEdge {
node: Todo
}
+"""
+Autogenerated input type of TodoMarkDone
+"""
+input TodoMarkDoneInput {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ The global id of the todo to mark as done
+ """
+ id: ID!
+}
+
+"""
+Autogenerated return type of TodoMarkDone
+"""
+type TodoMarkDonePayload {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Reasons why the mutation failed.
+ """
+ errors: [String!]!
+
+ """
+ The requested todo
+ """
+ todo: Todo!
+}
+
enum TodoStateEnum {
done
pending
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 5f48eb01f72..da8ff669b72 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -14558,6 +14558,33 @@
"deprecationReason": null
},
{
+ "name": "todoMarkDone",
+ "description": null,
+ "args": [
+ {
+ "name": "input",
+ "description": null,
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "INPUT_OBJECT",
+ "name": "TodoMarkDoneInput",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "TodoMarkDonePayload",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "toggleAwardEmoji",
"description": null,
"args": [
@@ -16232,6 +16259,112 @@
},
{
"kind": "OBJECT",
+ "name": "TodoMarkDonePayload",
+ "description": "Autogenerated return type of TodoMarkDone",
+ "fields": [
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "errors",
+ "description": "Reasons why the mutation failed.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "todo",
+ "description": "The requested todo",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "Todo",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "INPUT_OBJECT",
+ "name": "TodoMarkDoneInput",
+ "description": "Autogenerated input type of TodoMarkDone",
+ "fields": null,
+ "inputFields": [
+ {
+ "name": "id",
+ "description": "The global id of the todo to mark as done",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
"name": "DesignManagementUploadPayload",
"description": "Autogenerated return type of DesignManagementUpload",
"fields": [
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index bfda8ff1194..aaba282882d 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -756,6 +756,14 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `state` | TodoStateEnum! | State of the todo |
| `createdAt` | Time! | Timestamp this todo was created |
+### TodoMarkDonePayload
+
+| Name | Type | Description |
+| --- | ---- | ---------- |
+| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
+| `errors` | String! => Array | Reasons why the mutation failed. |
+| `todo` | Todo! | The requested todo |
+
### ToggleAwardEmojiPayload
| Name | Type | Description |
diff --git a/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb b/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb
index f5fb33f1660..23e8be4a9ab 100644
--- a/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb
+++ b/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb
@@ -176,7 +176,7 @@ module Gitlab
self.table_name = 'projects'
def self.find_by_full_path(path)
- order_sql = "(CASE WHEN routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)"
+ order_sql = Arel.sql("(CASE WHEN routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)")
where_full_path_in(path).reorder(order_sql).take
end
diff --git a/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml
index eff20b84e3f..9cc3b4cade6 100644
--- a/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml
@@ -28,7 +28,7 @@ dast_environment_deploy:
variables:
- $CI_DEFAULT_BRANCH != $CI_COMMIT_REF_NAME
- $DAST_DISABLED || $DAST_DISABLED_FOR_DEFAULT_BRANCH
- - $DAST_WEBSITE # we don't need to create a review app if a URL is already given
+ - $DAST_WEBSITE # we don't need to create a review app if a URL is already given
stop_dast_environment:
extends: .auto-deploy
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index 6bada921ad4..7ea7565f758 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -955,7 +955,7 @@ into similar problems in the future (e.g. when new tables are created).
table_name = model_class.quoted_table_name
model_class.each_batch(of: batch_size) do |relation|
- start_id, end_id = relation.pluck("MIN(#{table_name}.id), MAX(#{table_name}.id)").first
+ start_id, end_id = relation.pluck("MIN(#{table_name}.id)", "MAX(#{table_name}.id)").first
if jobs.length >= BACKGROUND_MIGRATION_JOB_BUFFER_SIZE
# Note: This code path generally only helps with many millions of rows
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4501366a318..6dc257ec67a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -20349,6 +20349,9 @@ msgstr ""
msgid "failed to dismiss associated finding(id=%{finding_id}): %{message}"
msgstr ""
+msgid "finding is not found or is already attached to a vulnerability"
+msgstr ""
+
msgid "for %{link_to_merge_request} with %{link_to_merge_request_source_branch}"
msgstr ""
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index ca39f5dd9f2..53896c7f5c7 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -90,16 +90,18 @@ describe ApplicationController do
let(:format) { :html }
it_behaves_like 'setting gon variables'
- end
- context 'with json format' do
- let(:format) { :json }
+ context 'for peek requests' do
+ before do
+ request.path = '/-/peek'
+ end
- it_behaves_like 'not setting gon variables'
+ it_behaves_like 'not setting gon variables'
+ end
end
- context 'with atom format' do
- let(:format) { :atom }
+ context 'with json format' do
+ let(:format) { :json }
it_behaves_like 'not setting gon variables'
end
diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb
index f35babc1b56..1bcf3bb106b 100644
--- a/spec/controllers/uploads_controller_spec.rb
+++ b/spec/controllers/uploads_controller_spec.rb
@@ -228,10 +228,10 @@ describe UploadsController do
user.block
end
- it "responds with status 401" do
+ it "redirects to the sign in page" do
get :show, params: { model: "user", mounted_as: "avatar", id: user.id, filename: "dk.png" }
- expect(response).to have_gitlab_http_status(401)
+ expect(response).to redirect_to(new_user_session_path)
end
end
@@ -320,10 +320,10 @@ describe UploadsController do
end
context "when not signed in" do
- it "responds with status 401" do
+ it "redirects to the sign in page" do
get :show, params: { model: "project", mounted_as: "avatar", id: project.id, filename: "dk.png" }
- expect(response).to have_gitlab_http_status(401)
+ expect(response).to redirect_to(new_user_session_path)
end
end
@@ -343,10 +343,10 @@ describe UploadsController do
project.add_maintainer(user)
end
- it "responds with status 401" do
+ it "redirects to the sign in page" do
get :show, params: { model: "project", mounted_as: "avatar", id: project.id, filename: "dk.png" }
- expect(response).to have_gitlab_http_status(401)
+ expect(response).to redirect_to(new_user_session_path)
end
end
@@ -439,10 +439,10 @@ describe UploadsController do
user.block
end
- it "responds with status 401" do
+ it "redirects to the sign in page" do
get :show, params: { model: "group", mounted_as: "avatar", id: group.id, filename: "dk.png" }
- expect(response).to have_gitlab_http_status(401)
+ expect(response).to redirect_to(new_user_session_path)
end
end
@@ -526,10 +526,10 @@ describe UploadsController do
end
context "when not signed in" do
- it "responds with status 401" do
+ it "redirects to the sign in page" do
get :show, params: { model: "note", mounted_as: "attachment", id: note.id, filename: "dk.png" }
- expect(response).to have_gitlab_http_status(401)
+ expect(response).to redirect_to(new_user_session_path)
end
end
@@ -549,10 +549,10 @@ describe UploadsController do
project.add_maintainer(user)
end
- it "responds with status 401" do
+ it "redirects to the sign in page" do
get :show, params: { model: "note", mounted_as: "attachment", id: note.id, filename: "dk.png" }
- expect(response).to have_gitlab_http_status(401)
+ expect(response).to redirect_to(new_user_session_path)
end
end
diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb
index b81f52a541b..ef209fecac6 100644
--- a/spec/features/projects/blobs/edit_spec.rb
+++ b/spec/features/projects/blobs/edit_spec.rb
@@ -62,13 +62,6 @@ describe 'Editing file blob', :js do
expect(page).to have_content 'NextFeature'
end
- it 'renders a URL in the content of file as a link' do
- project.repository.create_file(user, 'file.yml', '# go to https://gitlab.com', message: 'testing', branch_name: branch)
- visit project_edit_blob_path(project, tree_join(branch, 'file.yml'))
-
- expect(page).to have_selector('.ace_content .ace_line a')
- end
-
context 'from blob file path' do
before do
visit project_blob_path(project, tree_join(branch, file_path))
diff --git a/spec/graphql/mutations/todos/mark_done_spec.rb b/spec/graphql/mutations/todos/mark_done_spec.rb
new file mode 100644
index 00000000000..761b153d5d1
--- /dev/null
+++ b/spec/graphql/mutations/todos/mark_done_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Mutations::Todos::MarkDone do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:other_user) { create(:user) }
+
+ let_it_be(:todo1) { create(:todo, user: current_user, author: author, state: :pending) }
+ let_it_be(:todo2) { create(:todo, user: current_user, author: author, state: :done) }
+
+ let_it_be(:other_user_todo) { create(:todo, user: other_user, author: author, state: :pending) }
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }) }
+
+ describe '#resolve' do
+ it 'marks a single todo as done' do
+ result = mark_done_mutation(todo1)
+
+ expect(todo1.reload.state).to eq('done')
+ expect(todo2.reload.state).to eq('done')
+ expect(other_user_todo.reload.state).to eq('pending')
+
+ todo = result[:todo]
+ expect(todo.id).to eq(todo1.id)
+ expect(todo.state).to eq('done')
+ end
+
+ it 'handles a todo which is already done as expected' do
+ result = mark_done_mutation(todo2)
+
+ expect(todo1.reload.state).to eq('pending')
+ expect(todo2.reload.state).to eq('done')
+ expect(other_user_todo.reload.state).to eq('pending')
+
+ todo = result[:todo]
+ expect(todo.id).to eq(todo2.id)
+ expect(todo.state).to eq('done')
+ end
+
+ it 'ignores requests for todos which do not belong to the current user' do
+ expect { mark_done_mutation(other_user_todo) }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+
+ expect(todo1.reload.state).to eq('pending')
+ expect(todo2.reload.state).to eq('done')
+ expect(other_user_todo.reload.state).to eq('pending')
+ end
+
+ it 'ignores invalid GIDs' do
+ expect { mutation.resolve(id: 'invalid_gid') }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+
+ expect(todo1.reload.state).to eq('pending')
+ expect(todo2.reload.state).to eq('done')
+ expect(other_user_todo.reload.state).to eq('pending')
+ end
+ end
+
+ def mark_done_mutation(todo)
+ mutation.resolve(id: global_id_of(todo))
+ end
+
+ def global_id_of(todo)
+ todo.to_global_id.to_s
+ end
+end
diff --git a/spec/javascripts/blob/viewer/index_spec.js b/spec/javascripts/blob/viewer/index_spec.js
index bbc59632f3c..06c06613887 100644
--- a/spec/javascripts/blob/viewer/index_spec.js
+++ b/spec/javascripts/blob/viewer/index_spec.js
@@ -11,13 +11,6 @@ describe('Blob viewer', () => {
preloadFixtures('snippets/show.html');
- const asyncClick = () =>
- new Promise(resolve => {
- document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
-
- setTimeout(resolve);
- });
-
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -73,12 +66,19 @@ describe('Blob viewer', () => {
});
it('doesnt reload file if already loaded', done => {
+ const asyncClick = () =>
+ new Promise(resolve => {
+ document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
+
+ setTimeout(resolve);
+ });
+
asyncClick()
.then(() => asyncClick())
.then(() => {
- expect(document.querySelector('.blob-viewer[data-type="simple"]').dataset.loaded).toBe(
- 'true',
- );
+ expect(
+ document.querySelector('.blob-viewer[data-type="simple"]').getAttribute('data-loaded'),
+ ).toBe('true');
done();
})
@@ -100,7 +100,9 @@ describe('Blob viewer', () => {
});
it('has tooltip when disabled', () => {
- expect(copyButton.dataset.title).toBe('Switch to the source to copy the file contents');
+ expect(copyButton.getAttribute('data-original-title')).toBe(
+ 'Switch to the source to copy the file contents',
+ );
});
it('is blurred when clicked and disabled', () => {
@@ -134,7 +136,7 @@ describe('Blob viewer', () => {
document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
setTimeout(() => {
- expect(copyButton.dataset.title).toBe('Copy file contents');
+ expect(copyButton.getAttribute('data-original-title')).toBe('Copy file contents');
done();
});
@@ -175,27 +177,4 @@ describe('Blob viewer', () => {
expect(axios.get.calls.count()).toBe(1);
});
});
-
- describe('a URL inside the blob content', () => {
- beforeEach(() => {
- mock.onGet('http://test.host/snippets/1.json?viewer=simple').reply(200, {
- html:
- '<div class="js-blob-content"><pre class="code"><code><span class="line" lang="yaml"><span class="c1">To install gitlab-shell you also need a Go compiler version 1.8 or newer. https://golang.org/dl/</span></span></code></pre></div>',
- });
- });
-
- it('is rendered as a link in simple view', done => {
- asyncClick()
- .then(() => {
- expect(document.querySelector('.blob-viewer[data-type="simple"]').innerHTML).toContain(
- '<a href="https://golang.org/dl/">https://golang.org/dl/</a>',
- );
- done();
- })
- .catch(() => {
- fail();
- done();
- });
- });
- });
});
diff --git a/spec/javascripts/boards/board_list_common_spec.js b/spec/javascripts/boards/board_list_common_spec.js
index 5cd17323d0d..ada7589b795 100644
--- a/spec/javascripts/boards/board_list_common_spec.js
+++ b/spec/javascripts/boards/board_list_common_spec.js
@@ -15,7 +15,12 @@ import boardsStore from '~/boards/stores/boards_store';
window.Sortable = Sortable;
-export default function createComponent({ done, listIssueProps = {}, componentProps = {} }) {
+export default function createComponent({
+ done,
+ listIssueProps = {},
+ componentProps = {},
+ listProps = {},
+}) {
const el = document.createElement('div');
document.body.appendChild(el);
@@ -25,7 +30,7 @@ export default function createComponent({ done, listIssueProps = {}, componentPr
boardsStore.create();
const BoardListComp = Vue.extend(BoardList);
- const list = new List(listObj);
+ const list = new List({ ...listObj, ...listProps });
const issue = new ListIssue({
title: 'Testing',
id: 1,
@@ -35,7 +40,9 @@ export default function createComponent({ done, listIssueProps = {}, componentPr
assignees: [],
...listIssueProps,
});
- list.issuesSize = 1;
+ if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) {
+ list.issuesSize = 1;
+ }
list.issues.push(issue);
const component = new BoardListComp({
diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js
index 6774a46ed58..37e96e97279 100644
--- a/spec/javascripts/boards/board_list_spec.js
+++ b/spec/javascripts/boards/board_list_spec.js
@@ -1,156 +1,210 @@
+/* global List */
+
import Vue from 'vue';
import eventHub from '~/boards/eventhub';
import createComponent from './board_list_common_spec';
+import waitForPromises from '../helpers/wait_for_promises';
+
+import '~/boards/models/list';
describe('Board list component', () => {
let mock;
let component;
+ let getIssues;
+ function generateIssues(compWrapper) {
+ for (let i = 1; i < 20; i += 1) {
+ const issue = Object.assign({}, compWrapper.list.issues[0]);
+ issue.id += i;
+ compWrapper.list.issues.push(issue);
+ }
+ }
- beforeEach(done => {
- ({ mock, component } = createComponent({ done }));
- });
+ describe('When Expanded', () => {
+ beforeEach(done => {
+ getIssues = spyOn(List.prototype, 'getIssues').and.returnValue(new Promise(() => {}));
+ ({ mock, component } = createComponent({ done }));
+ });
- afterEach(() => {
- mock.restore();
- });
+ afterEach(() => {
+ mock.restore();
+ component.$destroy();
+ });
- it('renders component', () => {
- expect(component.$el.classList.contains('board-list-component')).toBe(true);
- });
+ it('loads first page of issues', done => {
+ waitForPromises()
+ .then(() => {
+ expect(getIssues).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
- it('renders loading icon', done => {
- component.loading = true;
+ it('renders component', () => {
+ expect(component.$el.classList.contains('board-list-component')).toBe(true);
+ });
+
+ it('renders loading icon', done => {
+ component.loading = true;
- Vue.nextTick(() => {
- expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
- done();
+ done();
+ });
});
- });
- it('renders issues', () => {
- expect(component.$el.querySelectorAll('.board-card').length).toBe(1);
- });
+ it('renders issues', () => {
+ expect(component.$el.querySelectorAll('.board-card').length).toBe(1);
+ });
- it('sets data attribute with issue id', () => {
- expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1');
- });
+ it('sets data attribute with issue id', () => {
+ expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1');
+ });
- it('shows new issue form', done => {
- component.toggleForm();
+ it('shows new issue form', done => {
+ component.toggleForm();
- Vue.nextTick(() => {
- expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
- expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
+ expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
- done();
+ done();
+ });
});
- });
- it('shows new issue form after eventhub event', done => {
- eventHub.$emit(`hide-issue-form-${component.list.id}`);
+ it('shows new issue form after eventhub event', done => {
+ eventHub.$emit(`hide-issue-form-${component.list.id}`);
- Vue.nextTick(() => {
- expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
- expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
+ expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
- done();
+ done();
+ });
});
- });
- it('does not show new issue form for closed list', done => {
- component.list.type = 'closed';
- component.toggleForm();
+ it('does not show new issue form for closed list', done => {
+ component.list.type = 'closed';
+ component.toggleForm();
- Vue.nextTick(() => {
- expect(component.$el.querySelector('.board-new-issue-form')).toBeNull();
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.board-new-issue-form')).toBeNull();
- done();
+ done();
+ });
});
- });
- it('shows count list item', done => {
- component.showCount = true;
+ it('shows count list item', done => {
+ component.showCount = true;
- Vue.nextTick(() => {
- expect(component.$el.querySelector('.board-list-count')).not.toBeNull();
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.board-list-count')).not.toBeNull();
- expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
- 'Showing all issues',
- );
+ expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
+ 'Showing all issues',
+ );
- done();
+ done();
+ });
});
- });
- it('sets data attribute with invalid id', done => {
- component.showCount = true;
+ it('sets data attribute with invalid id', done => {
+ component.showCount = true;
- Vue.nextTick(() => {
- expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe(
- '-1',
- );
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe(
+ '-1',
+ );
- done();
+ done();
+ });
});
- });
- it('shows how many more issues to load', done => {
- component.showCount = true;
- component.list.issuesSize = 20;
+ it('shows how many more issues to load', done => {
+ component.showCount = true;
+ component.list.issuesSize = 20;
- Vue.nextTick(() => {
- expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
- 'Showing 1 of 20 issues',
- );
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
+ 'Showing 1 of 20 issues',
+ );
- done();
+ done();
+ });
});
- });
-
- it('loads more issues after scrolling', done => {
- spyOn(component.list, 'nextPage');
- component.$refs.list.style.height = '100px';
- component.$refs.list.style.overflow = 'scroll';
- for (let i = 1; i < 20; i += 1) {
- const issue = Object.assign({}, component.list.issues[0]);
- issue.id += i;
- component.list.issues.push(issue);
- }
+ it('loads more issues after scrolling', done => {
+ spyOn(component.list, 'nextPage');
+ component.$refs.list.style.height = '100px';
+ component.$refs.list.style.overflow = 'scroll';
+ generateIssues(component);
+
+ Vue.nextTick(() => {
+ component.$refs.list.scrollTop = 20000;
+
+ waitForPromises()
+ .then(() => {
+ expect(component.list.nextPage).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
- Vue.nextTick(() => {
- component.$refs.list.scrollTop = 20000;
+ it('does not load issues if already loading', done => {
+ component.list.nextPage = spyOn(component.list, 'nextPage').and.returnValue(
+ new Promise(() => {}),
+ );
- setTimeout(() => {
- expect(component.list.nextPage).toHaveBeenCalled();
+ component.onScroll();
+ component.onScroll();
- done();
- });
+ waitForPromises()
+ .then(() => {
+ expect(component.list.nextPage).toHaveBeenCalledTimes(1);
+ })
+ .then(done)
+ .catch(done.fail);
});
- });
- it('does not load issues if already loading', () => {
- component.list.nextPage = spyOn(component.list, 'nextPage').and.returnValue(
- new Promise(() => {}),
- );
+ it('shows loading more spinner', done => {
+ component.showCount = true;
+ component.list.loadingMore = true;
- component.onScroll();
- component.onScroll();
+ Vue.nextTick(() => {
+ expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull();
- expect(component.list.nextPage).toHaveBeenCalledTimes(1);
+ done();
+ });
+ });
});
- it('shows loading more spinner', done => {
- component.showCount = true;
- component.list.loadingMore = true;
+ describe('When Collapsed', () => {
+ beforeEach(done => {
+ getIssues = spyOn(List.prototype, 'getIssues').and.returnValue(new Promise(() => {}));
+ ({ mock, component } = createComponent({
+ done,
+ listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
+ }));
+ generateIssues(component);
+ component.scrollHeight = spyOn(component, 'scrollHeight').and.returnValue(0);
+ });
- Vue.nextTick(() => {
- expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull();
+ afterEach(() => {
+ mock.restore();
+ component.$destroy();
+ });
- done();
+ it('does not load all issues', done => {
+ waitForPromises()
+ .then(() => {
+ // Initial getIssues from list constructor
+ expect(getIssues).toHaveBeenCalledTimes(1);
+ })
+ .then(done)
+ .catch(done.fail);
});
});
});
diff --git a/spec/models/concerns/redactable_spec.rb b/spec/models/concerns/redactable_spec.rb
index 57c7d2cb767..3f6a2e2410c 100644
--- a/spec/models/concerns/redactable_spec.rb
+++ b/spec/models/concerns/redactable_spec.rb
@@ -7,44 +7,6 @@ describe Redactable do
stub_commonmark_sourcepos_disabled
end
- shared_examples 'model with redactable field' do
- it 'redacts unsubscribe token' do
- model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text'
-
- model.save!
-
- expect(model[field]).to eq 'some text /sent_notifications/REDACTED/unsubscribe more text'
- end
-
- it 'ignores not hexadecimal tokens' do
- text = 'some text /sent_notifications/token/unsubscribe more text'
- model[field] = text
-
- model.save!
-
- expect(model[field]).to eq text
- end
-
- it 'ignores not matching texts' do
- text = 'some text /sent_notifications/.*/unsubscribe more text'
- model[field] = text
-
- model.save!
-
- expect(model[field]).to eq text
- end
-
- it 'redacts the field when saving the model before creating markdown cache' do
- model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text'
-
- model.save!
-
- expected = 'some text /sent_notifications/REDACTED/unsubscribe more text'
- expect(model[field]).to eq expected
- expect(model["#{field}_html"]).to eq "<p dir=\"auto\">#{expected}</p>"
- end
- end
-
context 'when model is an issue' do
it_behaves_like 'model with redactable field' do
let(:model) { create(:issue) }
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index d4137d2ada4..adf178330f4 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -2177,6 +2177,50 @@ describe MergeRequest do
end
end
+ describe '#check_mergeability' do
+ let(:mergeability_service) { double }
+
+ before do
+ allow(MergeRequests::MergeabilityCheckService).to receive(:new) do
+ mergeability_service
+ end
+ end
+
+ context 'if the merge status is unchecked' do
+ before do
+ subject.mark_as_unchecked!
+ end
+
+ it 'executes MergeabilityCheckService' do
+ expect(mergeability_service).to receive(:execute)
+
+ subject.check_mergeability
+ end
+ end
+
+ context 'if the merge status is checked' do
+ context 'and feature flag is enabled' do
+ it 'executes MergeabilityCheckService' do
+ expect(mergeability_service).not_to receive(:execute)
+
+ subject.check_mergeability
+ end
+ end
+
+ context 'and feature flag is disabled' do
+ before do
+ stub_feature_flags(merge_requests_conditional_mergeability_check: false)
+ end
+
+ it 'does not execute MergeabilityCheckService' do
+ expect(mergeability_service).to receive(:execute)
+
+ subject.check_mergeability
+ end
+ end
+ end
+ end
+
describe '#mergeable_state?' do
let(:project) { create(:project, :repository) }
diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb
index 487a1c619c6..e79a2bc325b 100644
--- a/spec/models/todo_spec.rb
+++ b/spec/models/todo_spec.rb
@@ -150,6 +150,19 @@ describe Todo do
end
end
+ describe '#done?' do
+ let_it_be(:todo1) { create(:todo, state: :pending) }
+ let_it_be(:todo2) { create(:todo, state: :done) }
+
+ it 'returns true for todos with done state' do
+ expect(todo2.done?).to be_truthy
+ end
+
+ it 'returns false for todos with state pending' do
+ expect(todo1.done?).to be_falsey
+ end
+ end
+
describe '#self_assigned?' do
let(:user_1) { build(:user) }
diff --git a/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb b/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb
new file mode 100644
index 00000000000..fabbb3aeb49
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/todos/mark_done_spec.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Marking todos done' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:other_user) { create(:user) }
+
+ let_it_be(:todo1) { create(:todo, user: current_user, author: author, state: :pending) }
+ let_it_be(:todo2) { create(:todo, user: current_user, author: author, state: :done) }
+
+ let_it_be(:other_user_todo) { create(:todo, user: other_user, author: author, state: :pending) }
+
+ let(:input) { { id: todo1.to_global_id.to_s } }
+
+ let(:mutation) do
+ graphql_mutation(:todo_mark_done, input,
+ <<-QL.strip_heredoc
+ clientMutationId
+ errors
+ todo {
+ id
+ state
+ }
+ QL
+ )
+ end
+
+ def mutation_response
+ graphql_mutation_response(:todo_mark_done)
+ end
+
+ it 'marks a single todo as done' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(todo1.reload.state).to eq('done')
+ expect(todo2.reload.state).to eq('done')
+ expect(other_user_todo.reload.state).to eq('pending')
+
+ todo = mutation_response['todo']
+ expect(todo['id']).to eq(todo1.to_global_id.to_s)
+ expect(todo['state']).to eq('done')
+ end
+
+ context 'when todo is already marked done' do
+ let(:input) { { id: todo2.to_global_id.to_s } }
+
+ it 'has the expected response' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(todo1.reload.state).to eq('pending')
+ expect(todo2.reload.state).to eq('done')
+ expect(other_user_todo.reload.state).to eq('pending')
+
+ todo = mutation_response['todo']
+ expect(todo['id']).to eq(todo2.to_global_id.to_s)
+ expect(todo['state']).to eq('done')
+ end
+ end
+
+ context 'when todo does not belong to requesting user' do
+ let(:input) { { id: other_user_todo.to_global_id.to_s } }
+ let(:access_error) { 'The resource that you are attempting to access does not exist or you don\'t have permission to perform this action' }
+
+ it 'contains the expected error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ errors = json_response['errors']
+ expect(errors).not_to be_blank
+ expect(errors.first['message']).to eq(access_error)
+
+ expect(todo1.reload.state).to eq('pending')
+ expect(todo2.reload.state).to eq('done')
+ expect(other_user_todo.reload.state).to eq('pending')
+ end
+ end
+
+ context 'when using an invalid gid' do
+ let(:input) { { id: 'invalid_gid' } }
+ let(:invalid_gid_error) { 'invalid_gid is not a valid GitLab id.' }
+
+ it 'contains the expected error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ errors = json_response['errors']
+ expect(errors).not_to be_blank
+ expect(errors.first['message']).to eq(invalid_gid_error)
+
+ expect(todo1.reload.state).to eq('pending')
+ expect(todo2.reload.state).to eq('done')
+ expect(other_user_todo.reload.state).to eq('pending')
+ end
+ end
+end
diff --git a/spec/requests/user_avatar_spec.rb b/spec/requests/user_avatar_spec.rb
deleted file mode 100644
index 9451674161c..00000000000
--- a/spec/requests/user_avatar_spec.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe 'Loading a user avatar' do
- let(:user) { create(:user, :with_avatar) }
-
- context 'when logged in' do
- # The exact query count will vary depending on the 2FA settings of the
- # instance, group, and user. Removing those extra 2FA queries in this case
- # may not be a good idea, so we just set up the ideal case.
- before do
- stub_application_setting(require_two_factor_authentication: true)
-
- login_as(create(:user, :two_factor))
- end
-
- # One each for: current user, avatar user, and upload record
- it 'only performs three SQL queries' do
- get user.avatar_url # Skip queries on first application load
-
- expect(response).to have_gitlab_http_status(200)
- expect { get user.avatar_url }.not_to exceed_query_limit(3)
- end
- end
-
- context 'when logged out' do
- # One each for avatar user and upload record
- it 'only performs two SQL queries' do
- get user.avatar_url # Skip queries on first application load
-
- expect(response).to have_gitlab_http_status(200)
- expect { get user.avatar_url }.not_to exceed_query_limit(2)
- end
- end
-end
diff --git a/spec/support/shared_examples/models/concern/issuable_shared_examples.rb b/spec/support/shared_examples/models/concerns/issuable_shared_examples.rb
index 4978a403324..4978a403324 100644
--- a/spec/support/shared_examples/models/concern/issuable_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/issuable_shared_examples.rb
diff --git a/spec/support/shared_examples/models/concerns/redactable_shared_examples.rb b/spec/support/shared_examples/models/concerns/redactable_shared_examples.rb
new file mode 100644
index 00000000000..c5c14901268
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/redactable_shared_examples.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+shared_examples 'model with redactable field' do
+ it 'redacts unsubscribe token' do
+ model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text'
+
+ model.save!
+
+ expect(model[field]).to eq 'some text /sent_notifications/REDACTED/unsubscribe more text'
+ end
+
+ it 'ignores not hexadecimal tokens' do
+ text = 'some text /sent_notifications/token/unsubscribe more text'
+ model[field] = text
+
+ model.save!
+
+ expect(model[field]).to eq text
+ end
+
+ it 'ignores not matching texts' do
+ text = 'some text /sent_notifications/.*/unsubscribe more text'
+ model[field] = text
+
+ model.save!
+
+ expect(model[field]).to eq text
+ end
+
+ it 'redacts the field when saving the model before creating markdown cache' do
+ model[field] = 'some text /sent_notifications/00000000000000000000000000000000/unsubscribe more text'
+
+ model.save!
+
+ expected = 'some text /sent_notifications/REDACTED/unsubscribe more text'
+ expect(model[field]).to eq expected
+ expect(model["#{field}_html"]).to eq "<p dir=\"auto\">#{expected}</p>"
+ end
+end