Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
70ce746bd0
commit
bd25f1d9c6
|
@ -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"},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
|
|
|
@ -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
|
||||
};
|
|
@ -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');
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
ac1aa3697f6e4230bfdc41f34e2e87ef49f697cfa46139fe3ac91a42b7bf4b91
|
|
@ -0,0 +1 @@
|
|||
44e6b2519ef285366d1a2b4ea6efe18a9c22bfdb545c11502eae9383123b6001
|
|
@ -0,0 +1 @@
|
|||
a982eed3131805db693882a8da7c5c5d1572f7825eb51a45c468bd5dfbded58b
|
|
@ -0,0 +1 @@
|
|||
a3c66e57959f3e183a5b933138c9deedb5575e0b90b3a862b7b8e20331ffa31e
|
|
@ -0,0 +1 @@
|
|||
82f67746e79bcc63e5674f2e009eb9a827e019409c9277f6cd1ce2e41c50c296
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 |
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -14540,6 +14540,12 @@ msgstr ""
|
|||
msgid "Edited %{timeago}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edited %{timeago} by %{author}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edited by %{author}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Editing"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
require "devise/pbkdf2_encryptable/encryptable"
|
||||
require "devise/models/two_factor_backupable_pbkdf2"
|
||||
|
|
58
vendor/gems/devise-pbkdf2-encryptable/lib/devise/models/two_factor_backupable_pbkdf2.rb
vendored
Normal file
58
vendor/gems/devise-pbkdf2-encryptable/lib/devise/models/two_factor_backupable_pbkdf2.rb
vendored
Normal 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
|
100
vendor/gems/devise-pbkdf2-encryptable/spec/lib/models/two_factor_backupable_pbkdf2_spec.rb
vendored
Normal file
100
vendor/gems/devise-pbkdf2-encryptable/spec/lib/models/two_factor_backupable_pbkdf2_spec.rb
vendored
Normal 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
|
|
@ -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'
|
||||
|
|
Loading…
Reference in New Issue