Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-10-13 15:09:32 +00:00
parent 70ce746bd0
commit bd25f1d9c6
75 changed files with 1886 additions and 782 deletions

View File

@ -257,7 +257,7 @@
{"name":"hashdiff","version":"1.0.1","platform":"ruby","checksum":"2cd4d04f5080314ecc8403c4e2e00dbaa282dff395e2d031bc16c8d501bdd6db"},
{"name":"hashie","version":"4.1.0","platform":"ruby","checksum":"7890dcb9ec18a4b66acec797018c73824b89cef5eb8cda36e8e8501845e87a09"},
{"name":"hashie-forbidden_attributes","version":"0.1.1","platform":"ruby","checksum":"3a6ed37f3a314e4fb1dd1e2df6eb7721bcadd023a30bc0b951b2b5285a790fb2"},
{"name":"health_check","version":"3.0.0","platform":"ruby","checksum":"1b336c5c49036a993153e75c8d14e9742377fb9b7361c0a6af2e5fedb45b991f"},
{"name":"health_check","version":"3.1.0","platform":"ruby","checksum":"10146508237dc54ed7e24c292d8ba7fb8f9590cf26c66e325b947438c4103b57"},
{"name":"heapy","version":"0.2.0","platform":"ruby","checksum":"74141e845d61ffc7c1e8bf8b127c8cf94544ec7a1181aec613288682543585ea"},
{"name":"html-pipeline","version":"2.13.2","platform":"ruby","checksum":"a1de83f7bd2d3464f3a068e391b661983fc6099d194c8d9ceb91ace02dadb803"},
{"name":"html2text","version":"0.2.0","platform":"ruby","checksum":"31c2f0be9ab7aa4fc780b07d5f84882ebc22a9024c29a45f4f5adfe42e92ad4f"},

View File

@ -15,6 +15,7 @@ PATH
specs:
devise-pbkdf2-encryptable (0.0.0)
devise (~> 4.0)
devise-two-factor (~> 4.0)
PATH
remote: vendor/gems/error_tracking_open_api
@ -711,7 +712,7 @@ GEM
hashie (4.1.0)
hashie-forbidden_attributes (0.1.1)
hashie (>= 3.0)
health_check (3.0.0)
health_check (3.1.0)
railties (>= 5.0)
heapy (0.2.0)
thor

View File

@ -6,26 +6,28 @@ const isBlank = (str) => !str || /^\s*$/.test(str);
const isMatch = (s1, s2) => !isBlank(s1) && s1.trim() === s2.trim();
const createSpan = (content) => {
const createSpan = (content, classList) => {
const span = document.createElement('span');
span.innerText = content;
span.classList = classList || '';
return span;
};
const wrapSpacesWithSpans = (text) => text.replace(/ /g, createSpan(' ').outerHTML);
const wrapSpacesWithSpans = (text) =>
text.replace(/ /g, createSpan(' ').outerHTML).replace(/\t/g, createSpan(' ').outerHTML);
const wrapTextWithSpan = (el, text) => {
const wrapTextWithSpan = (el, text, classList) => {
if (isTextNode(el) && isMatch(el.textContent, text)) {
const newEl = createSpan(text.trim());
const newEl = createSpan(text.trim(), classList);
el.replaceWith(newEl);
}
};
const wrapNodes = (text) => {
const wrapNodes = (text, classList) => {
const wrapper = createSpan();
// eslint-disable-next-line no-unsanitized/property
wrapper.innerHTML = wrapSpacesWithSpans(text);
wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text));
wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text, classList));
return wrapper.childNodes;
};

View File

@ -17,11 +17,9 @@ export const addInteractionClass = ({ path, d, wrapTextNodes }) => {
if (wrapTextNodes) {
line.childNodes.forEach((elm) => {
if (isTextNode(elm)) {
// Highlight.js does not wrap all text nodes by default
// We need all text nodes to be wrapped in order to append code nav attributes
elm.replaceWith(...wrapNodes(elm.textContent));
}
// Highlight.js does not wrap all text nodes by default
// We need all text nodes to be wrapped in order to append code nav attributes
elm.replaceWith(...wrapNodes(elm.textContent, elm.classList));
});
}

View File

@ -23,7 +23,12 @@ export default {
<template>
<div class="d-flex align-items-center">
<ci-icon is-borderless :status="job.status" :size="24" class="d-flex" />
<ci-icon
is-borderless
:status="job.status"
:size="24"
class="gl-align-items-center gl-border gl-display-inline-flex gl-z-index-1"
/>
<span class="gl-ml-3">
{{ job.name }}
<a

View File

@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon, GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { __ } from '~/locale';
import Item from './item.vue';
export default {
@ -10,7 +10,6 @@ export default {
components: {
GlIcon,
GlBadge,
CiIcon,
Item,
GlLoadingIcon,
},
@ -27,11 +26,15 @@ export default {
},
computed: {
collapseIcon() {
return this.stage.isCollapsed ? 'chevron-lg-left' : 'chevron-lg-down';
return this.stage.isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up';
},
showLoadingIcon() {
return this.stage.isLoading && !this.stage.jobs.length;
},
stageTitle() {
const prefix = __('Stage');
return `${prefix}: ${this.stage.name}`;
},
jobsCount() {
return this.stage.jobs.length;
},
@ -57,29 +60,29 @@ export default {
<template>
<div class="ide-stage card gl-mt-3">
<div
ref="cardHeader"
:class="{
'border-bottom-0': stage.isCollapsed,
}"
class="card-header"
class="card-header gl-align-items-center gl-cursor-pointer gl-display-flex"
data-testid="card-header"
@click="toggleCollapsed"
>
<ci-icon :status="stage.status" :size="24" />
<strong
ref="stageTitle"
v-gl-tooltip="showTooltip"
:title="showTooltip ? stage.name : null"
data-container="body"
class="gl-ml-3 text-truncate"
class="gl-text-truncate"
data-testid="stage-title"
>
{{ stage.name }}
{{ stageTitle }}
</strong>
<div v-if="!stage.isLoading || stage.jobs.length" class="gl-mr-3 gl-ml-2">
<gl-badge>{{ jobsCount }}</gl-badge>
</div>
<gl-icon :name="collapseIcon" class="ide-stage-collapse-icon" />
<gl-icon :name="collapseIcon" class="gl-absolute gl-right-5" />
</div>
<div v-show="!stage.isCollapsed" ref="jobList" class="card-body p-0">
<div v-show="!stage.isCollapsed" class="card-body p-0" data-testid="job-list">
<gl-loading-icon v-if="showLoadingIcon" size="sm" />
<template v-else>
<item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" />

View File

@ -150,7 +150,7 @@ export const TOKEN_TYPE_CONTACT = 'crm_contact';
export const TOKEN_TYPE_ORGANIZATION = 'crm_organization';
export const TOKEN_TYPE_HEALTH = 'health_status';
export const TYPE_TOKEN_TASK_OPTION = { icon: 'task-done', title: 'task', value: 'task' };
export const TYPE_TOKEN_TASK_OPTION = { icon: 'issue-type-task', title: 'task', value: 'task' };
// This should be consistent with Issue::TYPES_FOR_LIST in the backend
// https://gitlab.com/gitlab-org/gitlab/-/blob/1379c2d7bffe2a8d809f23ac5ef9b4114f789c07/app/models/issue.rb#L48

View File

@ -1,10 +1,11 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlSprintf } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
TimeAgoTooltip,
GlSprintf,
},
props: {
updatedAt: {
@ -33,13 +34,27 @@ export default {
<template>
<small class="edited-text js-issue-widgets">
Edited
<time-ago-tooltip v-if="updatedAt" :time="updatedAt" tooltip-placement="bottom" />
<span v-if="hasUpdatedBy">
by
<a :href="updatedByPath" class="author-link">
<span>{{ updatedByName }}</span>
</a>
</span>
<gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')">
<template #timeago>
<time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
</template>
</gl-sprintf>
<gl-sprintf v-else-if="!updatedAt" :message="__('Edited by %{author}')">
<template #author>
<a :href="updatedByPath" class="author-link">
<span>{{ updatedByName }}</span>
</a>
</template>
</gl-sprintf>
<gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')">
<template #timeago>
<time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
</template>
<template #author>
<a :href="updatedByPath" class="author-link">
<span>{{ updatedByName }}</span>
</a>
</template>
</gl-sprintf>
</small>
</template>

View File

@ -3,12 +3,21 @@ import { getNormalizedURL, getBaseURL, relativePathToAbsolute } from '~/lib/util
const { sanitize: dompurifySanitize, addHook, isValidAttribute } = DOMPurify;
const defaultConfig = {
export const defaultConfig = {
// Safely allow SVG <use> tags
ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
// Prevent possible XSS attacks with data-* attributes used by @rails/ujs
// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421
FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'],
FORBID_ATTR: [
'data-remote',
'data-url',
'data-type',
'data-method',
'data-disable-with',
'data-disabled',
'data-disable',
'data-turbo',
],
FORBID_TAGS: ['style', 'mstyle'],
ALLOW_UNKNOWN_PROTOCOLS: true,
};

View File

@ -233,6 +233,7 @@ export default {
<div
v-if="isFormVisible"
class="js-add-related-issues-form-area card-body bordered-box bg-white"
:class="{ 'gl-mb-5': shouldShowTokenBody }"
>
<add-issuable-form
:show-categorized-issues="showCategorizedIssues"
@ -253,7 +254,7 @@ export default {
</div>
<template v-if="shouldShowTokenBody">
<related-issues-list
v-for="category in categorisedIssues"
v-for="(category, index) in categorisedIssues"
:key="category.linkType"
:list-link-type="category.linkType"
:heading="$options.linkedIssueTypesTextMap[category.linkType]"
@ -263,6 +264,7 @@ export default {
:issuable-type="issuableType"
:path-id-separator="pathIdSeparator"
:related-issues="category.issues"
:class="{ 'gl-mt-5': index > 0 }"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
@saveReorder="$emit('saveReorder', $event)"
/>

View File

@ -48,7 +48,7 @@ export default {
</div>
<pre
class="gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-normal"
class="gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0"
><code><span :id="`LC${number}`" v-safe-html="content" :lang="language" class="line" data-testid="content"></span></code></pre>
</div>
</template>

View File

@ -139,6 +139,4 @@ export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip';
export const BIDI_CHAR_TOOLTIP = 'Potentially unwanted character detected: Unicode BiDi Control';
export const HLJS_COMMENT_SELECTOR = 'hljs-comment';
export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';

View File

@ -1,4 +1,4 @@
import wrapComments from './wrap_comments';
import wrapChildNodes from './wrap_child_nodes';
import linkDependencies from './link_dependencies';
import wrapBidiChars from './wrap_bidi_chars';
@ -12,8 +12,8 @@ export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
* @param {Object} hljs - the Highlight.js instance.
*/
export const registerPlugins = (hljs, fileType, rawContent) => {
hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapChildNodes });
hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapBidiChars });
hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments });
hljs.addPlugin({
[HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent),
});

View File

@ -0,0 +1,45 @@
import { escape } from 'lodash';
/**
* Highlight.js plugin for wrapping nodes with the correct selectors to ensure
* child-elements are highlighted correctly after we split up the result into chunks and lines.
*
* Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst
*
* @param {Object} Result - an object that represents the highlighted result from Highlight.js
*/
const newlineRegex = /\r?\n/;
const generateClassName = (suffix) => (suffix ? `hljs-${escape(suffix)}` : '');
const generateCloseTag = (includeClose) => (includeClose ? '</span>' : '');
const generateHLJSTag = (kind, content = '', includeClose) =>
`<span class="${generateClassName(kind)}">${escape(content)}${generateCloseTag(includeClose)}`;
const format = (node, kind = '') => {
let buffer = '';
if (typeof node === 'string') {
buffer += node
.split(newlineRegex)
.map((newline) => generateHLJSTag(kind, newline, true))
.join('\n');
} else if (node.kind) {
const { children } = node;
if (children.length && children.length === 1) {
buffer += format(children[0], node.kind);
} else {
buffer += generateHLJSTag(node.kind);
children.forEach((subChild) => {
buffer += format(subChild, node.kind);
});
buffer += `</span>`;
}
}
return buffer;
};
export default (result) => {
// NOTE: We're using the private Emitter API here as we expect the Emitter API to be publicly available soon (https://github.com/highlightjs/highlight.js/issues/3621)
// eslint-disable-next-line no-param-reassign, no-underscore-dangle
result.value = result._emitter.rootNode.children.reduce((val, node) => val + format(node), ''); // Highlight.js expects the result param to be mutated for plugins to work
};

View File

@ -1,41 +0,0 @@
import { HLJS_COMMENT_SELECTOR } from '../constants';
const createWrapper = (content) => {
const span = document.createElement('span');
span.className = HLJS_COMMENT_SELECTOR;
// eslint-disable-next-line no-unsanitized/property
span.innerHTML = content;
return span.outerHTML;
};
/**
* Highlight.js plugin for wrapping multi-line comments in the `hljs-comment` class.
* This ensures that multi-line comments are rendered correctly in the GitLab UI.
*
* Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst
*
* @param {Object} Result - an object that represents the highlighted result from Highlight.js
*/
export default (result) => {
if (!result.value.includes(HLJS_COMMENT_SELECTOR)) return;
let wrapComment = false;
// eslint-disable-next-line no-param-reassign
result.value = result.value // Highlight.js expects the result param to be mutated for plugins to work
.split('\n')
.map((lineContent) => {
const includesClosingTag = lineContent.includes('</span>');
if (lineContent.includes(HLJS_COMMENT_SELECTOR) && !includesClosingTag) {
wrapComment = true;
return lineContent;
}
const line = wrapComment ? createWrapper(lineContent) : lineContent;
if (includesClosingTag) {
wrapComment = false;
}
return line;
})
.join('\n');
};

View File

@ -0,0 +1,25 @@
import { sanitize } from '~/lib/dompurify';
// Mitigate against future dompurify mXSS bypasses by
// avoiding additional serialize/parse round trip.
// See https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1782
// and https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2127
// for more details.
const DEFAULT_CONFIG = {
RETURN_DOM_FRAGMENT: true,
};
const transform = (el, binding) => {
if (binding.oldValue !== binding.value) {
const config = { ...DEFAULT_CONFIG, ...(binding.arg ?? {}) };
el.textContent = '';
el.appendChild(sanitize(binding.value, config));
}
};
export default {
bind: transform,
update: transform,
};

View File

@ -970,9 +970,6 @@ $ide-commit-header-height: 48px;
.ide-stage {
.card-header {
display: flex;
cursor: pointer;
.ci-status-icon {
display: flex;
align-items: center;
@ -980,10 +977,6 @@ $ide-commit-header-height: 48px;
}
}
.ide-stage-collapse-icon {
margin: auto 0 auto auto;
}
.ide-job-header {
min-height: 60px;
padding: 0 $gl-padding;

View File

@ -15,31 +15,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
feature_category :authentication_and_authorization
def show
if two_factor_authentication_required? && !current_user.two_factor_enabled?
two_factor_authentication_reason(
global: lambda do
flash.now[:alert] =
_('The global settings require you to enable Two-Factor Authentication for your account.')
end,
group: lambda do |groups|
flash.now[:alert] = groups_notification(groups)
end
)
unless two_factor_grace_period_expired?
grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
flash.now[:alert] = flash.now[:alert] + _(" You need to do this before %{grace_period_deadline}.") % { grace_period_deadline: l(grace_period_deadline) }
end
end
@qr_code = build_qr_code
@account_string = account_string
if Feature.enabled?(:webauthn)
setup_webauthn_registration
else
setup_u2f_registration
end
setup_show_page
end
def create
@ -147,7 +123,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
current_user.increment_failed_attempts!
redirect_to profile_two_factor_auth_path, alert: _('You must provide a valid current password')
@error = { message: _('You must provide a valid current password') }
setup_show_page
render 'show'
end
def current_password_required?
@ -245,4 +225,32 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
redirect_to profile_emails_path, notice: s_('You need to verify your primary email first before enabling Two-Factor Authentication.')
end
end
def setup_show_page
if two_factor_authentication_required? && !current_user.two_factor_enabled?
two_factor_authentication_reason(
global: lambda do
flash.now[:alert] =
_('The global settings require you to enable Two-Factor Authentication for your account.')
end,
group: lambda do |groups|
flash.now[:alert] = groups_notification(groups)
end
)
unless two_factor_grace_period_expired?
grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
flash.now[:alert] = flash.now[:alert] + _(" You need to do this before %{grace_period_deadline}.") % { grace_period_deadline: l(grace_period_deadline) }
end
end
@qr_code = build_qr_code
@account_string = account_string
if Feature.enabled?(:webauthn)
setup_webauthn_registration
else
setup_u2f_registration
end
end
end

View File

@ -79,6 +79,7 @@ class User < ApplicationRecord
otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base
devise :two_factor_backupable, otp_number_of_backup_codes: 10
devise :two_factor_backupable_pbkdf2
serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize
devise :lockable, :recoverable, :rememberable, :trackable,
@ -950,6 +951,22 @@ class User < ApplicationRecord
false
end
def generate_otp_backup_codes!
if Gitlab::FIPS.enabled?
generate_otp_backup_codes_pbkdf2!
else
super
end
end
def invalidate_otp_backup_code!(code)
if Gitlab::FIPS.enabled? && pbkdf2?
invalidate_otp_backup_code_pdkdf2!(code)
else
super(code)
end
end
# This method should be removed once the :pbkdf2_password_encryption feature flag is removed.
def password=(new_password)
if Feature.enabled?(:pbkdf2_password_encryption) && Feature.enabled?(:pbkdf2_password_encryption_write, self)
@ -2200,6 +2217,12 @@ class User < ApplicationRecord
private
def pbkdf2?
return false unless otp_backup_codes&.any?
otp_backup_codes.first.start_with?("$pbkdf2-sha512$")
end
# To enable JiHu repository to modify the default language options
def default_preferred_language
'en'

View File

@ -0,0 +1,85 @@
# frozen_string_literal: true
module Admin
class SetFeatureFlagService
def initialize(feature_flag_name:, params:)
@name = feature_flag_name
@params = params
end
def execute
unless params[:force]
error = validate_feature_flag_name
return ServiceResponse.error(message: error, reason: :invalid_feature_flag) if error
end
flag_target = Feature::Target.new(params)
value = gate_value(params)
case value
when true
enable!(flag_target)
when false
disable!(flag_target)
else
enable_partially!(value, params)
end
feature_flag = Feature.get(name) # rubocop:disable Gitlab/AvoidFeatureGet
ServiceResponse.success(payload: { feature_flag: feature_flag })
rescue Feature::Target::UnknowTargetError => e
ServiceResponse.error(message: e.message, reason: :actor_not_found)
end
private
attr_reader :name, :params
def enable!(flag_target)
if flag_target.gate_specified?
flag_target.targets.each { |target| Feature.enable(name, target) }
else
Feature.enable(name)
end
end
def disable!(flag_target)
if flag_target.gate_specified?
flag_target.targets.each { |target| Feature.disable(name, target) }
else
Feature.disable(name)
end
end
def enable_partially!(value, params)
if params[:key] == 'percentage_of_actors'
Feature.enable_percentage_of_actors(name, value)
else
Feature.enable_percentage_of_time(name, value)
end
end
def validate_feature_flag_name
# overridden in EE
end
def gate_value(params)
case params[:value]
when 'true'
true
when '0', 'false'
false
else
# https://github.com/jnunemaker/flipper/blob/master/lib/flipper/typecast.rb#L47
if params[:value].to_s.include?('.')
params[:value].to_f
else
params[:value].to_i
end
end
end
end
end
Admin::SetFeatureFlagService.prepend_mod

View File

@ -17,6 +17,13 @@
= _("You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication.")
%p
= _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.')
- if @error
= render Pajamas::AlertComponent.new(title: @error[:message],
variant: :danger,
alert_options: { class: 'gl-mb-3' },
dismissible: false) do |c|
= c.body do
= link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'
.js-manage-two-factor-form{ data: { webauthn_enabled: webauthn_enabled, current_password_required: current_password_required?.to_s, profile_two_factor_auth_path: profile_two_factor_auth_path, profile_two_factor_auth_method: 'delete', codes_profile_two_factor_auth_path: codes_profile_two_factor_auth_path, codes_profile_two_factor_auth_method: 'post' } }
- else
@ -46,6 +53,7 @@
- if @error
= render Pajamas::AlertComponent.new(title: @error[:message],
variant: :danger,
alert_options: { class: 'gl-mb-3' },
dismissible: false) do |c|
= c.body do
= link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'

View File

@ -0,0 +1,8 @@
---
name: set_feature_flag_service
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87028
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/373176
milestone: '15.4'
type: development
group: group::pipeline execution
default_enabled: false

View File

@ -10,8 +10,11 @@ value_type: number
status: active
time_frame: 28d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
instrumentation_class: AggregatedMetric
options:
aggregate:
operator: OR
attribute: user_id
events:
- users_viewing_analytics_group_devops_adoption
- i_analytics_dev_ops_adoption

View File

@ -13,8 +13,11 @@ value_type: number
status: active
time_frame: 28d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
instrumentation_class: AggregatedMetric
options:
aggregate:
operator: OR
attribute: user_id
events:
- i_search_total
- i_search_advanced

View File

@ -10,8 +10,11 @@ value_type: number
status: active
time_frame: 28d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
instrumentation_class: AggregatedMetric
options:
aggregate:
operator: OR
attribute: user_id
events:
- i_code_review_click_diff_view_setting
- i_code_review_click_file_browser_setting

View File

@ -10,8 +10,11 @@ value_type: number
status: active
time_frame: 28d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
instrumentation_class: AggregatedMetric
options:
aggregate:
operator: OR
attribute: user_id
events:
- i_package_composer_user
- i_package_conan_user

View File

@ -10,8 +10,11 @@ value_type: number
status: active
time_frame: 28d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
instrumentation_class: AggregatedMetric
options:
aggregate:
operator: OR
attribute: user_id
events:
- i_ecosystem_jira_service_close_issue
- i_ecosystem_jira_service_cross_reference

View File

@ -10,8 +10,11 @@ value_type: number
status: active
time_frame: 7d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
instrumentation_class: AggregatedMetric
options:
aggregate:
operator: OR
attribute: user_id
events:
- users_viewing_analytics_group_devops_adoption
- i_analytics_dev_ops_adoption

View File

@ -10,8 +10,11 @@ value_type: number
status: active
time_frame: 7d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
instrumentation_class: AggregatedMetric
options:
aggregate:
operator: OR
attribute: user_id
events:
- i_search_total
- i_search_advanced

View File

@ -10,8 +10,11 @@ value_type: number
status: active
time_frame: 7d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
instrumentation_class: AggregatedMetric
options:
aggregate:
operator: OR
attribute: user_id
events:
- i_code_review_click_diff_view_setting
- i_code_review_click_file_browser_setting

View File

@ -10,8 +10,11 @@ value_type: number
status: active
time_frame: 7d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
instrumentation_class: AggregatedMetric
options:
aggregate:
operator: OR
attribute: user_id
events:
- i_package_composer_user
- i_package_conan_user

View File

@ -10,8 +10,11 @@ value_type: number
status: active
time_frame: 7d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
instrumentation_class: AggregatedMetric
options:
aggregate:
operator: OR
attribute: user_id
events:
- i_ecosystem_jira_service_close_issue
- i_ecosystem_jira_service_cross_reference

View File

@ -459,6 +459,8 @@
- 1
- - security_orchestration_policy_rule_schedule_namespace
- 1
- - security_process_scan_result_policy
- 1
- - security_scans
- 2
- - security_sync_scan_policies

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class AddIndexMergeRequestIdOnScanFindingApprovalMergeRequestRules < Gitlab::Database::Migration[2.0]
INDEX_NAME = 'scan_finding_approval_mr_rule_index_merge_request_id'
SCAN_FINDING_REPORT_TYPE = 4
disable_ddl_transaction!
def up
add_concurrent_index :approval_merge_request_rules, :merge_request_id,
where: "report_type = #{SCAN_FINDING_REPORT_TYPE}", name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :approval_merge_request_rules, INDEX_NAME
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddComplianceFrameworkIdToNamespaceSettings < Gitlab::Database::Migration[2.0]
def change
add_column :namespace_settings, :default_compliance_framework_id, :bigint
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddIndexToNamespaceSettingsOnDefaultComplianceFrameworkId < Gitlab::Database::Migration[2.0]
disable_ddl_transaction!
INDEX_NAME = 'idx_namespace_settings_on_default_compliance_framework_id'
def up
add_concurrent_index :namespace_settings, :default_compliance_framework_id, unique: true, name: INDEX_NAME
end
def down
remove_concurrent_index :namespace_settings, :default_compliance_framework_id, name: INDEX_NAME
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddComplianceFrameworkFkToNamespaceSettings < Gitlab::Database::Migration[2.0]
disable_ddl_transaction!
def up
add_concurrent_foreign_key :namespace_settings, :compliance_management_frameworks,
column: :default_compliance_framework_id, on_delete: :nullify, reverse_lock_order: true
end
def down
with_lock_retries do
remove_foreign_key :namespace_settings, column: :default_compliance_framework_id
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class RemoveNamespaceSettingsCohortFreeUserCapColumns < Gitlab::Database::Migration[2.0]
enable_lock_retries!
def up
remove_column :namespace_settings, :exclude_from_free_user_cap
remove_column :namespace_settings, :include_for_free_user_cap_preview
end
def down
add_column :namespace_settings, :exclude_from_free_user_cap, :boolean, null: false, default: false
add_column :namespace_settings, :include_for_free_user_cap_preview, :boolean, null: false, default: false
end
end

View File

@ -0,0 +1 @@
ac1aa3697f6e4230bfdc41f34e2e87ef49f697cfa46139fe3ac91a42b7bf4b91

View File

@ -0,0 +1 @@
44e6b2519ef285366d1a2b4ea6efe18a9c22bfdb545c11502eae9383123b6001

View File

@ -0,0 +1 @@
a982eed3131805db693882a8da7c5c5d1572f7825eb51a45c468bd5dfbded58b

View File

@ -0,0 +1 @@
a3c66e57959f3e183a5b933138c9deedb5575e0b90b3a862b7b8e20331ffa31e

View File

@ -0,0 +1 @@
82f67746e79bcc63e5674f2e009eb9a827e019409c9277f6cd1ce2e41c50c296

View File

@ -17925,18 +17925,17 @@ CREATE TABLE namespace_settings (
runner_token_expiration_interval integer,
subgroup_runner_token_expiration_interval integer,
project_runner_token_expiration_interval integer,
exclude_from_free_user_cap boolean DEFAULT false NOT NULL,
show_diff_preview_in_email boolean DEFAULT true NOT NULL,
enabled_git_access_protocol smallint DEFAULT 0 NOT NULL,
unique_project_download_limit smallint DEFAULT 0 NOT NULL,
unique_project_download_limit_interval_in_seconds integer DEFAULT 0 NOT NULL,
project_import_level smallint DEFAULT 50 NOT NULL,
include_for_free_user_cap_preview boolean DEFAULT false NOT NULL,
unique_project_download_limit_allowlist text[] DEFAULT '{}'::text[] NOT NULL,
auto_ban_user_on_excessive_projects_download boolean DEFAULT false NOT NULL,
only_allow_merge_if_pipeline_succeeds boolean DEFAULT false NOT NULL,
allow_merge_on_skipped_pipeline boolean DEFAULT false NOT NULL,
only_allow_merge_if_all_discussions_are_resolved boolean DEFAULT false NOT NULL,
default_compliance_framework_id bigint,
CONSTRAINT check_0ba93c78c7 CHECK ((char_length(default_branch_name) <= 255)),
CONSTRAINT namespace_settings_unique_project_download_limit_allowlist_size CHECK ((cardinality(unique_project_download_limit_allowlist) <= 100))
);
@ -27735,6 +27734,8 @@ CREATE INDEX idx_mr_cc_diff_files_on_mr_cc_id_and_sha ON merge_request_context_c
CREATE INDEX idx_mrs_on_target_id_and_created_at_and_state_id ON merge_requests USING btree (target_project_id, state_id, created_at, id);
CREATE UNIQUE INDEX idx_namespace_settings_on_default_compliance_framework_id ON namespace_settings USING btree (default_compliance_framework_id);
CREATE UNIQUE INDEX idx_on_compliance_management_frameworks_namespace_id_name ON compliance_management_frameworks USING btree (namespace_id, name);
CREATE UNIQUE INDEX idx_on_external_approval_rules_project_id_external_url ON external_approval_rules USING btree (project_id, external_url);
@ -30965,6 +30966,8 @@ CREATE UNIQUE INDEX partial_index_sop_configs_on_project_id ON security_orchestr
CREATE INDEX partial_index_user_id_app_id_created_at_token_not_revoked ON oauth_access_tokens USING btree (resource_owner_id, application_id, created_at) WHERE (revoked_at IS NULL);
CREATE INDEX scan_finding_approval_mr_rule_index_merge_request_id ON approval_merge_request_rules USING btree (merge_request_id) WHERE (report_type = 4);
CREATE INDEX security_findings_confidence_idx ON ONLY security_findings USING btree (confidence);
CREATE INDEX security_findings_project_fingerprint_idx ON ONLY security_findings USING btree (project_fingerprint);
@ -32508,6 +32511,9 @@ ALTER TABLE ONLY ghost_user_migrations
ALTER TABLE ONLY coverage_fuzzing_corpuses
ADD CONSTRAINT fk_204d40056a FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY namespace_settings
ADD CONSTRAINT fk_20cf0eb2f9 FOREIGN KEY (default_compliance_framework_id) REFERENCES compliance_management_frameworks(id) ON DELETE SET NULL;
ALTER TABLE ONLY geo_container_repository_updated_events
ADD CONSTRAINT fk_212c89c706 FOREIGN KEY (container_repository_id) REFERENCES container_repositories(id) ON DELETE CASCADE;

View File

@ -262,36 +262,7 @@ This content has been converted to a Rake task, see [verify database values can
### Transfer mirror users and tokens to a single service account
Use case: If you have multiple users using their own GitHub credentials to set up
repository mirroring, mirroring breaks when people leave the company. Use this
script to migrate disparate mirroring users and tokens into a single service account:
```ruby
svc_user = User.find_by(username: 'ourServiceUser')
token = 'githubAccessToken'
Project.where(mirror: true).each do |project|
import_url = project.import_url
# The url we want is https://token@project/path.git
repo_url = if import_url.include?('@')
# Case 1: The url is something like https://23423432@project/path.git
import_url.split('@').last
elsif import_url.include?('//')
# Case 2: The url is something like https://project/path.git
import_url.split('//').last
end
next unless repo_url
final_url = "https://#{token}@#{repo_url}"
project.mirror_user = svc_user
project.import_url = final_url
project.username_only_import_url = final_url
project.save
end
```
This content has been moved to [Troubleshooting Repository mirroring](../../user/project/repository/mirror/index.md#transfer-mirror-users-and-tokens-to-a-single-service-account-in-rails-console).
## Users

View File

@ -79,7 +79,7 @@ Example response:
## Update metric image
```plaintext
PUT /projects/:id/alert_management_alerts/:alert_iid/metric_image/:image_id
PUT /projects/:id/alert_management_alerts/:alert_iid/metric_images/:image_id
```
| Attribute | Type | Required | Description |

View File

@ -21,7 +21,7 @@ Make sure to define the `cube_api_base_url` and `cube_api_key` application setti
Generate an access token that can be used to query the Cube API. For example:
```plaintext
POST /projects/:id/product_analytics/request
POST /projects/:id/product_analytics/request/load
```
| Attribute | Type | Required | Description |
@ -62,6 +62,7 @@ The body of the request should be a valid Cube query.
"Jitsu.docPath"
],
"limit": 23
}
},
"queryType": "multi"
}
```

View File

@ -316,3 +316,38 @@ Mirroring does not support the short version of SSH clone URLs (`git@gitlab.com:
and requires the full version including the protocol (`ssh://git@gitlab.com/gitlab-org/gitlab.git`).
Make sure that host and project path are separated using `/` instead of `:`.
### Transfer mirror users and tokens to a single service account in Rails console
This requires access to the [GitLab Rails console](../../../../administration/operations/rails_console.md#starting-a-rails-console-session).
Use case: If you have multiple users using their own GitHub credentials to set up
repository mirroring, mirroring breaks when people leave the company. Use this
script to migrate disparate mirroring users and tokens into a single service account:
```ruby
svc_user = User.find_by(username: 'ourServiceUser')
token = 'githubAccessToken'
Project.where(mirror: true).each do |project|
import_url = project.import_url
# The url we want is https://token@project/path.git
repo_url = if import_url.include?('@')
# Case 1: The url is something like https://23423432@project/path.git
import_url.split('@').last
elsif import_url.include?('//')
# Case 2: The url is something like https://project/path.git
import_url.split('//').last
end
next unless repo_url
final_url = "https://#{token}@#{repo_url}"
project.mirror_user = svc_user
project.import_url = final_url
project.username_only_import_url = final_url
project.save
end
```

View File

@ -7,6 +7,7 @@ module API
feature_category :feature_flags
urgency :low
# TODO: remove these helpers with feature flag set_feature_flag_service
helpers do
def gate_value(params)
case params[:value]
@ -87,35 +88,49 @@ module API
mutually_exclusive :key, :project
end
post ':name' do
validate_feature_flag_name!(params[:name]) unless params[:force]
if Feature.enabled?(:set_feature_flag_service)
flag_params = declared_params(include_missing: false)
response = ::Admin::SetFeatureFlagService
.new(feature_flag_name: params[:name], params: flag_params)
.execute
targets = gate_targets(params)
value = gate_value(params)
key = gate_key(params)
case value
when true
if gate_specified?(params)
targets.each { |target| Feature.enable(params[:name], target) }
if response.success?
present response.payload[:feature_flag],
with: Entities::Feature, current_user: current_user
else
Feature.enable(params[:name])
end
when false
if gate_specified?(params)
targets.each { |target| Feature.disable(params[:name], target) }
else
Feature.disable(params[:name])
bad_request!(response.message)
end
else
if key == :percentage_of_actors
Feature.enable_percentage_of_actors(params[:name], value)
else
Feature.enable_percentage_of_time(params[:name], value)
end
end
validate_feature_flag_name!(params[:name]) unless params[:force]
present Feature.get(params[:name]), # rubocop:disable Gitlab/AvoidFeatureGet
with: Entities::Feature, current_user: current_user
targets = gate_targets(params)
value = gate_value(params)
key = gate_key(params)
case value
when true
if gate_specified?(params)
targets.each { |target| Feature.enable(params[:name], target) }
else
Feature.enable(params[:name])
end
when false
if gate_specified?(params)
targets.each { |target| Feature.disable(params[:name], target) }
else
Feature.disable(params[:name])
end
else
if key == :percentage_of_actors
Feature.enable_percentage_of_actors(params[:name], value)
else
Feature.enable_percentage_of_time(params[:name], value)
end
end
present Feature.get(params[:name]), # rubocop:disable Gitlab/AvoidFeatureGet
with: Entities::Feature, current_user: current_user
end
rescue Feature::Target::UnknowTargetError => e
bad_request!(e.message)
end
@ -128,6 +143,7 @@ module API
end
end
# TODO: remove this helper with feature flag set_feature_flag_service
helpers do
def validate_feature_flag_name!(name)
# no-op

View File

@ -14540,6 +14540,12 @@ msgstr ""
msgid "Edited %{timeago}"
msgstr ""
msgid "Edited %{timeago} by %{author}"
msgstr ""
msgid "Edited by %{author}"
msgstr ""
msgid "Editing"
msgstr ""

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
require_relative '../../migration_helpers'
module RuboCop
module Cop
module Migration
# Cop that checks `ActiveSupport::Concern` is included in EE batched background migrations
# if they define `scope_to`.
class BackgroundMigrationMissingActiveConcern < RuboCop::Cop::Base
include MigrationHelpers
MSG = <<~MSG
Extend `ActiveSupport::Concern` in the EE background migration if it defines `scope_to`.
MSG
def_node_matcher :prepended_block_uses_scope_to?, <<~PATTERN
(:block (:send nil? :prepended) (:args) `(:send nil? :scope_to ...))
PATTERN
def_node_matcher :scope_to?, <<~PATTERN
(:send nil? :scope_to ...)
PATTERN
def_node_matcher :extend_activesupport_concern?, <<~PATTERN
(:send nil? :extend (:const (:const nil? :ActiveSupport) :Concern))
PATTERN
def on_block(node)
return unless in_ee_background_migration?(node)
return unless prepended_block_uses_scope_to?(node)
return if module_extends_activesupport_concern?(node)
node.descendants.each do |descendant|
next unless scope_to?(descendant)
add_offense(descendant)
end
end
private
def module_extends_activesupport_concern?(node)
while node = node.parent
break if node.type == :module
end
return false unless node
node.descendants.any? do |descendant|
extend_activesupport_concern?(descendant)
end
end
end
end
end
end

View File

@ -34,7 +34,11 @@ module RuboCop
def in_background_migration?(node)
filepath(node).include?('/lib/gitlab/background_migration/') ||
filepath(node).include?('/ee/lib/ee/gitlab/background_migration/')
in_ee_background_migration?(node)
end
def in_ee_background_migration?(node)
filepath(node).include?('/ee/lib/ee/gitlab/background_migration/')
end
def in_deployment_migration?(node)

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe HealthCheckController, :request_store do
RSpec.describe HealthCheckController, :request_store, :use_clean_rails_memory_store_caching do
include StubENV
let(:xml_response) { Hash.from_xml(response.body)['hash'] }
@ -93,12 +93,13 @@ RSpec.describe HealthCheckController, :request_store do
context 'when a service is down and an endpoint is accessed from whitelisted ip' do
before do
allow(HealthCheck::Utils).to receive(:process_checks).with(['standard']).and_return('The server is on fire')
allow(HealthCheck::Utils).to receive(:process_checks).with(['email']).and_return('Email is on fire')
allow(::HealthCheck).to receive(:include_error_in_response_body).and_return(true)
allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(whitelisted_ip)
end
it 'supports failure plaintext response' do
expect(HealthCheck::Utils).to receive(:process_checks).with(['standard']).and_return('The server is on fire')
get :index
expect(response).to have_gitlab_http_status(:internal_server_error)
@ -107,6 +108,8 @@ RSpec.describe HealthCheckController, :request_store do
end
it 'supports failure json response' do
expect(HealthCheck::Utils).to receive(:process_checks).with(['standard']).and_return('The server is on fire')
get :index, format: :json
expect(response).to have_gitlab_http_status(:internal_server_error)
@ -116,6 +119,8 @@ RSpec.describe HealthCheckController, :request_store do
end
it 'supports failure xml response' do
expect(HealthCheck::Utils).to receive(:process_checks).with(['standard']).and_return('The server is on fire')
get :index, format: :xml
expect(response).to have_gitlab_http_status(:internal_server_error)
@ -125,6 +130,8 @@ RSpec.describe HealthCheckController, :request_store do
end
it 'supports failure responses for specific checks' do
expect(HealthCheck::Utils).to receive(:process_checks).with(['email']).and_return('Email is on fire')
get :index, params: { checks: 'email' }, format: :json
expect(response).to have_gitlab_http_status(:internal_server_error)

View File

@ -31,13 +31,26 @@ RSpec.describe Profiles::TwoFactorAuthsController do
shared_examples 'user must enter a valid current password' do
let(:current_password) { '123' }
let(:redirect_path) { profile_two_factor_auth_path }
let(:error_message) { { message: _('You must provide a valid current password') } }
it 'requires the current password', :aggregate_failures do
go
expect(response).to redirect_to(redirect_path)
expect(flash[:alert]).to eq(_('You must provide a valid current password'))
expect(assigns[:error]).to eq(error_message)
expect(response).to render_template(:show)
end
it 'assigns qr_code' do
code = double('qr code')
expect(subject).to receive(:build_qr_code).and_return(code)
go
expect(assigns[:qr_code]).to eq(code)
end
it 'assigns account_string' do
go
expect(assigns[:account_string]).to eq("#{Gitlab.config.gitlab.host}:#{user.email}")
end
context 'when the user is on the last sign in attempt' do
@ -58,8 +71,7 @@ RSpec.describe Profiles::TwoFactorAuthsController do
it 'does not require the current password', :aggregate_failures do
go
expect(response).not_to redirect_to(redirect_path)
expect(flash[:alert]).to be_nil
expect(assigns[:error]).not_to eq(error_message)
end
end
@ -71,8 +83,7 @@ RSpec.describe Profiles::TwoFactorAuthsController do
it 'does not require the current password', :aggregate_failures do
go
expect(response).not_to redirect_to(redirect_path)
expect(flash[:alert]).to be_nil
expect(assigns[:error]).not_to eq(error_message)
end
end
@ -84,8 +95,7 @@ RSpec.describe Profiles::TwoFactorAuthsController do
it 'does not require the current password', :aggregate_failures do
go
expect(response).not_to redirect_to(redirect_path)
expect(flash[:alert]).to be_nil
expect(assigns[:error]).not_to eq(error_message)
end
end
end

View File

@ -6,6 +6,8 @@ RSpec.describe 'Two factor auths' do
include Spec::Support::Helpers::ModalHelpers
context 'when signed in' do
let(:invalid_current_pwd_msg) { 'You must provide a valid current password' }
before do
sign_in(user)
end
@ -18,7 +20,7 @@ RSpec.describe 'Two factor auths' do
register_2fa(user.current_otp, '123')
expect(page).to have_content('You must provide a valid current password')
expect(page).to have_selector('.gl-alert-title', text: invalid_current_pwd_msg, count: 1)
register_2fa(user.reload.current_otp, user.password)
@ -76,7 +78,7 @@ RSpec.describe 'Two factor auths' do
click_button 'Disable'
end
expect(page).to have_content('You must provide a valid current password')
expect(page).to have_selector('.gl-alert-title', text: invalid_current_pwd_msg, count: 1)
fill_in 'current_password', with: user.password
@ -97,7 +99,7 @@ RSpec.describe 'Two factor auths' do
click_button 'Regenerate recovery codes'
expect(page).to have_content('You must provide a valid current password')
expect(page).to have_selector('.gl-alert-title', text: invalid_current_pwd_msg, count: 1)
fill_in 'current_password', with: user.password

View File

@ -87,5 +87,13 @@ describe('addInteractionClass', () => {
expect(spans[1].textContent).toBe('Text');
expect(spans[2].textContent).toBe(' ');
});
it('adds the correct class names to wrapped nodes', () => {
setHTMLFixture(
'<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"><span class="test"> Text </span></div></div></div>',
);
addInteractionClass({ ...params, wrapTextNodes: true });
expect(findAllSpans()[1].classList.contains('test')).toBe(true);
});
});
});

View File

@ -1,60 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`IDE pipeline stage renders stage details & icon 1`] = `
<div
class="ide-stage card gl-mt-3"
>
<div
class="card-header"
>
<ci-icon-stub
cssclasses=""
size="24"
status="[object Object]"
/>
<strong
class="gl-ml-3 text-truncate"
data-container="body"
>
build
</strong>
<div
class="gl-mr-3 gl-ml-2"
>
<gl-badge-stub
size="md"
variant="muted"
>
4
</gl-badge-stub>
</div>
<gl-icon-stub
class="ide-stage-collapse-icon"
name="chevron-lg-down"
size="16"
/>
</div>
<div
class="card-body p-0"
>
<item-stub
job="[object Object]"
/>
<item-stub
job="[object Object]"
/>
<item-stub
job="[object Object]"
/>
<item-stub
job="[object Object]"
/>
</div>
</div>
`;

View File

@ -18,8 +18,9 @@ describe('IDE pipeline stage', () => {
},
};
const findHeader = () => wrapper.findComponent({ ref: 'cardHeader' });
const findJobList = () => wrapper.findComponent({ ref: 'jobList' });
const findHeader = () => wrapper.find('[data-testid="card-header"]');
const findJobList = () => wrapper.find('[data-testid="job-list"]');
const findStageTitle = () => wrapper.find('[data-testid="stage-title"]');
const createComponent = (props) => {
wrapper = shallowMount(Stage, {
@ -65,9 +66,9 @@ describe('IDE pipeline stage', () => {
expect(wrapper.emitted().clickViewLog[0][0]).toBe(job);
});
it('renders stage details & icon', () => {
it('renders stage title', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
expect(findStageTitle().isVisible()).toBe(true);
});
describe('when collapsed', () => {

View File

@ -1,7 +1,10 @@
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { getTimeago } from '~/lib/utils/datetime_utility';
import Edited from '~/issues/show/components/edited.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
const timeago = getTimeago();
describe('Edited component', () => {
let wrapper;
@ -9,7 +12,8 @@ describe('Edited component', () => {
const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
const formatText = (text) => text.trim().replace(/\s\s+/g, ' ');
const mountComponent = (propsData) => shallowMount(Edited, { propsData });
const mountComponent = (propsData) => mount(Edited, { propsData });
const updatedAt = '2017-05-15T12:31:04.428Z';
afterEach(() => {
wrapper.destroy();
@ -17,12 +21,12 @@ describe('Edited component', () => {
it('renders an edited at+by string', () => {
wrapper = mountComponent({
updatedAt: '2017-05-15T12:31:04.428Z',
updatedAt,
updatedByName: 'Some User',
updatedByPath: '/some_user',
});
expect(formatText(wrapper.text())).toBe('Edited by Some User');
expect(formatText(wrapper.text())).toBe(`Edited ${timeago.format(updatedAt)} by Some User`);
expect(findAuthorLink().attributes('href')).toBe('/some_user');
expect(findTimeAgoTooltip().exists()).toBe(true);
});
@ -40,10 +44,10 @@ describe('Edited component', () => {
it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => {
wrapper = mountComponent({
updatedAt: '2017-05-15T12:31:04.428Z',
updatedAt,
});
expect(formatText(wrapper.text())).toBe('Edited');
expect(formatText(wrapper.text())).toBe(`Edited ${timeago.format(updatedAt)}`);
expect(findAuthorLink().exists()).toBe(false);
expect(findTimeAgoTooltip().exists()).toBe(true);
});

View File

@ -1,4 +1,4 @@
import { sanitize } from '~/lib/dompurify';
import { sanitize, defaultConfig } from '~/lib/dompurify';
// GDK
const rootGon = {
@ -45,7 +45,7 @@ const invalidProtocolUrls = [
/* eslint-enable no-script-url */
const validProtocolUrls = ['slack://open', 'x-devonthink-item://90909', 'x-devonthink-item:90909'];
const forbiddenDataAttrs = ['data-remote', 'data-url', 'data-type', 'data-method'];
const forbiddenDataAttrs = defaultConfig.FORBID_ATTR;
const acceptedDataAttrs = ['data-random', 'data-custom'];
describe('~/lib/dompurify', () => {

View File

@ -2,10 +2,10 @@ import {
registerPlugins,
HLJS_ON_AFTER_HIGHLIGHT,
} from '~/vue_shared/components/source_viewer/plugins/index';
import wrapComments from '~/vue_shared/components/source_viewer/plugins/wrap_comments';
import wrapChildNodes from '~/vue_shared/components/source_viewer/plugins/wrap_child_nodes';
import wrapBidiChars from '~/vue_shared/components/source_viewer/plugins/wrap_bidi_chars';
jest.mock('~/vue_shared/components/source_viewer/plugins/wrap_comments');
jest.mock('~/vue_shared/components/source_viewer/plugins/wrap_child_nodes');
const hljsMock = { addPlugin: jest.fn() };
describe('Highlight.js plugin registration', () => {
@ -13,6 +13,6 @@ describe('Highlight.js plugin registration', () => {
it('registers our plugins', () => {
expect(hljsMock.addPlugin).toHaveBeenCalledWith({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapBidiChars });
expect(hljsMock.addPlugin).toHaveBeenCalledWith({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments });
expect(hljsMock.addPlugin).toHaveBeenCalledWith({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapChildNodes });
});
});

View File

@ -0,0 +1,22 @@
import wrapChildNodes from '~/vue_shared/components/source_viewer/plugins/wrap_child_nodes';
describe('Highlight.js plugin for wrapping _emitter nodes', () => {
it('mutates the input value by wrapping each node in a span tag', () => {
const hljsResultMock = {
_emitter: {
rootNode: {
children: [
{ kind: 'string', children: ['Text 1'] },
{ kind: 'string', children: ['Text 2', { kind: 'comment', children: ['Text 3'] }] },
'Text4\nText5',
],
},
},
};
const outputValue = `<span class="hljs-string">Text 1</span><span class="hljs-string"><span class="hljs-string">Text 2</span><span class="hljs-comment">Text 3</span></span><span class="">Text4</span>\n<span class="">Text5</span>`;
wrapChildNodes(hljsResultMock);
expect(hljsResultMock.value).toBe(outputValue);
});
});

View File

@ -1,29 +0,0 @@
import { HLJS_COMMENT_SELECTOR } from '~/vue_shared/components/source_viewer/constants';
import wrapComments from '~/vue_shared/components/source_viewer/plugins/wrap_comments';
describe('Highlight.js plugin for wrapping comments', () => {
it('mutates the input value by wrapping each line in a span tag', () => {
const inputValue = `<span class="${HLJS_COMMENT_SELECTOR}">/* Line 1 \n* Line 2 \n*/</span>`;
const outputValue = `<span class="${HLJS_COMMENT_SELECTOR}">/* Line 1 \n<span class="${HLJS_COMMENT_SELECTOR}">* Line 2 </span>\n<span class="${HLJS_COMMENT_SELECTOR}">*/</span>`;
const hljsResultMock = { value: inputValue };
wrapComments(hljsResultMock);
expect(hljsResultMock.value).toBe(outputValue);
});
it('does not mutate the input value if the hljs comment selector is not present', () => {
const inputValue = '<span class="hljs-keyword">const</span>';
const hljsResultMock = { value: inputValue };
wrapComments(hljsResultMock);
expect(hljsResultMock.value).toBe(inputValue);
});
it('does not mutate the input value if the hljs comment line includes a closing tag', () => {
const inputValue = `<span class="${HLJS_COMMENT_SELECTOR}">/* Line 1 </span> \n* Line 2 \n*/`;
const hljsResultMock = { value: inputValue };
wrapComments(hljsResultMock);
expect(hljsResultMock.value).toBe(inputValue);
});
});

View File

@ -0,0 +1,116 @@
import { shallowMount } from '@vue/test-utils';
import safeHtml from '~/vue_shared/directives/safe_html';
import { defaultConfig } from '~/lib/dompurify';
/* eslint-disable no-script-url */
const invalidProtocolUrls = [
'javascript:alert(1)',
'jAvascript:alert(1)',
'data:text/html,<script>alert(1);</script>',
' javascript:',
'javascript :',
];
/* eslint-enable no-script-url */
const validProtocolUrls = ['slack://open', 'x-devonthink-item://90909', 'x-devonthink-item:90909'];
describe('safe html directive', () => {
let wrapper;
const createComponent = ({ template, html, config } = {}) => {
const defaultTemplate = `<div v-safe-html="rawHtml"></div>`;
const defaultHtml = 'hello <script>alert(1)</script>world';
const component = {
directives: {
safeHtml,
},
data() {
return {
rawHtml: html || defaultHtml,
config: config || {},
};
},
template: template || defaultTemplate,
};
wrapper = shallowMount(component);
};
describe('default', () => {
it('should remove the script tag', () => {
createComponent();
expect(wrapper.html()).toEqual('<div>hello world</div>');
});
it('should remove javascript hrefs', () => {
createComponent({ html: '<a href="javascript:prompt(1)">click here</a>' });
expect(wrapper.html()).toEqual('<div><a>click here</a></div>');
});
it('should remove any existing children', () => {
createComponent({
template: `<div v-safe-html="rawHtml">foo <i>bar</i></div>`,
});
expect(wrapper.html()).toEqual('<div>hello world</div>');
});
describe('with non-http links', () => {
it.each(validProtocolUrls)('should allow %s', (url) => {
createComponent({
html: `<a href="${url}">internal link</a>`,
});
expect(wrapper.html()).toContain(`<a href="${url}">internal link</a>`);
});
it.each(invalidProtocolUrls)('should not allow %s', (url) => {
createComponent({
html: `<a href="${url}">internal link</a>`,
});
expect(wrapper.html()).toContain(`<a>internal link</a>`);
});
});
describe('handles data attributes correctly', () => {
const allowedDataAttrs = ['data-safe', 'data-random'];
it.each(defaultConfig.FORBID_ATTR)('removes dangerous `%s` attribute', (attr) => {
const html = `<a ${attr}="true"></a>`;
createComponent({ html });
expect(wrapper.html()).not.toContain(html);
});
it.each(allowedDataAttrs)('does not remove allowed `%s` attribute', (attr) => {
const html = `<a ${attr}="true"></a>`;
createComponent({ html });
expect(wrapper.html()).toContain(html);
});
});
});
describe('advance config', () => {
const template = '<div v-safe-html:[config]="rawHtml"></div>';
it('should only allow <b> tags', () => {
createComponent({
template,
html: '<a href="javascript:prompt(1)"><b>click here</b></a>',
config: { ALLOWED_TAGS: ['b'] },
});
expect(wrapper.html()).toEqual('<div><b>click here</b></div>');
});
it('should strip all html tags', () => {
createComponent({
template,
html: '<a href="javascript:prompt(1)"><u>click here</u></a>',
config: { ALLOWED_TAGS: [] },
});
expect(wrapper.html()).toEqual('<div>click here</div>');
});
});
});

View File

@ -4,16 +4,14 @@ require 'fast_spec_helper'
require_relative './simple_check_shared'
RSpec.describe Gitlab::HealthChecks::MasterCheck do
before do
stub_const('SUCCESS_CODE', 100)
stub_const('FAILURE_CODE', 101)
end
context 'when Puma runs in Clustered mode' do
before do
allow(Gitlab::Runtime).to receive(:puma_in_clustered_mode?).and_return(true)
described_class.register_master
# We need to capture the read pipe here to stub out the non-blocking read.
# The original implementation actually forked the test suite for a more
# end-to-end test but that caused knock-on effects on other tests.
@pipe_read, _ = described_class.register_master
end
after do
@ -25,34 +23,40 @@ RSpec.describe Gitlab::HealthChecks::MasterCheck do
end
describe '.readiness' do
context 'when master is running' do
it 'worker does return success' do
_, child_status = run_worker
context 'when no worker registered' do
it 'succeeds' do
expect(described_class.readiness.success).to be(true)
end
end
expect(child_status.exitstatus).to eq(SUCCESS_CODE)
context 'when worker registers itself' do
context 'when reading from pipe succeeds' do
it 'succeeds' do
expect(@pipe_read).to receive(:read_nonblock) # rubocop: disable RSpec/InstanceVariable
described_class.register_worker
expect(described_class.readiness.success).to be(true)
end
end
context 'when read pipe is open but not ready for reading' do
it 'succeeds' do
expect(@pipe_read).to receive(:read_nonblock).and_raise(IO::EAGAINWaitReadable) # rubocop: disable RSpec/InstanceVariable
described_class.register_worker
expect(described_class.readiness.success).to be(true)
end
end
end
context 'when master finishes early' do
before do
described_class.send(:close_write)
it 'fails' do
described_class.finish_master
expect(described_class.readiness.success).to be(false)
end
it 'worker does return failure' do
_, child_status = run_worker
expect(child_status.exitstatus).to eq(FAILURE_CODE)
end
end
def run_worker
pid = fork do
described_class.register_worker
exit(described_class.readiness.success ? SUCCESS_CODE : FAILURE_CODE)
end
Process.wait2(pid)
end
end
end

View File

@ -6275,6 +6275,57 @@ RSpec.describe User do
end
end
describe '#generate_otp_backup_codes!' do
let(:user) { create(:user) }
context 'with FIPS mode', :fips_mode do
it 'attempts to use #generate_otp_backup_codes_pbkdf2!' do
expect(user).to receive(:generate_otp_backup_codes_pbkdf2!).and_call_original
user.generate_otp_backup_codes!
end
end
context 'outside FIPS mode' do
it 'does not attempt to use #generate_otp_backup_codes_pbkdf2!' do
expect(user).not_to receive(:generate_otp_backup_codes_pbkdf2!)
user.generate_otp_backup_codes!
end
end
end
describe '#invalidate_otp_backup_code!' do
let(:user) { create(:user) }
context 'with FIPS mode', :fips_mode do
context 'with a PBKDF2-encrypted password' do
let(:encrypted_password) { '$pbkdf2-sha512$20000$boHGAw0hEyI$DBA67J7zNZebyzLtLk2X9wRDbmj1LNKVGnZLYyz6PGrIDGIl45fl/BPH0y1TPZnV90A20i.fD9C3G9Bp8jzzOA' }
it 'attempts to use #invalidate_otp_backup_code_pdkdf2!' do
expect(user).to receive(:otp_backup_codes).at_least(:once).and_return([encrypted_password])
expect(user).to receive(:invalidate_otp_backup_code_pdkdf2!).and_return(true)
user.invalidate_otp_backup_code!(user.password)
end
end
it 'does not attempt to use #invalidate_otp_backup_code_pdkdf2!' do
expect(user).not_to receive(:invalidate_otp_backup_code_pdkdf2!)
user.invalidate_otp_backup_code!(user.password)
end
end
context 'outside FIPS mode' do
it 'does not attempt to use #invalidate_otp_backup_code_pdkdf2!' do
expect(user).not_to receive(:invalidate_otp_backup_code_pdkdf2!)
user.invalidate_otp_backup_code!(user.password)
end
end
end
# These entire test section can be removed once the :pbkdf2_password_encryption feature flag is removed.
describe '#password=' do
let(:user) { create(:user) }

View File

@ -92,97 +92,277 @@ RSpec.describe API::Features, stub_feature_flags: false do
describe 'POST /feature' do
let(:feature_name) { known_feature_flag.name }
context 'when the feature does not exist' do
it 'returns a 401 for anonymous users' do
post api("/features/#{feature_name}")
# TODO: remove this shared examples block when set_feature_flag_service feature flag
# is removed. Then remove also any duplicate specs covered by the service class.
shared_examples 'sets the feature flag status' do
context 'when the feature does not exist' do
it 'returns a 401 for anonymous users' do
post api("/features/#{feature_name}")
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'returns a 403 for users' do
post api("/features/#{feature_name}", user)
expect(response).to have_gitlab_http_status(:forbidden)
end
context 'when passed value=true' do
it 'creates an enabled feature' do
post api("/features/#{feature_name}", admin), params: { value: 'true' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'on',
'gates' => [{ 'key' => 'boolean', 'value' => true }],
'definition' => known_feature_flag_definition_hash
)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'logs the event' do
expect(Feature.logger).to receive(:info).once
it 'returns a 403 for users' do
post api("/features/#{feature_name}", user)
post api("/features/#{feature_name}", admin), params: { value: 'true' }
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'creates an enabled feature for the given Flipper group when passed feature_group=perf_team' do
post api("/features/#{feature_name}", admin), params: { value: 'true', feature_group: 'perf_team' }
context 'when passed value=true' do
it 'creates an enabled feature' do
post api("/features/#{feature_name}", admin), params: { value: 'true' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'groups', 'value' => ['perf_team'] }
],
'definition' => known_feature_flag_definition_hash
)
end
it 'creates an enabled feature for the given user when passed user=username' do
post api("/features/#{feature_name}", admin), params: { value: 'true', user: user.username }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'on',
'gates' => [{ 'key' => 'boolean', 'value' => true }],
'definition' => known_feature_flag_definition_hash
)
end
it 'logs the event' do
expect(Feature.logger).to receive(:info).once
post api("/features/#{feature_name}", admin), params: { value: 'true' }
end
it 'creates an enabled feature for the given Flipper group when passed feature_group=perf_team' do
post api("/features/#{feature_name}", admin), params: { value: 'true', feature_group: 'perf_team' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'groups', 'value' => ['perf_team'] }
],
'definition' => known_feature_flag_definition_hash
)
end
it 'creates an enabled feature for the given user when passed user=username' do
post api("/features/#{feature_name}", admin), params: { value: 'true', user: user.username }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'actors', 'value' => ["User:#{user.id}"] }
],
'definition' => known_feature_flag_definition_hash
)
end
it 'creates an enabled feature for the given user and feature group when passed user=username and feature_group=perf_team' do
post api("/features/#{feature_name}", admin), params: { value: 'true', user: user.username, feature_group: 'perf_team' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(feature_name)
expect(json_response['state']).to eq('conditional')
expect(json_response['gates']).to contain_exactly(
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'groups', 'value' => ['perf_team'] },
{ 'key' => 'actors', 'value' => ["User:#{user.id}"] }
],
'definition' => known_feature_flag_definition_hash
)
)
end
end
it 'creates an enabled feature for the given user and feature group when passed user=username and feature_group=perf_team' do
post api("/features/#{feature_name}", admin), params: { value: 'true', user: user.username, feature_group: 'perf_team' }
shared_examples 'does not enable the flag' do |actor_type|
let(:actor_path) { raise NotImplementedError }
let(:expected_inexistent_path) { actor_path }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(feature_name)
expect(json_response['state']).to eq('conditional')
expect(json_response['gates']).to contain_exactly(
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'groups', 'value' => ['perf_team'] },
{ 'key' => 'actors', 'value' => ["User:#{user.id}"] }
)
it 'returns the current state of the flag without changes' do
post api("/features/#{feature_name}", admin), params: { value: 'true', actor_type => actor_path }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq("400 Bad request - #{expected_inexistent_path} is not found!")
end
end
end
shared_examples 'does not enable the flag' do |actor_type|
let(:actor_path) { raise NotImplementedError }
let(:expected_inexistent_path) { actor_path }
shared_examples 'enables the flag for the actor' do |actor_type|
it 'sets the feature gate' do
post api("/features/#{feature_name}", admin), params: { value: 'true', actor_type => actor.full_path }
it 'returns the current state of the flag without changes' do
post api("/features/#{feature_name}", admin), params: { value: 'true', actor_type => actor_path }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq("400 Bad request - #{expected_inexistent_path} is not found!")
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'actors', 'value' => ["#{actor.class}:#{actor.id}"] }
],
'definition' => known_feature_flag_definition_hash
)
end
end
end
shared_examples 'enables the flag for the actor' do |actor_type|
it 'sets the feature gate' do
post api("/features/#{feature_name}", admin), params: { value: 'true', actor_type => actor.full_path }
shared_examples 'creates an enabled feature for the specified entries' do
it do
post api("/features/#{feature_name}", admin), params: { value: 'true', **gate_params }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(feature_name)
expect(json_response['gates']).to contain_exactly(
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'actors', 'value' => array_including(expected_gate_params) }
)
end
end
context 'when enabling for a project by path' do
context 'when the project exists' do
it_behaves_like 'enables the flag for the actor', :project do
let(:actor) { create(:project) }
end
end
context 'when the project does not exist' do
it_behaves_like 'does not enable the flag', :project do
let(:actor_path) { 'mep/to/the/mep/mep' }
end
end
end
context 'when enabling for a group by path' do
context 'when the group exists' do
it_behaves_like 'enables the flag for the actor', :group do
let(:actor) { create(:group) }
end
end
context 'when the group does not exist' do
it_behaves_like 'does not enable the flag', :group do
let(:actor_path) { 'not/a/group' }
end
end
end
context 'when enabling for a namespace by path' do
context 'when the user namespace exists' do
it_behaves_like 'enables the flag for the actor', :namespace do
let(:actor) { create(:namespace) }
end
end
context 'when the group namespace exists' do
it_behaves_like 'enables the flag for the actor', :namespace do
let(:actor) { create(:group) }
end
end
context 'when the user namespace does not exist' do
it_behaves_like 'does not enable the flag', :namespace do
let(:actor_path) { 'not/a/group' }
end
end
context 'when a project namespace exists' do
let(:project_namespace) { create(:project_namespace) }
it_behaves_like 'does not enable the flag', :namespace do
let(:actor_path) { project_namespace.full_path }
end
end
end
context 'with multiple users' do
let_it_be(:users) { create_list(:user, 3) }
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { user: users.map(&:username).join(',') } }
let(:expected_gate_params) { users.map(&:flipper_id) }
end
context 'when empty value exists between comma' do
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { user: "#{users.first.username},,,," } }
let(:expected_gate_params) { users.first.flipper_id }
end
end
context 'when one of the users does not exist' do
it_behaves_like 'does not enable the flag', :user do
let(:actor_path) { "#{users.first.username},inexistent-entry" }
let(:expected_inexistent_path) { "inexistent-entry" }
end
end
end
context 'with multiple projects' do
let_it_be(:projects) { create_list(:project, 3) }
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { project: projects.map(&:full_path).join(',') } }
let(:expected_gate_params) { projects.map(&:flipper_id) }
end
context 'when empty value exists between comma' do
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { project: "#{projects.first.full_path},,,," } }
let(:expected_gate_params) { projects.first.flipper_id }
end
end
context 'when one of the projects does not exist' do
it_behaves_like 'does not enable the flag', :project do
let(:actor_path) { "#{projects.first.full_path},inexistent-entry" }
let(:expected_inexistent_path) { "inexistent-entry" }
end
end
end
context 'with multiple groups' do
let_it_be(:groups) { create_list(:group, 3) }
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { group: groups.map(&:full_path).join(',') } }
let(:expected_gate_params) { groups.map(&:flipper_id) }
end
context 'when empty value exists between comma' do
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { group: "#{groups.first.full_path},,,," } }
let(:expected_gate_params) { groups.first.flipper_id }
end
end
context 'when one of the groups does not exist' do
it_behaves_like 'does not enable the flag', :group do
let(:actor_path) { "#{groups.first.full_path},inexistent-entry" }
let(:expected_inexistent_path) { "inexistent-entry" }
end
end
end
context 'with multiple namespaces' do
let_it_be(:namespaces) { create_list(:namespace, 3) }
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { namespace: namespaces.map(&:full_path).join(',') } }
let(:expected_gate_params) { namespaces.map(&:flipper_id) }
end
context 'when empty value exists between comma' do
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { namespace: "#{namespaces.first.full_path},,,," } }
let(:expected_gate_params) { namespaces.first.flipper_id }
end
end
context 'when one of the namespaces does not exist' do
it_behaves_like 'does not enable the flag', :namespace do
let(:actor_path) { "#{namespaces.first.full_path},inexistent-entry" }
let(:expected_inexistent_path) { "inexistent-entry" }
end
end
end
it 'creates a feature with the given percentage of time if passed an integer' do
post api("/features/#{feature_name}", admin), params: { value: '50' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
@ -190,421 +370,259 @@ RSpec.describe API::Features, stub_feature_flags: false do
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'actors', 'value' => ["#{actor.class}:#{actor.id}"] }
{ 'key' => 'percentage_of_time', 'value' => 50 }
],
'definition' => known_feature_flag_definition_hash
)
end
end
shared_examples 'creates an enabled feature for the specified entries' do
it do
post api("/features/#{feature_name}", admin), params: { value: 'true', **gate_params }
it 'creates a feature with the given percentage of time if passed a float' do
post api("/features/#{feature_name}", admin), params: { value: '0.01' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(feature_name)
expect(json_response['gates']).to contain_exactly(
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'actors', 'value' => array_including(expected_gate_params) }
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_time', 'value' => 0.01 }
],
'definition' => known_feature_flag_definition_hash
)
end
end
context 'when enabling for a project by path' do
context 'when the project exists' do
it_behaves_like 'enables the flag for the actor', :project do
let(:actor) { create(:project) }
end
it 'creates a feature with the given percentage of actors if passed an integer' do
post api("/features/#{feature_name}", admin), params: { value: '50', key: 'percentage_of_actors' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_actors', 'value' => 50 }
],
'definition' => known_feature_flag_definition_hash
)
end
context 'when the project does not exist' do
it_behaves_like 'does not enable the flag', :project do
let(:actor_path) { 'mep/to/the/mep/mep' }
it 'creates a feature with the given percentage of actors if passed a float' do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_actors', 'value' => 0.01 }
],
'definition' => known_feature_flag_definition_hash
)
end
describe 'mutually exclusive parameters' do
shared_examples 'fails to set the feature flag' do
it 'returns an error' do
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to match(/key, \w+ are mutually exclusive/)
end
end
context 'when key and feature_group are provided' do
before do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', feature_group: 'some-value' }
end
it_behaves_like 'fails to set the feature flag'
end
context 'when key and user are provided' do
before do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', user: 'some-user' }
end
it_behaves_like 'fails to set the feature flag'
end
context 'when key and group are provided' do
before do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', group: 'somepath' }
end
it_behaves_like 'fails to set the feature flag'
end
context 'when key and namespace are provided' do
before do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', namespace: 'somepath' }
end
it_behaves_like 'fails to set the feature flag'
end
context 'when key and project are provided' do
before do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', project: 'somepath' }
end
it_behaves_like 'fails to set the feature flag'
end
end
end
context 'when enabling for a group by path' do
context 'when the group exists' do
it_behaves_like 'enables the flag for the actor', :group do
let(:actor) { create(:group) }
context 'when the feature exists' do
before do
Feature.disable(feature_name) # This also persists the feature on the DB
end
context 'when passed value=true' do
it 'enables the feature' do
post api("/features/#{feature_name}", admin), params: { value: 'true' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'on',
'gates' => [{ 'key' => 'boolean', 'value' => true }],
'definition' => known_feature_flag_definition_hash
)
end
it 'enables the feature for the given Flipper group when passed feature_group=perf_team' do
post api("/features/#{feature_name}", admin), params: { value: 'true', feature_group: 'perf_team' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'groups', 'value' => ['perf_team'] }
],
'definition' => known_feature_flag_definition_hash
)
end
it 'enables the feature for the given user when passed user=username' do
post api("/features/#{feature_name}", admin), params: { value: 'true', user: user.username }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'actors', 'value' => ["User:#{user.id}"] }
],
'definition' => known_feature_flag_definition_hash
)
end
end
context 'when the group does not exist' do
it_behaves_like 'does not enable the flag', :group do
let(:actor_path) { 'not/a/group' }
end
end
end
context 'when feature is enabled and value=false is passed' do
it 'disables the feature' do
Feature.enable(feature_name)
expect(Feature.enabled?(feature_name)).to eq(true)
context 'when enabling for a namespace by path' do
context 'when the user namespace exists' do
it_behaves_like 'enables the flag for the actor', :namespace do
let(:actor) { create(:namespace) }
post api("/features/#{feature_name}", admin), params: { value: 'false' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'off',
'gates' => [{ 'key' => 'boolean', 'value' => false }],
'definition' => known_feature_flag_definition_hash
)
end
it 'disables the feature for the given Flipper group when passed feature_group=perf_team' do
Feature.enable(feature_name, Feature.group(:perf_team))
expect(Feature.enabled?(feature_name, admin)).to be_truthy
post api("/features/#{feature_name}", admin), params: { value: 'false', feature_group: 'perf_team' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'off',
'gates' => [{ 'key' => 'boolean', 'value' => false }],
'definition' => known_feature_flag_definition_hash
)
end
it 'disables the feature for the given user when passed user=username' do
Feature.enable(feature_name, user)
expect(Feature.enabled?(feature_name, user)).to be_truthy
post api("/features/#{feature_name}", admin), params: { value: 'false', user: user.username }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'off',
'gates' => [{ 'key' => 'boolean', 'value' => false }],
'definition' => known_feature_flag_definition_hash
)
end
end
context 'when the group namespace exists' do
it_behaves_like 'enables the flag for the actor', :namespace do
let(:actor) { create(:group) }
end
end
context 'when the user namespace does not exist' do
it_behaves_like 'does not enable the flag', :namespace do
let(:actor_path) { 'not/a/group' }
end
end
context 'when a project namespace exists' do
let(:project_namespace) { create(:project_namespace) }
it_behaves_like 'does not enable the flag', :namespace do
let(:actor_path) { project_namespace.full_path }
end
end
end
context 'with multiple users' do
let_it_be(:users) { create_list(:user, 3) }
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { user: users.map(&:username).join(',') } }
let(:expected_gate_params) { users.map(&:flipper_id) }
end
context 'when empty value exists between comma' do
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { user: "#{users.first.username},,,," } }
let(:expected_gate_params) { users.first.flipper_id }
end
end
context 'when one of the users does not exist' do
it_behaves_like 'does not enable the flag', :user do
let(:actor_path) { "#{users.first.username},inexistent-entry" }
let(:expected_inexistent_path) { "inexistent-entry" }
end
end
end
context 'with multiple projects' do
let_it_be(:projects) { create_list(:project, 3) }
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { project: projects.map(&:full_path).join(',') } }
let(:expected_gate_params) { projects.map(&:flipper_id) }
end
context 'when empty value exists between comma' do
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { project: "#{projects.first.full_path},,,," } }
let(:expected_gate_params) { projects.first.flipper_id }
end
end
context 'when one of the projects does not exist' do
it_behaves_like 'does not enable the flag', :project do
let(:actor_path) { "#{projects.first.full_path},inexistent-entry" }
let(:expected_inexistent_path) { "inexistent-entry" }
end
end
end
context 'with multiple groups' do
let_it_be(:groups) { create_list(:group, 3) }
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { group: groups.map(&:full_path).join(',') } }
let(:expected_gate_params) { groups.map(&:flipper_id) }
end
context 'when empty value exists between comma' do
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { group: "#{groups.first.full_path},,,," } }
let(:expected_gate_params) { groups.first.flipper_id }
end
end
context 'when one of the groups does not exist' do
it_behaves_like 'does not enable the flag', :group do
let(:actor_path) { "#{groups.first.full_path},inexistent-entry" }
let(:expected_inexistent_path) { "inexistent-entry" }
end
end
end
context 'with multiple namespaces' do
let_it_be(:namespaces) { create_list(:namespace, 3) }
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { namespace: namespaces.map(&:full_path).join(',') } }
let(:expected_gate_params) { namespaces.map(&:flipper_id) }
end
context 'when empty value exists between comma' do
it_behaves_like 'creates an enabled feature for the specified entries' do
let(:gate_params) { { namespace: "#{namespaces.first.full_path},,,," } }
let(:expected_gate_params) { namespaces.first.flipper_id }
end
end
context 'when one of the namespaces does not exist' do
it_behaves_like 'does not enable the flag', :namespace do
let(:actor_path) { "#{namespaces.first.full_path},inexistent-entry" }
let(:expected_inexistent_path) { "inexistent-entry" }
end
end
end
it 'creates a feature with the given percentage of time if passed an integer' do
post api("/features/#{feature_name}", admin), params: { value: '50' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_time', 'value' => 50 }
],
'definition' => known_feature_flag_definition_hash
)
end
it 'creates a feature with the given percentage of time if passed a float' do
post api("/features/#{feature_name}", admin), params: { value: '0.01' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_time', 'value' => 0.01 }
],
'definition' => known_feature_flag_definition_hash
)
end
it 'creates a feature with the given percentage of actors if passed an integer' do
post api("/features/#{feature_name}", admin), params: { value: '50', key: 'percentage_of_actors' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_actors', 'value' => 50 }
],
'definition' => known_feature_flag_definition_hash
)
end
it 'creates a feature with the given percentage of actors if passed a float' do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_actors', 'value' => 0.01 }
],
'definition' => known_feature_flag_definition_hash
)
end
describe 'mutually exclusive parameters' do
shared_examples 'fails to set the feature flag' do
it 'returns an error' do
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to match(/key, \w+ are mutually exclusive/)
end
end
context 'when key and feature_group are provided' do
context 'with a pre-existing percentage of time value' do
before do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', feature_group: 'some-value' }
Feature.enable_percentage_of_time(feature_name, 50)
end
it_behaves_like 'fails to set the feature flag'
it 'updates the percentage of time if passed an integer' do
post api("/features/#{feature_name}", admin), params: { value: '30' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_time', 'value' => 30 }
],
'definition' => known_feature_flag_definition_hash
)
end
end
context 'when key and user are provided' do
context 'with a pre-existing percentage of actors value' do
before do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', user: 'some-user' }
Feature.enable_percentage_of_actors(feature_name, 42)
end
it_behaves_like 'fails to set the feature flag'
end
it 'updates the percentage of actors if passed an integer' do
post api("/features/#{feature_name}", admin), params: { value: '74', key: 'percentage_of_actors' }
context 'when key and group are provided' do
before do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', group: 'somepath' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_actors', 'value' => 74 }
],
'definition' => known_feature_flag_definition_hash
)
end
it_behaves_like 'fails to set the feature flag'
end
context 'when key and namespace are provided' do
before do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', namespace: 'somepath' }
end
it_behaves_like 'fails to set the feature flag'
end
context 'when key and project are provided' do
before do
post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', project: 'somepath' }
end
it_behaves_like 'fails to set the feature flag'
end
end
end
context 'when the feature exists' do
before do
stub_feature_flags(set_feature_flag_service: true)
end
it_behaves_like 'sets the feature flag status'
context 'when feature flag set_feature_flag_service is disabled' do
before do
Feature.disable(feature_name) # This also persists the feature on the DB
stub_feature_flags(set_feature_flag_service: false)
end
context 'when passed value=true' do
it 'enables the feature' do
post api("/features/#{feature_name}", admin), params: { value: 'true' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'on',
'gates' => [{ 'key' => 'boolean', 'value' => true }],
'definition' => known_feature_flag_definition_hash
)
end
it 'enables the feature for the given Flipper group when passed feature_group=perf_team' do
post api("/features/#{feature_name}", admin), params: { value: 'true', feature_group: 'perf_team' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'groups', 'value' => ['perf_team'] }
],
'definition' => known_feature_flag_definition_hash
)
end
it 'enables the feature for the given user when passed user=username' do
post api("/features/#{feature_name}", admin), params: { value: 'true', user: user.username }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'actors', 'value' => ["User:#{user.id}"] }
],
'definition' => known_feature_flag_definition_hash
)
end
end
context 'when feature is enabled and value=false is passed' do
it 'disables the feature' do
Feature.enable(feature_name)
expect(Feature.enabled?(feature_name)).to eq(true)
post api("/features/#{feature_name}", admin), params: { value: 'false' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'off',
'gates' => [{ 'key' => 'boolean', 'value' => false }],
'definition' => known_feature_flag_definition_hash
)
end
it 'disables the feature for the given Flipper group when passed feature_group=perf_team' do
Feature.enable(feature_name, Feature.group(:perf_team))
expect(Feature.enabled?(feature_name, admin)).to be_truthy
post api("/features/#{feature_name}", admin), params: { value: 'false', feature_group: 'perf_team' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'off',
'gates' => [{ 'key' => 'boolean', 'value' => false }],
'definition' => known_feature_flag_definition_hash
)
end
it 'disables the feature for the given user when passed user=username' do
Feature.enable(feature_name, user)
expect(Feature.enabled?(feature_name, user)).to be_truthy
post api("/features/#{feature_name}", admin), params: { value: 'false', user: user.username }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'off',
'gates' => [{ 'key' => 'boolean', 'value' => false }],
'definition' => known_feature_flag_definition_hash
)
end
end
context 'with a pre-existing percentage of time value' do
before do
Feature.enable_percentage_of_time(feature_name, 50)
end
it 'updates the percentage of time if passed an integer' do
post api("/features/#{feature_name}", admin), params: { value: '30' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_time', 'value' => 30 }
],
'definition' => known_feature_flag_definition_hash
)
end
end
context 'with a pre-existing percentage of actors value' do
before do
Feature.enable_percentage_of_actors(feature_name, 42)
end
it 'updates the percentage of actors if passed an integer' do
post api("/features/#{feature_name}", admin), params: { value: '74', key: 'percentage_of_actors' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to match(
'name' => feature_name,
'state' => 'conditional',
'gates' => [
{ 'key' => 'boolean', 'value' => false },
{ 'key' => 'percentage_of_actors', 'value' => 74 }
],
'definition' => known_feature_flag_definition_hash
)
end
end
it_behaves_like 'sets the feature flag status'
end
end

View File

@ -0,0 +1,86 @@
# frozen_string_literal: true
require 'rubocop_spec_helper'
require_relative '../../../../rubocop/cop/migration/background_migration_missing_active_concern'
RSpec.describe RuboCop::Cop::Migration::BackgroundMigrationMissingActiveConcern do
shared_examples 'offense is not registered' do
it 'does not register any offenses' do
expect_no_offenses(<<~RUBY)
module Gitlab
module BackgroundMigration
prepended do
scope_to -> (relation) { relation }
end
end
end
RUBY
end
end
context 'when outside of a migration' do
it_behaves_like 'offense is not registered'
end
context 'in non-ee background migration' do
before do
allow(cop).to receive(:in_ee_background_migration?).and_return(false)
end
it_behaves_like 'offense is not registered'
end
context 'in ee background migration' do
before do
allow(cop).to receive(:in_ee_background_migration?).and_return(true)
end
context 'when scope_to is not used inside prepended block' do
it 'does not register any offenses' do
expect_no_offenses(<<~RUBY)
module Gitlab
module BackgroundMigration
prepended do
some_method_to -> (relation) { relation }
end
def foo
scope_to -> (relation) { relation }
end
end
end
RUBY
end
end
context 'when scope_to is used inside prepended block' do
it 'does not register any offenses if the module does extend ActiveSupport::Concern' do
expect_no_offenses(<<~RUBY)
module Gitlab
module BackgroundMigration
extend ::Gitlab::Utils::Override
extend ActiveSupport::Concern
prepended do
scope_to -> (relation) { relation }
end
end
end
RUBY
end
it 'registers an offense if the module does not extend ActiveSupport::Concern' do
expect_offense(<<~RUBY)
module Gitlab
module BackgroundMigration
prepended do
scope_to -> (relation) { relation }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Extend `ActiveSupport::Concern` [...]
end
end
end
RUBY
end
end
end
end

View File

@ -0,0 +1,300 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Admin::SetFeatureFlagService do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
let(:feature_name) { known_feature_flag.name }
let(:service) { described_class.new(feature_flag_name: feature_name, params: params) }
# Find any `development` feature flag name
let(:known_feature_flag) do
Feature::Definition.definitions
.values.find(&:development?)
end
describe '#execute' do
before do
Feature.reset
Flipper.unregister_groups
Flipper.register(:perf_team) do |actor|
actor.respond_to?(:admin) && actor.admin?
end
end
subject { service.execute }
context 'when enabling the feature flag' do
let(:params) { { value: 'true' } }
it 'enables the feature flag' do
expect(Feature).to receive(:enable).with(feature_name)
expect(subject).to be_success
feature_flag = subject.payload[:feature_flag]
expect(feature_flag.name).to eq(feature_name)
end
it 'logs the event' do
expect(Feature.logger).to receive(:info).once
subject
end
context 'when enabling for a user actor' do
let(:params) { { value: 'true', user: user.username } }
it 'enables the feature flag' do
expect(Feature).to receive(:enable).with(feature_name, user)
expect(subject).to be_success
end
context 'when user does not exist' do
let(:params) { { value: 'true', user: 'unknown-user' } }
it 'does nothing' do
expect(Feature).not_to receive(:enable)
expect(subject).to be_error
expect(subject.reason).to eq(:actor_not_found)
end
end
end
context 'when enabling for a feature group' do
let(:params) { { value: 'true', feature_group: 'perf_team' } }
let(:feature_group) { Feature.group('perf_team') }
it 'enables the feature flag' do
expect(Feature).to receive(:enable).with(feature_name, feature_group)
expect(subject).to be_success
end
end
context 'when enabling for a project' do
let(:params) { { value: 'true', project: project.full_path } }
it 'enables the feature flag' do
expect(Feature).to receive(:enable).with(feature_name, project)
expect(subject).to be_success
end
end
context 'when enabling for a group' do
let(:params) { { value: 'true', group: group.full_path } }
it 'enables the feature flag' do
expect(Feature).to receive(:enable).with(feature_name, group)
expect(subject).to be_success
end
context 'when group does not exist' do
let(:params) { { value: 'true', group: 'unknown-group' } }
it 'returns an error' do
expect(Feature).not_to receive(:disable)
expect(subject).to be_error
expect(subject.reason).to eq(:actor_not_found)
end
end
end
context 'when enabling for a user namespace' do
let(:namespace) { user.namespace }
let(:params) { { value: 'true', namespace: namespace.full_path } }
it 'enables the feature flag' do
expect(Feature).to receive(:enable).with(feature_name, namespace)
expect(subject).to be_success
end
context 'when namespace does not exist' do
let(:params) { { value: 'true', namespace: 'unknown-namespace' } }
it 'returns an error' do
expect(Feature).not_to receive(:disable)
expect(subject).to be_error
expect(subject.reason).to eq(:actor_not_found)
end
end
end
context 'when enabling for a group namespace' do
let(:params) { { value: 'true', namespace: group.full_path } }
it 'enables the feature flag' do
expect(Feature).to receive(:enable).with(feature_name, group)
expect(subject).to be_success
end
end
context 'when enabling for a user actor and a feature group' do
let(:params) { { value: 'true', user: user.username, feature_group: 'perf_team' } }
let(:feature_group) { Feature.group('perf_team') }
it 'enables the feature flag' do
expect(Feature).to receive(:enable).with(feature_name, user)
expect(Feature).to receive(:enable).with(feature_name, feature_group)
expect(subject).to be_success
end
end
context 'when enabling given a percentage of time' do
let(:params) { { value: '50' } }
it 'enables the feature flag' do
expect(Feature).to receive(:enable_percentage_of_time).with(feature_name, 50)
expect(subject).to be_success
end
context 'when value is a float' do
let(:params) { { value: '0.01' } }
it 'enables the feature flag' do
expect(Feature).to receive(:enable_percentage_of_time).with(feature_name, 0.01)
expect(subject).to be_success
end
end
end
context 'when enabling given a percentage of actors' do
let(:params) { { value: '50', key: 'percentage_of_actors' } }
it 'enables the feature flag' do
expect(Feature).to receive(:enable_percentage_of_actors).with(feature_name, 50)
expect(subject).to be_success
end
context 'when value is a float' do
let(:params) { { value: '0.01', key: 'percentage_of_actors' } }
it 'enables the feature flag' do
expect(Feature).to receive(:enable_percentage_of_actors).with(feature_name, 0.01)
expect(subject).to be_success
end
end
end
end
context 'when disabling the feature flag' do
before do
Feature.enable(feature_name)
end
let(:params) { { value: 'false' } }
it 'disables the feature flag' do
expect(Feature).to receive(:disable).with(feature_name)
expect(subject).to be_success
feature_flag = subject.payload[:feature_flag]
expect(feature_flag.name).to eq(feature_name)
end
it 'logs the event' do
expect(Feature.logger).to receive(:info).once
subject
end
context 'when disabling for a user actor' do
let(:params) { { value: 'false', user: user.username } }
it 'disables the feature flag' do
expect(Feature).to receive(:disable).with(feature_name, user)
expect(subject).to be_success
end
context 'when user does not exist' do
let(:params) { { value: 'false', user: 'unknown-user' } }
it 'returns an error' do
expect(Feature).not_to receive(:disable)
expect(subject).to be_error
expect(subject.reason).to eq(:actor_not_found)
end
end
end
context 'when disabling for a feature group' do
let(:params) { { value: 'false', feature_group: 'perf_team' } }
let(:feature_group) { Feature.group('perf_team') }
it 'disables the feature flag' do
expect(Feature).to receive(:disable).with(feature_name, feature_group)
expect(subject).to be_success
end
end
context 'when disabling for a project' do
let(:params) { { value: 'false', project: project.full_path } }
it 'disables the feature flag' do
expect(Feature).to receive(:disable).with(feature_name, project)
expect(subject).to be_success
end
end
context 'when disabling for a group' do
let(:params) { { value: 'false', group: group.full_path } }
it 'disables the feature flag' do
expect(Feature).to receive(:disable).with(feature_name, group)
expect(subject).to be_success
end
context 'when group does not exist' do
let(:params) { { value: 'false', group: 'unknown-group' } }
it 'returns an error' do
expect(Feature).not_to receive(:disable)
expect(subject).to be_error
expect(subject.reason).to eq(:actor_not_found)
end
end
end
context 'when disabling for a user namespace' do
let(:namespace) { user.namespace }
let(:params) { { value: 'false', namespace: namespace.full_path } }
it 'disables the feature flag' do
expect(Feature).to receive(:disable).with(feature_name, namespace)
expect(subject).to be_success
end
context 'when namespace does not exist' do
let(:params) { { value: 'false', namespace: 'unknown-namespace' } }
it 'returns an error' do
expect(Feature).not_to receive(:disable)
expect(subject).to be_error
expect(subject.reason).to eq(:actor_not_found)
end
end
end
context 'when disabling for a group namespace' do
let(:params) { { value: 'false', namespace: group.full_path } }
it 'disables the feature flag' do
expect(Feature).to receive(:disable).with(feature_name, group)
expect(subject).to be_success
end
end
context 'when disabling for a user actor and a feature group' do
let(:params) { { value: 'false', user: user.username, feature_group: 'perf_team' } }
let(:feature_group) { Feature.group('perf_team') }
it 'disables the feature flag' do
expect(Feature).to receive(:disable).with(feature_name, user)
expect(Feature).to receive(:disable).with(feature_name, feature_group)
expect(subject).to be_success
end
end
end
end
end

View File

@ -3,6 +3,7 @@ PATH
specs:
devise-pbkdf2-encryptable (0.0.0)
devise (~> 4.0)
devise-two-factor (~> 4.0)
GEM
remote: https://rubygems.org/
@ -28,6 +29,8 @@ GEM
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
attr_encrypted (3.1.0)
encryptor (~> 3.0.0)
bcrypt (3.1.18)
builder (3.2.4)
concurrent-ruby (1.1.10)
@ -38,7 +41,14 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (4.0.2)
activesupport (< 7.1)
attr_encrypted (>= 1.3, < 4, != 2)
devise (~> 4.0)
railties (< 7.1)
rotp (~> 6.0)
diff-lcs (1.5.0)
encryptor (3.0.0)
erubi (1.10.0)
i18n (1.10.0)
concurrent-ruby (~> 1.0)
@ -71,6 +81,7 @@ GEM
responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
rotp (6.2.0)
rspec (3.10.0)
rspec-core (~> 3.10.0)
rspec-expectations (~> 3.10.0)

View File

@ -19,6 +19,7 @@ Gem::Specification.new do |spec|
spec.version = '0.0.0'
spec.add_runtime_dependency 'devise', '~> 4.0'
spec.add_runtime_dependency 'devise-two-factor', '~> 4.0'
spec.add_development_dependency 'activemodel', '~> 6.1', '< 8'
spec.add_development_dependency 'rspec', '~> 3.10.0'

View File

@ -1 +1,2 @@
require "devise/pbkdf2_encryptable/encryptable"
require "devise/models/two_factor_backupable_pbkdf2"

View File

@ -0,0 +1,58 @@
module Devise
module Models
module TwoFactorBackupablePbkdf2
extend ActiveSupport::Concern
# 1) Invalidates all existing backup codes
# 2) Generates otp_number_of_backup_codes backup codes
# 3) Stores the hashed backup codes in the database
# 4) Returns a plaintext array of the generated backup codes
#
def generate_otp_backup_codes_pbkdf2!
codes = []
number_of_codes = self.class.otp_number_of_backup_codes
code_length = self.class.otp_backup_code_length
number_of_codes.times do
codes << SecureRandom.hex(code_length / 2) # Hexstring has length 2*n
end
hashed_codes = codes.map do |code|
Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512.digest(
code,
Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512::STRETCHES,
Devise.friendly_token[0, 16])
end
self.otp_backup_codes = hashed_codes
codes
end
# Returns true and invalidates the given code if that code is a valid
# backup code.
#
def invalidate_otp_backup_code_pdkdf2!(code)
codes = self.otp_backup_codes || []
codes.each do |backup_code|
next unless Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512.compare(backup_code, code)
codes.delete(backup_code)
self.otp_backup_codes = codes
return true
end
false
end
protected
module ClassMethods
Devise::Models.config(self, :otp_backup_code_length,
:otp_number_of_backup_codes,
:pepper)
end
end
end
end

View File

@ -0,0 +1,100 @@
# frozen_string_literal: true
require 'spec_helper'
require 'active_model'
class TwoFactorBackupablePbkdf2Double
extend ::ActiveModel::Callbacks
include ::ActiveModel::Validations::Callbacks
extend ::Devise::Models
# stub out the ::ActiveRecord::Encryption::EncryptableRecord API
attr_accessor :otp_secret
def self.encrypts(*attrs)
nil
end
define_model_callbacks :update
devise :two_factor_backupable, otp_number_of_backup_codes: 10
devise :two_factor_backupable_pbkdf2
attr_accessor :otp_backup_codes
end
module Gitlab
class FIPS
def enabled?
end
end
end
RSpec.describe ::Devise::Models::TwoFactorBackupablePbkdf2 do
subject { TwoFactorBackupablePbkdf2Double.new }
describe '#generate_otp_backup_codes_pbkdf2!' do
context 'with no existing recovery codes' do
before do
@plaintext_codes = subject.generate_otp_backup_codes_pbkdf2!
end
it 'generates the correct number of new recovery codes' do
expect(subject.otp_backup_codes.length).to eq(subject.class.otp_number_of_backup_codes)
end
it 'generates recovery codes of the correct length' do
@plaintext_codes.each do |code|
expect(code.length).to eq(subject.class.otp_backup_code_length)
end
end
it 'generates distinct recovery codes' do
expect(@plaintext_codes.uniq).to contain_exactly(*@plaintext_codes)
end
it 'stores the codes as pbkdf2 hashes' do
subject.otp_backup_codes.each do |code|
expect(code.start_with?("$pbkdf2-sha512$")).to be_truthy
end
end
end
end
describe '#invalidate_otp_backup_code_pdkdf2!' do
before do
@plaintext_codes = subject.generate_otp_backup_codes_pbkdf2!
end
context 'given an invalid recovery code' do
it 'returns false' do
expect(subject.invalidate_otp_backup_code_pdkdf2!('password')).to be false
end
end
context 'given a valid recovery code' do
it 'returns true' do
@plaintext_codes.each do |code|
expect(subject.invalidate_otp_backup_code_pdkdf2!(code)).to be true
end
end
it 'invalidates that recovery code' do
code = @plaintext_codes.sample
subject.invalidate_otp_backup_code_pdkdf2!(code)
expect(subject.invalidate_otp_backup_code_pdkdf2!(code)).to be false
end
it 'does not invalidate the other recovery codes' do
code = @plaintext_codes.sample
subject.invalidate_otp_backup_code_pdkdf2!(code)
@plaintext_codes.delete(code)
@plaintext_codes.each do |code|
expect(subject.invalidate_otp_backup_code_pdkdf2!(code)).to be true
end
end
end
end
end

View File

@ -1,4 +1,6 @@
# frozen_string_literal: true
require 'devise'
require 'devise-two-factor'
require 'devise/pbkdf2_encryptable/encryptable'
require 'devise/models/two_factor_backupable_pbkdf2'