Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-02-25 03:16:18 +00:00
parent ca6f7d9e72
commit 80e6e0fcdd
22 changed files with 262 additions and 151 deletions

View File

@ -12,6 +12,7 @@ import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
@ -57,6 +58,7 @@ export default {
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
RunnerActionsCell,
},
props: {
registrationToken: {
@ -279,6 +281,9 @@ export default {
<runner-name :runner="runner" />
</gl-link>
</template>
<template #runner-actions-cell="{ runner }">
<runner-actions-cell :runner="runner" :edit-url="runner.editAdminUrl" />
</template>
</runner-list>
<runner-pagination
v-model="search.pagination"

View File

@ -17,6 +17,11 @@ export default {
type: Object,
required: true,
},
editUrl: {
type: String,
default: null,
required: false,
},
},
computed: {
canUpdate() {
@ -31,14 +36,7 @@ export default {
<template>
<gl-button-group>
<!--
This button appears for administrators: those with
access to the adminUrl. More advanced permissions policies
will allow more granular permissions.
See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
-->
<runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
<runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" />
<runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
<runner-delete-button v-if="canDelete" :runner="runner" :compact="true" />
</gl-button-group>

View File

@ -1,8 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
const I18N_EDIT = __('Edit');
import { I18N_EDIT } from '../constants';
export default {
components: {

View File

@ -5,7 +5,6 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { formatJobCount, tableField } from '../utils';
import RunnerActionsCell from './cells/runner_actions_cell.vue';
import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue';
@ -16,7 +15,6 @@ export default {
GlSkeletonLoader,
TooltipOnTruncate,
TimeAgo,
RunnerActionsCell,
RunnerSummaryCell,
RunnerTags,
RunnerStatusCell,
@ -121,7 +119,7 @@ export default {
</template>
<template #cell(actions)="{ item }">
<runner-actions-cell :runner="item" />
<slot name="runner-actions-cell" :runner="item"></slot>
</template>
</gl-table-lite>

View File

@ -35,7 +35,8 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__(
'Runners|No contact from this runner in over 3 months',
);
// Active flag
// Actions
export const I18N_EDIT = __('Edit');
export const I18N_PAUSE = __('Pause');
export const I18N_RESUME = __('Resume');
export const I18N_DELETE_RUNNER = s__('Runners|Delete runner');

View File

@ -27,6 +27,7 @@ query getGroupRunners(
) {
edges {
webUrl
editUrl
node {
__typename
...RunnerNode

View File

@ -12,6 +12,7 @@ import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import {
@ -55,6 +56,7 @@ export default {
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
RunnerActionsCell,
},
props: {
registrationToken: {
@ -74,8 +76,8 @@ export default {
return {
search: fromUrlQueryToSearch(),
runners: {
webUrls: [],
items: [],
urlsById: {},
pageInfo: {},
},
};
@ -91,12 +93,23 @@ export default {
return this.variables;
},
update(data) {
const { runners } = data?.group || {};
const { edges = [], pageInfo = {} } = data?.group?.runners || {};
const items = [];
const urlsById = {};
edges.forEach(({ node, webUrl, editUrl }) => {
items.push(node);
urlsById[node.id] = {
web: webUrl,
edit: editUrl,
};
});
return {
webUrls: runners?.edges.map(({ webUrl }) => webUrl) || [],
items: runners?.edges.map(({ node }) => node) || [],
pageInfo: runners?.pageInfo || {},
items,
urlsById,
pageInfo,
};
},
error(error) {
@ -222,6 +235,12 @@ export default {
}
return null;
},
webUrl(runner) {
return this.runners.urlsById[runner.id]?.web;
},
editUrl(runner) {
return this.runners.urlsById[runner.id]?.edit;
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
@ -273,13 +292,20 @@ export default {
</div>
<template v-else>
<runner-list :runners="runners.items" :loading="runnersLoading">
<template #runner-name="{ runner, index }">
<gl-link :href="runners.webUrls[index]">
<template #runner-name="{ runner }">
<gl-link :href="webUrl(runner)">
<runner-name :runner="runner" />
</gl-link>
</template>
<template #runner-actions-cell="{ runner }">
<runner-actions-cell :runner="runner" :edit-url="editUrl(runner)" />
</template>
</runner-list>
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
<runner-pagination
v-model="search.pagination"
class="gl-mt-3"
:page-info="runners.pageInfo"
/>
</template>
</div>
</template>

View File

@ -3,13 +3,11 @@ import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
import simplePoll from '../../../lib/utils/simple_poll';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import rebaseQuery from '../../queries/states/rebase.query.graphql';
import statusIcon from '../mr_widget_status_icon.vue';
import { REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY } from '../../constants';
export default {
name: 'MRWidgetRebase',
@ -28,7 +26,6 @@ export default {
components: {
statusIcon,
GlSkeletonLoader,
ActionsButton,
GlButton,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
@ -47,7 +44,6 @@ export default {
state: {},
isMakingRequest: false,
rebasingError: null,
selectedRebaseAction: REBASE_BUTTON_KEY,
};
},
computed: {
@ -93,28 +89,6 @@ export default {
fastForwardMergeText() {
return __('Merge blocked: the source branch must be rebased onto the target branch.');
},
actions() {
return [this.rebaseAction, this.rebaseWithoutCiAction].filter((action) => action);
},
rebaseAction() {
return {
key: REBASE_BUTTON_KEY,
text: __('Rebase'),
secondaryText: __('Rebases and triggers a pipeline'),
attrs: {
'data-qa-selector': 'mr_rebase_button',
},
handle: () => this.rebase(),
};
},
rebaseWithoutCiAction() {
return {
key: REBASE_WITHOUT_CI_BUTTON_KEY,
text: __('Rebase without CI'),
secondaryText: __('Performs a rebase but skips triggering a new pipeline'),
handle: () => this.rebase({ skipCi: true }),
};
},
},
methods: {
rebase({ skipCi = false } = {}) {
@ -138,8 +112,8 @@ export default {
}
});
},
selectRebaseAction(key) {
this.selectedRebaseAction = key;
rebaseWithoutCi() {
return this.rebase({ skipCi: true });
},
checkRebaseStatus(continuePolling, stopPolling) {
this.service
@ -198,10 +172,10 @@ export default {
>
<div
v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
class="accept-merge-holder clearfix js-toggle-container accept-action media space-children"
class="accept-merge-holder clearfix js-toggle-container accept-action media space-children gl-align-items-center"
>
<gl-button
v-if="!glFeatures.restructuredMrWidget && !showRebaseWithoutCi"
v-if="!glFeatures.restructuredMrWidget"
:loading="isMakingRequest"
variant="confirm"
data-qa-selector="mr_rebase_button"
@ -210,14 +184,16 @@ export default {
>
{{ __('Rebase') }}
</gl-button>
<actions-button
<gl-button
v-if="!glFeatures.restructuredMrWidget && showRebaseWithoutCi"
:actions="actions"
:selected-key="selectedRebaseAction"
:loading="isMakingRequest"
variant="confirm"
category="primary"
@select="selectRebaseAction"
/>
category="secondary"
data-testid="rebase-without-ci-button"
@click="rebaseWithoutCi"
>
{{ __('Rebase without pipeline') }}
</gl-button>
<span
v-if="!rebasingError"
:class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"

View File

@ -166,6 +166,3 @@ export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500';
export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700';
export { STATE_MACHINE };
export const REBASE_BUTTON_KEY = 'rebase';
export const REBASE_WITHOUT_CI_BUTTON_KEY = 'rebaseWithoutCi';

View File

@ -143,7 +143,7 @@ module UploadsActions
end
def bypass_auth_checks_on_uploads?
if ::Feature.enabled?(:enforce_auth_checks_on_uploads, default_enabled: :yaml)
if ::Feature.enabled?(:enforce_auth_checks_on_uploads, project, default_enabled: :yaml)
false
else
action_name == 'show' && embeddable?

View File

@ -4,7 +4,7 @@ class Groups::UploadsController < Groups::ApplicationController
include UploadsActions
include WorkhorseRequest
skip_before_action :group, if: -> { bypass_auth_checks_on_uploads? }
skip_before_action :group, if: -> { action_name == 'show' && embeddable? }
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]

View File

@ -253,6 +253,18 @@ the mitigations for a new feature.
- [More details](https://dev.gitlab.org/gitlab/gitlabhq/-/merge_requests/2530/diffs)
#### URL blocker & validation libraries
[`Gitlab::UrlBlocker`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/url_blocker.rb) can be used to validate that a
provided URL meets a set of constraints. Importantly, when `dns_rebind_protection` is `true`, the method returns a known-safe URI where the hostname
has been replaced with an IP address. This prevents DNS rebinding attacks, because the DNS record has been resolved. However, if we ignore this returned
value, we **will not** be protected against DNS rebinding.
This is the case with validators such as the `AddressableUrlValidator` (called with `validates :url, addressable_url: {opts}` or `public_url: {opts}`).
Validation errors are only raised when validations are called, for example when a record is created or saved. If we ignore the value returned by the validation
when persisting the record, **we need to recheck** its validity before using it. You can learn more about [Time of Check to Time of Use bugs](#time-of-check-to-time-of-use-bugs) in a later section
of these guidelines.
#### Feature-specific mitigations
For situations in which an allowlist or GitLab:HTTP cannot be used, it will be necessary to implement mitigations directly in the feature. It is best to validate the destination IP addresses themselves, not just domain names, as DNS can be controlled by the attacker. Below are a list of mitigations that should be implemented.
@ -270,9 +282,9 @@ There are many tricks to bypass common SSRF validations. If feature-specific mit
- `169.254.0.0/16`
- In particular, for GCP: `metadata.google.internal` -> `169.254.169.254`
- For HTTP connections: Disable redirects or validate the redirect destination
- To mitigate DNS rebinding attacks, validate and use the first IP address received
- To mitigate DNS rebinding attacks, validate and use the first IP address received.
See [`url_blocker_spec.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/spec/lib/gitlab/url_blocker_spec.rb) for examples of SSRF payloads
See [`url_blocker_spec.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/spec/lib/gitlab/url_blocker_spec.rb) for examples of SSRF payloads. See [time of check to time of use bugs](#time-of-check-to-time-of-use-bugs) to learn more about DNS rebinding's class of bug.
## XSS guidelines
@ -1120,3 +1132,52 @@ func printZipContents(src string) error {
return nil
}
```
## Time of check to time of use bugs
Time of check to time of use, or TOCTOU, is a class of error which occur when the state of something changes unexpectedly partway during a process.
More specifically, it's when the property you checked and validated has changed when you finally get around to using that property.
These types of bugs are often seen in environments which allow multi-threading and concurrency, like filesystems and distributed web applications; these are a type of race condition. TOCTOU also occurs when state is checked and stored, then after a period of time that state is relied on without re-checking its accuracy and/or validity.
### Examples
**Example 1:** you have a model which accepts a URL as input. When the model is created you verify that the URL's host resolves to a public IP address, to prevent attackers making internal network calls. But DNS records can change ([DNS rebinding](#server-side-request-forgery-ssrf)]). An attacker updates the DNS record to `127.0.0.1`, and when your code resolves those URL's host it results in sending a potentially malicious request to a server on the internal network. The property was valid at the "time of check", but invalid and malicious at "time of use".
GitLab-specific example can be found in [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/214401) where, although `Gitlab::UrlBlocker.validate!` was called, the returned value was not used. This made it vulnerable to TOCTOU bug and SSRF protection bypass through [DNS rebinding](#server-side-request-forgery-ssrf). The fix was to [use the validated IP address](https://gitlab.com/gitlab-org/gitlab/-/commit/7af8abd4df9a98f7a1ae7c4ec9840d0a7a8c684d).
**Example 2:** you have a feature which schedules jobs. When the user schedules the job, they have permission to do so. But imagine if, between the time they schedule the job and the time it is run, their permissions are restricted. Unless you re-check permissions at time of use, you could inadvertently allow unauthorized activity.
**Example 3:** you need to fetch a remote file, and perform a `HEAD` request to get and validate the content length and content type. When you subsequently make a `GET` request, though, the file delivered is a different size or different file type. (This is stretching the definition of TOCTOU, but things _have_ changed between time of check and time of use).
**Example 4:** you allow users to upvote a comment if they haven't already. The server is multi-threaded, and you aren't using transactions or an applicable database index. By repeatedly clicking upvote in quick succession a malicious user is able to add multiple upvotes: the requests arrive at the same time, the checks run in parallel and confirm that no upvote exists yet, and so each upvote is written to the database.
Here's some pseudocode showing an example of a potential TOCTOU bug:
```ruby
def upvote(comment, user)
# The time between calling .exists? and .create can lead to TOCTOU,
# particulary if .create is a slow method, or runs in a background job
if Upvote.exists?(comment: comment, user: user)
return
else
Upvote.create(comment: comment, user: user)
end
end
```
### Prevention & defense
- Assume values will change between the time you validate them and the time you use them.
- Perform checks as close to execution time as possible.
- Perform checks after your operation completes.
- Use your framework's validations and database features to impose constraints and atomic reads and writes.
- Read about [Server Side Request Forgery (SSRF) and DNS rebinding](#server-side-request-forgery-ssrf)
An example of well implemented `Gitlab::UrlBlocker.validate!` call that prevents TOCTOU bug:
1. [Preventing DNS rebinding in Gitea importer](https://gitlab.com/gitlab-org/gitlab/-/commit/7af8abd4df9a98f7a1ae7c4ec9840d0a7a8c684d)
### Resources
- [CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition](https://cwe.mitre.org/data/definitions/367.html)

View File

@ -43,7 +43,7 @@ You can also rebase without running a CI/CD pipeline.
The rebase action is also available as a [quick action command: `/rebase`](../../../topics/git/git_rebase.md#rebase-from-the-gitlab-ui).
![Fast forward merge request](img/ff_merge_rebase_v14_7.png)
![Fast forward merge request](img/ff_merge_rebase_v14_9.png)
If the target branch is ahead of the source branch and a conflict free rebase is
not possible, you need to rebase the

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -26531,9 +26531,6 @@ msgstr ""
msgid "PerformanceBar|wall"
msgstr ""
msgid "Performs a rebase but skips triggering a new pipeline"
msgstr ""
msgid "Period in seconds"
msgstr ""
@ -30041,10 +30038,7 @@ msgstr ""
msgid "Rebase source branch on the target branch."
msgstr ""
msgid "Rebase without CI"
msgstr ""
msgid "Rebases and triggers a pipeline"
msgid "Rebase without pipeline"
msgstr ""
msgid "Recaptcha verified?"

View File

@ -19,6 +19,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
import {
@ -188,6 +189,21 @@ describe('AdminRunnersApp', () => {
expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`);
});
it('renders runner actions for each runner', async () => {
createComponent({ mountFn: mountExtended });
await waitForPromises();
const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell);
const runner = runnersData.data.runners.nodes[0];
expect(runnerActions.props()).toEqual({
runner,
editUrl: runner.editAdminUrl,
});
});
it('requests the runners with no filters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: undefined,

View File

@ -15,9 +15,10 @@ describe('RunnerActionsCell', () => {
const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton);
const findDeleteBtn = () => wrapper.findComponent(RunnerDeleteButton);
const createComponent = (runner = {}, options) => {
const createComponent = ({ runner = {}, ...props } = {}) => {
wrapper = shallowMountExtended(RunnerActionsCell, {
propsData: {
editUrl: mockRunner.editAdminUrl,
runner: {
id: mockRunner.id,
shortSha: mockRunner.shortSha,
@ -25,8 +26,8 @@ describe('RunnerActionsCell', () => {
userPermissions: mockRunner.userPermissions,
...runner,
},
...props,
},
...options,
});
};
@ -43,18 +44,20 @@ describe('RunnerActionsCell', () => {
it('Does not render the runner edit link when user cannot update', () => {
createComponent({
userPermissions: {
...mockRunner.userPermissions,
updateRunner: false,
runner: {
userPermissions: {
...mockRunner.userPermissions,
updateRunner: false,
},
},
});
expect(findEditBtn().exists()).toBe(false);
});
it('Does not render the runner edit link when editAdminUrl is not provided', () => {
it('Does not render the runner edit link when editUrl is not provided', () => {
createComponent({
editAdminUrl: null,
editUrl: null,
});
expect(findEditBtn().exists()).toBe(false);
@ -70,9 +73,11 @@ describe('RunnerActionsCell', () => {
it('Does not render the runner pause button when user cannot update', () => {
createComponent({
userPermissions: {
...mockRunner.userPermissions,
updateRunner: false,
runner: {
userPermissions: {
...mockRunner.userPermissions,
updateRunner: false,
},
},
});
@ -89,9 +94,11 @@ describe('RunnerActionsCell', () => {
it('Does not render the runner delete button when user cannot delete', () => {
createComponent({
userPermissions: {
...mockRunner.userPermissions,
deleteRunner: false,
runner: {
userPermissions: {
...mockRunner.userPermissions,
deleteRunner: false,
},
},
});

View File

@ -6,9 +6,6 @@ import {
} from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes;
@ -24,13 +21,14 @@ describe('RunnerList', () => {
const findCell = ({ row = 0, fieldKey }) =>
extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`));
const createComponent = ({ props = {} } = {}, mountFn = shallowMountExtended) => {
const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => {
wrapper = mountFn(RunnerList, {
propsData: {
runners: mockRunners,
activeRunnersCount: mockActiveRunnersCount,
...props,
},
...options,
});
};
@ -91,11 +89,31 @@ describe('RunnerList', () => {
expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String));
// Actions
const actions = findCell({ fieldKey: 'actions' });
expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true);
});
expect(actions.findComponent(RunnerEditButton).exists()).toBe(true);
expect(actions.findComponent(RunnerPauseButton).exists()).toBe(true);
expect(actions.findComponent(RunnerDeleteButton).exists()).toBe(true);
describe('Scoped cell slots', () => {
it('Render #runner-name slot in "summary" cell', () => {
createComponent(
{
scopedSlots: { 'runner-name': ({ runner }) => `Summary: ${runner.id}` },
},
mountExtended,
);
expect(findCell({ fieldKey: 'summary' }).text()).toContain(`Summary: ${mockRunners[0].id}`);
});
it('Render #runner-actions-cell slot in "actions" cell', () => {
createComponent(
{
scopedSlots: { 'runner-actions-cell': ({ runner }) => `Actions: ${runner.id}` },
},
mountExtended,
);
expect(findCell({ fieldKey: 'actions' }).text()).toBe(`Actions: ${mockRunners[0].id}`);
});
});
describe('Table data formatting', () => {

View File

@ -1,5 +1,5 @@
import Vue, { nextTick } from 'vue';
import { GlLink } from '@gitlab/ui';
import { GlButton, GlLink } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
@ -30,6 +30,7 @@ import {
PARAM_KEY_STATUS,
STATUS_ACTIVE,
RUNNER_PAGE_SIZE,
I18N_EDIT,
} from '~/runner/constants';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import getGroupRunnersCountQuery from '~/runner/graphql/get_group_runners_count.query.graphql';
@ -42,7 +43,8 @@ Vue.use(VueApollo);
const mockGroupFullPath = 'group1';
const mockRegistrationToken = 'AABBCC';
const mockGroupRunnersLimitedCount = groupRunnersData.data.group.runners.edges.length;
const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges;
const mockGroupRunnersLimitedCount = mockGroupRunnersEdges.length;
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
@ -60,6 +62,7 @@ describe('GroupRunnersApp', () => {
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`));
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () =>
findRunnerPagination().findByLabelText('Go to previous page');
@ -156,20 +159,7 @@ describe('GroupRunnersApp', () => {
it('shows the runners list', () => {
const runners = findRunnerList().props('runners');
expect(runners).toEqual(groupRunnersData.data.group.runners.edges.map(({ node }) => node));
});
it('runner item links to the runner group page', async () => {
const { webUrl, node } = groupRunnersData.data.group.runners.edges[0];
const { id, shortSha } = node;
createComponent({ mountFn: mountExtended });
await waitForPromises();
const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink);
expect(runnerLink.text()).toBe(`#${getIdFromGraphQLId(id)} (${shortSha})`);
expect(runnerLink.attributes('href')).toBe(webUrl);
expect(runners).toEqual(mockGroupRunnersEdges.map(({ node }) => node));
});
it('requests the runners with group path and no other filters', () => {
@ -196,6 +186,34 @@ describe('GroupRunnersApp', () => {
);
});
describe('Single runner row', () => {
const { webUrl, editUrl, node } = mockGroupRunnersEdges[0];
const { id: graphqlId, shortSha } = node;
const id = getIdFromGraphQLId(graphqlId);
beforeEach(async () => {
createComponent({ mountFn: mountExtended });
await waitForPromises();
});
it('view link is displayed correctly', () => {
const viewLink = findRunnerRow(id).findByTestId('td-summary').findComponent(GlLink);
expect(viewLink.text()).toBe(`#${id} (${shortSha})`);
expect(viewLink.attributes('href')).toBe(webUrl);
});
it('edit link is displayed correctly', () => {
const editLink = findRunnerRow(id).findByTestId('td-actions').findComponent(GlButton);
expect(editLink.attributes()).toMatchObject({
'aria-label': I18N_EDIT,
href: editUrl,
});
});
});
describe('when a filter is preselected', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`);

View File

@ -2,11 +2,6 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import WidgetRebase from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
import {
REBASE_BUTTON_KEY,
REBASE_WITHOUT_CI_BUTTON_KEY,
} from '~/vue_merge_request_widget/constants';
let wrapper;
@ -38,8 +33,8 @@ function createWrapper(propsData, mergeRequestWidgetGraphql, rebaseWithoutCiUi)
describe('Merge request widget rebase component', () => {
const findRebaseMessage = () => wrapper.find('[data-testid="rebase-message"]');
const findRebaseMessageText = () => findRebaseMessage().text();
const findRebaseButtonActions = () => wrapper.find(ActionsButton);
const findStandardRebaseButton = () => wrapper.find('[data-testid="standard-rebase-button"]');
const findRebaseWithoutCiButton = () => wrapper.find('[data-testid="rebase-without-ci-button"]');
afterEach(() => {
wrapper.destroy();
@ -112,7 +107,7 @@ describe('Merge request widget rebase component', () => {
expect(findRebaseMessageText()).toContain('Something went wrong!');
});
describe('Rebase button with flag rebaseWithoutCiUi', () => {
describe('Rebase buttons with flag rebaseWithoutCiUi', () => {
beforeEach(() => {
createWrapper(
{
@ -130,30 +125,13 @@ describe('Merge request widget rebase component', () => {
);
});
it('rebase button with actions is rendered', () => {
expect(findRebaseButtonActions().exists()).toBe(true);
expect(findStandardRebaseButton().exists()).toBe(false);
});
it('has rebase and rebase without CI actions', () => {
const actionNames = findRebaseButtonActions()
.props('actions')
.map((action) => action.key);
expect(actionNames).toStrictEqual([REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY]);
});
it('defaults to rebase action', () => {
expect(findRebaseButtonActions().props('selectedKey')).toStrictEqual(REBASE_BUTTON_KEY);
it('renders both buttons', () => {
expect(findRebaseWithoutCiButton().exists()).toBe(true);
expect(findStandardRebaseButton().exists()).toBe(true);
});
it('starts the rebase when clicking', async () => {
// ActionButtons use the actions props instead of emitting
// a click event, therefore simulating the behavior here:
findRebaseButtonActions()
.props('actions')
.find((x) => x.key === REBASE_BUTTON_KEY)
.handle();
findStandardRebaseButton().vm.$emit('click');
await nextTick();
@ -161,12 +139,7 @@ describe('Merge request widget rebase component', () => {
});
it('starts the CI-skipping rebase when clicking on "Rebase without CI"', async () => {
// ActionButtons use the actions props instead of emitting
// a click event, therefore simulating the behavior here:
findRebaseButtonActions()
.props('actions')
.find((x) => x.key === REBASE_WITHOUT_CI_BUTTON_KEY)
.handle();
findRebaseWithoutCiButton().vm.$emit('click');
await nextTick();
@ -193,7 +166,7 @@ describe('Merge request widget rebase component', () => {
it('standard rebase button is rendered', () => {
expect(findStandardRebaseButton().exists()).toBe(true);
expect(findRebaseButtonActions().exists()).toBe(false);
expect(findRebaseWithoutCiButton().exists()).toBe(false);
});
it('calls rebase method with skip_ci false', () => {
@ -240,7 +213,7 @@ describe('Merge request widget rebase component', () => {
});
});
it('does not render the rebase actions button with rebaseWithoutCiUI flag enabled', () => {
it('does not render the "Rebase without pipeline" button with rebaseWithoutCiUI flag enabled', () => {
createWrapper(
{
mr: {
@ -254,7 +227,7 @@ describe('Merge request widget rebase component', () => {
{ rebaseWithoutCiUi: true },
);
expect(findRebaseButtonActions().exists()).toBe(false);
expect(findRebaseWithoutCiButton().exists()).toBe(false);
});
it('does not render the standard rebase button with rebaseWithoutCiUI flag disabled', () => {

View File

@ -211,10 +211,22 @@ RSpec.shared_examples 'handle uploads' do
stub_feature_flags(enforce_auth_checks_on_uploads: true)
end
it "responds with status 302" do
it "responds with appropriate status" do
show_upload
expect(response).to have_gitlab_http_status(:redirect)
# We're switching here based on the class due to the feature
# flag :enforce_auth_checks_on_uploads switching on project.
# When it is enabled fully, we will apply the code it guards
# to both Projects::UploadsController as well as
# Groups::UploadsController.
#
# https://gitlab.com/gitlab-org/gitlab/-/issues/352291
#
if model.instance_of?(Group)
expect(response).to have_gitlab_http_status(:ok)
else
expect(response).to have_gitlab_http_status(:redirect)
end
end
end
@ -305,7 +317,19 @@ RSpec.shared_examples 'handle uploads' do
it "responds with status 404" do
show_upload
expect(response).to have_gitlab_http_status(:not_found)
# We're switching here based on the class due to the feature
# flag :enforce_auth_checks_on_uploads switching on
# project. When it is enabled fully, we will apply the
# code it guards to both Projects::UploadsController as
# well as Groups::UploadsController.
#
# https://gitlab.com/gitlab-org/gitlab/-/issues/352291
#
if model.instance_of?(Group)
expect(response).to have_gitlab_http_status(:ok)
else
expect(response).to have_gitlab_http_status(:not_found)
end
end
end