Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-03-11 03:08:14 +00:00
parent ca443618b0
commit b131b6f680
80 changed files with 1587 additions and 286 deletions

View File

@ -45,6 +45,17 @@ export const bufferToBase64 = (input) => {
return btoa(String.fromCharCode(...arr));
};
/**
* Return a URL-safe base64 string.
*
* RFC: https://datatracker.ietf.org/doc/html/rfc4648#section-5
* @param {String} base64Str
* @returns {String}
*/
export const base64ToBase64Url = (base64Str) => {
return base64Str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
/**
* Returns a copy of the given object with the id property converted to buffer
*

View File

@ -3,6 +3,7 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapMutations } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '../constants';
import { SET_ALERT } from '../store/mutation_types';
import SignInPage from '../pages/sign_in.vue';
import SubscriptionsPage from '../pages/subscriptions.vue';
@ -28,6 +29,11 @@ export default {
default: [],
},
},
data() {
return {
user: null,
};
},
computed: {
...mapState(['alert']),
shouldShowAlert() {
@ -37,7 +43,7 @@ export default {
return !isEmpty(this.subscriptions);
},
userSignedIn() {
return Boolean(!this.usersPath);
return Boolean(!this.usersPath || this.user);
},
},
created() {
@ -51,6 +57,15 @@ export default {
const { linkUrl, title, message, variant } = retrieveAlert() || {};
this.setAlert({ linkUrl, title, message, variant });
},
onSignInOauth(user) {
this.user = user;
},
onSignInError() {
this.setAlert({
message: I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE,
variant: 'danger',
});
},
},
};
</script>
@ -78,11 +93,16 @@ export default {
</template>
</gl-alert>
<user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" />
<user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" :user="user" />
<h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
<div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
<sign-in-page v-if="!userSignedIn" :has-subscriptions="hasSubscriptions" />
<sign-in-page
v-if="!userSignedIn"
:has-subscriptions="hasSubscriptions"
@sign-in-oauth="onSignInOauth"
@error="onSignInError"
/>
<subscriptions-page v-else :has-subscriptions="hasSubscriptions" />
</div>
</div>

View File

@ -1,7 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
import { s__ } from '~/locale';
import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/constants';
export default {
components: {
@ -27,7 +27,7 @@ export default {
},
},
i18n: {
defaultButtonText: s__('Integrations|Sign in to GitLab'),
defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
},
};
</script>

View File

@ -0,0 +1,124 @@
<script>
import { GlButton } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import {
I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
OAUTH_WINDOW_OPTIONS,
PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM,
} from '~/jira_connect/subscriptions/constants';
import { setUrlParams } from '~/lib/utils/url_utility';
import AccessorUtilities from '~/lib/utils/accessor';
import { createCodeVerifier, createCodeChallenge } from '../pkce';
export default {
components: {
GlButton,
},
inject: ['oauthMetadata'],
data() {
return {
token: null,
loading: false,
codeVerifier: null,
canUseCrypto: AccessorUtilities.canUseCrypto(),
};
},
mounted() {
window.addEventListener('message', this.handleWindowMessage);
},
beforeDestroy() {
window.removeEventListener('message', this.handleWindowMessage);
},
methods: {
async startOAuthFlow() {
this.loading = true;
// Generate state necessary for PKCE OAuth flow
this.codeVerifier = createCodeVerifier();
const codeChallenge = await createCodeChallenge(this.codeVerifier);
// Build the initial OAuth authorization URL
const { oauth_authorize_url: oauthAuthorizeURL } = this.oauthMetadata;
const oauthAuthorizeURLWithChallenge = setUrlParams(
{
code_challenge: codeChallenge,
code_challenge_method: PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.short,
},
oauthAuthorizeURL,
);
window.open(
oauthAuthorizeURLWithChallenge,
this.$options.i18n.defaultButtonText,
OAUTH_WINDOW_OPTIONS,
);
},
async handleWindowMessage(event) {
if (window.origin !== event.origin) {
this.loading = false;
this.handleError();
return;
}
// Verify that OAuth state isn't altered.
const state = event.data?.state;
if (state !== this.oauthMetadata.state) {
this.loading = false;
this.handleError();
return;
}
// Request access token and load the authenticated user.
const code = event.data?.code;
try {
const accessToken = await this.getOAuthToken(code);
await this.loadUser(accessToken);
} catch (e) {
this.handleError();
} finally {
this.loading = false;
}
},
handleError() {
this.$emit('error');
},
async getOAuthToken(code) {
const {
oauth_token_payload: oauthTokenPayload,
oauth_token_url: oauthTokenURL,
} = this.oauthMetadata;
const { data } = await axios.post(oauthTokenURL, {
...oauthTokenPayload,
code,
code_verifier: this.codeVerifier,
});
return data.access_token;
},
async loadUser(accessToken) {
const { data } = await axios.get('/api/v4/user', {
headers: { Authorization: `Bearer ${accessToken}` },
});
this.$emit('sign-in', data);
},
},
i18n: {
defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
},
};
</script>
<template>
<gl-button
category="primary"
variant="info"
:loading="loading"
:disabled="!canUseCrypto"
@click="startOAuthFlow"
>
<slot>
{{ $options.i18n.defaultButtonText }}
</slot>
</gl-button>
</template>

View File

@ -25,6 +25,11 @@ export default {
type: Boolean,
required: true,
},
user: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
@ -32,8 +37,19 @@ export default {
};
},
computed: {
gitlabUserName() {
return gon.current_username ?? this.user?.username;
},
gitlabUserHandle() {
return `@${gon.current_username}`;
return this.gitlabUserName ? `@${this.gitlabUserName}` : undefined;
},
gitlabUserLink() {
return this.gitlabUserPath ?? `${gon.relative_root_url}/${this.gitlabUserName}`;
},
signedInText() {
return this.gitlabUserHandle
? this.$options.i18n.signedInAsUserText
: this.$options.i18n.signedInText;
},
},
async created() {
@ -42,14 +58,15 @@ export default {
i18n: {
signInText: __('Sign in to GitLab'),
signedInAsUserText: __('Signed in to GitLab as %{user_link}'),
signedInText: __('Signed in to GitLab'),
},
};
</script>
<template>
<div class="jira-connect-user gl-font-base">
<gl-sprintf v-if="userSignedIn" :message="$options.i18n.signedInAsUserText">
<gl-sprintf v-if="userSignedIn" :message="signedInText">
<template #user_link>
<gl-link data-testid="gitlab-user-link" :href="gitlabUserPath" target="_blank">
<gl-link data-testid="gitlab-user-link" :href="gitlabUserLink" target="_blank">
{{ gitlabUserHandle }}
</gl-link>
</template>

View File

@ -1,5 +1,26 @@
import { s__ } from '~/locale';
export const DEFAULT_GROUPS_PER_PAGE = 10;
export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert';
export const MINIMUM_SEARCH_TERM_LENGTH = 3;
export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal';
export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to GitLab');
export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.');
const OAUTH_WINDOW_SIZE = 800;
export const OAUTH_WINDOW_OPTIONS = [
'resizable=yes',
'scrollbars=yes',
'status=yes',
`width=${OAUTH_WINDOW_SIZE}`,
`height=${OAUTH_WINDOW_SIZE}`,
`left=${window.screen.width / 2 - OAUTH_WINDOW_SIZE / 2}`,
`top=${window.screen.height / 2 - OAUTH_WINDOW_SIZE / 2}`,
].join(',');
export const PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM = {
long: 'SHA-256',
short: 'S256',
};

View File

@ -21,7 +21,14 @@ export function initJiraConnect() {
Vue.use(Translate);
Vue.use(GlFeatureFlagsPlugin);
const { groupsPath, subscriptions, subscriptionsPath, usersPath, gitlabUserPath } = el.dataset;
const {
groupsPath,
subscriptions,
subscriptionsPath,
usersPath,
gitlabUserPath,
oauthMetadata,
} = el.dataset;
sizeToParent();
return new Vue({
@ -33,6 +40,7 @@ export function initJiraConnect() {
subscriptionsPath,
usersPath,
gitlabUserPath,
oauthMetadata: oauthMetadata ? JSON.parse(oauthMetadata) : null,
},
render(createElement) {
return createElement(JiraConnectApp);

View File

@ -1,14 +1,17 @@
<script>
import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SubscriptionsList from '../components/subscriptions_list.vue';
import SignInButton from '../components/sign_in_button.vue';
export default {
name: 'SignInPage',
components: {
SubscriptionsList,
SignInButton,
SignInLegacyButton: () => import('../components/sign_in_legacy_button.vue'),
SignInOauthButton: () => import('../components/sign_in_oauth_button.vue'),
},
mixins: [glFeatureFlagMixin()],
inject: ['usersPath'],
props: {
hasSubscriptions: {
@ -16,25 +19,47 @@ export default {
required: true,
},
},
computed: {
useSignInOauthButton() {
return this.glFeatures.jiraConnectOauth;
},
},
i18n: {
signinButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
signInText: s__('JiraService|Sign in to GitLab.com to get started.'),
},
methods: {
onSignInError() {
this.$emit('error');
},
},
};
</script>
<template>
<div v-if="hasSubscriptions">
<div class="gl-display-flex gl-justify-content-end">
<sign-in-button :users-path="usersPath">
{{ $options.i18n.signinButtonTextWithSubscriptions }}
</sign-in-button>
<sign-in-oauth-button
v-if="useSignInOauthButton"
@sign-in="$emit('sign-in-oauth', $event)"
@error="onSignInError"
>
{{ $options.i18n.signInButtonTextWithSubscriptions }}
</sign-in-oauth-button>
<sign-in-legacy-button v-else :users-path="usersPath">
{{ $options.i18n.signInButtonTextWithSubscriptions }}
</sign-in-legacy-button>
</div>
<subscriptions-list />
</div>
<div v-else class="gl-text-center">
<p class="gl-mb-7">{{ $options.i18n.signInText }}</p>
<sign-in-button class="gl-mb-7" :users-path="usersPath" />
<sign-in-oauth-button
v-if="useSignInOauthButton"
@sign-in="$emit('sign-in-oauth', $event)"
@error="onSignInError"
/>
<sign-in-legacy-button v-else class="gl-mb-7" :users-path="usersPath" />
</div>
</template>

View File

@ -0,0 +1,60 @@
import { bufferToBase64, base64ToBase64Url } from '~/authentication/webauthn/util';
import { PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM } from './constants';
// PKCE codeverifier should have a maximum length of 128 characters.
// Using 96 bytes generates a string of 128 characters.
// RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
export const CODE_VERIFIER_BYTES = 96;
/**
* Generate a cryptographically random string.
* @param {Number} lengthBytes
* @returns {String} a random string
*/
function getRandomString(lengthBytes) {
// generate random values and load them into byteArray.
const byteArray = new Uint8Array(lengthBytes);
window.crypto.getRandomValues(byteArray);
// Convert array to string
const randomString = bufferToBase64(byteArray);
return randomString;
}
/**
* Creates a code verifier to be used for OAuth PKCE authentication.
* The code verifier has 128 characters.
*
* RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
* @returns {String} code verifier
*/
export function createCodeVerifier() {
const verifier = getRandomString(CODE_VERIFIER_BYTES);
return base64ToBase64Url(verifier);
}
/**
* Creates a code challenge for OAuth PKCE authentication.
* The code challenge is derived from the given [codeVerifier].
* [codeVerifier] is tranformed in the following way (as per the RFC):
* code_challenge = BASE64URL-ENCODE(SHA256(ASCII(codeVerifier)))
*
* RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
* @param {String} codeVerifier
* @returns {String} code challenge
*/
export async function createCodeChallenge(codeVerifier) {
// Generate SHA-256 digest of the [codeVerifier]
const buffer = new TextEncoder().encode(codeVerifier);
const digestArrayBuffer = await window.crypto.subtle.digest(
PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.long,
buffer,
);
// Convert digest to a Base64URL-encoded string
const digestHash = bufferToBase64(digestArrayBuffer);
// Escape string to remove reserved charaters
const codeChallenge = base64ToBase64Url(digestHash);
return codeChallenge;
}

View File

@ -50,8 +50,16 @@ function canUseLocalStorage() {
return safe;
}
/**
* Determines if `window.crypto` is available.
*/
function canUseCrypto() {
return window.crypto?.subtle !== undefined;
}
const AccessorUtilities = {
canUseLocalStorage,
canUseCrypto,
};
export default AccessorUtilities;

View File

@ -0,0 +1,28 @@
function getOriginURL() {
const origin = new URL(window.opener.location);
origin.hash = '';
origin.search = '';
return origin;
}
function postMessageToJiraConnectApp(data) {
window.opener.postMessage(data, getOriginURL().toString());
}
function initOAuthCallbacks() {
const params = new URLSearchParams(window.location.search);
if (params.has('code') && params.has('state')) {
postMessageToJiraConnectApp({
success: true,
code: params.get('code'),
state: params.get('state'),
});
} else {
postMessageToJiraConnectApp({ success: false });
}
window.close();
}
initOAuthCallbacks();

View File

@ -21,7 +21,9 @@ export default {
<gl-dropdown
right
text="Use an existing commit message"
variant="link"
category="tertiary"
variant="confirm"
size="small"
class="mr-commit-dropdown"
>
<gl-dropdown-item

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
# This module should be included to support CAPTCHA check for REST API actions via Grape.
#
# If the request is directly handled by a controller action, then the corresponding module which
# supports HTML or JSON formats should be used instead.
module SpammableActions::CaptchaCheck::RestApiActionsSupport
extend ActiveSupport::Concern
include SpammableActions::CaptchaCheck::Common
include Spam::Concerns::HasSpamActionResponseFields
private
def with_captcha_check_rest_api(spammable:, &block)
# In the case of the REST API, the request is handled by Grape, so if there is a spam-related
# error, we don't render directly, instead we will pass the error message and other necessary
# fields to the Grape api error helper for it to handle.
captcha_render_lambda = -> do
fields = spam_action_response_fields(spammable)
fields.delete :spam
# NOTE: "409 - Conflict" seems to be the most appropriate HTTP status code for a response
# which requires a CAPTCHA to be solved in order for the request to be resubmitted.
# https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.10
status = 409
# NOTE: This nested 'error' key may not be consistent with all other API error responses,
# because they are not currently consistent across different API endpoints
# and models. Some (snippets) will nest errors in an errors key like this,
# while others (issues) will return the model's errors hash without an errors key,
# while still others just return a plain string error.
# See https://gitlab.com/groups/gitlab-org/-/epics/5527#revisit-inconsistent-shape-of-error-responses-in-rest-api
fields[:message] = { error: spammable.errors.full_messages.to_sentence }
render_structured_api_error!(fields, status)
end
with_captcha_check_common(spammable: spammable, captcha_render_lambda: captcha_render_lambda, &block)
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
# This controller's role is to serve as a landing page
# that users get redirected to after installing and authenticating
# The GitLab.com for Jira App (https://marketplace.atlassian.com/apps/1221011/gitlab-com-for-jira-cloud)
#
class JiraConnect::OauthCallbacksController < ApplicationController
feature_category :integrations
def index; end
end

View File

@ -16,6 +16,10 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
p.style_src(*style_src_values)
end
before_action do
push_frontend_feature_flag(:jira_connect_oauth, @user, default_enabled: :yaml)
end
before_action :allow_rendering_in_iframe, only: :index
before_action :verify_qsh_claim!, only: :index
before_action :authenticate_user!, only: :create

View File

@ -178,7 +178,7 @@ module AuthHelper
end
def google_tag_manager_enabled?
return false unless Gitlab.dev_env_or_com?
return false unless Gitlab.com?
if Feature.enabled?(:gtm_nonce, type: :ops)
extra_config.has_key?('google_tag_manager_nonce_id') &&

View File

@ -9,12 +9,38 @@ module JiraConnectHelper
subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json,
subscriptions_path: jira_connect_subscriptions_path,
users_path: current_user ? nil : jira_connect_users_path, # users_path is used to determine if user is signed in
gitlab_user_path: current_user ? user_path(current_user) : nil
gitlab_user_path: current_user ? user_path(current_user) : nil,
oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data.to_json : nil
}
end
private
def jira_connect_oauth_data
oauth_authorize_url = oauth_authorization_url(
client_id: ENV['JIRA_CONNECT_OAUTH_CLIENT_ID'],
response_type: 'code',
scope: 'api',
redirect_uri: jira_connect_oauth_callbacks_url,
state: oauth_state
)
{
oauth_authorize_url: oauth_authorize_url,
oauth_token_url: oauth_token_url,
state: oauth_state,
oauth_token_payload: {
grant_type: :authorization_code,
client_id: ENV['JIRA_CONNECT_OAUTH_CLIENT_ID'],
redirect_uri: jira_connect_oauth_callbacks_url
}
}
end
def oauth_state
@oauth_state ||= SecureRandom.hex(32)
end
def serialize_subscription(subscription)
{
group: {

View File

@ -5,7 +5,7 @@ module SessionsHelper
def recently_confirmed_com?
strong_memoize(:recently_confirmed_com) do
::Gitlab.dev_env_or_com? &&
::Gitlab.com? &&
!!flash[:notice]&.include?(t(:confirmed, scope: [:devise, :confirmations]))
end
end

View File

@ -10,7 +10,7 @@ module WhatsNewHelper
end
def display_whats_new?
(Gitlab.dev_env_org_or_com? || user_signed_in?) &&
(Gitlab.org_or_com? || user_signed_in?) &&
!Gitlab::CurrentSettings.current_application_settings.whats_new_variant_disabled?
end

View File

@ -241,7 +241,6 @@ module Integrations
def notify_for_ref?(data)
return true if data[:object_kind] == 'tag_push'
return true if data[:object_kind] == 'deployment' && !Feature.enabled?(:chat_notification_deployment_protected_branch_filter, project)
ref = data[:ref] || data.dig(:object_attributes, :ref)
return true if ref.blank? # No need to check protected branches when there is no ref

View File

@ -67,7 +67,7 @@ class BasePolicy < DeclarativePolicy::Base
rule { default }.enable :read_cross_project
condition(:is_gitlab_com, score: 0, scope: :global) { ::Gitlab.dev_env_or_com? }
condition(:is_gitlab_com, score: 0, scope: :global) { ::Gitlab.com? }
end
BasePolicy.prepend_mod_with('BasePolicy')

View File

@ -25,6 +25,7 @@ module Spam
# then the spam check may fail, or the SpamLog or UserAgentDetail may have missing fields.
class SpamParams
def self.new_from_request(request:)
self.normalize_grape_request_headers(request: request)
self.new(
captcha_response: request.headers['X-GitLab-Captcha-Response'],
spam_log_id: request.headers['X-GitLab-Spam-Log-Id'],
@ -52,5 +53,14 @@ module Spam
other.user_agent == user_agent &&
other.referer == referer
end
def self.normalize_grape_request_headers(request:)
# If needed, make a normalized copy of Grape headers with the case of 'GitLab' (with an
# uppercase 'L') instead of 'Gitlab' (with a lowercase 'l'), because Grape header helper keys
# are "coerced into a capitalized kebab case". See https://github.com/ruby-grape/grape#request
%w[X-Gitlab-Captcha-Response X-Gitlab-Spam-Log-Id].each do |header|
request.headers[header.gsub('Gitlab', 'GitLab')] = request.headers[header] if request.headers.key?(header)
end
end
end
end

View File

@ -1,4 +1,4 @@
- return unless Gitlab.dev_env_or_com?
- return unless Gitlab.com?
.gl-mb-3.js-email-opt-in.hidden
.gl-font-weight-bold.gl-mb-3

View File

@ -1,7 +1,7 @@
- return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms?
%p.gl-text-gray-500.gl-mt-5.gl-mb-0
- if Gitlab.dev_env_or_com?
- if Gitlab.com?
= html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the GitLab %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text,
link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe }
- else

View File

@ -0,0 +1 @@
%p= s_('Integrations|You can close this window.')

View File

@ -1,8 +1,8 @@
---
name: chat_notification_deployment_protected_branch_filter
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74423
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/349131
milestone: '14.7'
name: jira_connect_oauth
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81126
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/355048
milestone: '14.9'
type: development
group: group::integrations
default_enabled: false

View File

@ -49,7 +49,7 @@ Gitlab::Experiment.configure do |config|
#
valid_domains = %w[about.gitlab.com docs.gitlab.com gitlab.com gdk.test localhost]
config.redirect_url_validator = lambda do |url|
Gitlab.dev_env_or_com? && (url = URI.parse(url)) && valid_domains.include?(url.host)
Gitlab.com? && (url = URI.parse(url)) && valid_domains.include?(url.host)
rescue URI::InvalidURIError
false
end

View File

@ -20,4 +20,6 @@ namespace :jira_connect do
put :update
end
end
resources :oauth_callbacks, only: [:index]
end

View File

@ -24,7 +24,7 @@ class AddNewTrailPlans < ActiveRecord::Migration[6.0]
end
def up
return unless Gitlab.dev_env_or_com?
return unless Gitlab.com?
ultimate_trial = Plan.create!(name: 'ultimate_trial', title: 'Ultimate Trial')
premium_trial = Plan.create!(name: 'premium_trial', title: 'Premium Trial')
@ -34,7 +34,7 @@ class AddNewTrailPlans < ActiveRecord::Migration[6.0]
end
def down
return unless Gitlab.dev_env_or_com?
return unless Gitlab.com?
Plan.where(name: %w(ultimate_trial premium_trial)).delete_all
end

View File

@ -29,7 +29,7 @@ class UpdateTrialPlansCiDailyPipelineScheduleTriggers < ActiveRecord::Migration[
end
def up
return unless Gitlab.dev_env_or_com?
return unless Gitlab.com?
if plan_limits_present?
create_or_update_plan_limit('ci_daily_pipeline_schedule_triggers', PREMIUM_TRIAL, EVERY_5_MINUTES)
@ -38,7 +38,7 @@ class UpdateTrialPlansCiDailyPipelineScheduleTriggers < ActiveRecord::Migration[
end
def down
return unless Gitlab.dev_env_or_com?
return unless Gitlab.com?
if plan_limits_present?
create_or_update_plan_limit('ci_daily_pipeline_schedule_triggers', PREMIUM_TRIAL, 0)

View File

@ -24,7 +24,7 @@ class AddOpenSourcePlan < Gitlab::Database::Migration[1.0]
end
def up
return unless Gitlab.dev_env_or_com?
return unless Gitlab.com?
opensource = Plan.create!(name: 'opensource', title: 'Open Source Program')
@ -32,7 +32,7 @@ class AddOpenSourcePlan < Gitlab::Database::Migration[1.0]
end
def down
return unless Gitlab.dev_env_or_com?
return unless Gitlab.com?
Plan.where(name: 'opensource').delete_all
end

View File

@ -94,6 +94,46 @@ Example response:
]
```
### Get a project deploy token
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82467) in GitLab 14.9.
Get a single project's deploy token by ID.
```plaintext
GET /projects/:id/deploy_tokens/:token_id
```
Parameters:
| Attribute | Type | Required | Description |
| ---------- | -------------- | ---------------------- | ----------- |
| `id` | integer/string | **{check-circle}** Yes | ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user |
| `token_id` | integer | **{check-circle}** Yes | ID of the deploy token |
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deploy_tokens/1"
```
Example response:
```json
{
"id": 1,
"name": "MyToken",
"username": "gitlab+deploy-token-1",
"expires_at": "2020-02-14T00:00:00.000Z",
"revoked": false,
"expired": false,
"scopes": [
"read_repository",
"read_registry"
]
}
```
### Create a project deploy token
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/21811) in GitLab 12.9.
@ -108,7 +148,7 @@ Parameters:
| Attribute | Type | Required | Description |
| ------------ | ---------------- | ---------------------- | ----------- |
| `id` | integer/string | **{check-circle}** Yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user |
| `id` | integer/string | **{check-circle}** Yes | ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user |
| `name` | string | **{check-circle}** Yes | New deploy token's name |
| `expires_at` | datetime | **{dotted-circle}** No | Expiration date for the deploy token. Does not expire if no value is provided. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`) |
| `username` | string | **{dotted-circle}** No | Username for deploy token. Default is `gitlab+deploy-token-{n}` |
@ -153,8 +193,8 @@ Parameters:
| Attribute | Type | Required | Description |
| ---------- | -------------- | ---------------------- | ----------- |
| `id` | integer/string | **{check-circle}** Yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user |
| `token_id` | integer | **{check-circle}** Yes | The ID of the deploy token |
| `id` | integer/string | **{check-circle}** Yes | ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user |
| `token_id` | integer | **{check-circle}** Yes | ID of the deploy token |
Example request:
@ -210,6 +250,46 @@ Example response:
]
```
### Get a group deploy token
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82467) in GitLab 14.9.
Get a single group's deploy token by ID.
```plaintext
GET /groups/:id/deploy_tokens/:token_id
```
Parameters:
| Attribute | Type | Required | Description |
| ----------- | -------------- | ---------------------- | ----------- |
| `id` | integer/string | **{check-circle}** Yes | ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) owned by the authenticated user |
| `token_id` | integer | **{check-circle}** Yes | ID of the deploy token |
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/1/deploy_tokens/1"
```
Example response:
```json
{
"id": 1,
"name": "MyToken",
"username": "gitlab+deploy-token-1",
"expires_at": "2020-02-14T00:00:00.000Z",
"revoked": false,
"expired": false,
"scopes": [
"read_repository",
"read_registry"
]
}
```
### Create a group deploy token
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/21811) in GitLab 12.9.
@ -224,7 +304,7 @@ Parameters:
| Attribute | Type | Required | Description |
| ------------ | ---- | --------- | ----------- |
| `id` | integer/string | **{check-circle}** Yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) owned by the authenticated user |
| `id` | integer/string | **{check-circle}** Yes | ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) owned by the authenticated user |
| `name` | string | **{check-circle}** Yes | New deploy token's name |
| `expires_at` | datetime | **{dotted-circle}** No | Expiration date for the deploy token. Does not expire if no value is provided. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`) |
| `username` | string | **{dotted-circle}** No | Username for deploy token. Default is `gitlab+deploy-token-{n}` |
@ -269,8 +349,8 @@ Parameters:
| Attribute | Type | Required | Description |
| ----------- | -------------- | ---------------------- | ----------- |
| `id` | integer/string | **{check-circle}** Yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user |
| `token_id` | integer | **{check-circle}** Yes | The ID of the deploy token |
| `id` | integer/string | **{check-circle}** Yes | ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) owned by the authenticated user |
| `token_id` | integer | **{check-circle}** Yes | ID of the deploy token |
Example request:

View File

@ -187,55 +187,74 @@ NOTE:
The complexity limits may be revised in future, and additionally, the complexity
of a query may be altered.
## Spam
## Resolve mutations detected as spam
GraphQL mutations can be detected as spam. If this happens, a
[GraphQL top-level error](https://spec.graphql.org/June2018/#sec-Errors) is raised. For example:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/327360) in GitLab 13.11.
```json
{
"errors": [
{
"message": "Request denied. Spam detected",
"locations": [ { "line": 6, "column": 7 } ],
"path": [ "updateSnippet" ],
"extensions": {
"spam": true
GraphQL mutations can be detected as spam. If a mutation is detected as spam and:
- A CAPTCHA service is not configured, a
[GraphQL top-level error](https://spec.graphql.org/June2018/#sec-Errors) is raised. For example:
```json
{
"errors": [
{
"message": "Request denied. Spam detected",
"locations": [ { "line": 6, "column": 7 } ],
"path": [ "updateSnippet" ],
"extensions": {
"spam": true
}
}
],
"data": {
"updateSnippet": {
"snippet": null
}
}
],
"data": {
"updateSnippet": {
"snippet": null
}
```
- A CAPTCHA service is configured, you receive a response with:
- `needsCaptchaResponse` set to `true`.
- The `spamLogId` and `captchaSiteKey` fields set.
For example:
```json
{
"errors": [
{
"message": "Request denied. Solve CAPTCHA challenge and retry",
"locations": [ { "line": 6, "column": 7 } ],
"path": [ "updateSnippet" ],
"extensions": {
"needsCaptchaResponse": true,
"captchaSiteKey": "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
"spamLogId": 67
}
}
],
"data": {
"updateSnippet": {
"snippet": null,
}
}
}
}
```
If a mutation is detected as potential spam and a CAPTCHA service is configured:
```
- Use the `captchaSiteKey` to obtain a CAPTCHA response value using the appropriate CAPTCHA API.
Only [Google reCAPTCHA v2](https://developers.google.com/recaptcha/docs/display) is supported.
- Resubmit the request with the `X-GitLab-Captcha-Response` and `X-GitLab-Spam-Log-Id` headers set.
NOTE:
The GitLab GraphiQL implementation doesn't permit passing of headers, so we must write
this as a cURL query. `--data-binary` is used to properly handle escaped double quotes
in the JSON-embedded query.
```json
{
"errors": [
{
"message": "Request denied. Solve CAPTCHA challenge and retry",
"locations": [ { "line": 6, "column": 7 } ],
"path": [ "updateSnippet" ],
"extensions": {
"needsCaptchaResponse": true,
"captchaSiteKey": "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
"spamLogId": 67
}
}
],
"data": {
"updateSnippet": {
"snippet": null,
}
}
}
```shell
export CAPTCHA_RESPONSE="<CAPTCHA response obtained from CAPTCHA service>"
export SPAM_LOG_ID="<spam_log_id obtained from initial REST response>"
curl --header "Authorization: Bearer $PRIVATE_TOKEN" --header "Content-Type: application/json" --header "X-GitLab-Captcha-Response: $CAPTCHA_RESPONSE" --header "X-GitLab-Spam-Log-Id: $SPAM_LOG_ID" --request POST --data-binary '{"query": "mutation {createSnippet(input: {title: \"Title\" visibilityLevel: public blobActions: [ { action: create filePath: \"BlobPath\" content: \"BlobContent\" } ] }) { snippet { id title } errors }}"}' "https://gitlab.example.com/api/graphql"
```

View File

@ -767,3 +767,35 @@ some API endpoints also support `text/plain`.
In [GitLab 13.10 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/250342),
API endpoints do not support `text/plain` by default, unless it's explicitly documented.
## Resolve requests detected as spam
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/352913) in GitLab 14.9.
REST API requests can be detected as spam. If a request is detected as spam and:
- A CAPTCHA service is not configured, an error response is returned. For example:
```json
{"message":{"error":"Your snippet has been recognized as spam and has been discarded."}}
```
- A CAPTCHA service is configured, you receive a response with:
- `needs_captcha_response` set to `true`.
- The `spam_log_id` and `captcha_site_key` fields set.
For example:
```json
{"needs_captcha_response":true,"spam_log_id":42,"captcha_site_key":"6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI","message":{"error":"Your snippet has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."}}
```
- Use the `captcha_site_key` to obtain a CAPTCHA response value using the appropriate CAPTCHA API.
Only [Google reCAPTCHA v2](https://developers.google.com/recaptcha/docs/display) is supported.
- Resubmit the request with the `X-GitLab-Captcha-Response` and `X-GitLab-Spam-Log-Id` headers set.
```shell
export CAPTCHA_RESPONSE="<CAPTCHA response obtained from CAPTCHA service>"
export SPAM_LOG_ID="<spam_log_id obtained from initial REST response>"
curl --request POST --header "PRIVATE-TOKEN: $PRIVATE_TOKEN" --header "X-GitLab-Captcha-Response: $CAPTCHA_RESPONSE" --header "X-GitLab-Spam-Log-Id: $SPAM_LOG_ID" "https://gitlab.example.com/api/v4/snippets?title=Title&file_name=FileName&content=Content&visibility=public"
```

View File

@ -74,3 +74,45 @@ If you use Gitpod and you get an error about Jira not being able to access the d
1. When the GDK is running, select **Ports** in the bottom-right corner.
1. On the left sidebar, select the port the GDK is listening to (typically `3000`).
1. If the port is marked as private, select the lock icon to make it public.
## Test the GitLab OAuth authentication flow
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81126) in GitLab 14.9 [with a flag](../../administration/feature_flags.md) named `jira_connect_oauth`. Disabled by default.
GitLab for Jira users can authenticate with GitLab using GitLab OAuth.
FLAG:
By default this feature is not available. To make it available,
ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `jira_connect_oauth`.
The feature is not ready for production use.
The following steps describe setting up an environment to test the GitLab OAuth flow:
1. Start a Gitpod session and open the rails console.
```shell
bundle exec rails console
```
1. Enable the feature flag.
```shell
Feature.enable(:jira_connect_oauth)
```
1. On your GitLab instance, go to **Admin > Applications**.
1. Create a new application with the following settings:
- Name: `Jira Connect`
- Redirect URI: `YOUR_GITPOD_INSTANCE/-/jira_connect/oauth_callbacks`
- Scopes: `api`
- Trusted: **No**
- Confidential: **No**
1. Copy the Application ID.
1. Go to [gitpod.io/variables](https://gitpod.io/variables).
1. Create a new variable named `JIRA_CONNECT_OAUTH_CLIENT_ID`, with a scope of `*/*`, and paste the Application ID as the value.
If you already have an active Gitpod instance, use the following command in the Gitpod terminal to set the environment variable:
```shell
eval $(gp env -e JIRA_CONNECT_OAUTH_CLIENT_ID=$YOUR_APPLICATION_ID)
```

View File

@ -315,7 +315,6 @@ To disable analyzer rules:
1. In one or more `ruleset.identifier` sub sections, list the rules that you want disabled. Every `ruleset.identifier` section has:
- a `type` field, to name the predefined rule identifier that the targeted analyzer uses.
- a `value` field, to name the rule to be disabled.
##### Example: Disable predefined rules of SAST analyzers
@ -345,6 +344,9 @@ and `sobelow` by matching the `type` and `value` of identifiers:
value = "sql_injection"
```
Those vulnerabilities containing the provided type and value are now disabled, meaning
they won't be displayed in Merge Request nor the Vulnerability Report.
#### Override predefined analyzer rules
To override analyzer rules:
@ -365,30 +367,40 @@ To override analyzer rules:
##### Example: Override predefined rules of SAST analyzers
In the following example, rules from `eslint`
and `gosec` are matched by the `type` and `value` of identifiers and
then overridden:
Before adding a ruleset, we verify which vulnerability will be overwritten by viewing the [`gl-sast-report.json`](#reports-json-format):
```json
"identifiers": [
{
"type": "gosec_rule_id",
"name": "Gosec Rule ID G307",
"value": "G307"
},
{
"type": "CWE",
"name": "CWE-703",
"value": "703",
"url": "https://cwe.mitre.org/data/definitions/703.html"
}
]
```
In the following example, rules from `gosec` are matched by the `type`
and `value` of identifiers and then overridden:
```toml
[eslint]
[[eslint.ruleset]]
[eslint.ruleset.identifier]
type = "eslint_rule_id"
value = "security/detect-object-injection"
[eslint.ruleset.override]
description = "OVERRIDDEN description"
message = "OVERRIDDEN message"
name = "OVERRIDDEN name"
severity = "Critical"
[gosec]
[[gosec.ruleset]]
[gosec.ruleset.identifier]
type = "CWE"
value = "CWE-79"
value = "703"
[gosec.ruleset.override]
severity = "Critical"
```
If a vulnerability is found with a type `CWE` with a value of `703` then
the vulnerability severity is overwritten to `Critical`.
#### Synthesize a custom configuration
To create a custom configuration, you can use passthrough chains.

View File

@ -93,6 +93,21 @@ module API
end
end
desc 'Get a project deploy token' do
detail 'This feature was introduced in GitLab 14.9'
success Entities::DeployToken
end
params do
requires :token_id, type: Integer, desc: 'The deploy token ID'
end
get ':id/deploy_tokens/:token_id' do
authorize!(:read_deploy_token, user_project)
deploy_token = user_project.deploy_tokens.find(params[:token_id])
present deploy_token, with: Entities::DeployToken
end
desc 'Delete a project deploy token' do
detail 'This feature was introduced in GitLab 12.9'
end
@ -159,6 +174,21 @@ module API
end
end
desc 'Get a group deploy token' do
detail 'This feature was introduced in GitLab 14.9'
success Entities::DeployToken
end
params do
requires :token_id, type: Integer, desc: 'The deploy token ID'
end
get ':id/deploy_tokens/:token_id' do
authorize!(:read_deploy_token, user_group)
deploy_token = user_group.deploy_tokens.find(params[:token_id])
present deploy_token, with: Entities::DeployToken
end
desc 'Delete a group deploy token' do
detail 'This feature was introduced in GitLab 12.9'
end

View File

@ -474,17 +474,22 @@ module API
model.errors.messages
end
def render_spam_error!
render_api_error!({ error: 'Spam detected' }, 400)
def render_api_error!(message, status)
render_structured_api_error!({ 'message' => message }, status)
end
def render_api_error!(message, status)
def render_structured_api_error!(hash, status)
# Use this method instead of `render_api_error!` when you have additional top-level
# hash entries in addition to 'message' which need to be passed to `#error!`
set_status_code_in_env(status)
error!(hash, status, header)
end
def set_status_code_in_env(status)
# grape-logging doesn't pass the status code, so this is a
# workaround for getting that information in the loggers:
# https://github.com/aserafin/grape_logging/issues/71
env[API_RESPONSE_STATUS_CODE] = Rack::Utils.status_code(status)
error!({ 'message' => message }, status, header)
end
def handle_api_exception(exception)

View File

@ -4,6 +4,7 @@ module API
class Issues < ::API::Base
include PaginationParams
helpers Helpers::IssuesHelpers
helpers SpammableActions::CaptchaCheck::RestApiActionsSupport
before { authenticate_non_get! }
@ -275,14 +276,12 @@ module API
params: issue_params,
spam_params: spam_params).execute
if issue.spam?
render_api_error!({ error: 'Spam detected' }, 400)
end
if issue.valid?
present issue, with: Entities::Issue, current_user: current_user, project: user_project
else
render_validation_error!(issue)
with_captcha_check_rest_api(spammable: issue) do
render_validation_error!(issue)
end
end
rescue ::ActiveRecord::RecordNotUnique
render_api_error!('Duplicated issue', 409)
@ -320,12 +319,12 @@ module API
params: update_params,
spam_params: spam_params).execute(issue)
render_spam_error! if issue.spam?
if issue.valid?
present issue, with: Entities::Issue, current_user: current_user, project: user_project
else
render_validation_error!(issue)
with_captcha_check_rest_api(spammable: issue) do
render_validation_error!(issue)
end
end
end
# rubocop: enable CodeReuse/ActiveRecord

View File

@ -13,6 +13,7 @@ module API
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
helpers Helpers::SnippetsHelpers
helpers SpammableActions::CaptchaCheck::RestApiActionsSupport
helpers do
def check_snippets_enabled
forbidden! unless user_project.feature_available?(:snippets, current_user)
@ -82,9 +83,9 @@ module API
if service_response.success?
present snippet, with: Entities::ProjectSnippet, current_user: current_user
else
render_spam_error! if snippet.spam?
render_api_error!({ error: service_response.message }, service_response.http_status)
with_captcha_check_rest_api(spammable: snippet) do
render_api_error!({ error: service_response.message }, service_response.http_status)
end
end
end
@ -124,9 +125,9 @@ module API
if service_response.success?
present snippet, with: Entities::ProjectSnippet, current_user: current_user
else
render_spam_error! if snippet.spam?
render_api_error!({ error: service_response.message }, service_response.http_status)
with_captcha_check_rest_api(spammable: snippet) do
render_api_error!({ error: service_response.message }, service_response.http_status)
end
end
end
# rubocop: enable CodeReuse/ActiveRecord

View File

@ -9,6 +9,7 @@ module API
resource :snippets do
helpers Helpers::SnippetsHelpers
helpers SpammableActions::CaptchaCheck::RestApiActionsSupport
helpers do
def snippets_for_current_user
SnippetsFinder.new(current_user, author: current_user).execute
@ -91,9 +92,9 @@ module API
if service_response.success?
present snippet, with: Entities::PersonalSnippet, current_user: current_user
else
render_spam_error! if snippet.spam?
render_api_error!({ error: service_response.message }, service_response.http_status)
with_captcha_check_rest_api(spammable: snippet) do
render_api_error!({ error: service_response.message }, service_response.http_status)
end
end
end
@ -135,9 +136,9 @@ module API
if service_response.success?
present snippet, with: Entities::PersonalSnippet, current_user: current_user
else
render_spam_error! if snippet.spam?
render_api_error!({ error: service_response.message }, service_response.http_status)
with_captcha_check_rest_api(spammable: snippet) do
render_api_error!({ error: service_response.message }, service_response.http_status)
end
end
end

View File

@ -88,12 +88,8 @@ module Gitlab
Gitlab::Saas.subdomain_regex === Gitlab.config.gitlab.url
end
def self.dev_env_org_or_com?
dev_env_or_com? || org?
end
def self.dev_env_or_com?
com?
def self.org_or_com?
org? || com?
end
def self.dev_or_test_env?

View File

@ -13,7 +13,7 @@ module Gitlab
# no inclusions, etc.)
def enabled?
return false unless feature_flag_defined?
return false unless Gitlab.dev_env_or_com?
return false unless Gitlab.com?
return false unless ::Feature.enabled?(:gitlab_experiment, type: :ops, default_enabled: :yaml)
feature_flag_instance.state != :off

View File

@ -20,7 +20,7 @@ module Gitlab
end
def set_experimentation_subject_id_cookie
if Gitlab.dev_env_or_com?
if Gitlab.com?
return if cookies[:experimentation_subject_id].present?
cookies.permanent.signed[:experimentation_subject_id] = {

View File

@ -18,7 +18,7 @@ module Gitlab
# Temporary change, we will change `experiment_percentage` in future to `Feature.enabled?
Feature.enabled?(feature_flag_name, type: :experiment, default_enabled: :yaml)
::Gitlab.dev_env_or_com? && experiment_percentage > 0
::Gitlab.com? && experiment_percentage > 0
end
def enabled_for_index?(index)

View File

@ -40,7 +40,6 @@ module Gitlab
gon.ee = Gitlab.ee?
gon.jh = Gitlab.jh?
gon.dot_com = Gitlab.com?
gon.dev_env_or_com = Gitlab.dev_env_or_com?
if current_user
gon.current_user_id = current_user.id

View File

@ -19843,6 +19843,9 @@ msgstr ""
msgid "Integrations|Failed to load namespaces. Please try again."
msgstr ""
msgid "Integrations|Failed to sign in to GitLab."
msgstr ""
msgid "Integrations|Failed to unlink namespace. Please try again."
msgstr ""
@ -19957,6 +19960,9 @@ msgstr ""
msgid "Integrations|Use default settings"
msgstr ""
msgid "Integrations|You can close this window."
msgstr ""
msgid "Integrations|You can now close this window and return to the GitLab for Jira application."
msgstr ""
@ -34205,6 +34211,9 @@ msgstr ""
msgid "Signed in"
msgstr ""
msgid "Signed in to GitLab"
msgstr ""
msgid "Signed in to GitLab as %{user_link}"
msgstr ""

View File

@ -20,7 +20,7 @@ module QA
end
# Removing a runner via the UI is covered by `spec/features/runners_spec.rb``
it 'removes the runner' do
it 'removes the runner', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/355302', type: :investigating } do
expect(runner.project.runners.size).to eq(1)
expect(runner.project.runners.first[:description]).to eq(executor)

View File

@ -4,6 +4,8 @@ module QA
RSpec.describe(
'Create',
:runner,
# TODO: remove limitation to only run on main when the bug is fixed
only: { pipeline: :main },
quarantine: {
issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338179',
type: :bug

View File

@ -0,0 +1,86 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe SpammableActions::CaptchaCheck::RestApiActionsSupport do
include Rack::Test::Methods
subject do
Class.new(Grape::API) do
helpers API::Helpers
helpers SpammableActions::CaptchaCheck::RestApiActionsSupport
get ':id' do
# NOTE: This was the only way that seemed to work to inject the mock spammable into the
# Grape rack app instance. If there's a better way, improvements are welcome.
spammable = Object.fake_spammable_factory
with_captcha_check_rest_api(spammable: spammable) do
render_api_error!(spammable.errors, 400)
end
end
end
end
def app
subject
end
before do
allow(Gitlab::Recaptcha).to receive(:load_configurations!) { true }
end
describe '#with_captcha_check_json_format' do
let(:spammable) { instance_double(Snippet) }
before do
expect(spammable).to receive(:render_recaptcha?).at_least(:once) { render_recaptcha }
allow(Object).to receive(:fake_spammable_factory) { spammable }
end
context 'when spammable.render_recaptcha? is true' do
let(:render_recaptcha) { true }
let(:spam_log) { instance_double(SpamLog, id: 1) }
let(:spammable) { instance_double(Snippet, spam?: true, render_recaptcha?: render_recaptcha, spam_log: spam_log) }
let(:recaptcha_site_key) { 'abc123' }
let(:err_msg) { 'You gotta solve the CAPTCHA' }
let(:spam_action_response_fields) do
{
spam: true,
needs_captcha_response: render_recaptcha,
spam_log_id: 1,
captcha_site_key: recaptcha_site_key
}
end
it 'renders json containing spam_action_response_fields' do
allow(spammable).to receive_message_chain('errors.full_messages.to_sentence') { err_msg }
allow(Gitlab::CurrentSettings).to receive(:recaptcha_site_key) { recaptcha_site_key }
response = get '/test'
expected_response = {
'needs_captcha_response' => render_recaptcha,
'spam_log_id' => 1,
'captcha_site_key' => recaptcha_site_key,
'message' => { 'error' => err_msg }
}
expect(Gitlab::Json.parse(response.body)).to eq(expected_response)
expect(response.status).to eq(409)
end
end
context 'when spammable.render_recaptcha? is false' do
let(:render_recaptcha) { false }
let(:errors) { { 'base' => "It's definitely spam" } }
it 'yields to block' do
allow(spammable).to receive(:errors) { errors }
response = get 'test'
expected_response = {
'message' => errors
}
expect(Gitlab::Json.parse(response.body)).to eq(expected_response)
expect(response.status).to eq(400)
end
end
end
end

View File

@ -1018,6 +1018,7 @@ RSpec.describe Projects::IssuesController do
end
it 'returns 200 status' do
update_verified_issue
expect(response).to have_gitlab_http_status(:ok)
end

View File

@ -243,12 +243,12 @@ RSpec.describe ApplicationExperiment, :experiment do
with_them do
it "returns the url or nil if invalid" do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
allow(Gitlab).to receive(:com?).and_return(true)
expect(application_experiment.process_redirect_url(url)).to eq(processed_url)
end
it "considers all urls invalid when not on dev or com" do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(false)
allow(Gitlab).to receive(:com?).and_return(false)
expect(application_experiment.process_redirect_url(url)).to be_nil
end
end

View File

@ -5,7 +5,9 @@
"name",
"username",
"expires_at",
"scopes"
"scopes",
"revoked",
"expired"
],
"properties": {
"id": {
@ -26,6 +28,12 @@
},
"token": {
"type": "string"
},
"revoked": {
"type": "boolean"
},
"expired": {
"type": "boolean"
}
}
}

View File

@ -1,4 +1,4 @@
import { base64ToBuffer, bufferToBase64 } from '~/authentication/webauthn/util';
import { base64ToBuffer, bufferToBase64, base64ToBase64Url } from '~/authentication/webauthn/util';
const encodedString = 'SGVsbG8gd29ybGQh';
const stringBytes = [72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33];
@ -16,4 +16,19 @@ describe('Webauthn utils', () => {
const buffer = base64ToBuffer(encodedString);
expect(bufferToBase64(buffer)).toBe(encodedString);
});
describe('base64ToBase64Url', () => {
it.each`
argument | expectedResult
${'asd+'} | ${'asd-'}
${'asd/'} | ${'asd_'}
${'asd='} | ${'asd'}
${'+asd'} | ${'-asd'}
${'/asd'} | ${'_asd'}
${'=asd'} | ${'=asd'}
${'a+bc/def=ghigjk=='} | ${'a-bc_def=ghigjk'}
`('returns $expectedResult when argument is $argument', ({ argument, expectedResult }) => {
expect(base64ToBase64Url(argument)).toBe(expectedResult);
});
});
});

View File

@ -8,6 +8,7 @@ import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions.
import UserLink from '~/jira_connect/subscriptions/components/user_link.vue';
import createStore from '~/jira_connect/subscriptions/store';
import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants';
import { __ } from '~/locale';
import { mockSubscription } from '../mock_data';
@ -24,6 +25,7 @@ describe('JiraConnectApp', () => {
const findAlertLink = () => findAlert().findComponent(GlLink);
const findSignInPage = () => wrapper.findComponent(SignInPage);
const findSubscriptionsPage = () => wrapper.findComponent(SubscriptionsPage);
const findUserLink = () => wrapper.findComponent(UserLink);
const createComponent = ({ provide, mountFn = shallowMountExtended } = {}) => {
store = createStore();
@ -78,10 +80,11 @@ describe('JiraConnectApp', () => {
},
});
const userLink = wrapper.findComponent(UserLink);
const userLink = findUserLink();
expect(userLink.exists()).toBe(true);
expect(userLink.props()).toEqual({
hasSubscriptions: false,
user: null,
userSignedIn: false,
});
});
@ -153,4 +156,55 @@ describe('JiraConnectApp', () => {
});
});
});
describe('when user signed out', () => {
describe('when sign in page emits `sign-in-oauth` event', () => {
const mockUser = { name: 'test' };
beforeEach(async () => {
createComponent({
provide: {
usersPath: '/mock',
subscriptions: [],
},
});
findSignInPage().vm.$emit('sign-in-oauth', mockUser);
await nextTick();
});
it('hides sign in page and renders subscriptions page', () => {
expect(findSignInPage().exists()).toBe(false);
expect(findSubscriptionsPage().exists()).toBe(true);
});
it('sets correct UserLink props', () => {
expect(findUserLink().props()).toMatchObject({
user: mockUser,
userSignedIn: true,
});
});
});
describe('when sign in page emits `error` event', () => {
beforeEach(async () => {
createComponent({
provide: {
usersPath: '/mock',
subscriptions: [],
},
});
findSignInPage().vm.$emit('error');
await nextTick();
});
it('displays alert', () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.html()).toContain(I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE);
expect(alert.props('variant')).toBe('danger');
});
});
});
});

View File

@ -1,18 +1,18 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button.vue';
import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_legacy_button.vue';
import waitForPromises from 'helpers/wait_for_promises';
const MOCK_USERS_PATH = '/user';
jest.mock('~/jira_connect/subscriptions/utils');
describe('SignInButton', () => {
describe('SignInLegacyButton', () => {
let wrapper;
const createComponent = ({ slots } = {}) => {
wrapper = shallowMount(SignInButton, {
wrapper = shallowMount(SignInLegacyButton, {
propsData: {
usersPath: MOCK_USERS_PATH,
},
@ -30,7 +30,7 @@ describe('SignInButton', () => {
createComponent();
expect(findButton().exists()).toBe(true);
expect(findButton().text()).toBe(SignInButton.i18n.defaultButtonText);
expect(findButton().text()).toBe(SignInLegacyButton.i18n.defaultButtonText);
});
describe.each`

View File

@ -0,0 +1,204 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
import {
I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
OAUTH_WINDOW_OPTIONS,
} from '~/jira_connect/subscriptions/constants';
import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises';
import httpStatus from '~/lib/utils/http_status';
import AccessorUtilities from '~/lib/utils/accessor';
jest.mock('~/lib/utils/accessor');
jest.mock('~/jira_connect/subscriptions/utils');
jest.mock('~/jira_connect/subscriptions/pkce', () => ({
createCodeVerifier: jest.fn().mockReturnValue('mock-verifier'),
createCodeChallenge: jest.fn().mockResolvedValue('mock-challenge'),
}));
const mockOauthMetadata = {
oauth_authorize_url: 'https://gitlab.com/mockOauth',
oauth_token_url: 'https://gitlab.com/mockOauthToken',
state: 'good-state',
};
describe('SignInOauthButton', () => {
let wrapper;
let mockAxios;
const createComponent = ({ slots } = {}) => {
wrapper = shallowMount(SignInOauthButton, {
slots,
provide: {
oauthMetadata: mockOauthMetadata,
},
});
};
beforeEach(() => {
mockAxios = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mockAxios.restore();
});
const findButton = () => wrapper.findComponent(GlButton);
it('displays a button', () => {
createComponent();
expect(findButton().exists()).toBe(true);
expect(findButton().text()).toBe(I18N_DEFAULT_SIGN_IN_BUTTON_TEXT);
});
it.each`
scenario | cryptoAvailable
${'when crypto API is available'} | ${true}
${'when crypto API is unavailable'} | ${false}
`('$scenario when canUseCrypto returns $cryptoAvailable', ({ cryptoAvailable }) => {
AccessorUtilities.canUseCrypto = jest.fn().mockReturnValue(cryptoAvailable);
createComponent();
expect(findButton().props('disabled')).toBe(!cryptoAvailable);
});
describe('on click', () => {
beforeEach(async () => {
jest.spyOn(window, 'open').mockReturnValue();
createComponent();
findButton().vm.$emit('click');
await nextTick();
});
it('sets `loading` prop of button to `true`', () => {
expect(findButton().props('loading')).toBe(true);
});
it('calls `window.open` with correct arguments', () => {
expect(window.open).toHaveBeenCalledWith(
`${mockOauthMetadata.oauth_authorize_url}?code_challenge=mock-challenge&code_challenge_method=S256`,
I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
OAUTH_WINDOW_OPTIONS,
);
});
it('sets the `codeVerifier` internal state', () => {
expect(wrapper.vm.codeVerifier).toBe('mock-verifier');
});
describe('on window message event', () => {
describe('when window message properties are corrupted', () => {
describe.each`
origin | state | messageOrigin | messageState
${window.origin} | ${mockOauthMetadata.state} | ${'bad-origin'} | ${mockOauthMetadata.state}
${window.origin} | ${mockOauthMetadata.state} | ${window.origin} | ${'bad-state'}
`(
'when message is [state=$messageState, origin=$messageOrigin]',
({ messageOrigin, messageState }) => {
beforeEach(async () => {
const mockEvent = {
origin: messageOrigin,
data: {
state: messageState,
code: '1234',
},
};
window.dispatchEvent(new MessageEvent('message', mockEvent));
await waitForPromises();
});
it('emits `error` event', () => {
expect(wrapper.emitted('error')).toBeTruthy();
});
it('does not emit `sign-in` event', () => {
expect(wrapper.emitted('sign-in')).toBeFalsy();
});
it('sets `loading` prop of button to `false`', () => {
expect(findButton().props('loading')).toBe(false);
});
},
);
});
describe('when window message properties are valid', () => {
const mockAccessToken = '5678';
const mockUser = { name: 'test user' };
const mockEvent = {
origin: window.origin,
data: {
state: mockOauthMetadata.state,
code: '1234',
},
};
describe('when API requests succeed', () => {
beforeEach(async () => {
jest.spyOn(axios, 'post');
jest.spyOn(axios, 'get');
mockAxios
.onPost(mockOauthMetadata.oauth_token_url)
.replyOnce(httpStatus.OK, { access_token: mockAccessToken });
mockAxios.onGet('/api/v4/user').replyOnce(httpStatus.OK, mockUser);
window.dispatchEvent(new MessageEvent('message', mockEvent));
await waitForPromises();
});
it('executes POST request to Oauth token endpoint', () => {
expect(axios.post).toHaveBeenCalledWith(mockOauthMetadata.oauth_token_url, {
code: '1234',
code_verifier: 'mock-verifier',
});
});
it('executes GET request to fetch user data', () => {
expect(axios.get).toHaveBeenCalledWith('/api/v4/user', {
headers: { Authorization: `Bearer ${mockAccessToken}` },
});
});
it('emits `sign-in` event with user data', () => {
expect(wrapper.emitted('sign-in')[0]).toEqual([mockUser]);
});
});
describe('when API requests fail', () => {
beforeEach(async () => {
jest.spyOn(axios, 'post');
jest.spyOn(axios, 'get');
mockAxios
.onPost(mockOauthMetadata.oauth_token_url)
.replyOnce(httpStatus.INTERNAL_SERVER_ERROR, { access_token: mockAccessToken });
mockAxios.onGet('/api/v4/user').replyOnce(httpStatus.INTERNAL_SERVER_ERROR, mockUser);
window.dispatchEvent(new MessageEvent('message', mockEvent));
await waitForPromises();
});
it('emits `error` event', () => {
expect(wrapper.emitted('error')).toBeTruthy();
});
it('does not emit `sign-in` event', () => {
expect(wrapper.emitted('sign-in')).toBeFalsy();
});
it('sets `loading` prop of button to `false`', () => {
expect(findButton().props('loading')).toBe(false);
});
});
});
});
});
});

View File

@ -7,7 +7,7 @@ jest.mock('~/jira_connect/subscriptions/utils', () => ({
getGitlabSignInURL: jest.fn().mockImplementation((path) => Promise.resolve(path)),
}));
describe('SubscriptionsList', () => {
describe('UserLink', () => {
let wrapper;
const createComponent = (propsData = {}, { provide } = {}) => {
@ -68,24 +68,35 @@ describe('SubscriptionsList', () => {
});
describe('gitlab user link', () => {
window.gon = { current_username: 'root' };
describe.each`
current_username | gitlabUserPath | user | expectedUserHandle | expectedUserLink
${'root'} | ${'/root'} | ${{ username: 'test-user' }} | ${'@root'} | ${'/root'}
${'root'} | ${'/root'} | ${undefined} | ${'@root'} | ${'/root'}
${undefined} | ${undefined} | ${{ username: 'test-user' }} | ${'@test-user'} | ${'/test-user'}
`(
'when current_username=$current_username, gitlabUserPath=$gitlabUserPath and user=$user',
({ current_username, gitlabUserPath, user, expectedUserHandle, expectedUserLink }) => {
beforeEach(() => {
window.gon = { current_username, relative_root_url: '' };
beforeEach(() => {
createComponent(
{
userSignedIn: true,
hasSubscriptions: true,
},
{ provide: { gitlabUserPath: '/root' } },
);
});
createComponent(
{
userSignedIn: true,
hasSubscriptions: true,
user,
},
{ provide: { gitlabUserPath } },
);
});
it('renders with correct href', () => {
expect(findGitlabUserLink().attributes('href')).toBe('/root');
});
it(`sets href to ${expectedUserLink}`, () => {
expect(findGitlabUserLink().attributes('href')).toBe(expectedUserLink);
});
it('contains GitLab user handle', () => {
expect(findGitlabUserLink().text()).toBe('@root');
});
it(`renders ${expectedUserHandle} as text`, () => {
expect(findGitlabUserLink().text()).toBe(expectedUserHandle);
});
},
);
});
});

View File

@ -1,26 +1,44 @@
import { mount } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import SignInPage from '~/jira_connect/subscriptions/pages/sign_in.vue';
import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button.vue';
import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_legacy_button.vue';
import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue';
import createStore from '~/jira_connect/subscriptions/store';
import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '../../../../../app/assets/javascripts/jira_connect/subscriptions/constants';
jest.mock('~/jira_connect/subscriptions/utils');
const mockUsersPath = '/test';
const defaultProvide = {
oauthMetadata: {},
usersPath: mockUsersPath,
};
describe('SignInPage', () => {
let wrapper;
let store;
const findSignInButton = () => wrapper.findComponent(SignInButton);
const findSignInLegacyButton = () => wrapper.findComponent(SignInLegacyButton);
const findSignInOauthButton = () => wrapper.findComponent(SignInOauthButton);
const findSubscriptionsList = () => wrapper.findComponent(SubscriptionsList);
const createComponent = ({ provide, props } = {}) => {
const createComponent = ({ props, jiraConnectOauthEnabled } = {}) => {
store = createStore();
wrapper = mount(SignInPage, {
wrapper = shallowMount(SignInPage, {
store,
provide,
provide: {
...defaultProvide,
glFeatures: {
jiraConnectOauth: jiraConnectOauthEnabled,
},
},
propsData: props,
stubs: {
SignInLegacyButton,
SignInOauthButton,
},
});
};
@ -29,33 +47,74 @@ describe('SignInPage', () => {
});
describe('template', () => {
const mockUsersPath = '/test';
describe.each`
scenario | expectSubscriptionsList | signInButtonText
${'with subscriptions'} | ${true} | ${SignInPage.i18n.signinButtonTextWithSubscriptions}
${'without subscriptions'} | ${false} | ${SignInButton.i18n.defaultButtonText}
`('$scenario', ({ expectSubscriptionsList, signInButtonText }) => {
beforeEach(() => {
createComponent({
provide: {
usersPath: mockUsersPath,
},
props: {
hasSubscriptions: expectSubscriptionsList,
},
scenario | hasSubscriptions | signInButtonText
${'with subscriptions'} | ${true} | ${SignInPage.i18n.signInButtonTextWithSubscriptions}
${'without subscriptions'} | ${false} | ${I18N_DEFAULT_SIGN_IN_BUTTON_TEXT}
`('$scenario', ({ hasSubscriptions, signInButtonText }) => {
describe('when `jiraConnectOauthEnabled` feature flag is disabled', () => {
beforeEach(() => {
createComponent({
jiraConnectOauthEnabled: false,
props: {
hasSubscriptions,
},
});
});
it('renders legacy sign in button', () => {
const button = findSignInLegacyButton();
expect(button.props('usersPath')).toBe(mockUsersPath);
expect(button.text()).toMatchInterpolatedText(signInButtonText);
});
});
it(`renders sign in button with text ${signInButtonText}`, () => {
expect(findSignInButton().text()).toMatchInterpolatedText(signInButtonText);
describe('when `jiraConnectOauthEnabled` feature flag is enabled', () => {
beforeEach(() => {
createComponent({
jiraConnectOauthEnabled: true,
props: {
hasSubscriptions,
},
});
});
describe('oauth sign in button', () => {
it('renders oauth sign in button', () => {
const button = findSignInOauthButton();
expect(button.text()).toMatchInterpolatedText(signInButtonText);
});
describe('when button emits `sign-in` event', () => {
it('emits `sign-in-oauth` event', () => {
const button = findSignInOauthButton();
const mockUser = { name: 'test' };
button.vm.$emit('sign-in', mockUser);
expect(wrapper.emitted('sign-in-oauth')[0]).toEqual([mockUser]);
});
});
describe('when button emits `error` event', () => {
it('emits `error` event', () => {
const button = findSignInOauthButton();
button.vm.$emit('error');
expect(wrapper.emitted('error')).toBeTruthy();
});
});
});
});
it('renders sign in button with `usersPath` prop', () => {
expect(findSignInButton().props('usersPath')).toBe(mockUsersPath);
});
it(`${hasSubscriptions ? 'renders' : 'does not render'} subscriptions list`, () => {
createComponent({
props: {
hasSubscriptions,
},
});
it(`${expectSubscriptionsList ? 'renders' : 'does not render'} subscriptions list`, () => {
expect(findSubscriptionsList().exists()).toBe(expectSubscriptionsList);
expect(findSubscriptionsList().exists()).toBe(hasSubscriptions);
});
});
});

View File

@ -0,0 +1,48 @@
import crypto from 'crypto';
import { TextEncoder, TextDecoder } from 'util';
import { createCodeVerifier, createCodeChallenge } from '~/jira_connect/subscriptions/pkce';
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
describe('pkce', () => {
beforeAll(() => {
Object.defineProperty(global.self, 'crypto', {
value: {
getRandomValues: (arr) => crypto.randomBytes(arr.length),
subtle: {
digest: jest.fn().mockResolvedValue(new ArrayBuffer(1)),
},
},
});
});
describe('createCodeVerifier', () => {
it('calls `window.crypto.getRandomValues`', () => {
window.crypto.getRandomValues = jest.fn();
createCodeVerifier();
expect(window.crypto.getRandomValues).toHaveBeenCalled();
});
it(`returns a string with 128 characters`, () => {
const codeVerifier = createCodeVerifier();
expect(codeVerifier).toHaveLength(128);
});
});
describe('createCodeChallenge', () => {
it('calls `window.crypto.subtle.digest` with correct arguments', async () => {
await createCodeChallenge('1234');
expect(window.crypto.subtle.digest).toHaveBeenCalledWith('SHA-256', expect.anything());
});
it('returns base64 URL-encoded string', async () => {
const codeChallenge = await createCodeChallenge('1234');
expect(codeChallenge).toBe('AA');
});
});
});

View File

@ -7,6 +7,11 @@ RSpec.describe JiraConnectHelper do
let_it_be(:subscription) { create(:jira_connect_subscription) }
let(:user) { create(:user) }
let(:client_id) { '123' }
before do
stub_env('JIRA_CONNECT_OAUTH_CLIENT_ID', client_id)
end
subject { helper.jira_connect_app_data([subscription]) }
@ -29,6 +34,47 @@ RSpec.describe JiraConnectHelper do
expect(subject[:users_path]).to eq(jira_connect_users_path)
end
context 'with oauth_metadata' do
let(:oauth_metadata) { helper.jira_connect_app_data([subscription])[:oauth_metadata] }
subject(:parsed_oauth_metadata) { Gitlab::Json.parse(oauth_metadata).deep_symbolize_keys }
it 'assigns oauth_metadata' do
expect(parsed_oauth_metadata).to include(
oauth_authorize_url: start_with('http://test.host/oauth/authorize?'),
oauth_token_url: 'http://test.host/oauth/token',
state: %r/[a-z0-9.]{32}/,
oauth_token_payload: hash_including(
grant_type: 'authorization_code',
client_id: client_id,
redirect_uri: 'http://test.host/-/jira_connect/oauth_callbacks'
)
)
end
it 'includes oauth_authorize_url with all params' do
params = Rack::Utils.parse_nested_query(URI.parse(parsed_oauth_metadata[:oauth_authorize_url]).query)
expect(params).to include(
'client_id' => client_id,
'response_type' => 'code',
'scope' => 'api',
'redirect_uri' => 'http://test.host/-/jira_connect/oauth_callbacks',
'state' => parsed_oauth_metadata[:state]
)
end
context 'jira_connect_oauth feature is disabled' do
before do
stub_feature_flags(jira_connect_oauth: false)
end
it 'does not assign oauth_metadata' do
expect(oauth_metadata).to be_nil
end
end
end
it 'passes group as "skip_groups" param' do
skip_groups_param = CGI.escape('skip_groups[]')

View File

@ -8,7 +8,7 @@ RSpec.describe SessionsHelper do
context 'when on .com' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
allow(Gitlab).to receive(:com?).and_return(true)
end
it 'when flash notice is empty it is false' do
@ -29,7 +29,7 @@ RSpec.describe SessionsHelper do
context 'when not on .com' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(false)
allow(Gitlab).to receive(:com?).and_return(false)
end
it 'when flash notice is devise confirmed message it is false' do

View File

@ -39,14 +39,14 @@ RSpec.describe WhatsNewHelper do
subject { helper.display_whats_new? }
it 'returns true when gitlab.com' do
allow(Gitlab).to receive(:dev_env_org_or_com?).and_return(true)
allow(Gitlab).to receive(:org_or_com?).and_return(true)
expect(subject).to be true
end
context 'when self-managed' do
before do
allow(Gitlab).to receive(:dev_env_org_or_com?).and_return(false)
allow(Gitlab).to receive(:org_or_com?).and_return(false)
end
it 'returns true if user is signed in' do
@ -71,7 +71,7 @@ RSpec.describe WhatsNewHelper do
with_them do
it 'returns correct result depending on variant' do
allow(Gitlab).to receive(:dev_env_org_or_com?).and_return(true)
allow(Gitlab).to receive(:org_or_com?).and_return(true)
Gitlab::CurrentSettings.update!(whats_new_variant: ApplicationSetting.whats_new_variants[variant])
expect(subject).to eq(result)

View File

@ -11,7 +11,7 @@ RSpec.describe Gitlab::Experiment::Rollout::Feature, :experiment do
before do
stub_feature_flags(gitlab_experiment: true)
allow(subject).to receive(:feature_flag_defined?).and_return(true)
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
allow(Gitlab).to receive(:com?).and_return(true)
allow(subject).to receive(:feature_flag_instance).and_return(double(state: :on))
end
@ -26,7 +26,7 @@ RSpec.describe Gitlab::Experiment::Rollout::Feature, :experiment do
end
it "isn't enabled if we're not in dev or dotcom environments" do
expect(Gitlab).to receive(:dev_env_or_com?).and_return(false)
expect(Gitlab).to receive(:com?).and_return(false)
expect(subject).not_to be_enabled
end

View File

@ -17,7 +17,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
}
)
allow(Gitlab).to receive(:dev_env_or_com?).and_return(is_gitlab_com)
allow(Gitlab).to receive(:com?).and_return(is_gitlab_com)
Feature.enable_percentage_of_time(:test_experiment_experiment_percentage, enabled_percentage)
end

View File

@ -25,7 +25,7 @@ RSpec.describe Gitlab::Experimentation::Experiment do
describe '#active?' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(on_gitlab_com)
allow(Gitlab).to receive(:com?).and_return(on_gitlab_com)
end
subject { experiment.active? }

View File

@ -80,36 +80,42 @@ RSpec.describe Gitlab do
end
describe '.com?' do
it "is true when on #{Gitlab::Saas.com_url}" do
stub_config_setting(url: Gitlab::Saas.com_url)
context 'when not simulating SaaS' do
before do
stub_env('GITLAB_SIMULATE_SAAS', '0')
end
expect(described_class.com?).to eq true
end
it "is true when on #{Gitlab::Saas.com_url}" do
stub_config_setting(url: Gitlab::Saas.com_url)
it "is true when on #{Gitlab::Saas.staging_com_url}" do
stub_config_setting(url: Gitlab::Saas.staging_com_url)
expect(described_class.com?).to eq true
end
expect(described_class.com?).to eq true
end
it "is true when on #{Gitlab::Saas.staging_com_url}" do
stub_config_setting(url: Gitlab::Saas.staging_com_url)
it 'is true when on other gitlab subdomain' do
url_with_subdomain = Gitlab::Saas.com_url.gsub('https://', 'https://example.')
stub_config_setting(url: url_with_subdomain)
expect(described_class.com?).to eq true
end
expect(described_class.com?).to eq true
end
it 'is true when on other gitlab subdomain' do
url_with_subdomain = Gitlab::Saas.com_url.gsub('https://', 'https://example.')
stub_config_setting(url: url_with_subdomain)
it 'is true when on other gitlab subdomain with hyphen' do
url_with_subdomain = Gitlab::Saas.com_url.gsub('https://', 'https://test-example.')
stub_config_setting(url: url_with_subdomain)
expect(described_class.com?).to eq true
end
expect(described_class.com?).to eq true
end
it 'is true when on other gitlab subdomain with hyphen' do
url_with_subdomain = Gitlab::Saas.com_url.gsub('https://', 'https://test-example.')
stub_config_setting(url: url_with_subdomain)
it 'is false when not on GitLab.com' do
stub_config_setting(url: 'http://example.com')
expect(described_class.com?).to eq true
end
expect(described_class.com?).to eq false
it 'is false when not on GitLab.com' do
stub_config_setting(url: 'http://example.com')
expect(described_class.com?).to eq false
end
end
it 'is true when GITLAB_SIMULATE_SAAS is true and in development' do
@ -210,31 +216,23 @@ RSpec.describe Gitlab do
end
end
describe '.dev_env_org_or_com?' do
describe '.org_or_com?' do
it 'is true when on .com' do
allow(described_class).to receive_messages(com?: true, org?: false)
expect(described_class.dev_env_org_or_com?).to eq true
expect(described_class.org_or_com?).to eq true
end
it 'is true when org' do
allow(described_class).to receive_messages(com?: false, org?: true)
expect(described_class.dev_env_org_or_com?).to eq true
expect(described_class.org_or_com?).to eq true
end
it 'is false when not dev, org or com' do
allow(described_class).to receive_messages(com?: false, org?: false)
expect(described_class.dev_env_org_or_com?).to eq false
end
end
describe '.dev_env_or_com?' do
it 'is correctly calling com?' do
expect(described_class).to receive(:com?).and_call_original
expect(described_class.dev_env_or_com?).to eq false
expect(described_class.org_or_com?).to eq false
end
end

View File

@ -16,7 +16,7 @@ RSpec.describe UpdateTrialPlansCiDailyPipelineScheduleTriggers, :migration do
context 'when the environment is dev or com' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
allow(Gitlab).to receive(:com?).and_return(true)
end
it 'sets the trial plan limits for ci_daily_pipeline_schedule_triggers' do
@ -57,7 +57,7 @@ RSpec.describe UpdateTrialPlansCiDailyPipelineScheduleTriggers, :migration do
context 'when the environment is anything other than dev or com' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(false)
allow(Gitlab).to receive(:com?).and_return(false)
end
it 'does not update the plan limits' do
@ -75,7 +75,7 @@ RSpec.describe UpdateTrialPlansCiDailyPipelineScheduleTriggers, :migration do
context 'when the environment is dev or com' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
allow(Gitlab).to receive(:com?).and_return(true)
end
it 'sets the trial plan limits ci_daily_pipeline_schedule_triggers to zero' do
@ -116,7 +116,7 @@ RSpec.describe UpdateTrialPlansCiDailyPipelineScheduleTriggers, :migration do
context 'when the environment is anything other than dev or com' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(false)
allow(Gitlab).to receive(:com?).and_return(false)
end
it 'does not change the ultimate trial plan limits' do

View File

@ -7,7 +7,7 @@ require_migration!
RSpec.describe AddNewTrailPlans, :migration do
describe '#up' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return true
allow(Gitlab).to receive(:com?).and_return true
end
it 'creates 2 entries within the plans table' do
@ -40,7 +40,7 @@ RSpec.describe AddNewTrailPlans, :migration do
context 'when the instance is not SaaS' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return false
allow(Gitlab).to receive(:com?).and_return false
end
it 'does not create plans and plan limits and returns' do
@ -58,7 +58,7 @@ RSpec.describe AddNewTrailPlans, :migration do
context 'when the instance is SaaS' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return true
allow(Gitlab).to receive(:com?).and_return true
end
it 'removes the newly added ultimate and premium trial entries' do
@ -77,7 +77,7 @@ RSpec.describe AddNewTrailPlans, :migration do
context 'when the instance is not SaaS' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return false
allow(Gitlab).to receive(:com?).and_return false
table(:plans).create!(id: 1, name: 'ultimate_trial', title: 'Ultimate Trial')
table(:plans).create!(id: 2, name: 'premium_trial', title: 'Premium Trial')
table(:plan_limits).create!(id: 1, plan_id: 1)

View File

@ -7,7 +7,7 @@ require_migration!
RSpec.describe AddOpenSourcePlan, :migration do
describe '#up' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return true
allow(Gitlab).to receive(:com?).and_return true
end
it 'creates 1 entry within the plans table' do
@ -35,7 +35,7 @@ RSpec.describe AddOpenSourcePlan, :migration do
context 'when the instance is not SaaS' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return false
allow(Gitlab).to receive(:com?).and_return false
end
it 'does not create plans and plan limits and returns' do
@ -52,7 +52,7 @@ RSpec.describe AddOpenSourcePlan, :migration do
context 'when the instance is SaaS' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return true
allow(Gitlab).to receive(:com?).and_return true
end
it 'removes the newly added opensource entry' do
@ -70,7 +70,7 @@ RSpec.describe AddOpenSourcePlan, :migration do
context 'when the instance is not SaaS' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return false
allow(Gitlab).to receive(:com?).and_return false
table(:plans).create!(id: 1, name: 'opensource', title: 'Open Source Program')
table(:plan_limits).create!(id: 1, plan_id: 1)
end

View File

@ -130,6 +130,55 @@ RSpec.describe API::DeployTokens do
end
end
describe 'GET /projects/:id/deploy_tokens/:token_id' do
subject do
get api("/projects/#{project.id}/deploy_tokens/#{deploy_token.id}", user)
response
end
context 'when unauthenticated' do
let(:user) { nil }
it { is_expected.to have_gitlab_http_status(:not_found) }
end
context 'when authenticated as non-admin user' do
before do
project.add_developer(user)
end
it { is_expected.to have_gitlab_http_status(:forbidden) }
end
context 'when authenticated as maintainer' do
before do
project.add_maintainer(user)
end
it { is_expected.to have_gitlab_http_status(:ok) }
it 'returns specific deploy token for the project' do
subject
expect(response).to match_response_schema('public_api/v4/deploy_token')
end
context 'invalid request' do
it 'returns not found with invalid project id' do
get api("/projects/bad_id/deploy_tokens/#{deploy_token.id}", user)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns not found with invalid token id' do
get api("/projects/#{project.id}/deploy_tokens/#{non_existing_record_id}", user)
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
describe 'GET /groups/:id/deploy_tokens' do
subject do
get api("/groups/#{group.id}/deploy_tokens", user)
@ -188,6 +237,55 @@ RSpec.describe API::DeployTokens do
end
end
describe 'GET /groups/:id/deploy_tokens/:token_id' do
subject do
get api("/groups/#{group.id}/deploy_tokens/#{group_deploy_token.id}", user)
response
end
context 'when unauthenticated' do
let(:user) { nil }
it { is_expected.to have_gitlab_http_status(:forbidden) }
end
context 'when authenticated as non-admin user' do
before do
group.add_developer(user)
end
it { is_expected.to have_gitlab_http_status(:forbidden) }
end
context 'when authenticated as maintainer' do
before do
group.add_maintainer(user)
end
it { is_expected.to have_gitlab_http_status(:ok) }
it 'returns specific deploy token for the group' do
subject
expect(response).to match_response_schema('public_api/v4/deploy_token')
end
context 'invalid request' do
it 'returns not found with invalid group id' do
get api("/groups/bad_id/deploy_tokens/#{group_deploy_token.id}", user)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns not found with invalid token id' do
get api("/groups/#{group.id}/deploy_tokens/#{non_existing_record_id}", user)
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
describe 'DELETE /projects/:id/deploy_tokens/:token_id' do
subject do
delete api("/projects/#{project.id}/deploy_tokens/#{deploy_token.id}", user)
@ -232,10 +330,10 @@ RSpec.describe API::DeployTokens do
it 'returns bad_request with invalid token id' do
expect(::Projects::DeployTokens::DestroyService).to receive(:new)
.with(project, user, token_id: 999)
.with(project, user, token_id: non_existing_record_id)
.and_raise(ActiveRecord::RecordNotFound)
delete api("/projects/#{project.id}/deploy_tokens/999", user)
delete api("/projects/#{project.id}/deploy_tokens/#{non_existing_record_id}", user)
expect(response).to have_gitlab_http_status(:not_found)
end
@ -395,10 +493,10 @@ RSpec.describe API::DeployTokens do
it 'returns not found with invalid deploy token id' do
expect(::Groups::DeployTokens::DestroyService).to receive(:new)
.with(group, user, token_id: 999)
.with(group, user, token_id: non_existing_record_id)
.and_raise(ActiveRecord::RecordNotFound)
delete api("/groups/#{group.id}/deploy_tokens/999", user)
delete api("/groups/#{group.id}/deploy_tokens/#{non_existing_record_id}", user)
expect(response).to have_gitlab_http_status(:not_found)
end

View File

@ -447,7 +447,7 @@ RSpec.describe API::Issues do
post_issue
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ 'error' => 'Spam detected' })
expect(json_response['message']['base']).to match_array([/issue has been recognized as spam/])
end
it 'creates a new spam log entry' do

View File

@ -217,7 +217,7 @@ RSpec.describe API::Issues do
update_issue
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response).to include('message' => { 'error' => 'Spam detected' })
expect(json_response['message']['base']).to match_array([/issue has been recognized as spam/])
end
it 'creates a new spam log entry' do

View File

@ -276,7 +276,7 @@ RSpec.describe API::ProjectSnippets do
it 'rejects the snippet' do
expect { subject }.not_to change { Snippet.count }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
expect(json_response['message']['error']).to match(/snippet has been recognized as spam/)
end
it 'creates a spam log' do
@ -344,7 +344,7 @@ RSpec.describe API::ProjectSnippets do
.not_to change { snippet.reload.title }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
expect(json_response['message']['error']).to match(/snippet has been recognized as spam/)
end
it 'creates a spam log' do

View File

@ -325,7 +325,7 @@ RSpec.describe API::Snippets, factory_default: :keep do
expect { subject }.not_to change { Snippet.count }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
expect(json_response['message']['error']).to match(/snippet has been recognized as spam/)
end
it 'creates a spam log' do
@ -392,7 +392,7 @@ RSpec.describe API::Snippets, factory_default: :keep do
.not_to change { snippet.reload.title }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
expect(json_response['message']['error']).to match(/snippet has been recognized as spam/)
end
it 'creates a spam log' do

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe JiraConnect::OauthCallbacksController do
describe 'GET /-/jira_connect/oauth_callbacks' do
context 'when logged in' do
let_it_be(:user) { create(:user) }
before do
sign_in(user)
end
it 'renders a page prompting the user to close the window' do
get '/-/jira_connect/oauth_callbacks'
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to include('You can close this window.')
end
end
end
end

View File

@ -3,29 +3,7 @@
require 'spec_helper'
RSpec.describe Spam::SpamParams do
describe '.new_from_request' do
let(:captcha_response) { 'abc123' }
let(:spam_log_id) { 42 }
let(:ip_address) { '0.0.0.0' }
let(:user_agent) { 'Lynx' }
let(:referer) { 'http://localhost' }
let(:headers) do
{
'X-GitLab-Captcha-Response' => captcha_response,
'X-GitLab-Spam-Log-Id' => spam_log_id
}
end
let(:env) do
{
'action_dispatch.remote_ip' => ip_address,
'HTTP_USER_AGENT' => user_agent,
'HTTP_REFERER' => referer
}
end
let(:request) {double(:request, headers: headers, env: env)}
shared_examples 'constructs from a request' do
it 'constructs from a request' do
expected = ::Spam::SpamParams.new(
captcha_response: captcha_response,
@ -37,4 +15,44 @@ RSpec.describe Spam::SpamParams do
expect(described_class.new_from_request(request: request)).to eq(expected)
end
end
describe '.new_from_request' do
let(:captcha_response) { 'abc123' }
let(:spam_log_id) { 42 }
let(:ip_address) { '0.0.0.0' }
let(:user_agent) { 'Lynx' }
let(:referer) { 'http://localhost' }
let(:env) do
{
'action_dispatch.remote_ip' => ip_address,
'HTTP_USER_AGENT' => user_agent,
'HTTP_REFERER' => referer
}
end
let(:request) { double(:request, headers: headers, env: env) }
context 'with a normal Rails request' do
let(:headers) do
{
'X-GitLab-Captcha-Response' => captcha_response,
'X-GitLab-Spam-Log-Id' => spam_log_id
}
end
it_behaves_like 'constructs from a request'
end
context 'with a grape request' do
let(:headers) do
{
'X-Gitlab-Captcha-Response' => captcha_response,
'X-Gitlab-Spam-Log-Id' => spam_log_id
}
end
it_behaves_like 'constructs from a request'
end
end
end

View File

@ -692,16 +692,6 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
context 'notification enabled for all branches' do
it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "all"
end
context 'when chat_notification_deployment_protected_branch_filter is disabled' do
before do
stub_feature_flags(chat_notification_deployment_protected_branch_filter: false)
end
context 'notification enabled only for default branch' do
it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default"
end
end
end
end
end

View File

@ -9,7 +9,7 @@ RSpec.describe 'devise/sessions/new' do
before do
stub_devise
disable_captcha
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
allow(Gitlab).to receive(:com?).and_return(true)
end
it 'when flash is anything it renders marketing text' do

View File

@ -27,7 +27,7 @@ RSpec.describe 'devise/shared/_signup_box' do
context 'when on .com' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
allow(Gitlab).to receive(:com?).and_return(true)
end
it 'shows expected GitLab text' do
@ -39,7 +39,7 @@ RSpec.describe 'devise/shared/_signup_box' do
context 'when not on .com' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(false)
allow(Gitlab).to receive(:com?).and_return(false)
end
it 'shows expected text without GitLab' do
@ -53,7 +53,7 @@ RSpec.describe 'devise/shared/_signup_box' do
context 'when terms are not enforced' do
before do
allow(Gitlab::CurrentSettings.current_application_settings).to receive(:enforce_terms?).and_return(false)
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
allow(Gitlab).to receive(:com?).and_return(true)
end
it 'shows expected text with placeholders' do