Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-08-30 18:09:50 +00:00
parent 4b1fc3dc32
commit 6619ed911f
105 changed files with 1747 additions and 568 deletions

View File

@ -726,6 +726,7 @@ Gitlab/NamespacedClass:
- 'app/validators/top_level_group_validator.rb'
- 'app/validators/untrusted_regexp_validator.rb'
- 'app/validators/x509_certificate_credentials_validator.rb'
- 'app/validators/bytesize_validator.rb'
- 'app/workers/admin_email_worker.rb'
- 'app/workers/approve_blocked_pending_approval_users_worker.rb'
- 'app/workers/archive_trace_worker.rb'

View File

@ -2,6 +2,28 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 15.3.2 (2022-08-30)
### Security (17 changes)
- [No overriding methods for Sawyer class](gitlab-org/security/gitlab@397aa9e269676f4ab3dfba4c3ba8fef131b5b4bd) ([merge request](gitlab-org/security/gitlab!2754))
- [Update Oj to v3.13.21](gitlab-org/security/gitlab@15f86c00b579ad1b4aeedd395f9239e8229c6f8b) ([merge request](gitlab-org/security/gitlab!2730))
- [Prevent long loops when generating suggested branch name](gitlab-org/security/gitlab@1479c9e2a0444794ea274b07e0f59e8a50ced6ee) ([merge request](gitlab-org/security/gitlab!2743))
- [IDOR in Zentao integration issue show page](gitlab-org/security/gitlab@92fdf89045bf294d4ee0338ba3f26c91094a073e) ([merge request](gitlab-org/security/gitlab!2740))
- [Patch VULNDB-255039 (potential Rack cache poisoning)](gitlab-org/security/gitlab@383c926cc8aa4e2c4273556a181e1ddc1b71049f) ([merge request](gitlab-org/security/gitlab!2697))
- [HTML escape the label background color](gitlab-org/security/gitlab@1e43656560fbc13907af72d5d4f696df95d7f49c) ([merge request](gitlab-org/security/gitlab!2719))
- [Sandbox jupyter notebook HTML output](gitlab-org/security/gitlab@3ade5f2fadbb0c15d9e5a14306d0a79136a8f23e) ([merge request](gitlab-org/security/gitlab!2710))
- [Fix unauthorized GFM references in Incident Timeline](gitlab-org/security/gitlab@2e18b59472b5a43921d39433e60038b0f254d123) ([merge request](gitlab-org/security/gitlab!2707))
- [Optimize handling repositories with huge trees](gitlab-org/security/gitlab@4bfaca71c8d8f663242138049cf5639e69326bbb) ([merge request](gitlab-org/security/gitlab!2706))
- [Parse commit trailers without using regexp](gitlab-org/security/gitlab@c15b2cd9b5e572a9bbc7c0c5cb7c9511f1a04ead) ([merge request](gitlab-org/security/gitlab!2699))
- [Check for pathological markdown input](gitlab-org/security/gitlab@2fd5e1133e1acd82cdb524f059b554976cd68f51) ([merge request](gitlab-org/security/gitlab!2733))
- [Replaced smooshpack to fix the vulnerability in LivePreview](gitlab-org/security/gitlab@114637f8f0d9add00914ac3e4562419b0f1b4f63) ([merge request](gitlab-org/security/gitlab!2739))
- [Update package auth for group IP allowlist](gitlab-org/security/gitlab@7e830349a8425dbab65ce92d3e8ebd0afa734381) ([merge request](gitlab-org/security/gitlab!2686))
- [Don't show pipeline status](gitlab-org/security/gitlab@1b5fbb9bcb4dde12a2af075e45407cbc6109494d) ([merge request](gitlab-org/security/gitlab!2712))
- [Sanitize img attributes in Banzai::Filter::ImageLinkFilter](gitlab-org/security/gitlab@22ece3568d6b3aed305ed97aab9fdbb22ca068e8) ([merge request](gitlab-org/security/gitlab!2722))
- [Validate description length for snippets](gitlab-org/security/gitlab@24592d39d7b8956a0e712026e5b988a82d37e771) ([merge request](gitlab-org/security/gitlab!2702))
- [Prevent brute force vuln for Git over HTTP(S) requests](gitlab-org/security/gitlab@fcff307eff525d15e835e65e0e3e3a2395f0b840) ([merge request](gitlab-org/security/gitlab!2716))
## 15.3.1 (2022-08-22)
### Security (1 change)
@ -613,6 +635,29 @@ entry.
- [Remove FF import_release_authors_from_github](gitlab-org/gitlab@c4d6871e4438a1626d688856903778623138f671) ([merge request](gitlab-org/gitlab!92686))
- [Remove unused feature](gitlab-org/gitlab@0ef95d341e4a15150d6ccb3d104ebbe064aa062a) ([merge request](gitlab-org/gitlab!92753))
## 15.2.4 (2022-08-30)
### Security (18 changes)
- [No overriding methods for Sawyer class](gitlab-org/security/gitlab@fafcaf91c510ace5c3fc845197fa71d2ad8943cc) ([merge request](gitlab-org/security/gitlab!2755))
- [Update Oj to v3.13.21](gitlab-org/security/gitlab@e14f62112f51315288f3f08108b59cf40ab5635e) ([merge request](gitlab-org/security/gitlab!2729))
- [Bump yajl-ruby gem version](gitlab-org/security/gitlab@ad7469e802aff36989276bd77afcebf9bcb8a545) ([merge request](gitlab-org/security/gitlab!2689))
- [Prevent long loops when generating suggested branch name](gitlab-org/security/gitlab@f8f1631a7751b40444debbd69188187c895d2ad6) ([merge request](gitlab-org/security/gitlab!2744))
- [IDOR in Zentao integration issue show page](gitlab-org/security/gitlab@01004871400564e5b18a2efa4f6d87c8ca37db5c) ([merge request](gitlab-org/security/gitlab!2741))
- [Patch VULNDB-255039 (potential Rack cache poisoning)](gitlab-org/security/gitlab@a951318f5870e8f35c742eab58132c63d6d36198) ([merge request](gitlab-org/security/gitlab!2694))
- [HTML escape the label background color](gitlab-org/security/gitlab@de115e3b0896aa1504882d3230b5427506fee3e2) ([merge request](gitlab-org/security/gitlab!2720))
- [Sandbox jupyter notebook HTML output](gitlab-org/security/gitlab@67aeba4ae4c95d2668d0428cb66d263ee4247b68) ([merge request](gitlab-org/security/gitlab!2711))
- [Fix unauthorized GFM references in Incident Timeline](gitlab-org/security/gitlab@f091bc238efa1d669c1257aa146339f4b1134a0c) ([merge request](gitlab-org/security/gitlab!2708))
- [Optimize handling repositories with huge trees](gitlab-org/security/gitlab@9969c2cabccef2367631498f38ab8d0b19cf9da3) ([merge request](gitlab-org/security/gitlab!2666))
- [Parse commit trailers without using regexp](gitlab-org/security/gitlab@9bd64457525313a949f151fd27f2954ff71e399d) ([merge request](gitlab-org/security/gitlab!2700))
- [Check for pathological markdown input](gitlab-org/security/gitlab@c05642874c38e4d914297ad788a07c42b77b6b1e) ([merge request](gitlab-org/security/gitlab!2732))
- [Replaced smooshpack to fix the vulnerability in LivePreview](gitlab-org/security/gitlab@e48df65563c6c66fd6d89fb7bf626bdf8b465cc0) ([merge request](gitlab-org/security/gitlab!2662))
- [Update package auth for group IP allowlist](gitlab-org/security/gitlab@eb7b9e646732cc3590e00d5694d5a662e71c9f99) ([merge request](gitlab-org/security/gitlab!2684))
- [Don't show pipeline status](gitlab-org/security/gitlab@a5962d9ee7aec4f86a982f2d686a690806df6f15) ([merge request](gitlab-org/security/gitlab!2680))
- [Sanitize img attributes in Banzai::Filter::ImageLinkFilter](gitlab-org/security/gitlab@ee68b29c2199e1c399a4d0065ed53c50592e54a0) ([merge request](gitlab-org/security/gitlab!2676))
- [Validate description length for snippets](gitlab-org/security/gitlab@e9e4c3b3109590a5c12ecb2f25e4641dd408ce36) ([merge request](gitlab-org/security/gitlab!2703))
- [Prevent brute force vuln for Git over HTTP(S) requests](gitlab-org/security/gitlab@aab24e532b8c0b9e8acc90e7954434519e19b908) ([merge request](gitlab-org/security/gitlab!2717))
## 15.2.3 (2022-08-22)
### Security (2 changes)
@ -1336,6 +1381,28 @@ entry.
- [Update GitLab Runner Helm Chart to 0.42.0](gitlab-org/gitlab@cc89200f498fe216864914c79b5b0d1d578edab3) ([merge request](gitlab-org/gitlab!90605))
- [Address database documentation Vale warningss](gitlab-org/gitlab@e5f9a089766bace046d3bbd760a2979865a4bbc0) by @cgives ([merge request](gitlab-org/gitlab!90093))
## 15.1.6 (2022-08-30)
### Security (17 changes)
- [No overriding methods for Sawyer class](gitlab-org/security/gitlab@720a17d03791c298d193b2d49d322a5f259bb6f2) ([merge request](gitlab-org/security/gitlab!2756))
- [Bump yajl-ruby gem version](gitlab-org/security/gitlab@acb8bee73354ddbd7a7a52e3d09c870d1cd99e27) ([merge request](gitlab-org/security/gitlab!2690))
- [Prevent long loops when generating suggested branch name](gitlab-org/security/gitlab@e331ecf658de25901def2ea4a368104b82a0109c) ([merge request](gitlab-org/security/gitlab!2745))
- [IDOR in Zentao integration issue show page](gitlab-org/security/gitlab@0a238baf6a1d4aa0bc834448aefaf756d594a7be) ([merge request](gitlab-org/security/gitlab!2742))
- [Patch VULNDB-255039 (potential Rack cache poisoning)](gitlab-org/security/gitlab@1f5ecd95b3631c8352ff57cf4bee23d26aa51ecc) ([merge request](gitlab-org/security/gitlab!2695))
- [HTML escape the label background color](gitlab-org/security/gitlab@470b75a53ea4383ea30de5a482d39b322f87dfa2) ([merge request](gitlab-org/security/gitlab!2721))
- [Sandbox jupyter notebook HTML output](gitlab-org/security/gitlab@72089898a60de7f17c19a2fa9d4f1330d3052b52) ([merge request](gitlab-org/security/gitlab!2713))
- [Fix unauthorized GFM references in Incident Timeline](gitlab-org/security/gitlab@c62408682ed47bc2e5f93585a5b4e92e8cfebf9f) ([merge request](gitlab-org/security/gitlab!2709))
- [Optimize handling repositories with huge trees](gitlab-org/security/gitlab@396f20e019a9888d1645e9345a82fdf21153bf76) ([merge request](gitlab-org/security/gitlab!2667))
- [Parse commit trailers without using regexp](gitlab-org/security/gitlab@b377a1ecbb37c5359b2c2a0ecfbd911654664700) ([merge request](gitlab-org/security/gitlab!2701))
- [Check for pathological markdown input](gitlab-org/security/gitlab@e3a1376ec70d8d60f11a380cce6e0b3c35f68646) ([merge request](gitlab-org/security/gitlab!2731))
- [Replaced smooshpack to fix the vulnerability in LivePreview](gitlab-org/security/gitlab@d520ffd2a5a75d33ac98c39cd2f2fe623b0e1115) ([merge request](gitlab-org/security/gitlab!2664))
- [Update package auth for group IP allowlist](gitlab-org/security/gitlab@12bb8656bdaa9a7502c0a1b77c12fefb72677ba1) ([merge request](gitlab-org/security/gitlab!2685))
- [Don't show pipeline status](gitlab-org/security/gitlab@7fb43f899f2342704bda81643f8375a126efc2ae) ([merge request](gitlab-org/security/gitlab!2679))
- [Sanitize img attributes in Banzai::Filter::ImageLinkFilter](gitlab-org/security/gitlab@594fa5874fb7cc6b6588bbf8aff2f04b8acbbfd0) ([merge request](gitlab-org/security/gitlab!2677))
- [Validate description length for snippets](gitlab-org/security/gitlab@94ae3d05741bc69b9307e5f58f0d61bf2566c21b) ([merge request](gitlab-org/security/gitlab!2704))
- [Prevent brute force vuln for Git over HTTP(S) requests](gitlab-org/security/gitlab@7b76542e197ea72289c881c312b3a519c8b28e63) ([merge request](gitlab-org/security/gitlab!2718))
## 15.1.5 (2022-08-22)
### Security (2 changes)

View File

@ -2,7 +2,7 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { listen } from 'codesandbox-api';
import { isEmpty, debounce } from 'lodash';
import { Manager } from 'smooshpack';
import { SandpackClient } from '@codesandbox/sandpack-client';
import { mapActions, mapGetters, mapState } from 'vuex';
import {
packageJsonPath,
@ -21,7 +21,7 @@ export default {
},
data() {
return {
manager: {},
client: {},
loading: false,
sandpackReady: false,
};
@ -94,11 +94,11 @@ export default {
this.sandpackReady = false;
eventHub.$off('ide.files.change', this.onFilesChangeCallback);
if (!isEmpty(this.manager)) {
this.manager.listener();
if (!isEmpty(this.client)) {
this.client.cleanup();
}
this.manager = {};
this.client = {};
if (this.listener) {
this.listener();
@ -120,7 +120,7 @@ export default {
return this.loadFileContent(this.mainEntry)
.then(() => this.$nextTick())
.then(() => {
this.initManager();
this.initClient();
this.listener = listen((e) => {
switch (e.type) {
@ -136,15 +136,15 @@ export default {
update() {
if (!this.sandpackReady) return;
if (isEmpty(this.manager)) {
if (isEmpty(this.client)) {
this.initPreview();
return;
}
this.manager.updatePreview(this.sandboxOpts);
this.client.updatePreview(this.sandboxOpts);
},
initManager() {
initClient() {
const { codesandboxBundlerUrl: bundlerURL } = this;
const settings = {
@ -155,7 +155,7 @@ export default {
...(bundlerURL ? { bundlerURL } : {}),
};
this.manager = new Manager('#ide-preview', this.sandboxOpts, settings);
this.client = new SandpackClient('#ide-preview', this.sandboxOpts, settings);
},
},
};
@ -164,7 +164,7 @@ export default {
<template>
<div class="preview h-100 w-100 d-flex flex-column gl-bg-white">
<template v-if="showPreview">
<navigator :manager="manager" />
<navigator :client="client" />
<div id="ide-preview"></div>
</template>
<div

View File

@ -8,7 +8,7 @@ export default {
GlLoadingIcon,
},
props: {
manager: {
client: {
type: Object,
required: true,
},
@ -51,7 +51,7 @@ export default {
onUrlChange(e) {
const lastPath = this.path;
this.path = e.url.replace(this.manager.bundlerURL, '') || '/';
this.path = e.url.replace(this.client.bundlerURL, '') || '/';
if (lastPath !== this.path) {
this.currentBrowsingIndex =
@ -79,7 +79,7 @@ export default {
},
visitPath(path) {
// eslint-disable-next-line vue/no-mutating-props
this.manager.iframe.src = `${this.manager.bundlerURL}${path}`;
this.client.iframe.src = `${this.client.bundlerURL}${path}`;
},
},
};

View File

@ -26,4 +26,8 @@ export const timelineListI18n = Object.freeze({
'Incident|Something went wrong while deleting the incident timeline event.',
),
deleteModal: s__('Incident|Are you sure you want to delete this event?'),
editError: s__('Incident|Error updating incident timeline event: %{error}'),
editErrorGeneric: s__(
'Incident|Something went wrong while updating the incident timeline event.',
),
});

View File

@ -33,9 +33,6 @@ export default {
clearForm() {
this.$refs.eventForm.clear();
},
focusDate() {
this.$refs.eventForm.focusDate();
},
updateCache(store, { data }) {
const { timelineEvent: event, errors } = data?.timelineEventCreate || {};
@ -110,7 +107,7 @@ export default {
<template>
<div
class="gl-relative gl-display-flex gl-align-items-center"
class="create-timeline-event gl-relative gl-display-flex gl-align-items-start"
:class="{ 'timeline-entry-vertical-line': hasTimelineEvents }"
>
<div
@ -121,8 +118,9 @@ export default {
</div>
<timeline-events-form
ref="eventForm"
:class="{ 'gl-border-gray-50 gl-border-t': hasTimelineEvents }"
:is-event-processed="createTimelineEventActive"
:has-timeline-events="hasTimelineEvents"
show-save-and-add
@save-event="createIncidentTimelineEvent"
@cancel="$emit('hide-new-timeline-events-form')"
/>

View File

@ -0,0 +1,47 @@
<script>
import { GlIcon } from '@gitlab/ui';
import TimelineEventsForm from './timeline_events_form.vue';
export default {
name: 'EditTimelineEvent',
components: {
TimelineEventsForm,
GlIcon,
},
props: {
event: {
type: Object,
required: true,
validator: (item) => ['occurredAt', 'note'].every((key) => item[key]),
},
editTimelineEventActive: {
type: Boolean,
required: true,
},
},
methods: {
saveEvent(eventDetails) {
this.$emit('handle-save-edit', { ...eventDetails, id: this.event.id }, false);
},
},
};
</script>
<template>
<div class="gl-relative gl-display-flex gl-align-items-center">
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
>
<gl-icon name="comment" class="note-icon" />
</div>
<timeline-events-form
ref="eventForm"
class="timeline-event-border"
:is-event-processed="editTimelineEventActive"
:previous-occurred-at="event.occurredAt"
:previous-note="event.note"
@save-event="saveEvent"
@cancel="$emit('hide-edit')"
/>
</div>
</template>

View File

@ -0,0 +1,13 @@
mutation UpdateTimelineEvent($input: TimelineEventUpdateInput!) {
timelineEventUpdate(input: $input) {
timelineEvent {
id
note
noteHtml
action
occurredAt
createdAt
}
errors
}
}

View File

@ -3,7 +3,7 @@ import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { timelineFormI18n } from './constants';
import { getUtcShiftedDateNow } from './utils';
import { getUtcShiftedDate } from './utils';
export default {
name: 'TimelineEventsForm',
@ -29,21 +29,32 @@ export default {
autofocusonshow,
},
props: {
hasTimelineEvents: {
showSaveAndAdd: {
type: Boolean,
required: true,
required: false,
default: false,
},
isEventProcessed: {
type: Boolean,
required: true,
},
previousOccurredAt: {
type: String,
required: false,
default: null,
},
previousNote: {
type: String,
required: false,
default: '',
},
},
data() {
// if occurredAt is undefined, returns "now" in UTC
const placeholderDate = getUtcShiftedDateNow();
// if occurredAt is null, returns "now" in UTC
const placeholderDate = getUtcShiftedDate(this.previousOccurredAt);
return {
timelineText: '',
timelineText: this.previousNote,
placeholderDate,
hourPickerInput: placeholderDate.getHours(),
minutePickerInput: placeholderDate.getMinutes(),
@ -51,7 +62,7 @@ export default {
};
},
computed: {
occurredAt() {
occurredAtString() {
const year = this.datePickerInput.getFullYear();
const month = this.datePickerInput.getMonth();
const day = this.datePickerInput.getDate();
@ -63,38 +74,36 @@ export default {
return utcDate.toISOString();
},
},
mounted() {
this.focusDate();
},
methods: {
clear() {
const newPlaceholderDate = getUtcShiftedDateNow();
const newPlaceholderDate = getUtcShiftedDate();
this.datePickerInput = newPlaceholderDate;
this.hourPickerInput = newPlaceholderDate.getHours();
this.minutePickerInput = newPlaceholderDate.getMinutes();
this.timelineText = '';
},
focusDate() {
this.$refs.datepicker.$el.focus();
this.$refs.datepicker.$el.querySelector('input').focus();
},
handleSave(addAnotherEvent) {
const eventDetails = {
const event = {
note: this.timelineText,
occurredAt: this.occurredAt,
occurredAt: this.occurredAtString,
};
this.$emit('save-event', eventDetails, addAnotherEvent);
this.$emit('save-event', event, addAnotherEvent);
},
},
};
</script>
<template>
<form class="gl-flex-grow-1 gl-border-gray-50" :class="{ 'gl-border-t': hasTimelineEvents }">
<form class="gl-flex-grow-1 gl-border-gray-50">
<div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row">
<gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5">
<gl-datepicker
id="incident-date"
ref="datepicker"
v-model="datePickerInput"
data-testid="input-datepicker"
/>
<gl-datepicker id="incident-date" ref="datepicker" v-model="datePickerInput" />
</gl-form-group>
<div class="gl-display-flex gl-mt-5">
<gl-form-group :label="__('Time')">
@ -163,6 +172,7 @@ export default {
{{ $options.i18n.save }}
</gl-button>
<gl-button
v-if="showSaveAndAdd"
variant="confirm"
category="secondary"
class="gl-mr-3 gl-ml-n2"
@ -174,7 +184,7 @@ export default {
<gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')">
{{ $options.i18n.cancel }}
</gl-button>
<div class="gl-border-b gl-pt-5"></div>
<div class="timeline-event-bottom-border"></div>
</gl-form-group>
</form>
</template>

View File

@ -8,6 +8,7 @@ export default {
name: 'IncidentTimelineEventListItem',
i18n: {
delete: __('Delete'),
edit: __('Edit'),
moreActions: __('More actions'),
timeUTC: __('%{time} UTC'),
},
@ -22,10 +23,6 @@ export default {
},
inject: ['canUpdate'],
props: {
isLastItem: {
type: Boolean,
required: true,
},
occurredAt: {
type: String,
required: true,
@ -50,15 +47,14 @@ export default {
};
</script>
<template>
<div class="gl-display-flex gl-align-items-center">
<div class="gl-display-flex gl-align-items-start">
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-n2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1"
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1"
>
<gl-icon :name="getEventIcon(action)" class="note-icon" />
</div>
<div
class="timeline-event-note gl-w-full gl-display-flex gl-flex-direction-row"
:class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }"
class="timeline-event-note timeline-event-border gl-w-full gl-display-flex gl-flex-direction-row"
data-testid="event-text-container"
>
<div>
@ -72,13 +68,16 @@ export default {
<gl-dropdown
v-if="canUpdate"
right
class="event-note-actions gl-ml-auto gl-align-self-center"
class="event-note-actions gl-ml-auto gl-align-self-start"
icon="ellipsis_v"
text-sr-only
:text="$options.i18n.moreActions"
category="tertiary"
no-caret
>
<gl-dropdown-item @click="$emit('edit')">
{{ $options.i18n.edit }}
</gl-dropdown-item>
<gl-dropdown-item @click="$emit('delete')">
{{ $options.i18n.delete }}
</gl-dropdown-item>

View File

@ -5,7 +5,9 @@ import { sprintf } from '~/locale';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import IncidentTimelineEventItem from './timeline_events_item.vue';
import EditTimelineEvent from './edit_timeline_event.vue';
import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql';
import editTimelineEvent from './graphql/queries/edit_timeline_event.mutation.graphql';
import { timelineListI18n } from './constants';
export default {
@ -13,6 +15,7 @@ export default {
i18n: timelineListI18n,
components: {
IncidentTimelineEventItem,
EditTimelineEvent,
},
props: {
timelineEventLoading: {
@ -26,6 +29,9 @@ export default {
default: () => [],
},
},
data() {
return { eventToEdit: null, editTimelineEventActive: false };
},
computed: {
dateGroupedEvents() {
const groupedEvents = new Map();
@ -44,11 +50,12 @@ export default {
},
},
methods: {
isLastItem(groups, groupIndex, events, eventIndex) {
if (groupIndex < groups.size - 1) {
return false;
}
return eventIndex === events.length - 1;
handleEditSelection(event) {
this.eventToEdit = event.id;
this.$emit('hide-new-incident-timeline-event-form');
},
hideEdit() {
this.eventToEdit = null;
},
handleDelete: ignoreWhilePending(async function handleDelete(event) {
const msg = this.$options.i18n.deleteModal;
@ -85,6 +92,38 @@ export default {
createAlert({ message: this.$options.i18n.deleteErrorGeneric, captureError: true, error });
}
}),
handleSaveEdit(eventDetails) {
this.editTimelineEventActive = true;
return this.$apollo
.mutate({
mutation: editTimelineEvent,
variables: {
input: {
id: eventDetails.id,
note: eventDetails.note,
occurredAt: eventDetails.occurredAt,
},
},
})
.then(({ data }) => {
this.editTimelineEventActive = false;
const errors = data.timelineEventUpdate?.errors;
if (errors.length) {
createAlert({
message: sprintf(this.$options.i18n.editError, { error: errors.join('. ') }, false),
});
} else {
this.hideEdit();
}
})
.catch((error) => {
createAlert({
message: this.$options.i18n.editErrorGeneric,
captureError: true,
error,
});
});
},
},
};
</script>
@ -92,9 +131,10 @@ export default {
<template>
<div class="issuable-discussion incident-timeline-events">
<div
v-for="([eventDate, events], groupIndex) in dateGroupedEvents"
v-for="[eventDate, events] in dateGroupedEvents"
:key="eventDate"
data-testid="timeline-group"
class="timeline-group"
>
<div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid">
<strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong>
@ -103,15 +143,25 @@ export default {
<li
v-for="(event, eventIndex) in events"
:key="eventIndex"
class="timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!"
class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-2! gl-pr-0!"
>
<edit-timeline-event
v-if="eventToEdit === event.id"
:key="`edit-${event.id}`"
ref="eventForm"
:event="event"
:edit-timeline-event-active="editTimelineEventActive"
@handle-save-edit="handleSaveEdit"
@hide-edit="hideEdit()"
/>
<incident-timeline-event-item
v-else
:key="event.id"
:action="event.action"
:occurred-at="event.occurredAt"
:note-html="event.noteHtml"
:is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)"
@delete="handleDelete(event)"
@edit="handleEditSelection(event)"
/>
</li>
</ul>

View File

@ -69,11 +69,8 @@ export default {
hideEventForm() {
this.isEventFormVisible = false;
},
async showEventForm() {
this.$refs.createEventForm.clearForm();
showEventForm() {
this.isEventFormVisible = true;
await this.$nextTick();
this.$refs.createEventForm.focusDate();
},
},
};
@ -94,7 +91,7 @@ export default {
@hide-new-timeline-events-form="hideEventForm"
/>
<create-timeline-event
v-show="isEventFormVisible"
v-if="isEventFormVisible"
ref="createEventForm"
:has-timeline-events="hasTimelineEvents"
class="timeline-event-note timeline-event-note-form"

View File

@ -21,13 +21,14 @@ export const getEventIcon = (actionName) => {
};
/**
* Returns a date shifted by the current timezone offset. Allows
* date.getHours() and similar to return UTC values.
*
* Returns a date shifted by the current timezone offset set to now
* by default but can accept an existing date as an ISO date string
* @param {string} ISOString
* @returns {Date}
*/
export const getUtcShiftedDateNow = () => {
const date = new Date();
export const getUtcShiftedDate = (ISOString = null) => {
const date = ISOString ? new Date(ISOString) : new Date();
date.setMinutes(date.getMinutes() + date.getTimezoneOffset());
return date;
};

View File

@ -40,6 +40,13 @@ export default {
<template>
<div class="output">
<prompt type="Out" :count="count" :show-output="showOutput" />
<div v-safe-html:[$options.safeHtmlConfig]="rawCode" class="gl-overflow-auto"></div>
<iframe
sandbox
:srcdoc="rawCode"
frameborder="0"
scrolling="no"
width="100%"
class="gl-overflow-auto"
></iframe>
</div>
</template>

View File

@ -13,9 +13,9 @@
a,
button {
padding: $gl-padding-8;
font-size: 14px;
line-height: 28px;
padding: $gl-spacing-scale-5 $gl-spacing-scale-4;
font-size: $gl-font-size;
line-height: $gl-line-height-16;
color: $gl-text-color-secondary;
border: 0;
white-space: nowrap;
@ -88,10 +88,6 @@
float: left;
}
li a {
padding: 16px 15px 11px;
}
/* Small devices (phones, 768px and lower) */
@include media-breakpoint-down(sm) {
width: 100%;

View File

@ -962,40 +962,26 @@
border-left: 2px solid $gray-50;
position: absolute;
left: 39px;
height: 80%;
height: calc(100% + #{$gl-spacing-scale-5});
top: -#{$gl-spacing-scale-5};
}
&:first-child::before,
&:last-child::after {
&:first-child::before {
content: none;
}
&:first-child {
&::after {
top: 50%;
top: $gl-spacing-scale-5;
height: calc(100% + #{$gl-spacing-scale-5});
}
}
&:last-child {
&:last-child,
&.create-timeline-event {
&::before {
bottom: 50%;
}
}
&:not(:first-child):not(:last-child) {
&::before {
top: -10%;
}
&::after {
bottom: -10%;
}
}
&.timeline-event-note-form {
&::before {
top: -15% !important; // Override default positioning
height: 20%;
top: - #{$gl-spacing-scale-5} !important; // Override default positioning
@include gl-h-8;
}
&::after {
@ -1007,3 +993,22 @@
.timeline-event-note-form {
padding-left: 20px;
}
.timeline-entry:not(:last-child) {
.timeline-event-border {
@include gl-pb-5;
@include gl-border-gray-50;
@include gl-border-1;
@include gl-border-b-solid;
}
}
.timeline-group:last-child {
.timeline-entry:last-child,
.create-timeline-event {
.timeline-event-bottom-border {
@include gl-border-b;
@include gl-pt-5;
}
}
}

View File

@ -37,22 +37,11 @@ class JwtController < ApplicationController
if @authentication_result.failed?
log_authentication_failed(login, @authentication_result)
render_unauthorized
render_access_denied
end
end
rescue Gitlab::Auth::MissingPersonalAccessTokenError
render_missing_personal_access_token
end
def render_missing_personal_access_token
render json: {
errors: [
{ code: 'UNAUTHORIZED',
message: _('HTTP Basic: Access denied\n' \
'You must use a personal access token with \'api\' scope for Git over HTTP.\n' \
'You can generate one at %{profile_personal_access_tokens_url}') % { profile_personal_access_tokens_url: profile_personal_access_tokens_url } }
]
}, status: :unauthorized
render_access_denied
end
def log_authentication_failed(login, result)
@ -68,13 +57,19 @@ class JwtController < ApplicationController
Gitlab::AuthLogger.warn(log_info)
end
def render_unauthorized
render json: {
errors: [
{ code: 'UNAUTHORIZED',
message: 'HTTP Basic: Access denied' }
]
}, status: :unauthorized
def render_access_denied
help_page = help_page_url(
'user/profile/account/two_factor_authentication',
anchor: 'troubleshooting'
)
render(
json: { errors: [{
code: 'UNAUTHORIZED',
message: format(_("HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See %{help_page_url}"), help_page_url: help_page)
}] },
status: :unauthorized
)
end
def auth_params

View File

@ -67,9 +67,21 @@ module Repositories
end
send_challenges
render plain: "HTTP Basic: Access denied\n", status: :unauthorized
render_access_denied
rescue Gitlab::Auth::MissingPersonalAccessTokenError
render_missing_personal_access_token
render_access_denied
end
def render_access_denied
help_page = help_page_url(
'topics/git/troubleshooting_git',
anchor: 'error-on-git-fetch-http-basic-access-denied'
)
render(
plain: format(_("HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See %{help_page_url}"), help_page_url: help_page),
status: :unauthorized
)
end
def basic_auth_provided?
@ -103,13 +115,6 @@ module Repositories
@container, @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse(repository_path)
end
def render_missing_personal_access_token
render plain: "HTTP Basic: Access denied\n" \
"You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \
"You can generate one at #{profile_personal_access_tokens_url}",
status: :unauthorized
end
def repository
strong_memoize(:repository) do
repo_type.repository_for(container)

View File

@ -32,7 +32,11 @@ module Resolvers
page_token: cursor
}
tree = repository.tree(args[:ref], args[:path], recursive: args[:recursive], pagination_params: pagination_params)
tree = repository.tree(
args[:ref], args[:path], recursive: args[:recursive],
skip_flat_paths: false,
pagination_params: pagination_params
)
next_cursor = tree.cursor&.next_cursor
Gitlab::Graphql::ExternallyPaginatedArray.new(cursor, next_cursor, *tree)

View File

@ -33,11 +33,6 @@ module Types
null: true,
description: 'Text note of the timeline event.'
field :note_html,
GraphQL::Types::String,
null: true,
description: 'HTML note of the timeline event.'
field :promoted_from_note,
Types::Notes::NoteType,
null: true,
@ -67,6 +62,8 @@ module Types
Types::TimeType,
null: false,
description: 'Timestamp when the event updated.'
markdown_field :note_html, null: true, description: 'HTML note of the timeline event.'
end
end
end

View File

@ -171,7 +171,7 @@ module CommitsHelper
ref,
{
merge_request: merge_request&.cache_key,
pipeline_status: commit.status_for(ref)&.cache_key,
pipeline_status: commit.detailed_status_for(ref)&.cache_key,
xhr: request.xhr?,
controller: controller.controller_path,
path: @path # referred to in #link_to_browse_code

View File

@ -247,7 +247,7 @@ module LabelsHelper
class="#{css_class}"
data-container="body"
data-html="true"
#{"style=\"background-color: #{bg_color}\"" if bg_color}
#{"style=\"background-color: #{h bg_color}\"" if bg_color}
>#{ERB::Util.html_escape_once(name)}#{suffix}</span>
HTML
end

View File

@ -69,6 +69,10 @@ module Integrations
}
end
def client_url
api_url.presence || url
end
def self.to_param
name.demodulize.downcase
end

View File

@ -448,7 +448,13 @@ class Issue < ApplicationRecord
return to_branch_name unless project.repository.branch_exists?(to_branch_name)
start_counting_from = 2
Uniquify.new(start_counting_from).string(-> (counter) { "#{to_branch_name}-#{counter}" }) do |suggested_branch_name|
branch_name_generator = -> (counter) do
suffix = counter > 5 ? SecureRandom.hex(8) : counter
"#{to_branch_name}-#{suffix}"
end
Uniquify.new(start_counting_from).string(branch_name_generator) do |suggested_branch_name|
project.repository.branch_exists?(suggested_branch_name)
end
end

View File

@ -677,24 +677,24 @@ class Repository
@head_commit ||= commit(self.root_ref)
end
def head_tree
def head_tree(skip_flat_paths: true)
if head_commit
@head_tree ||= Tree.new(self, head_commit.sha, nil)
@head_tree ||= Tree.new(self, head_commit.sha, nil, skip_flat_paths: skip_flat_paths)
end
end
def tree(sha = :head, path = nil, recursive: false, pagination_params: nil)
def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil)
if sha == :head
return unless head_commit
if path.nil?
return head_tree
return head_tree(skip_flat_paths: skip_flat_paths)
else
sha = head_commit.sha
end
end
Tree.new(self, sha, path, recursive: recursive, pagination_params: pagination_params)
Tree.new(self, sha, path, recursive: recursive, skip_flat_paths: skip_flat_paths, pagination_params: pagination_params)
end
def blob_at_branch(branch_name, path)

View File

@ -22,6 +22,8 @@ class Snippet < ApplicationRecord
MAX_FILE_COUNT = 10
DESCRIPTION_LENGTH_MAX = 1.megabyte
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
cache_markdown_field :content
@ -57,19 +59,10 @@ class Snippet < ApplicationRecord
validates :title, presence: true, length: { maximum: 255 }
validates :file_name,
length: { maximum: 255 }
validates :description, bytesize: { maximum: -> { DESCRIPTION_LENGTH_MAX } }, if: :description_changed?
validates :content, presence: true
validates :content,
length: {
maximum: ->(_) { Gitlab::CurrentSettings.snippet_size_limit },
message: -> (_, data) do
current_value = ActiveSupport::NumberHelper.number_to_human_size(data[:value].size)
max_size = ActiveSupport::NumberHelper.number_to_human_size(Gitlab::CurrentSettings.snippet_size_limit)
_("is too long (%{current_value}). The maximum size is %{max_size}.") % { current_value: current_value, max_size: max_size }
end
},
if: :content_changed?
validates :content, bytesize: { maximum: -> { Gitlab::CurrentSettings.snippet_size_limit } }, if: :content_changed?
after_create :create_statistics

View File

@ -6,7 +6,7 @@ class Tree
attr_accessor :repository, :sha, :path, :entries, :cursor
def initialize(repository, sha, path = '/', recursive: false, pagination_params: nil)
def initialize(repository, sha, path = '/', recursive: false, skip_flat_paths: true, pagination_params: nil)
path = '/' if path.blank?
@repository = repository
@ -14,7 +14,7 @@ class Tree
@path = path
git_repo = @repository.raw_repository
@entries, @cursor = Gitlab::Git::Tree.where(git_repo, @sha, @path, recursive, pagination_params)
@entries, @cursor = Gitlab::Git::Tree.where(git_repo, @sha, @path, recursive, skip_flat_paths, pagination_params)
end
def readme_path

View File

@ -5,12 +5,20 @@ class CommitPresenter < Gitlab::View::Presenter::Delegated
presents ::Commit, as: :commit
def status_for(ref)
def detailed_status_for(ref)
return unless can?(current_user, :read_pipeline, commit.latest_pipeline(ref))
return unless can?(current_user, :read_commit_status, commit.project)
commit.latest_pipeline(ref)&.detailed_status(current_user)
end
def status_for(ref = nil)
return unless can?(current_user, :read_pipeline, commit.latest_pipeline(ref))
return unless can?(current_user, :read_commit_status, commit.project)
commit.status(ref)
end
def any_pipelines?
return false unless can?(current_user, :read_pipeline, commit.project)

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
# BytesizeValidator
#
# Custom validator for verifying that bytesize of a field doesn't exceed the specified limit.
# It is different from Rails length validator because it takes .bytesize into account instead of .size/.length
#
# Example:
#
# class Snippet < ActiveRecord::Base
# validates :content, bytesize: { maximum: -> { Gitlab::CurrentSettings.snippet_size_limit } }
# end
#
# Configuration options:
# * <tt>maximum</tt> - Proc that evaluates the bytesize limit that cannot be exceeded
class BytesizeValidator < ActiveModel::EachValidator
def validate_each(record, attr, value)
size = value.to_s.bytesize
max_size = options[:maximum].call
return if size <= max_size
error_message = format(_('is too long (%{size}). The maximum size is %{max_size}.'), {
size: ActiveSupport::NumberHelper.number_to_human_size(size),
max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size)
})
record.errors.add(attr, error_message)
end
end

View File

@ -14,7 +14,7 @@
- project = local_assigns.fetch(:project) { merge_request&.project }
- ref = local_assigns.fetch(:ref) { merge_request&.source_branch }
- commit = commit.present(current_user: current_user)
- commit_status = commit.status_for(ref)
- commit_status = commit.detailed_status_for(ref)
- collapsible = local_assigns.fetch(:collapsible, true)
- link_data_attrs = local_assigns.fetch(:link_data_attrs, {})
- link = commit_path(project, commit, merge_request: merge_request)

View File

@ -1,8 +0,0 @@
---
name: container_registry_legacy_authentication_for_deploy_tokens
introduced_by_url: https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/2470
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/365968
milestone: '15.1'
type: development
group: group::package
default_enabled: false

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
if Gem.loaded_specs['rack'].version >= Gem::Version.new("3.0.0")
raise <<~ERR
This patch is unnecessary in Rack versions 3.0.0 or newer.
Please remove this file and the associated spec.
See https://github.com/rack/rack/blob/main/CHANGELOG.md#security (issue #1733)
ERR
end
# Patches a cache poisoning attack vector in Rack by not allowing semicolons
# to delimit query parameters.
# See https://github.com/rack/rack/issues/1732.
#
# Solution is taken from the same issue.
#
# The actual patch is due for release in Rack 3.0.0.
module Rack
class Request
Helpers.module_eval do
# rubocop: disable Naming/MethodName
def GET
if get_header(RACK_REQUEST_QUERY_STRING) == query_string
get_header(RACK_REQUEST_QUERY_HASH)
else
query_hash = parse_query(query_string, '&') # only allow ampersand here
set_header(RACK_REQUEST_QUERY_STRING, query_string)
set_header(RACK_REQUEST_QUERY_HASH, query_hash)
end
end
# rubocop: enable Naming/MethodName
end
end
end

View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
#
# This patch updates SawyerResource class to not allow Ruby methods to be overridden and accessed.
# Any attempt to access a Ruby method will result in an exception.
module SawyerClassPatch
def attr_accessor(*attrs)
attrs.each do |attribute|
class_eval do
# rubocop:disable Gitlab/ModuleWithInstanceVariables
if method_defined?(attribute) || method_defined?("#{attribute}=") || method_defined?("#{attribute}?")
define_method attribute do
raise Sawyer::Error,
"Sawyer method \"#{attribute}\" overlaps Ruby method. Convert to a hash to access the attribute."
end
define_method "#{attribute}=" do |value|
raise Sawyer::Error,
"Sawyer method \"#{attribute}\" overlaps Ruby method. Convert to a hash to access the attribute."
end
define_method "#{attribute}?" do
raise Sawyer::Error,
"Sawyer method \"#{attribute}\" overlaps Ruby method. Convert to a hash to access the attribute."
end
else
define_method attribute do
@attrs[attribute.to_sym]
end
define_method "#{attribute}=" do |value|
@attrs[attribute.to_sym] = value
end
define_method "#{attribute}?" do
!!@attrs[attribute.to_sym]
end
end
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
end
end
Sawyer::Resource.singleton_class.prepend(SawyerClassPatch)

View File

@ -47,7 +47,7 @@ In addition, it:
Geo provides:
- Read-only **secondary** sites: Maintain one **primary** GitLab site while still enabling read-only **secondary** sites for each of your distributed teams.
- Authentication system hooks: **Secondary** sites receives all authentication data (like user accounts and logins) from the **primary** instance.
- Authentication system hooks: **Secondary** sites receive all authentication data (like user accounts and logins) from the **primary** instance.
- An intuitive UI: **Secondary** sites use the same web interface your team has grown accustomed to. In addition, there are visual notifications that block write operations and make it clear that a user is on a **secondary** sites.
### Gitaly Cluster

View File

@ -51,7 +51,7 @@ Memory, CPU, and storage resource amounts vary depending on the amount of data y
each node should have:
- [Memory](https://www.elastic.co/guide/en/elasticsearch/guide/current/hardware.html#_memory): 8 GiB (minimum).
- [CPU](https://www.elastic.co/guide/en/elasticsearch/guide/current/hardware.html#_cpus): Modern processor with multiple cores. GitLab.com has minimal CPU requirements for Elasticsearch. Multiple cores provide extra concurrency, which is more beneficial than faster CPUs.
- [CPU](https://www.elastic.co/guide/en/elasticsearch/guide/current/hardware.html#_cpus): Modern processor with multiple cores. GitLab has minimal CPU requirements for Elasticsearch. Multiple cores provide extra concurrency, which is more beneficial than faster CPUs.
- [Storage](https://www.elastic.co/guide/en/elasticsearch/guide/current/hardware.html#_disks): Use SSD storage. The total storage size of all Elasticsearch nodes is about 50% of the total size of your Git repositories. It includes one primary and one replica. The [`estimate_cluster_size`](#gitlab-advanced-search-rake-tasks) Rake task ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/221177) in GitLab 13.10) uses total repository size to estimate the Advanced Search storage requirements.
## Install Elasticsearch

View File

@ -267,3 +267,8 @@ To resolve this issue, you can update the password expiration by either:
```
The bug was reported [in this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/332455).
## Error on Git fetch: "HTTP Basic: Access Denied"
If you receive an `HTTP Basic: Access denied` error when using Git over HTTP(S),
refer to the [two-factor authentication troubleshooting guide](../../user/profile/account/two_factor_authentication.md#troubleshooting).

View File

@ -299,6 +299,10 @@ hub_docker_quota_check:
## Troubleshooting
## Authentication error: "HTTP Basic: Access Denied"
If you receive an `HTTP Basic: Access denied` error when authenticating against the Dependency Proxy, refer to the [two-factor authentication troubleshooting guide](../../profile/account/two_factor_authentication.md#troubleshooting).
### Dependency Proxy Connection Failure
If a service alias is not set the `docker:20.10.16` image is unable to find the

View File

@ -345,6 +345,11 @@ when a PyPI package is not found in the Package Registry, the request is forward
Administrators can disable this behavior in the [Continuous Integration settings](../../admin_area/settings/continuous_integration.md).
WARNING:
When you use the `--index-url` option, do not specify the port if it is a default
port, such as `80` for a URL starting with `http` or `443` for a URL starting
with `https`.
### Install from the project level
To install the latest version of a package, use the following command:

View File

@ -427,6 +427,39 @@ a GitLab global administrator disable 2FA for your account:
## Troubleshooting
### Error: "HTTP Basic: Access denied. The provided password or token ..."
When making a request, you can receive the following error:
```plaintext
HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal
access token instead of a password.
```
This error occurs in the following scenarios:
- You have 2FA enabled and have attempted to authenticate with a username and
password. For 2FA-enabled users, a [personal access token](../personal_access_tokens.md) (PAT)
must be used instead of a password. To authenticate:
- Git requests over HTTP(S), a PAT with `read_repository` or `write_repository` scope is required.
- [GitLab Container Registry](../../packages/container_registry/index.md#authenticate-with-the-container-registry) requests, a PAT
with `read_registry` or `write_registry` scope is required.
- [Dependency Proxy](../../packages/dependency_proxy/index.md#authenticate-with-the-dependency-proxy) requests, a PAT with
`read_registry` and `write_registry` scopes is required.
- You do not have 2FA enabled and have sent an incorrect username or password
with your request.
- You do not have 2FA enabled but an administrator has enabled the
[enforce 2FA for all users](../../../security/two_factor_authentication.md#enforce-2fa-for-all-users) setting.
- You do not have 2FA enabled, but an administrator has disabled the
[password authentication enabled for Git over HTTP(S)](../../admin_area/settings/sign_in_restrictions.md#password-authentication-enabled)
setting. If LDAP is:
- Configured, an [LDAP password](../../../administration/auth/ldap/index.md)
or a [personal access token](../personal_access_tokens.md)
must be used to authenticate Git requests over HTTP(S).
- Not configured, you must use a [personal access token](../personal_access_tokens.md).
### Error: "invalid pin code"
If you receive an `invalid pin code` error, this can indicate that there is a time sync issue between the authentication
application and the GitLab instance itself. To avoid the time sync issue, enable time synchronization in the device that
generates the codes. For example:

View File

@ -100,7 +100,7 @@ To delete a task:
1. In the task window, in the options menu (**{ellipsis_v}**), select **Delete task**.
1. Select **OK**.
## Set task weight **PREMIUM**
## Set task weight **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/362550) in GitLab 15.3.

View File

@ -144,7 +144,7 @@ module API
Gitlab::UsageDataCounters::EditorUniqueCounter.track_web_ide_edit_action(author: current_user, project: user_project)
end
present commit_detail, with: Entities::CommitDetail, stats: params[:stats]
present commit_detail, with: Entities::CommitDetail, include_stats: params[:stats], current_user: current_user
else
render_api_error!(result[:message], 400)
end
@ -163,7 +163,7 @@ module API
not_found! 'Commit' unless commit
present commit, with: Entities::CommitDetail, stats: params[:stats], current_user: current_user
present commit, with: Entities::CommitDetail, include_stats: params[:stats], current_user: current_user
end
desc 'Get the diff for a specific commit of a project' do

View File

@ -12,7 +12,9 @@ module API
expose :trailers
expose :web_url do |commit, _options|
Gitlab::UrlBuilder.build(commit)
c = commit
c = c.__subject__ if c.is_a?(Gitlab::View::Presenter::Base)
Gitlab::UrlBuilder.build(c)
end
end
end

View File

@ -3,8 +3,10 @@
module API
module Entities
class CommitDetail < Commit
expose :stats, using: Entities::CommitStats, if: :stats
expose :status
include ::API::Helpers::Presentable
expose :stats, using: Entities::CommitStats, if: :include_stats
expose :status_for, as: :status
expose :project_id
expose :last_pipeline do |commit, options|

View File

@ -14,28 +14,12 @@ module API
include Constants
include Gitlab::Utils::StrongMemoize
def unauthorized_user_project
@unauthorized_user_project ||= find_project(params[:id])
end
def unauthorized_user_project!
unauthorized_user_project || not_found!
end
def unauthorized_user_group
@unauthorized_user_group ||= find_group(params[:id])
end
def unauthorized_user_group!
unauthorized_user_group || not_found!
end
def authorized_user_project
@authorized_user_project ||= authorized_project_find!
end
def authorized_project_find!
project = unauthorized_user_project
project = find_project(params[:id])
unless project && can?(current_user, :read_project, project)
return unauthorized_or! { not_found! }

View File

@ -84,6 +84,16 @@ module API
body content
end
def ensure_group!
find_group(params[:id]) || not_found!
find_authorized_group!
end
def ensure_project!
find_project(params[:id]) || not_found!
authorized_user_project
end
end
params do
@ -91,7 +101,7 @@ module API
end
resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
after_validation do
unauthorized_user_group!
ensure_group!
end
namespace ':id/-/packages/pypi' do
@ -101,7 +111,8 @@ module API
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'files/:sha256/*file_identifier' do
group = unauthorized_user_group!
group = find_authorized_group!
authorize_read_package!(group)
filename = "#{params[:file_identifier]}.#{params[:format]}"
package = Packages::Pypi::PackageFinder.new(current_user, group, { filename: filename, sha256: params[:sha256] }).execute
@ -146,7 +157,7 @@ module API
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
before do
unauthorized_user_project!
ensure_project!
end
namespace ':id/packages/pypi' do
@ -160,7 +171,8 @@ module API
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'files/:sha256/*file_identifier' do
project = unauthorized_user_project!
project = authorized_user_project
authorize_read_package!(project)
filename = "#{params[:file_identifier]}.#{params[:format]}"
package = Packages::Pypi::PackageFinder.new(current_user, project, { filename: filename, sha256: params[:sha256] }).execute

View File

@ -189,7 +189,7 @@ module API
compare = CompareService.new(user_project, params[:to]).execute(target_project, params[:from], straight: params[:straight])
if compare
present compare, with: Entities::Compare
present compare, with: Entities::Compare, current_user: current_user
else
not_found!("Ref")
end

View File

@ -123,7 +123,7 @@ module API
get do
verify_search_scope!(resource: nil)
present search, with: entity
present search, with: entity, current_user: current_user
end
end
@ -145,7 +145,7 @@ module API
get ':id/(-/)search' do
verify_search_scope!(resource: user_group)
present search(group_id: user_group.id), with: entity
present search(group_id: user_group.id), with: entity, current_user: current_user
end
end
@ -166,7 +166,7 @@ module API
use :pagination
end
get ':id/(-/)search' do
present search({ project_id: user_project.id, repository_ref: params[:ref] }), with: entity
present search({ project_id: user_project.id, repository_ref: params[:ref] }), with: entity, current_user: current_user
end
end
end

View File

@ -39,7 +39,7 @@ module API
if result[:status] == :success
commit_detail = user_project.repository.commit(result[:result])
present commit_detail, with: Entities::CommitDetail
present commit_detail, with: Entities::CommitDetail, current_user: current_user
else
render_api_error!(result[:message], result[:http_status] || 400)
end

View File

@ -17,21 +17,10 @@ module Banzai
include ActionView::Helpers::TagHelper
include AvatarsHelper
TRAILER_REGEXP = /(?<label>[[:alpha:]-]+-by:)/i.freeze
AUTHOR_REGEXP = /(?<author_name>.+)/.freeze
# Devise.email_regexp wouldn't work here since its designed to match
# against strings that only contains email addresses; the \A and \z
# around the expression will only match if the string being matched
# contains just the email nothing else.
MAIL_REGEXP = /&lt;(?<author_email>[^@\s]+@[^@\s]+)&gt;/.freeze
FILTER_REGEXP = /(?<trailer>^\s*#{TRAILER_REGEXP}\s*#{AUTHOR_REGEXP}\s+#{MAIL_REGEXP}$)/mi.freeze
def call
doc.xpath('descendant-or-self::text()').each do |node|
content = node.to_html
next unless content.match(FILTER_REGEXP)
html = trailer_filter(content)
next if html == content
@ -52,11 +41,24 @@ module Banzai
# Returns a String with all trailer lines replaced with links to GitLab
# users and mailto links to non GitLab users. All links have `data-trailer`
# and `data-user` attributes attached.
#
# The code intentionally avoids using Regex for security and performance
# reasons: https://gitlab.com/gitlab-org/gitlab/-/issues/363734
def trailer_filter(text)
text.gsub(FILTER_REGEXP) do |author_match|
label = $~[:label]
"#{label} #{parse_user($~[:author_name], $~[:author_email], label)}"
end
text.lines.map! do |line|
trailer, rest = line.split(':', 2)
next line unless trailer.downcase.end_with?('-by')
chunks = rest.split
author_email = chunks.pop.delete_prefix('&lt;').delete_suffix('&gt;')
next line unless Devise.email_regexp.match(author_email)
author_name = chunks.join(' ').strip
trailer = "#{trailer.strip}:"
"#{trailer} #{link_to_user_or_email(author_name, author_email, trailer)}\n"
end.join
end
# Find a GitLab user using the supplied email and generate
@ -67,7 +69,7 @@ module Banzai
# trailer - String trailer used in the commit message
#
# Returns a String with a link to the user.
def parse_user(name, email, trailer)
def link_to_user_or_email(name, email, trailer)
link_to_user User.find_by_any_email(email),
name: name,
email: email,

View File

@ -34,17 +34,20 @@ module Banzai
img.remove_attribute('data-diagram-src')
end
link.children = if link_replaces_image
img['alt'] || img['data-src'] || img['src']
else
img.clone
end
link.children = link_replaces_image ? link_children(img) : img.clone
img.replace(link)
end
doc
end
private
def link_children(img)
[img['alt'], img['data-src'], img['src']]
.map { |f| Sanitize.fragment(f).presence }.compact.first || ''
end
end
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
module Banzai
module Filter
class PathologicalMarkdownFilter < HTML::Pipeline::TextFilter
# It's not necessary for this to be precise - we just need to detect
# when there are a non-trivial number of unclosed image links.
# So we don't really care about code blocks, etc.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/370428
REGEX = /!\[(?:[^\]])+?!\[/.freeze
DETECTION_MAX = 10
def call
count = 0
@text.scan(REGEX) do |_match|
count += 1
break if count > DETECTION_MAX
end
return @text if count <= DETECTION_MAX
"_Unable to render markdown - too many unclosed markdown image links detected._"
end
end
end
end

View File

@ -5,6 +5,7 @@ module Banzai
class PlainMarkdownPipeline < BasePipeline
def self.filters
FilterArray[
Filter::PathologicalMarkdownFilter,
Filter::MarkdownPreEscapeFilter,
Filter::MarkdownFilter,
Filter::MarkdownPostEscapeFilter

View File

@ -16,9 +16,10 @@ module Gitlab
TREE_SORT_ORDER = { tree: 0, blob: 1, commit: 2 }.freeze
override :tree_entries
def tree_entries(repository, sha, path, recursive, pagination_params = nil)
def tree_entries(repository, sha, path, recursive, skip_flat_paths, pagination_params = nil)
if use_rugged?(repository, :rugged_tree_entries)
entries = execute_rugged_call(:tree_entries_with_flat_path_from_rugged, repository, sha, path, recursive)
entries = execute_rugged_call(
:tree_entries_with_flat_path_from_rugged, repository, sha, path, recursive, skip_flat_paths)
if pagination_params
paginated_response(entries, pagination_params[:limit], pagination_params[:page_token].to_s)
@ -60,11 +61,11 @@ module Gitlab
[result, cursor]
end
def tree_entries_with_flat_path_from_rugged(repository, sha, path, recursive)
def tree_entries_with_flat_path_from_rugged(repository, sha, path, recursive, skip_flat_paths)
tree_entries_from_rugged(repository, sha, path, recursive).tap do |entries|
# This was an optimization to reduce N+1 queries for Gitaly
# (https://gitlab.com/gitlab-org/gitaly/issues/530).
rugged_populate_flat_path(repository, sha, path, entries)
rugged_populate_flat_path(repository, sha, path, entries) unless skip_flat_paths
end
end

View File

@ -15,15 +15,16 @@ module Gitlab
# Uses rugged for raw objects
#
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/320
def where(repository, sha, path = nil, recursive = false, pagination_params = nil)
def where(repository, sha, path = nil, recursive = false, skip_flat_paths = true, pagination_params = nil)
path = nil if path == '' || path == '/'
tree_entries(repository, sha, path, recursive, pagination_params)
tree_entries(repository, sha, path, recursive, skip_flat_paths, pagination_params)
end
def tree_entries(repository, sha, path, recursive, pagination_params = nil)
def tree_entries(repository, sha, path, recursive, skip_flat_paths, pagination_params = nil)
wrapped_gitaly_errors do
repository.gitaly_commit_client.tree_entries(repository, sha, path, recursive, pagination_params)
repository.gitaly_commit_client.tree_entries(
repository, sha, path, recursive, skip_flat_paths, pagination_params)
end
end

View File

@ -5,6 +5,8 @@ module Gitlab
class CommitService
include Gitlab::EncodingHelper
TREE_ENTRIES_DEFAULT_LIMIT = 100_000
def initialize(repository)
@gitaly_repo = repository.gitaly_repository
@repository = repository
@ -111,12 +113,16 @@ module Gitlab
nil
end
def tree_entries(repository, revision, path, recursive, pagination_params)
def tree_entries(repository, revision, path, recursive, skip_flat_paths, pagination_params)
pagination_params ||= {}
pagination_params[:limit] ||= TREE_ENTRIES_DEFAULT_LIMIT
request = Gitaly::GetTreeEntriesRequest.new(
repository: @gitaly_repo,
revision: encode_binary(revision),
path: path.present? ? encode_binary(path) : '.',
recursive: recursive,
skip_flat_paths: skip_flat_paths,
pagination_params: pagination_params
)
request.sort = Gitaly::GetTreeEntriesRequest::SortBy::TREES_FIRST if pagination_params

View File

@ -11,7 +11,7 @@ module Gitlab
# this if the change to the renderer output is a new feature or a
# minor bug fix.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/330313
CACHE_COMMONMARK_VERSION = 31
CACHE_COMMONMARK_VERSION = 32
CACHE_COMMONMARK_VERSION_START = 10
BaseError = Class.new(StandardError)

View File

@ -68,6 +68,10 @@ module Gitlab
with { |redis| redis.ttl(cache_key(key)) }
end
def count(key)
with { |redis| redis.scard(cache_key(key)) }
end
private
def with(&blk)

View File

@ -5,6 +5,10 @@ module Gitlab
class Client
Error = Class.new(StandardError)
ConfigError = Class.new(Error)
RequestError = Class.new(Error)
CACHE_MAX_SET_SIZE = 5_000
CACHE_TTL = 1.month.freeze
attr_reader :integration
@ -33,11 +37,21 @@ module Gitlab
end
def fetch_issues(params = {})
get("products/#{zentao_product_xid}/issues", params)
get("products/#{zentao_product_xid}/issues", params).tap do |response|
mark_issues_as_seen_in_product(response['issues'])
end
end
def fetch_issue(issue_id)
raise Gitlab::Zentao::Client::Error, 'invalid issue id' unless issue_id_pattern.match(issue_id)
raise Error, 'invalid issue id' unless issue_id_pattern.match(issue_id)
# Only return issues that are associated with the product configured in
# the integration. Due to a lack of available data in the ZenTao APIs, we
# can only determine if an issue belongs to a product if the issue was
# previously returned in the `#fetch_issues` call.
#
# See https://gitlab.com/gitlab-org/gitlab/-/issues/360372#note_1016963713
raise RequestError unless issue_seen_in_product?(issue_id)
get("issues/#{issue_id}")
end
@ -52,17 +66,15 @@ module Gitlab
options = { headers: headers, query: params }
response = Gitlab::HTTP.get(url(path), options)
raise Gitlab::Zentao::Client::Error, 'request error' unless response.success?
raise RequestError unless response.success?
Gitlab::Json.parse(response.body)
rescue JSON::ParserError
raise Gitlab::Zentao::Client::Error, 'invalid response format'
raise Error, 'invalid response format'
end
def url(path)
host = integration.api_url.presence || integration.url
URI.parse(Gitlab::Utils.append_path(host, "api.php/v1/#{path}"))
URI.parse(Gitlab::Utils.append_path(integration.client_url, "api.php/v1/#{path}"))
end
def headers
@ -75,6 +87,30 @@ module Gitlab
def zentao_product_xid
integration.zentao_product_xid
end
def issue_ids_cache_key
@issue_ids_cache_key ||= [
:zentao_product_issues,
OpenSSL::Digest::SHA256.hexdigest(integration.client_url),
zentao_product_xid
].join(':')
end
def issue_ids_cache
@issue_ids_cache ||= ::Gitlab::SetCache.new(expires_in: CACHE_TTL)
end
def mark_issues_as_seen_in_product(issues)
return unless issues && issue_ids_cache.count(issue_ids_cache_key) < CACHE_MAX_SET_SIZE
ids = issues.map { _1['id'] }
issue_ids_cache.write(issue_ids_cache_key, ids)
end
def issue_seen_in_product?(id)
issue_ids_cache.include?(issue_ids_cache_key, id)
end
end
end
end

View File

@ -19169,7 +19169,7 @@ msgstr ""
msgid "HTTP Archive (HAR)"
msgstr ""
msgid "HTTP Basic: Access denied\\nYou must use a personal access token with 'api' scope for Git over HTTP.\\nYou can generate one at %{profile_personal_access_tokens_url}"
msgid "HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See %{help_page_url}"
msgstr ""
msgid "Harbor Registry"
@ -20872,6 +20872,9 @@ msgstr ""
msgid "Incident|Error deleting incident timeline event: %{error}"
msgstr ""
msgid "Incident|Error updating incident timeline event: %{error}"
msgstr ""
msgid "Incident|Metrics"
msgstr ""
@ -20890,6 +20893,9 @@ msgstr ""
msgid "Incident|Something went wrong while fetching incident timeline events."
msgstr ""
msgid "Incident|Something went wrong while updating the incident timeline event."
msgstr ""
msgid "Incident|Summary"
msgstr ""
@ -46721,6 +46727,9 @@ msgstr ""
msgid "is too long (%{current_value}). The maximum size is %{max_size}."
msgstr ""
msgid "is too long (%{size}). The maximum size is %{max_size}."
msgstr ""
msgid "is too long (maximum is %{count} characters)"
msgstr ""

View File

@ -49,6 +49,7 @@
"@apollo/client": "^3.5.10",
"@babel/core": "^7.18.5",
"@babel/preset-env": "^7.18.2",
"@codesandbox/sandpack-client": "^1.2.2",
"@gitlab/at.js": "1.5.7",
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/svgs": "3.2.0",
@ -164,7 +165,6 @@
"remark-rehype": "^10.1.0",
"scrollparent": "^2.0.1",
"select2": "3.5.2-browserify",
"smooshpack": "^0.0.62",
"sortablejs": "^1.10.2",
"string-hash": "1.1.3",
"style-loader": "^2.0.0",

View File

@ -16,4 +16,4 @@ install:
script:
- "pip install <%= package.name %> --no-deps --index-url <%= uri.scheme %>://<%= personal_access_token %>:<%= personal_access_token %>@<%= gitlab_host_with_port %>/api/v4/projects/${CI_PROJECT_ID}/packages/pypi/simple --trusted-host <%= gitlab_host_with_port %>"
tags:
- runner-for-<%= project.name %>
- runner-for-<%= project.name %>

View File

@ -30,9 +30,16 @@ module QA
end
let(:uri) { URI.parse(Runtime::Scenario.gitlab_address) }
let(:gitlab_address_with_port) { "#{uri.scheme}://#{uri.host}:#{uri.port}" }
let(:gitlab_host_with_port) { "#{uri.host}:#{uri.port}" }
let(:personal_access_token) { use_ci_variable(name: 'PERSONAL_ACCESS_TOKEN', value: Runtime::Env.personal_access_token, project: project) }
let(:gitlab_address_with_port) { "#{uri.scheme}://#{uri.host}:#{uri.port}" }
let(:gitlab_host_with_port) do
# Don't specify port if it is a standard one
if uri.port == 80 || uri.port == 443
uri.host
else
"#{uri.host}:#{uri.port}"
end
end
before do
Flow::Login.sign_in

View File

@ -29,27 +29,28 @@ RSpec.describe 'Render Static HTML', :api, type: :request do # rubocop:disable R
include Glfm::Constants
include Glfm::Shared
let(:user) { create(:user, :admin, username: 'glfm_user') }
# TODO: Remove duplication of fixtures & logic with spec/support/shared_contexts/markdown_snapshot_shared_examples.rb
before do
stub_licensed_features(group_wikis: true)
group = create(:group, name: 'glfm_group')
group.add_owner(user)
project = create(:project, :repository, creator: user, group: group, name: 'glfm_project')
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, name: 'glfm_group').tap { |group| group.add_owner(user) } }
let_it_be(:project) do
# NOTE: We hardcode the IDs on all fixtures to prevent variability in the
# rendered HTML/Prosemirror JSON, and to minimize the need for normalization:
# https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#normalization
create(:project_snippet, id: 88888, project: project) # project snippet
create(:snippet, id: 99999) # personal snippet
create(:project, :repository, creator: user, group: group, name: 'glfm_project', id: 77777)
end
let_it_be(:project_snippet) { create(:project_snippet, id: 88888, project: project) }
let_it_be(:personal_snippet) { create(:snippet, id: 99999) }
before do
stub_licensed_features(group_wikis: true)
sign_in(user)
end
it 'can create a project dependency graph using factories' do
markdown_hash = YAML.load_file(ENV.fetch('INPUT_MARKDOWN_YML_PATH'))
markdown_hash = YAML.safe_load(File.open(ENV.fetch('INPUT_MARKDOWN_YML_PATH')), symbolize_names: true)
# NOTE: We cannot parallelize this loop like the Javascript WYSIWYG example generation does,
# because the rspec `post` API cannot be parallized (it is not thread-safe, it can't find
@ -58,12 +59,14 @@ RSpec.describe 'Render Static HTML', :api, type: :request do # rubocop:disable R
api_url = api "/markdown"
post api_url, params: { text: markdown, gfm: true }
# noinspection RubyResolve
expect(response).to be_successful
returned_html_value =
begin
parsed_response = Gitlab::Json.parse(response.body)
# The response may contain the HTML in either the `body` or `html` keys
parsed_response['body'] || parsed_response['html']
parsed_response = Gitlab::Json.parse(response.body, symbolize_names: true)
# Some responses have the HTML in the `html` key, others in the `body` key.
parsed_response[:body] || parsed_response[:html]
rescue JSON::ParserError
# if we got a parsing error, just return the raw response body for debugging purposes.
response.body
@ -79,7 +82,7 @@ RSpec.describe 'Render Static HTML', :api, type: :request do # rubocop:disable R
def write_output_file(static_html_hash)
tmpfile = File.open(ENV.fetch('OUTPUT_STATIC_HTML_TEMPFILE_PATH'), 'w')
YAML.dump(static_html_hash, tmpfile)
tmpfile.close
yaml_string = dump_yaml_with_formatting(static_html_hash)
write_file(tmpfile, yaml_string)
end
end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'fileutils'
require 'open3'
require 'active_support/core_ext/hash/keys'
module Glfm
module Shared
@ -39,5 +40,38 @@ module Glfm
warn(stdout_and_stderr_str)
raise
end
# Construct an AST so we can control YAML formatting for
# YAML block scalar literals and key quoting.
#
# Note that when Psych dumps the markdown to YAML, it will
# automatically use the default "clip" behavior of the Block Chomping Indicator (`|`)
# https://yaml.org/spec/1.2.2/#8112-block-chomping-indicator,
# when the markdown strings contain a trailing newline. The type of
# Block Chomping Indicator is automatically determined, you cannot specify it
# manually.
def dump_yaml_with_formatting(hash, literal_scalars: false)
stringified_keys_hash = hash.deep_stringify_keys
visitor = Psych::Visitors::YAMLTree.create
visitor << stringified_keys_hash
ast = visitor.tree
# Force all scalars to have literal formatting (using Block Chomping Indicator instead of quotes)
if literal_scalars
ast.grep(Psych::Nodes::Scalar).each do |node|
node.style = Psych::Nodes::Scalar::LITERAL
end
end
# Do not quote the keys
ast.grep(Psych::Nodes::Mapping).each do |node|
node.children.each_slice(2) do |k, _|
k.quoted = false
k.style = Psych::Nodes::Scalar::PLAIN
end
end
ast.to_yaml
end
end
end

View File

@ -125,7 +125,7 @@ module Glfm
def write_snapshot_example_files(all_examples, skip_static_and_wysiwyg:)
output("Reading #{GLFM_EXAMPLE_STATUS_YML_PATH}...")
glfm_examples_statuses = YAML.safe_load(File.open(GLFM_EXAMPLE_STATUS_YML_PATH))
glfm_examples_statuses = YAML.safe_load(File.open(GLFM_EXAMPLE_STATUS_YML_PATH), symbolize_names: true)
validate_glfm_example_status_yml(glfm_examples_statuses)
write_examples_index_yml(all_examples)
@ -153,8 +153,8 @@ module Glfm
def validate_glfm_example_status_yml(glfm_examples_statuses)
glfm_examples_statuses.each do |example_name, statuses|
next unless statuses &&
statuses['skip_update_example_snapshots'] &&
statuses.any? { |key, value| key.include?('skip_update_example_snapshot_') && !!value }
statuses[:skip_update_example_snapshots] &&
statuses.any? { |key, value| key.to_s.include?('skip_update_example_snapshot_') && !!value }
raise "Error: '#{example_name}' must not have any 'skip_update_example_snapshot_*' values specified " \
"if 'skip_update_example_snapshots' is truthy"
@ -165,7 +165,8 @@ module Glfm
generate_and_write_for_all_examples(
all_examples, ES_EXAMPLES_INDEX_YML_PATH, literal_scalars: false
) do |example, hash|
hash[example.fetch(:name)] = {
name = example.fetch(:name).to_sym
hash[name] = {
'spec_txt_example_position' => example.fetch(:example),
'source_specification' =>
if example[:extensions].empty?
@ -181,7 +182,8 @@ module Glfm
def write_markdown_yml(all_examples)
generate_and_write_for_all_examples(all_examples, ES_MARKDOWN_YML_PATH) do |example, hash|
hash[example.fetch(:name)] = example.fetch(:markdown)
name = example.fetch(:name).to_sym
hash[name] = example.fetch(:markdown)
end
end
@ -225,7 +227,7 @@ module Glfm
run_external_cmd(cmd)
output("Reading generated static HTML from tempfile #{static_html_tempfile_path}...")
YAML.load_file(static_html_tempfile_path)
YAML.safe_load(File.open(static_html_tempfile_path), symbolize_names: true)
end
def generate_wysiwyg_html_and_json
@ -241,26 +243,26 @@ module Glfm
output("Reading generated WYSIWYG HTML and prosemirror JSON from tempfile " \
"#{wysiwyg_html_and_json_tempfile_path}...")
YAML.load_file(wysiwyg_html_and_json_tempfile_path)
YAML.safe_load(File.open(wysiwyg_html_and_json_tempfile_path), symbolize_names: true)
end
def write_html_yml(all_examples, static_html_hash, wysiwyg_html_and_json_hash, glfm_examples_statuses)
generate_and_write_for_all_examples(
all_examples, ES_HTML_YML_PATH, glfm_examples_statuses
all_examples, ES_HTML_YML_PATH, glfm_examples_statuses: glfm_examples_statuses
) do |example, hash, existing_hash|
name = example.fetch(:name)
name = example.fetch(:name).to_sym
example_statuses = glfm_examples_statuses[name] || {}
static = if example_statuses['skip_update_example_snapshot_html_static']
existing_hash.dig(name, 'static')
static = if example_statuses[:skip_update_example_snapshot_html_static]
existing_hash.dig(name, :static)
else
static_html_hash[name]
end
wysiwyg = if example_statuses['skip_update_example_snapshot_html_wysiwyg']
existing_hash.dig(name, 'wysiwyg')
wysiwyg = if example_statuses[:skip_update_example_snapshot_html_wysiwyg]
existing_hash.dig(name, :wysiwyg)
else
wysiwyg_html_and_json_hash.dig(name, 'html')
wysiwyg_html_and_json_hash.dig(name, :html)
end
hash[name] = {
@ -273,14 +275,14 @@ module Glfm
def write_prosemirror_json_yml(all_examples, wysiwyg_html_and_json_hash, glfm_examples_statuses)
generate_and_write_for_all_examples(
all_examples, ES_PROSEMIRROR_JSON_YML_PATH, glfm_examples_statuses
all_examples, ES_PROSEMIRROR_JSON_YML_PATH, glfm_examples_statuses: glfm_examples_statuses
) do |example, hash, existing_hash|
name = example.fetch(:name)
name = example.fetch(:name).to_sym
json = if glfm_examples_statuses.dig(name, 'skip_update_example_snapshot_prosemirror_json')
json = if glfm_examples_statuses.dig(name, :skip_update_example_snapshot_prosemirror_json)
existing_hash[name]
else
wysiwyg_html_and_json_hash.dig(name, 'json')
wysiwyg_html_and_json_hash.dig(name, :json)
end
# Do not assign nil values
@ -289,15 +291,15 @@ module Glfm
end
def generate_and_write_for_all_examples(
all_examples, output_file_path, glfm_examples_statuses = {}, literal_scalars: true
all_examples, output_file_path, glfm_examples_statuses: {}, literal_scalars: true
)
preserve_existing = !glfm_examples_statuses.empty?
output("#{preserve_existing ? 'Creating/Updating' : 'Creating/Overwriting'} #{output_file_path}...")
existing_hash = preserve_existing ? YAML.safe_load(File.open(output_file_path)) : {}
existing_hash = preserve_existing ? YAML.safe_load(File.open(output_file_path), symbolize_names: true) : {}
output_hash = all_examples.each_with_object({}) do |example, hash|
name = example.fetch(:name)
if (reason = glfm_examples_statuses.dig(name, 'skip_update_example_snapshots'))
name = example.fetch(:name).to_sym
if (reason = glfm_examples_statuses.dig(name, :skip_update_example_snapshots))
# Output the reason for skipping the example, but only once, not multiple times for each file
output("Skipping '#{name}'. Reason: #{reason}") unless glfm_examples_statuses.dig(name, :already_printed)
# We just store the `:already_printed` flag in the hash entry itself. Then we
@ -317,37 +319,5 @@ module Glfm
yaml_string = dump_yaml_with_formatting(output_hash, literal_scalars: literal_scalars)
write_file(output_file_path, yaml_string)
end
# Construct an AST so we can control YAML formatting for
# YAML block scalar literals and key quoting.
#
# Note that when Psych dumps the markdown to YAML, it will
# automatically use the default "clip" behavior of the Block Chomping Indicator (`|`)
# https://yaml.org/spec/1.2.2/#8112-block-chomping-indicator,
# when the markdown strings contain a trailing newline. The type of
# Block Chomping Indicator is automatically determined, you cannot specify it
# manually.
def dump_yaml_with_formatting(hash, literal_scalars:)
visitor = Psych::Visitors::YAMLTree.create
visitor << hash
ast = visitor.tree
# Force all scalars to have literal formatting (using Block Chomping Indicator instead of quotes)
if literal_scalars
ast.grep(Psych::Nodes::Scalar).each do |node|
node.style = Psych::Nodes::Scalar::LITERAL
end
end
# Do not quote the keys
ast.grep(Psych::Nodes::Mapping).each do |node|
node.children.each_slice(2) do |k, _|
k.quoted = false
k.style = Psych::Nodes::Scalar::ANY
end
end
ast.to_yaml
end
end
end

View File

@ -41,7 +41,39 @@ RSpec.describe 'Incident timeline events', :js do
end
end
context 'when delete event is clicked' do
context 'when edit is clicked' do
before do
click_button 'Add new timeline event'
fill_in 'Description', with: 'Event note to edit'
click_button 'Save'
end
it 'shows the confirmation modal and edits the event' do
click_button 'More actions'
page.within '.gl-new-dropdown-contents' do
expect(page).to have_content('Edit')
page.find('.gl-new-dropdown-item-text-primary', text: 'Edit').click
end
expect(page).to have_selector('.common-note-form')
fill_in 'Description', with: 'Event note goes here'
fill_in 'timeline-input-hours', with: '07'
fill_in 'timeline-input-minutes', with: '25'
click_button 'Save'
wait_for_requests
page.within '.timeline-event-note' do
expect(page).to have_content('Event note goes here')
expect(page).to have_content('07:25')
end
end
end
context 'when delete is clicked' do
before do
click_button 'Add new timeline event'
fill_in 'Description', with: 'Event note to delete'
@ -51,7 +83,7 @@ RSpec.describe 'Incident timeline events', :js do
it 'shows the confirmation modal and deletes the event' do
click_button 'More actions'
page.within '.gl-new-dropdown-item-text-wrapper' do
page.within '.gl-new-dropdown-contents' do
expect(page).to have_content('Delete')
page.find('.gl-new-dropdown-item-text-primary', text: 'Delete').click
end

View File

@ -2,15 +2,15 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import { dispatch } from 'codesandbox-api';
import smooshpack from 'smooshpack';
import { SandpackClient } from '@codesandbox/sandpack-client';
import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
import Clientside from '~/ide/components/preview/clientside.vue';
import { PING_USAGE_PREVIEW_KEY, PING_USAGE_PREVIEW_SUCCESS_KEY } from '~/ide/constants';
import eventHub from '~/ide/eventhub';
jest.mock('smooshpack', () => ({
Manager: jest.fn(),
jest.mock('@codesandbox/sandpack-client', () => ({
SandpackClient: jest.fn(),
}));
Vue.use(Vuex);
@ -78,8 +78,8 @@ describe('IDE clientside preview', () => {
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
sandpackReady: true,
manager: {
listener: jest.fn(),
client: {
cleanup: jest.fn(),
updatePreview: jest.fn(),
},
});
@ -90,9 +90,9 @@ describe('IDE clientside preview', () => {
});
describe('without main entry', () => {
it('creates sandpack manager', () => {
it('creates sandpack client', () => {
createComponent();
expect(smooshpack.Manager).not.toHaveBeenCalled();
expect(SandpackClient).not.toHaveBeenCalled();
});
});
describe('with main entry', () => {
@ -102,8 +102,8 @@ describe('IDE clientside preview', () => {
return waitForPromises();
});
it('creates sandpack manager', () => {
expect(smooshpack.Manager).toHaveBeenCalledWith(
it('creates sandpack client', () => {
expect(SandpackClient).toHaveBeenCalledWith(
'#ide-preview',
expectedSandpackOptions(),
expectedSandpackSettings(),
@ -141,8 +141,8 @@ describe('IDE clientside preview', () => {
return waitForPromises();
});
it('creates sandpack manager with bundlerURL', () => {
expect(smooshpack.Manager).toHaveBeenCalledWith('#ide-preview', expectedSandpackOptions(), {
it('creates sandpack client with bundlerURL', () => {
expect(SandpackClient).toHaveBeenCalledWith('#ide-preview', expectedSandpackOptions(), {
...expectedSandpackSettings(),
bundlerURL: TEST_BUNDLER_URL,
});
@ -156,8 +156,8 @@ describe('IDE clientside preview', () => {
return waitForPromises();
});
it('creates sandpack manager', () => {
expect(smooshpack.Manager).toHaveBeenCalledWith(
it('creates sandpack client', () => {
expect(SandpackClient).toHaveBeenCalledWith(
'#ide-preview',
{
files: {},
@ -332,7 +332,7 @@ describe('IDE clientside preview', () => {
});
describe('update', () => {
it('initializes manager if manager is empty', () => {
it('initializes client if client is empty', () => {
createComponent({ getters: { packageJson: dummyPackageJson } });
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
@ -340,7 +340,7 @@ describe('IDE clientside preview', () => {
wrapper.vm.update();
return waitForPromises().then(() => {
expect(smooshpack.Manager).toHaveBeenCalled();
expect(SandpackClient).toHaveBeenCalled();
});
});
@ -349,7 +349,7 @@ describe('IDE clientside preview', () => {
wrapper.vm.update();
expect(wrapper.vm.manager.updatePreview).toHaveBeenCalledWith(wrapper.vm.sandboxOpts);
expect(wrapper.vm.client.updatePreview).toHaveBeenCalledWith(wrapper.vm.sandboxOpts);
});
});
@ -361,7 +361,7 @@ describe('IDE clientside preview', () => {
});
it('calls updatePreview', () => {
expect(wrapper.vm.manager.updatePreview).toHaveBeenCalledWith(wrapper.vm.sandboxOpts);
expect(wrapper.vm.client.updatePreview).toHaveBeenCalledWith(wrapper.vm.sandboxOpts);
});
});
});
@ -405,7 +405,7 @@ describe('IDE clientside preview', () => {
beforeEach(() => {
createInitializedComponent();
spy = wrapper.vm.manager.updatePreview;
spy = wrapper.vm.client.updatePreview;
wrapper.destroy();
});

View File

@ -11,7 +11,7 @@ jest.mock('codesandbox-api', () => ({
describe('IDE clientside preview navigator', () => {
let wrapper;
let manager;
let client;
let listenHandler;
const findBackButton = () => wrapper.findAll('button').at(0);
@ -20,9 +20,9 @@ describe('IDE clientside preview navigator', () => {
beforeEach(() => {
listen.mockClear();
manager = { bundlerURL: TEST_HOST, iframe: { src: '' } };
client = { bundlerURL: TEST_HOST, iframe: { src: '' } };
wrapper = shallowMount(ClientsideNavigator, { propsData: { manager } });
wrapper = shallowMount(ClientsideNavigator, { propsData: { client } });
[[listenHandler]] = listen.mock.calls;
});
@ -31,7 +31,7 @@ describe('IDE clientside preview navigator', () => {
});
it('renders readonly URL bar', async () => {
listenHandler({ type: 'urlchange', url: manager.bundlerURL });
listenHandler({ type: 'urlchange', url: client.bundlerURL });
await nextTick();
expect(wrapper.find('input[readonly]').element.value).toBe('/');
});
@ -89,13 +89,13 @@ describe('IDE clientside preview navigator', () => {
expect(findBackButton().attributes('disabled')).toBe('disabled');
});
it('updates manager iframe src', async () => {
it('updates client iframe src', async () => {
listenHandler({ type: 'urlchange', url: `${TEST_HOST}/url1` });
listenHandler({ type: 'urlchange', url: `${TEST_HOST}/url2` });
await nextTick();
findBackButton().trigger('click');
expect(manager.iframe.src).toBe(`${TEST_HOST}/url1`);
expect(client.iframe.src).toBe(`${TEST_HOST}/url1`);
});
});
@ -133,13 +133,13 @@ describe('IDE clientside preview navigator', () => {
expect(findForwardButton().attributes('disabled')).toBe('disabled');
});
it('updates manager iframe src', async () => {
it('updates client iframe src', async () => {
listenHandler({ type: 'urlchange', url: `${TEST_HOST}/url1` });
listenHandler({ type: 'urlchange', url: `${TEST_HOST}/url2` });
await nextTick();
findBackButton().trigger('click');
expect(manager.iframe.src).toBe(`${TEST_HOST}/url1`);
expect(client.iframe.src).toBe(`${TEST_HOST}/url1`);
});
});
@ -152,10 +152,10 @@ describe('IDE clientside preview navigator', () => {
});
it('calls refresh with current path', () => {
manager.iframe.src = 'something-other';
client.iframe.src = 'something-other';
findRefreshButton().trigger('click');
expect(manager.iframe.src).toBe(url);
expect(client.iframe.src).toBe(url);
});
});
});

View File

@ -42,17 +42,15 @@ describe('Create Timeline events', () => {
const findDatePicker = () => wrapper.findComponent(GlDatepicker);
const findNoteInput = () => wrapper.findByTestId('input-note');
const setNoteInput = () => {
const textarea = findNoteInput().element;
textarea.value = mockInputData.note;
textarea.dispatchEvent(new Event('input'));
findNoteInput().setValue(mockInputData.note);
};
const findHourInput = () => wrapper.findByTestId('input-hours');
const findMinuteInput = () => wrapper.findByTestId('input-minutes');
const setDatetime = () => {
const inputDate = new Date(mockInputData.occurredAt);
findDatePicker().vm.$emit('input', inputDate);
findHourInput().vm.$emit('input', inputDate.getHours());
findMinuteInput().vm.$emit('input', inputDate.getMinutes());
findHourInput().setValue(inputDate.getHours());
findMinuteInput().setValue(inputDate.getMinutes());
};
const fillForm = () => {
setDatetime();

View File

@ -0,0 +1,44 @@
import EditTimelineEvent from '~/issues/show/components/incidents/edit_timeline_event.vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import TimelineEventsForm from '~/issues/show/components/incidents/timeline_events_form.vue';
import { mockEvents, fakeEventData, mockInputData } from './mock_data';
describe('Edit Timeline events', () => {
let wrapper;
const mountComponent = () => {
wrapper = mountExtended(EditTimelineEvent, {
propsData: {
event: mockEvents[0],
editTimelineEventActive: false,
},
});
};
beforeEach(() => {
mountComponent();
});
const findTimelineEventsForm = () => wrapper.findComponent(TimelineEventsForm);
const mockSaveData = { ...fakeEventData, ...mockInputData };
describe('editTimelineEvent', () => {
const saveEventEvent = { 'handle-save-edit': [[mockSaveData, false]] };
it('should call the mutation with the right variables', async () => {
await findTimelineEventsForm().vm.$emit('save-event', mockSaveData, false);
expect(wrapper.emitted()).toEqual(saveEventEvent);
});
it('should close the form on cancel', async () => {
const cancelEvent = { 'hide-edit': [[]] };
await findTimelineEventsForm().vm.$emit('cancel');
expect(wrapper.emitted()).toEqual(cancelEvent);
});
});
});

View File

@ -49,6 +49,15 @@ export const mockEvents = [
},
];
const mockUpdatedEvent = {
id: 'gid://gitlab/IncidentManagement::TimelineEvent/8',
note: 'another one23',
noteHtml: '<p>another one23</p>',
action: 'comment',
occurredAt: '2022-07-01T12:47:00Z',
createdAt: '2022-07-20T12:47:40Z',
};
export const timelineEventsQueryListResponse = {
data: {
project: {
@ -93,6 +102,29 @@ export const timelineEventsCreateEventError = {
},
};
export const timelineEventsEditEventResponse = {
data: {
timelineEventUpdate: {
timelineEvent: {
...mockUpdatedEvent,
},
errors: [],
__typename: 'TimelineEventUpdatePayload',
},
},
};
export const timelineEventsEditEventError = {
data: {
timelineEventUpdate: {
timelineEvent: {
...mockUpdatedEvent,
},
errors: ['Create error'],
},
},
};
const timelineEventDeleteData = (errors = []) => {
return {
data: {
@ -128,5 +160,10 @@ export const mockGetTimelineData = {
export const fakeDate = '2020-07-08T00:00:00.000Z';
export const mockInputData = {
note: 'test',
occurredAt: '2020-08-10T02:30:00.000Z',
};
const { id, note, occurredAt } = mockEvents[0];
export const fakeEventData = { id, note, occurredAt };

View File

@ -23,7 +23,7 @@ describe('Timeline events form', () => {
const mountComponent = ({ mountMethod = shallowMountExtended }) => {
wrapper = mountMethod(TimelineEventsForm, {
propsData: {
hasTimelineEvents: true,
showSaveAndAdd: true,
isEventProcessed: false,
},
});
@ -42,8 +42,8 @@ describe('Timeline events form', () => {
const findMinuteInput = () => wrapper.findByTestId('input-minutes');
const setDatetime = () => {
findDatePicker().vm.$emit('input', mockInputDate);
findHourInput().vm.$emit('input', 5);
findMinuteInput().vm.$emit('input', 45);
findHourInput().setValue(5);
findMinuteInput().setValue(45);
};
const submitForm = async () => {

View File

@ -15,7 +15,6 @@ describe('IncidentTimelineEventList', () => {
action,
noteHtml,
occurredAt,
isLastItem: false,
...propsData,
},
provide: {
@ -26,7 +25,6 @@ describe('IncidentTimelineEventList', () => {
};
const findCommentIcon = () => wrapper.findComponent(GlIcon);
const findTextContainer = () => wrapper.findByTestId('event-text-container');
const findEventTime = () => wrapper.findByTestId('event-time');
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDeleteButton = () => wrapper.findByText('Delete');
@ -50,20 +48,6 @@ describe('IncidentTimelineEventList', () => {
expect(findEventTime().text()).toBe('15:59 UTC');
});
describe('last item in list', () => {
it('shows a bottom border when not the last item', () => {
mountComponent();
expect(findTextContainer().classes()).toContain('gl-border-1');
});
it('does not show a bottom border when the last item', () => {
mountComponent({ propsData: { isLastItem: true } });
expect(wrapper.classes()).not.toContain('gl-border-1');
});
});
describe.each`
timezone
${'Europe/London'}

View File

@ -4,10 +4,11 @@ import Vue from 'vue';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import IncidentTimelineEventList from '~/issues/show/components/incidents/timeline_events_list.vue';
import IncidentTimelineEventItem from '~/issues/show/components/incidents/timeline_events_item.vue';
import EditTimelineEvent from '~/issues/show/components/incidents/edit_timeline_event.vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import deleteTimelineEventMutation from '~/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql';
import getTimelineEvents from '~/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql';
import editTimelineEventMutation from '~/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { useFakeDate } from 'helpers/fake_date';
import { createAlert } from '~/flash';
@ -15,9 +16,11 @@ import {
mockEvents,
timelineEventsDeleteEventResponse,
timelineEventsDeleteEventError,
timelineEventsEditEventResponse,
timelineEventsEditEventError,
fakeDate,
fakeEventData,
timelineEventsQueryListResponse,
mockInputData,
} from './mock_data';
Vue.use(VueApollo);
@ -32,20 +35,15 @@ const mockConfirmAction = ({ confirmed }) => {
describe('IncidentTimelineEventList', () => {
useFakeDate(fakeDate);
let wrapper;
const responseSpy = jest.fn().mockResolvedValue(timelineEventsDeleteEventResponse);
const deleteResponseSpy = jest.fn().mockResolvedValue(timelineEventsDeleteEventResponse);
const editResponseSpy = jest.fn().mockResolvedValue(timelineEventsEditEventResponse);
const requestHandlers = [[deleteTimelineEventMutation, responseSpy]];
const requestHandlers = [
[deleteTimelineEventMutation, deleteResponseSpy],
[editTimelineEventMutation, editResponseSpy],
];
const apolloProvider = createMockApollo(requestHandlers);
apolloProvider.clients.defaultClient.cache.writeQuery({
query: getTimelineEvents,
data: timelineEventsQueryListResponse.data,
variables: {
fullPath: 'group/project',
incidentId: 'gid://gitlab/Issue/1',
},
});
const mountComponent = () => {
wrapper = mountExtended(IncidentTimelineEventList, {
propsData: {
@ -70,6 +68,10 @@ describe('IncidentTimelineEventList', () => {
await waitForPromises();
};
const clickFirstEditButton = async () => {
findItems().at(0).vm.$emit('edit');
await waitForPromises();
};
beforeEach(() => {
mountComponent();
});
@ -86,12 +88,6 @@ describe('IncidentTimelineEventList', () => {
expect(findItems(findSecondTimelineEventGroup())).toHaveLength(2);
});
it('sets the isLastItem prop correctly', () => {
expect(findItems().at(0).props('isLastItem')).toBe(false);
expect(findItems().at(1).props('isLastItem')).toBe(false);
expect(findItems().at(2).props('isLastItem')).toBe(true);
});
it('sets the event props correctly', () => {
expect(findItems().at(1).props('occurredAt')).toBe(mockEvents[1].occurredAt);
expect(findItems().at(1).props('action')).toBe(mockEvents[1].action);
@ -133,7 +129,7 @@ describe('IncidentTimelineEventList', () => {
const expectedVars = { input: { id: mockEvents[0].id } };
await clickFirstDeleteButton();
expect(responseSpy).toHaveBeenCalledWith(expectedVars);
expect(deleteResponseSpy).toHaveBeenCalledWith(expectedVars);
});
it('should show an error when delete returns an error', async () => {
@ -141,7 +137,7 @@ describe('IncidentTimelineEventList', () => {
message: 'Error deleting incident timeline event: Item does not exist',
};
responseSpy.mockResolvedValue(timelineEventsDeleteEventError);
deleteResponseSpy.mockResolvedValue(timelineEventsDeleteEventError);
await clickFirstDeleteButton();
@ -154,7 +150,7 @@ describe('IncidentTimelineEventList', () => {
error: new Error(),
message: 'Something went wrong while deleting the incident timeline event.',
};
responseSpy.mockRejectedValueOnce();
deleteResponseSpy.mockRejectedValueOnce();
await clickFirstDeleteButton();
@ -162,4 +158,76 @@ describe('IncidentTimelineEventList', () => {
});
});
});
describe('Edit Functionality', () => {
beforeEach(() => {
mountComponent();
clickFirstEditButton();
});
const findEditEvent = () => wrapper.findComponent(EditTimelineEvent);
const mockSaveData = { ...fakeEventData, ...mockInputData };
describe('editTimelineEvent', () => {
it('should call the mutation with the right variables', async () => {
await findEditEvent().vm.$emit('handle-save-edit', mockSaveData);
await waitForPromises();
expect(editResponseSpy).toHaveBeenCalledWith({
input: mockSaveData,
});
});
it('should close the form on successful addition', async () => {
await findEditEvent().vm.$emit('handle-save-edit', mockSaveData);
await waitForPromises();
expect(findEditEvent().exists()).toBe(false);
});
it('should close the form on cancel', async () => {
await findEditEvent().vm.$emit('hide-edit');
await waitForPromises();
expect(findEditEvent().exists()).toBe(false);
});
});
describe('error handling', () => {
it('should show an error when submission returns an error', async () => {
const expectedAlertArgs = {
message: `Error updating incident timeline event: ${timelineEventsEditEventError.data.timelineEventUpdate.errors[0]}`,
};
editResponseSpy.mockResolvedValueOnce(timelineEventsEditEventError);
await findEditEvent().vm.$emit('handle-save-edit', mockSaveData);
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs);
});
it('should show an error when submission fails', async () => {
const expectedAlertArgs = {
captureError: true,
error: new Error(),
message: 'Something went wrong while updating the incident timeline event.',
};
editResponseSpy.mockRejectedValueOnce();
await findEditEvent().vm.$emit('handle-save-edit', mockSaveData);
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs);
});
it('should keep the form open on failed addition', async () => {
editResponseSpy.mockResolvedValueOnce(timelineEventsEditEventError);
await findEditEvent().vm.$emit('handle-save-edit', mockSaveData);
await waitForPromises();
expect(findEditEvent().exists()).toBe(true);
});
});
});
});

View File

@ -143,22 +143,13 @@ describe('TimelineEventsTab', () => {
});
it('should not show a form by default', () => {
expect(findCreateTimelineEvent().isVisible()).toBe(false);
expect(findCreateTimelineEvent().exists()).toBe(false);
});
it('should show a form when button is clicked', async () => {
await findAddEventButton().trigger('click');
expect(findCreateTimelineEvent().isVisible()).toBe(true);
});
it('should clear the form when button is clicked', async () => {
const mockClear = jest.fn();
wrapper.vm.$refs.createEventForm.clearForm = mockClear;
await findAddEventButton().trigger('click');
expect(mockClear).toHaveBeenCalled();
expect(findCreateTimelineEvent().exists()).toBe(true);
});
it('should hide the form when the hide event is emitted', async () => {
@ -167,7 +158,7 @@ describe('TimelineEventsTab', () => {
await findCreateTimelineEvent().vm.$emit('hide-new-timeline-events-form');
expect(findCreateTimelineEvent().isVisible()).toBe(false);
expect(findCreateTimelineEvent().exists()).toBe(false);
});
});
});

View File

@ -2,7 +2,7 @@ import timezoneMock from 'timezone-mock';
import {
displayAndLogError,
getEventIcon,
getUtcShiftedDateNow,
getUtcShiftedDate,
} from '~/issues/show/components/incidents/utils';
import { createAlert } from '~/flash';
@ -34,7 +34,7 @@ describe('incident utils', () => {
});
});
describe('getUtcShiftedDateNow', () => {
describe('getUtcShiftedDate', () => {
beforeEach(() => {
timezoneMock.register('US/Pacific');
});
@ -46,7 +46,7 @@ describe('incident utils', () => {
it('should shift the date by the timezone offset', () => {
const date = new Date();
const shiftedDate = getUtcShiftedDateNow();
const shiftedDate = getUtcShiftedDate();
expect(shiftedDate > date).toBe(true);
});

View File

@ -38,7 +38,7 @@ export default [
'</tr>\n',
'</table>',
].join(''),
output: '<table>',
output: '<table data-myattr=&quot;XSS&quot;>',
},
],
// Note: style is sanitized out
@ -98,7 +98,7 @@ export default [
'</svg>',
].join(),
output:
'<svg xmlns="http://www.w3.org/2000/svg" width="388.84pt" version="1.0" id="svg2" height="115.02pt">',
'<svg height=&quot;115.02pt&quot; id=&quot;svg2&quot; version=&quot;1.0&quot; width=&quot;388.84pt&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;>',
},
],
];

View File

@ -49,15 +49,17 @@ describe('Output component', () => {
const htmlType = json.cells[4];
createComponent(htmlType.outputs[0]);
expect(wrapper.findAll('p')).toHaveLength(1);
expect(wrapper.text()).toContain('test');
const iframe = wrapper.find('iframe');
expect(iframe.exists()).toBe(true);
expect(iframe.element.getAttribute('sandbox')).toBe('');
expect(iframe.element.getAttribute('srcdoc')).toBe('<p>test</p>');
});
it('renders multiple raw HTML outputs', () => {
const htmlType = json.cells[4];
createComponent([htmlType.outputs[0], htmlType.outputs[0]]);
expect(wrapper.findAll('p')).toHaveLength(2);
expect(wrapper.findAll('iframe')).toHaveLength(2);
});
});
@ -84,7 +86,11 @@ describe('Output component', () => {
});
it('renders as an svg', () => {
expect(wrapper.find('svg').exists()).toBe(true);
const iframe = wrapper.find('iframe');
expect(iframe.exists()).toBe(true);
expect(iframe.element.getAttribute('sandbox')).toBe('');
expect(iframe.element.getAttribute('srcdoc')).toBe('<svg></svg>');
});
});

View File

@ -320,7 +320,7 @@ RSpec.describe CommitsHelper do
let(:current_path) { "test" }
before do
expect(commit).to receive(:status_for).with(ref).and_return(commit_status)
expect(commit).to receive(:detailed_status_for).with(ref).and_return(commit_status)
assign(:path, current_path)
end

View File

@ -112,6 +112,14 @@ RSpec.describe LabelsHelper do
end
end
describe 'render_label_text' do
it 'html escapes the bg_color correctly' do
xss_payload = '"><img src=x onerror=prompt(1)>'
label_text = render_label_text('xss', bg_color: xss_payload)
expect(label_text).to include(html_escape(xss_payload))
end
end
describe 'text_color_for_bg' do
it 'uses light text on dark backgrounds' do
expect(text_color_for_bg('#222E2E')).to be_color('#FFFFFF')

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Rack VULNDB-255039' do
context 'when handling query params in GET requests' do
it 'does not treat semicolons as query delimiters' do
env = ::Rack::MockRequest.env_for('http://gitlab.com?a=b;c=1')
query_hash = ::Rack::Request.new(env).GET
# Prior to this patch, this was splitting around the semicolon, which
# would return {"a"=>"b", "c"=>"1"}
expect(query_hash).to eq({ "a" => "b;c=1" })
end
end
end

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require 'sawyer'
require_relative '../../config/initializers/sawyer_patch'
RSpec.describe 'sawyer_patch' do
it 'raises error when acessing a method that overlaps a Ruby method' do
sawyer_resource = Sawyer::Resource.new(
Sawyer::Agent.new(''),
{
to_s: 'Overriding method',
user: { to_s: 'Overriding method', name: 'User name' }
}
)
error_message = 'Sawyer method "to_s" overlaps Ruby method. Convert to a hash to access the attribute.'
expect { sawyer_resource.to_s }.to raise_error(Sawyer::Error, error_message)
expect { sawyer_resource.to_s? }.to raise_error(Sawyer::Error, error_message)
expect { sawyer_resource.to_s = 'new value' }.to raise_error(Sawyer::Error, error_message)
expect { sawyer_resource.user.to_s }.to raise_error(Sawyer::Error, error_message)
expect(sawyer_resource.user.name).to eq('User name')
end
it 'raises error when acessing a boolean method that overlaps a Ruby method' do
sawyer_resource = Sawyer::Resource.new(
Sawyer::Agent.new(''),
{
nil?: 'value'
}
)
expect { sawyer_resource.nil? }.to raise_error(Sawyer::Error)
end
it 'raises error when acessing a method that expects an argument' do
sawyer_resource = Sawyer::Resource.new(
Sawyer::Agent.new(''),
{
'user': 'value',
'user=': 'value',
'==': 'value',
'!=': 'value',
'+': 'value'
}
)
expect(sawyer_resource.user).to eq('value')
expect { sawyer_resource.user = 'New user' }.to raise_error(ArgumentError)
expect { sawyer_resource == true }.to raise_error(ArgumentError)
expect { sawyer_resource != true }.to raise_error(ArgumentError)
expect { sawyer_resource + 1 }.to raise_error(ArgumentError)
end
it 'does not raise error if is not an overlapping method' do
sawyer_resource = Sawyer::Resource.new(
Sawyer::Agent.new(''),
{
count_total: 1,
user: { name: 'User name' }
}
)
expect(sawyer_resource.count_total).to eq(1)
expect(sawyer_resource.count_total?).to eq(true)
expect(sawyer_resource.count_total + 1).to eq(2)
expect(sawyer_resource.user.name).to eq('User name')
end
end

View File

@ -18,10 +18,20 @@ RSpec.describe Banzai::Filter::CommitTrailersFilter do
context 'detects' do
let(:email) { FFaker::Internet.email }
it 'trailers in the form of *-by and replace users with links' do
doc = filter(commit_message_html)
context 'trailers in the form of *-by' do
where(:commit_trailer) do
["#{FFaker::Lorem.word}-by:", "#{FFaker::Lorem.word}-BY:", "#{FFaker::Lorem.word}-By:"]
end
expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer)
with_them do
let(:trailer) { commit_trailer }
it 'replaces users with links' do
doc = filter(commit_message_html)
expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer)
end
end
end
it 'trailers prefixed with whitespaces' do
@ -121,7 +131,7 @@ RSpec.describe Banzai::Filter::CommitTrailersFilter do
context "ignores" do
it 'commit messages without trailers' do
exp = message = commit_html(FFaker::Lorem.sentence)
exp = message = commit_html(Array.new(5) { FFaker::Lorem.sentence }.join("\n"))
doc = filter(message)
expect(doc.to_html).to match Regexp.escape(exp)

View File

@ -92,5 +92,50 @@ RSpec.describe Banzai::Filter::ImageLinkFilter do
expect(doc.at_css('a')['class']).to match(%r{with-attachment-icon})
end
context 'when link attributes contain malicious code' do
let(:malicious_code) do
# rubocop:disable Layout/LineLength
%q(<a class='fixed-top fixed-bottom' data-create-path=/malicious-url><style> .tab-content>.tab-pane{display: block !important}</style>)
# rubocop:enable Layout/LineLength
end
context 'when image alt contains malicious code' do
it 'ignores image alt and uses image path as the link text', :aggregate_failures do
doc = filter(image(path, alt: malicious_code), context)
expect(doc.to_html).to match(%r{^<a[^>]*>#{path}</a>$})
expect(doc.at_css('a')['href']).to eq(path)
end
end
context 'when image src contains malicious code' do
it 'ignores image src and does not use it as the link text' do
doc = filter(image(malicious_code), context)
expect(doc.to_html).to match(%r{^<a[^>]*></a>$})
end
it 'keeps image src unchanged, malicious code does not execute as part of url' do
doc = filter(image(malicious_code), context)
expect(doc.at_css('a')['href']).to eq(malicious_code)
end
end
context 'when image data-src contains malicious code' do
it 'ignores data-src and uses image path as the link text', :aggregate_failures do
doc = filter(image(path, data_src: malicious_code), context)
expect(doc.to_html).to match(%r{^<a[^>]*>#{path}</a>$})
end
it 'uses image data-src, malicious code does not execute as part of url' do
doc = filter(image(path, data_src: malicious_code), context)
expect(doc.at_css('a')['href']).to eq(malicious_code)
end
end
end
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Banzai::Filter::PathologicalMarkdownFilter do
include FilterSpecHelper
let_it_be(:short_text) { '![a' * 5 }
let_it_be(:long_text) { ([short_text] * 10).join(' ') }
let_it_be(:with_images_text) { "![One ![one](one.jpg) #{'and\n' * 200} ![two ![two](two.jpg)" }
it 'detects a significat number of unclosed image links' do
msg = <<~TEXT
_Unable to render markdown - too many unclosed markdown image links detected._
TEXT
expect(filter(long_text)).to eq(msg.strip)
end
it 'does nothing when there are only a few unclosed image links' do
expect(filter(short_text)).to eq(short_text)
end
it 'does nothing when there are only a few unclosed image links and images' do
expect(filter(with_images_text)).to eq(with_images_text)
end
end

View File

@ -167,4 +167,16 @@ RSpec.describe Banzai::Pipeline::FullPipeline do
expect(output).to include('<em>@test_</em>')
end
end
describe 'unclosed image links' do
it 'detects a significat number of unclosed image links' do
markdown = '![a ' * 30
msg = <<~TEXT
Unable to render markdown - too many unclosed markdown image links detected.
TEXT
output = described_class.to_html(markdown, project: nil)
expect(output).to include(msg.strip)
end
end
end

View File

@ -9,12 +9,13 @@ RSpec.describe Gitlab::Git::Tree do
let(:repository) { project.repository.raw }
shared_examples :repo do
subject(:tree) { Gitlab::Git::Tree.where(repository, sha, path, recursive, pagination_params) }
subject(:tree) { Gitlab::Git::Tree.where(repository, sha, path, recursive, skip_flat_paths, pagination_params) }
let(:sha) { SeedRepo::Commit::ID }
let(:path) { nil }
let(:recursive) { false }
let(:pagination_params) { nil }
let(:skip_flat_paths) { false }
let(:entries) { tree.first }
let(:cursor) { tree.second }
@ -107,6 +108,12 @@ RSpec.describe Gitlab::Git::Tree do
end
it { expect(subdir_file.flat_path).to eq('files/flat/path/correct') }
context 'when skip_flat_paths is true' do
let(:skip_flat_paths) { true }
it { expect(subdir_file.flat_path).to be_blank }
end
end
end
@ -162,7 +169,7 @@ RSpec.describe Gitlab::Git::Tree do
allow(instance).to receive(:lookup).with(SeedRepo::Commit::ID)
end
described_class.where(repository, SeedRepo::Commit::ID, 'files', false)
described_class.where(repository, SeedRepo::Commit::ID, 'files', false, false)
end
it_behaves_like :repo do
@ -180,7 +187,7 @@ RSpec.describe Gitlab::Git::Tree do
let(:entries_count) { entries.count }
it 'returns all entries without a cursor' do
result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: entries_count, page_token: nil })
result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, skip_flat_paths, { limit: entries_count, page_token: nil })
expect(cursor).to be_nil
expect(result.entries.count).to eq(entries_count)
@ -209,7 +216,7 @@ RSpec.describe Gitlab::Git::Tree do
let(:entries_count) { entries.count }
it 'returns all entries' do
result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: -1, page_token: nil })
result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, skip_flat_paths, { limit: -1, page_token: nil })
expect(result.count).to eq(entries_count)
expect(cursor).to be_nil
@ -220,7 +227,7 @@ RSpec.describe Gitlab::Git::Tree do
let(:token) { entries.second.id }
it 'returns all entries after token' do
result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: -1, page_token: token })
result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, skip_flat_paths, { limit: -1, page_token: token })
expect(result.count).to eq(entries.count - 2)
expect(cursor).to be_nil
@ -252,7 +259,7 @@ RSpec.describe Gitlab::Git::Tree do
expected_entries = entries
loop do
result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: 5, page_token: token })
result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, skip_flat_paths, { limit: 5, page_token: token })
collected_entries += result.entries
token = cursor&.next_cursor

View File

@ -150,16 +150,18 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
end
describe '#tree_entries' do
subject { client.tree_entries(repository, revision, path, recursive, pagination_params) }
subject { client.tree_entries(repository, revision, path, recursive, skip_flat_paths, pagination_params) }
let(:path) { '/' }
let(:recursive) { false }
let(:pagination_params) { nil }
let(:skip_flat_paths) { false }
it 'sends a get_tree_entries message' do
it 'sends a get_tree_entries message with default limit' do
expected_pagination_params = Gitaly::PaginationParameter.new(limit: Gitlab::GitalyClient::CommitService::TREE_ENTRIES_DEFAULT_LIMIT)
expect_any_instance_of(Gitaly::CommitService::Stub)
.to receive(:get_tree_entries)
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
.with(gitaly_request_with_params({ pagination_params: expected_pagination_params }), kind_of(Hash))
.and_return([])
is_expected.to eq([[], nil])
@ -189,9 +191,10 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
pagination_cursor: pagination_cursor
)
expected_pagination_params = Gitaly::PaginationParameter.new(limit: 3)
expect_any_instance_of(Gitaly::CommitService::Stub)
.to receive(:get_tree_entries)
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
.with(gitaly_request_with_params({ pagination_params: expected_pagination_params }), kind_of(Hash))
.and_return([response])
is_expected.to eq([[], pagination_cursor])

View File

@ -72,4 +72,18 @@ RSpec.describe Gitlab::ReactiveCacheSetCache, :clean_gitlab_redis_cache do
it { is_expected.to be(true) }
end
end
describe 'count' do
subject { cache.count(cache_prefix) }
it { is_expected.to be(0) }
context 'item added' do
before do
cache.write(cache_prefix, 'test_item')
end
it { is_expected.to be(1) }
end
end
end

View File

@ -2,17 +2,21 @@
require 'spec_helper'
RSpec.describe Gitlab::Zentao::Client do
subject(:integration) { described_class.new(zentao_integration) }
RSpec.describe Gitlab::Zentao::Client, :clean_gitlab_redis_cache do
subject(:client) { described_class.new(zentao_integration) }
let(:zentao_integration) { create(:zentao_integration) }
def mock_get_products_url
integration.send(:url, "products/#{zentao_integration.zentao_product_xid}")
client.send(:url, "products/#{zentao_integration.zentao_product_xid}")
end
def mock_fetch_issues_url
client.send(:url, "products/#{zentao_integration.zentao_product_xid}/issues")
end
def mock_fetch_issue_url(issue_id)
integration.send(:url, "issues/#{issue_id}")
client.send(:url, "issues/#{issue_id}")
end
let(:mock_headers) do
@ -29,13 +33,13 @@ RSpec.describe Gitlab::Zentao::Client do
let(:zentao_integration) { nil }
it 'raises ConfigError' do
expect { integration }.to raise_error(described_class::ConfigError)
expect { client }.to raise_error(described_class::ConfigError)
end
end
context 'integration is provided' do
it 'is initialized successfully' do
expect { integration }.not_to raise_error
expect { client }.not_to raise_error
end
end
end
@ -50,7 +54,7 @@ RSpec.describe Gitlab::Zentao::Client do
end
it 'fetches the product' do
expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq mock_response
expect(client.fetch_product(zentao_integration.zentao_product_xid)).to eq mock_response
end
end
@ -62,8 +66,8 @@ RSpec.describe Gitlab::Zentao::Client do
it 'fetches the empty product' do
expect do
integration.fetch_product(zentao_integration.zentao_product_xid)
end.to raise_error(Gitlab::Zentao::Client::Error, 'request error')
client.fetch_product(zentao_integration.zentao_product_xid)
end.to raise_error(Gitlab::Zentao::Client::RequestError)
end
end
@ -75,7 +79,7 @@ RSpec.describe Gitlab::Zentao::Client do
it 'fetches the empty product' do
expect do
integration.fetch_product(zentao_integration.zentao_product_xid)
client.fetch_product(zentao_integration.zentao_product_xid)
end.to raise_error(Gitlab::Zentao::Client::Error, 'invalid response format')
end
end
@ -89,7 +93,7 @@ RSpec.describe Gitlab::Zentao::Client do
end
it 'responds with success' do
expect(integration.ping[:success]).to eq true
expect(client.ping[:success]).to eq true
end
end
@ -100,7 +104,69 @@ RSpec.describe Gitlab::Zentao::Client do
end
it 'responds with unsuccess' do
expect(integration.ping[:success]).to eq false
expect(client.ping[:success]).to eq false
end
end
end
describe '#fetch_issues' do
let(:mock_response) { { 'issues' => [{ 'id' => 'story-1' }, { 'id' => 'bug-11' }] } }
before do
WebMock.stub_request(:get, mock_fetch_issues_url)
.with(mock_headers).to_return(status: 200, body: mock_response.to_json)
end
it 'returns the response' do
expect(client.fetch_issues).to eq(mock_response)
end
describe 'marking the issues as seen in the product' do
let(:cache) { ::Gitlab::SetCache.new }
let(:cache_key) do
[
:zentao_product_issues,
OpenSSL::Digest::SHA256.hexdigest(zentao_integration.client_url),
zentao_integration.zentao_product_xid
].join(':')
end
it 'adds issue ids to the cache' do
expect { client.fetch_issues }.to change { cache.read(cache_key) }
.from(be_empty)
.to match_array(%w[bug-11 story-1])
end
it 'does not add issue ids to the cache if max set size has been reached' do
cache.write(cache_key, %w[foo bar])
stub_const("#{described_class}::CACHE_MAX_SET_SIZE", 1)
client.fetch_issues
expect(cache.read(cache_key)).to match_array(%w[foo bar])
end
it 'does not duplicate issue ids in the cache' do
client.fetch_issues
client.fetch_issues
expect(cache.read(cache_key)).to match_array(%w[bug-11 story-1])
end
it 'touches the cache ttl every time issues are fetched' do
fresh_ttl = 1.month.to_i
freeze_time do
client.fetch_issues
expect(cache.ttl(cache_key)).to eq(fresh_ttl)
end
travel_to(1.minute.from_now) do
client.fetch_issues
expect(cache.ttl(cache_key)).to eq(fresh_ttl)
end
end
end
end
@ -109,9 +175,9 @@ RSpec.describe Gitlab::Zentao::Client do
context 'with invalid id' do
let(:invalid_ids) { ['story', 'story-', '-', '123', ''] }
it 'returns empty object' do
it 'raises Error' do
invalid_ids.each do |id|
expect { integration.fetch_issue(id) }
expect { client.fetch_issue(id) }
.to raise_error(Gitlab::Zentao::Client::Error, 'invalid issue id')
end
end
@ -120,12 +186,31 @@ RSpec.describe Gitlab::Zentao::Client do
context 'with valid id' do
let(:valid_ids) { %w[story-1 bug-23] }
it 'fetches current issue' do
valid_ids.each do |id|
WebMock.stub_request(:get, mock_fetch_issue_url(id))
.with(mock_headers).to_return(status: 200, body: { issue: { id: id } }.to_json)
context 'when issue has been seen on the index' do
before do
issues_body = { issues: valid_ids.map { { id: _1 } } }.to_json
expect(integration.fetch_issue(id).dig('issue', 'id')).to eq id
WebMock.stub_request(:get, mock_fetch_issues_url)
.with(mock_headers).to_return(status: 200, body: issues_body)
client.fetch_issues
end
it 'fetches the issue' do
valid_ids.each do |id|
WebMock.stub_request(:get, mock_fetch_issue_url(id))
.with(mock_headers).to_return(status: 200, body: { issue: { id: id } }.to_json)
expect(client.fetch_issue(id).dig('issue', 'id')).to eq id
end
end
end
context 'when issue has not been seen on the index' do
it 'raises RequestError' do
valid_ids.each do |id|
expect { client.fetch_issue(id) }.to raise_error(Gitlab::Zentao::Client::RequestError)
end
end
end
end
@ -135,7 +220,7 @@ RSpec.describe Gitlab::Zentao::Client do
context 'api url' do
shared_examples 'joins api_url correctly' do
it 'verify url' do
expect(integration.send(:url, "products/1").to_s)
expect(client.send(:url, "products/1").to_s)
.to eq("https://jihudemo.zentao.net/zentao/api.php/v1/products/1")
end
end
@ -157,7 +242,7 @@ RSpec.describe Gitlab::Zentao::Client do
let(:zentao_integration) { create(:zentao_integration, url: 'https://jihudemo.zentao.net') }
it 'joins url correctly' do
expect(integration.send(:url, "products/1").to_s)
expect(client.send(:url, "products/1").to_s)
.to eq("https://jihudemo.zentao.net/api.php/v1/products/1")
end
end

View File

@ -81,4 +81,24 @@ RSpec.describe Integrations::Zentao do
expect(zentao_integration.help).not_to be_empty
end
end
describe '#client_url' do
subject(:integration) { build(:zentao_integration, api_url: api_url, url: 'url').client_url }
context 'when api_url is set' do
let(:api_url) { 'api_url' }
it 'returns the api_url' do
is_expected.to eq(api_url)
end
end
context 'when api_url is not set' do
let(:api_url) { '' }
it 'returns the url' do
is_expected.to eq('url')
end
end
end
end

View File

@ -831,13 +831,21 @@ RSpec.describe Issue do
end
describe '#to_branch_name exists ending with -index' do
before do
it 'returns #to_branch_name ending with max index + 1' do
allow(repository).to receive(:branch_exists?).and_return(true)
allow(repository).to receive(:branch_exists?).with("#{subject.to_branch_name}-3").and_return(false)
expect(subject.suggested_branch_name).to eq("#{subject.to_branch_name}-3")
end
it 'returns #to_branch_name ending with max index + 1' do
expect(subject.suggested_branch_name).to eq("#{subject.to_branch_name}-3")
context 'when branch name still exists after 5 attempts' do
it 'returns #to_branch_name ending with random characters' do
allow(repository).to receive(:branch_exists?).with(subject.to_branch_name).and_return(true)
allow(repository).to receive(:branch_exists?).with(/#{subject.to_branch_name}-\d/).and_return(true)
allow(repository).to receive(:branch_exists?).with(/#{subject.to_branch_name}-\h{8}/).and_return(false)
expect(subject.suggested_branch_name).to match(/#{subject.to_branch_name}-\h{8}/)
end
end
end
end

View File

@ -2625,7 +2625,7 @@ RSpec.describe Repository do
end
shared_examples '#tree' do
subject { repository.tree(sha, path, recursive: recursive, pagination_params: pagination_params) }
subject { repository.tree(sha, path, recursive: recursive, skip_flat_paths: false, pagination_params: pagination_params) }
let(:sha) { :head }
let(:path) { nil }

View File

@ -91,6 +91,45 @@ RSpec.describe Snippet do
end
end
end
context 'description validations' do
let_it_be(:invalid_description) { 'a' * (described_class::DESCRIPTION_LENGTH_MAX * 2) }
context 'with existing snippets' do
let(:snippet) { create(:personal_snippet, description: 'This is a valid content at the time of creation') }
it 'does not raise a validation error if the description is not changed' do
snippet.title = 'new title'
expect(snippet).to be_valid
end
it 'raises and error if the description is changed and the size is bigger than limit' do
expect(snippet).to be_valid
snippet.description = invalid_description
expect(snippet).not_to be_valid
end
end
context 'with new snippets' do
it 'is valid when description is smaller than the limit' do
snippet = build(:personal_snippet, description: 'Valid Desc')
expect(snippet).to be_valid
end
it 'raises error when description is bigger than setting limit' do
snippet = build(:personal_snippet, description: invalid_description)
aggregate_failures do
expect(snippet).not_to be_valid
expect(snippet.errors.messages_for(:description)).to include("is too long (2 MB). The maximum size is 1 MB.")
end
end
end
end
end
describe 'callbacks' do

View File

@ -12,29 +12,51 @@ RSpec.describe CommitPresenter do
it { expect(presenter.web_path).to eq("/#{project.full_path}/-/commit/#{commit.sha}") }
end
describe '#status_for' do
subject { presenter.status_for('ref') }
describe '#detailed_status_for' do
using RSpec::Parameterized::TableSyntax
context 'when user can read_commit_status' do
before do
allow(presenter).to receive(:can?).with(user, :read_commit_status, project).and_return(true)
end
let(:pipeline) { create(:ci_pipeline, :success, project: project, sha: commit.sha, ref: 'ref') }
it 'returns commit status for ref' do
pipeline = double
status = double
subject { presenter.detailed_status_for('ref')&.text }
expect(commit).to receive(:latest_pipeline).with('ref').and_return(pipeline)
expect(pipeline).to receive(:detailed_status).with(user).and_return(status)
expect(subject).to eq(status)
end
where(:read_commit_status, :read_pipeline, :expected_result) do
true | true | 'passed'
true | false | nil
false | true | nil
false | false | nil
end
context 'when user can not read_commit_status' do
it 'is nil' do
is_expected.to eq(nil)
with_them do
before do
allow(presenter).to receive(:can?).with(user, :read_commit_status, project).and_return(read_commit_status)
allow(presenter).to receive(:can?).with(user, :read_pipeline, pipeline).and_return(read_pipeline)
end
it { is_expected.to eq expected_result }
end
end
describe '#status_for' do
using RSpec::Parameterized::TableSyntax
let(:pipeline) { create(:ci_pipeline, :success, project: project, sha: commit.sha) }
subject { presenter.status_for }
where(:read_commit_status, :read_pipeline, :expected_result) do
true | true | 'success'
true | false | nil
false | true | nil
false | false | nil
end
with_them do
before do
allow(presenter).to receive(:can?).with(user, :read_commit_status, project).and_return(read_commit_status)
allow(presenter).to receive(:can?).with(user, :read_pipeline, pipeline).and_return(read_pipeline)
end
it { is_expected.to eq expected_result }
end
end

View File

@ -6,11 +6,16 @@ RSpec.describe 'getting incident timeline events' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:private_project) { create(:project, :private) }
let_it_be(:issue) { create(:issue, project: private_project) }
let_it_be(:current_user) { create(:user) }
let_it_be(:updated_by_user) { create(:user) }
let_it_be(:incident) { create(:incident, project: project) }
let_it_be(:another_incident) { create(:incident, project: project) }
let_it_be(:promoted_from_note) { create(:note, project: project, noteable: incident) }
let_it_be(:issue_url) { project_issue_url(private_project, issue) }
let_it_be(:issue_ref) { "#{private_project.full_path}##{issue.iid}" }
let_it_be(:issue_link) { %Q(<a href="#{issue_url}">#{issue_url}</a>) }
let_it_be(:timeline_event) do
create(
@ -18,7 +23,8 @@ RSpec.describe 'getting incident timeline events' do
incident: incident,
project: project,
updated_by_user: updated_by_user,
promoted_from_note: promoted_from_note
promoted_from_note: promoted_from_note,
note: "Referencing #{issue.to_reference(full: true)} - Full URL #{issue_url}"
)
end
@ -89,7 +95,7 @@ RSpec.describe 'getting incident timeline events' do
'title' => incident.title
},
'note' => timeline_event.note,
'noteHtml' => timeline_event.note_html,
'noteHtml' => "<p>Referencing #{issue_ref} - Full URL #{issue_link}</p>",
'promotedFromNote' => {
'id' => promoted_from_note.to_global_id.to_s,
'body' => promoted_from_note.note

View File

@ -763,6 +763,96 @@ RSpec.describe API::Search do
it_behaves_like 'pagination', scope: :commits, search: 'merge'
it_behaves_like 'ping counters', scope: :commits
describe 'pipeline visibility' do
shared_examples 'pipeline information visible' do
it 'contains status and last_pipeline' do
request
expect(json_response[0]['status']).to eq 'success'
expect(json_response[0]['last_pipeline']).not_to be_nil
end
end
shared_examples 'pipeline information not visible' do
it 'does not contain status and last_pipeline' do
request
expect(json_response[0]['status']).to be_nil
expect(json_response[0]['last_pipeline']).to be_nil
end
end
let(:request) { get api(endpoint, user), params: { scope: 'commits', search: repo_project.commit.sha } }
before do
create(:ci_pipeline, :success, project: repo_project, sha: repo_project.commit.sha)
end
context 'with non public pipeline' do
let_it_be(:repo_project) do
create(:project, :public, :repository, public_builds: false, group: group)
end
context 'user is project member with reporter role or above' do
before do
repo_project.add_reporter(user)
end
it_behaves_like 'pipeline information visible'
end
context 'user is project member with guest role' do
before do
repo_project.add_guest(user)
end
it_behaves_like 'pipeline information not visible'
end
context 'user is not project member' do
let_it_be(:user) { create(:user) }
it_behaves_like 'pipeline information not visible'
end
end
context 'with public pipeline' do
let_it_be(:repo_project) do
create(:project, :public, :repository, public_builds: true, group: group)
end
context 'user is project member with reporter role or above' do
before do
repo_project.add_reporter(user)
end
it_behaves_like 'pipeline information visible'
end
context 'user is project member with guest role' do
before do
repo_project.add_guest(user)
end
it_behaves_like 'pipeline information visible'
end
context 'user is not project member' do
let_it_be(:user) { create(:user) }
it_behaves_like 'pipeline information visible'
context 'when CI/CD is set to only project members' do
before do
repo_project.project_feature.update!(builds_access_level: ProjectFeature::PRIVATE)
end
it_behaves_like 'pipeline information not visible'
end
end
end
end
end
context 'for commits scope with project path as id' do

View File

@ -643,17 +643,17 @@ RSpec.describe 'Git HTTP requests' do
end
context 'when username and password are provided' do
it 'rejects pulls with personal access token error message' do
it 'rejects pulls with generic error message' do
download(path, user: user.username, password: user.password) do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('You must use a personal access token with \'read_repository\' or \'write_repository\' scope for Git over HTTP')
expect(response.body).to eq('HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/topics/git/troubleshooting_git#error-on-git-fetch-http-basic-access-denied')
end
end
it 'rejects the push attempt with personal access token error message' do
it 'rejects the push attempt with generic error message' do
upload(path, user: user.username, password: user.password) do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('You must use a personal access token with \'read_repository\' or \'write_repository\' scope for Git over HTTP')
expect(response.body).to eq('HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/topics/git/troubleshooting_git#error-on-git-fetch-http-basic-access-denied')
end
end
end
@ -750,17 +750,17 @@ RSpec.describe 'Git HTTP requests' do
allow_any_instance_of(ApplicationSetting).to receive(:password_authentication_enabled_for_git?) { false }
end
it 'rejects pulls with personal access token error message' do
it 'rejects pulls with generic error message' do
download(path, user: 'foo', password: 'bar') do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('You must use a personal access token with \'read_repository\' or \'write_repository\' scope for Git over HTTP')
expect(response.body).to eq('HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/topics/git/troubleshooting_git#error-on-git-fetch-http-basic-access-denied')
end
end
it 'rejects pushes with personal access token error message' do
it 'rejects pushes with generic error message' do
upload(path, user: 'foo', password: 'bar') do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('You must use a personal access token with \'read_repository\' or \'write_repository\' scope for Git over HTTP')
expect(response.body).to eq('HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/topics/git/troubleshooting_git#error-on-git-fetch-http-basic-access-denied')
end
end
@ -771,10 +771,10 @@ RSpec.describe 'Git HTTP requests' do
.to receive(:login).and_return(nil)
end
it 'does not display the personal access token error message' do
it 'displays the generic error message' do
upload(path, user: 'foo', password: 'bar') do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).not_to include('You must use a personal access token with \'read_repository\' or \'write_repository\' scope for Git over HTTP')
expect(response.body).to eq('HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/topics/git/troubleshooting_git#error-on-git-fetch-http-basic-access-denied')
end
end
end
@ -1300,17 +1300,18 @@ RSpec.describe 'Git HTTP requests' do
end
context 'when username and password are provided' do
it 'rejects pulls with personal access token error message' do
it 'rejects pulls with generic error message' do
download(path, user: user.username, password: user.password) do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('You must use a personal access token with \'read_repository\' or \'write_repository\' scope for Git over HTTP')
expect(response.body).to eq('HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/topics/git/troubleshooting_git#error-on-git-fetch-http-basic-access-denied')
end
end
it 'rejects the push attempt with personal access token error message' do
it 'rejects the push attempt with generic error message' do
upload(path, user: user.username, password: user.password) do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('You must use a personal access token with \'read_repository\' or \'write_repository\' scope for Git over HTTP')
expect(response.body).to eq('HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/topics/git/troubleshooting_git#error-on-git-fetch-http-basic-access-denied')
end
end
end
@ -1381,17 +1382,17 @@ RSpec.describe 'Git HTTP requests' do
allow_any_instance_of(ApplicationSetting).to receive(:password_authentication_enabled_for_git?) { false }
end
it 'rejects pulls with personal access token error message' do
it 'rejects pulls with generic error message' do
download(path, user: 'foo', password: 'bar') do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('You must use a personal access token with \'read_repository\' or \'write_repository\' scope for Git over HTTP')
expect(response.body).to eq('HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/topics/git/troubleshooting_git#error-on-git-fetch-http-basic-access-denied')
end
end
it 'rejects pushes with personal access token error message' do
it 'rejects pushes with generic error message' do
upload(path, user: 'foo', password: 'bar') do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('You must use a personal access token with \'read_repository\' or \'write_repository\' scope for Git over HTTP')
expect(response.body).to eq('HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/topics/git/troubleshooting_git#error-on-git-fetch-http-basic-access-denied')
end
end
@ -1402,10 +1403,10 @@ RSpec.describe 'Git HTTP requests' do
.to receive(:login).and_return(nil)
end
it 'does not display the personal access token error message' do
it 'returns a generic error message' do
upload(path, user: 'foo', password: 'bar') do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).not_to include('You must use a personal access token with \'read_repository\' or \'write_repository\' scope for Git over HTTP')
expect(response.body).to eq('HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/topics/git/troubleshooting_git#error-on-git-fetch-http-basic-access-denied')
end
end
end

View File

@ -37,6 +37,22 @@ RSpec.describe JwtController do
end
end
shared_examples "with invalid credentials" do
it "returns a generic error message" do
subject
expect(response).to have_gitlab_http_status(:unauthorized)
expect(json_response).to eq(
{
"errors" => [{
"code" => "UNAUTHORIZED",
"message" => "HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled and you must use a personal access token instead of a password. See http://www.example.com/help/user/profile/account/two_factor_authentication#troubleshooting"
}]
}
)
end
end
context 'authenticating against container registry' do
context 'existing service' do
subject! { get '/jwt/auth', params: parameters }
@ -55,10 +71,7 @@ RSpec.describe JwtController do
context 'with blocked user' do
let(:user) { create(:user, :blocked) }
it 'rejects the request as unauthorized' do
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('HTTP Basic: Access denied')
end
it_behaves_like 'with invalid credentials'
end
end
@ -158,10 +171,7 @@ RSpec.describe JwtController do
let(:user) { create(:user, :two_factor) }
context 'without personal token' do
it 'rejects the authorization attempt' do
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
it_behaves_like 'with invalid credentials'
end
context 'with personal token' do
@ -185,14 +195,10 @@ RSpec.describe JwtController do
context 'using invalid login' do
let(:headers) { { authorization: credentials('invalid', 'password') } }
let(:subject) { get '/jwt/auth', params: parameters, headers: headers }
context 'when internal auth is enabled' do
it 'rejects the authorization attempt' do
get '/jwt/auth', params: parameters, headers: headers
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).not_to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
it_behaves_like 'with invalid credentials'
end
context 'when internal auth is disabled' do
@ -200,12 +206,7 @@ RSpec.describe JwtController do
stub_application_setting(password_authentication_enabled_for_git: false)
end
it 'rejects the authorization attempt with personal access token message' do
get '/jwt/auth', params: parameters, headers: headers
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end
it_behaves_like 'with invalid credentials'
end
end
end

View File

@ -9,6 +9,16 @@ RSpec.describe Glfm::Shared do
end.new
end
describe '#write_file' do
it 'works' do
filename = Dir::Tmpname.create('basename') do |path|
instance.write_file(path, 'test')
end
expect(File.read(filename)).to eq 'test'
end
end
describe '#run_external_cmd' do
it 'works' do
expect(instance.run_external_cmd('echo "hello"')).to eq("hello\n")
@ -24,6 +34,14 @@ RSpec.describe Glfm::Shared do
end
end
describe '#dump_yaml_with_formatting' do
it 'works' do
hash = { a: 'b' }
yaml = instance.dump_yaml_with_formatting(hash, literal_scalars: true)
expect(yaml).to eq("---\na: |-\n b\n")
end
end
describe '#output' do
# NOTE: The #output method is normally always mocked, to prevent output while the specs are
# running. However, in order to provide code coverage for the method, we have to invoke

Some files were not shown because too many files have changed in this diff Show More