Commit 580622bd authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent b211a4ea
......@@ -4,7 +4,6 @@ entry.
## 12.9.2 (2020-03-31)
- No changes.
### Fixed (5 changes)
- Ensure import by URL works after a failed import. !27546
......
......@@ -3,6 +3,9 @@ import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import Cookies from 'js-cookie';
import { glEmojiTag } from '~/emoji';
import Tracking from '~/tracking';
const trackingMixin = Tracking.mixin();
export default {
beginnerLink:
......@@ -23,6 +26,7 @@ export default {
GlSprintf,
GlLink,
},
mixins: [trackingMixin],
props: {
goToPipelinesPath: {
type: String,
......@@ -32,8 +36,21 @@ export default {
type: String,
required: true,
},
humanAccess: {
type: String,
required: true,
},
},
data() {
return {
tracking: {
label: 'congratulate_first_pipeline',
property: this.humanAccess,
},
};
},
mounted() {
this.track();
this.disableModalFromRenderingAgain();
},
methods: {
......
import initRegistryImages from '~/registry/list/index';
import registryExplorer from '~/registry/explorer/index';
document.addEventListener('DOMContentLoaded', () => {
initRegistryImages();
const { attachMainComponent, attachBreadcrumb } = registryExplorer();
attachBreadcrumb();
attachMainComponent();
const explorer = registryExplorer();
if (explorer) {
explorer.attachBreadcrumb();
explorer.attachMainComponent();
}
});
......@@ -45,12 +45,13 @@ document.addEventListener('DOMContentLoaded', () => {
new Vue({
el: successPipelineEl,
render(createElement) {
const { commitCookie, pipelinesPath: goToPipelinesPath } = this.$el.dataset;
const { commitCookie, goToPipelinesPath, humanAccess } = this.$el.dataset;
return createElement(PipelineTourSuccessModal, {
props: {
goToPipelinesPath,
commitCookie,
humanAccess,
},
});
},
......
import initRegistryImages from '~/registry/list/index';
import registryExplorer from '~/registry/explorer/index';
document.addEventListener('DOMContentLoaded', () => {
initRegistryImages();
const explorer = registryExplorer();
if (explorer) {
......
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import store from '../stores';
import CollapsibleContainer from './collapsible_container.vue';
import ProjectEmptyState from './project_empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
import { s__, sprintf } from '~/locale';
export default {
name: 'RegistryListApp',
components: {
CollapsibleContainer,
GlEmptyState,
GlLoadingIcon,
ProjectEmptyState,
GroupEmptyState,
},
props: {
characterError: {
type: Boolean,
required: false,
default: false,
},
containersErrorImage: {
type: String,
required: true,
},
endpoint: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: true,
},
noContainersImage: {
type: String,
required: true,
},
personalAccessTokensHelpLink: {
type: String,
required: false,
default: null,
},
registryHostUrlWithPort: {
type: String,
required: false,
default: null,
},
repositoryUrl: {
type: String,
required: true,
},
isGroupPage: {
type: Boolean,
default: false,
required: false,
},
twoFactorAuthHelpLink: {
type: String,
required: false,
default: null,
},
},
store,
computed: {
...mapGetters(['isLoading', 'repos']),
dockerConnectionErrorText() {
return sprintf(
s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an
issue with your project name or path.
%{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error" target="_blank">`,
docLinkEnd: '</a>',
},
false,
);
},
introText() {
return sprintf(
s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
project can have its own space to store its Docker images.
%{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
docLinkEnd: '</a>',
},
false,
);
},
noContainerImagesText() {
return sprintf(
s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
docLinkEnd: '</a>',
},
false,
);
},
},
created() {
this.setMainEndpoint(this.endpoint);
this.setIsDeleteDisabled(this.isGroupPage);
},
mounted() {
if (!this.characterError) {
this.fetchRepos();
}
},
methods: {
...mapActions(['setMainEndpoint', 'fetchRepos', 'setIsDeleteDisabled']),
},
};
</script>
<template>
<div>
<gl-empty-state
v-if="characterError"
:title="s__('ContainerRegistry|Docker connection error')"
:svg-path="containersErrorImage"
>
<template #description>
<p class="js-character-error-text" v-html="dockerConnectionErrorText"></p>
</template>
</gl-empty-state>
<gl-loading-icon v-else-if="isLoading" size="md" class="prepend-top-16" />
<div v-else-if="!isLoading && repos.length">
<h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
<p v-html="introText"></p>
<collapsible-container v-for="item in repos" :key="item.id" :repo="item" />
</div>
<project-empty-state
v-else-if="!isGroupPage"
:no-containers-image="noContainersImage"
:help-page-path="helpPagePath"
:repository-url="repositoryUrl"
:two-factor-auth-help-link="twoFactorAuthHelpLink"
:personal-access-tokens-help-link="personalAccessTokensHelpLink"
:registry-host-url-with-port="registryHostUrlWithPort"
/>
<group-empty-state
v-else-if="isGroupPage"
:no-containers-image="noContainersImage"
:help-page-path="helpPagePath"
/>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import {
GlLoadingIcon,
GlButton,
GlTooltipDirective,
GlModal,
GlModalDirective,
GlEmptyState,
} from '@gitlab/ui';
import createFlash from '~/flash';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TableRegistry from './table_registry.vue';
import { DELETE_REPO_ERROR_MESSAGE } from '../constants';
import { __, sprintf } from '~/locale';
export default {
name: 'CollapsibeContainerRegisty',
components: {
ClipboardButton,
TableRegistry,
GlLoadingIcon,
GlButton,
Icon,
GlModal,
GlEmptyState,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin()],
props: {
repo: {
type: Object,
required: true,
},
},
data() {
return {
isOpen: false,
modalId: `confirm-repo-deletion-modal-${this.repo.id}`,
tracking: {
label: 'registry_repository_delete',
},
};
},
computed: {
...mapGetters(['isDeleteDisabled']),
iconName() {
return this.isOpen ? 'angle-up' : 'angle-right';
},
canDeleteRepo() {
return this.repo.canDelete && !this.isDeleteDisabled;
},
deleteImageConfirmationMessage() {
return sprintf(__('Image %{imageName} was scheduled for deletion from the registry.'), {
imageName: this.repo.name,
});
},
},
methods: {
...mapActions(['fetchRepos', 'fetchList', 'deleteItem']),
toggleRepo() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
this.fetchList({ repo: this.repo });
}
},
handleDeleteRepository() {
this.track('confirm_delete');
return this.deleteItem(this.repo)
.then(() => {
createFlash(this.deleteImageConfirmationMessage, 'notice');
this.fetchRepos();
})
.catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE));
},
},
};
</script>
<template>
<div class="container-image">
<div class="container-image-head">
<gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo">
<icon :name="iconName" />
{{ repo.name }}
</gl-button>
<clipboard-button
v-if="repo.location"
:text="repo.location"
:title="repo.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
<div class="controls d-none d-sm-block float-right">
<gl-button
v-if="canDeleteRepo"
v-gl-tooltip
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
class="js-remove-repo btn-inverted"
variant="danger"
@click="track('click_button')"
>
<icon name="remove" />
</gl-button>
</div>
</div>
<gl-loading-icon v-if="repo.isLoading" size="md" class="append-bottom-20" />
<div v-else-if="!repo.isLoading && isOpen" class="container-image-tags">
<table-registry v-if="repo.list.length" :repo="repo" :can-delete-repo="canDeleteRepo" />
<gl-empty-state
v-else
:title="s__('ContainerRegistry|This image has no active tags')"
:description="
s__(
`ContainerRegistry|The last tag related to this image was recently removed.
This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
If you have any questions, contact your administrator.`,
)
"
class="mx-auto my-0"
/>
</div>
<gl-modal
ref="deleteModal"
:modal-id="modalId"
ok-variant="danger"
@ok="handleDeleteRepository"
@cancel="track('cancel_delete')"
>
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
<p
v-html="
sprintf(
s__(
'ContainerRegistry|You are about to remove repository <b>%{title}</b>. Once you confirm, this repository will be permanently deleted.',
),
{ title: repo.name },
)
"
></p>
<template v-slot:modal-ok>{{ __('Remove') }}</template>
</gl-modal>
</div>
</template>
<script>
import { GlEmptyState } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
name: 'GroupEmptyState',
components: {
GlEmptyState,
},
props: {
noContainersImage: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: true,
},
},
computed: {
noContainerImagesText() {
return sprintf(
s__(
`ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`,
),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
docLinkEnd: '</a>',
},
false,
);
},
},
};
</script>
<template>
<gl-empty-state
:title="s__('ContainerRegistry|There are no container images available in this group')"
:svg-path="noContainersImage"
class="container-message"
>
<template #description>
<p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
</template>
</gl-empty-state>
</template>
<script>
import { GlEmptyState } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { s__, sprintf } from '~/locale';
export default {
name: 'ProjectEmptyState',
components: {
ClipboardButton,
GlEmptyState,
},
props: {
noContainersImage: {
type: String,
required: true,
},
repositoryUrl: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: true,
},
twoFactorAuthHelpLink: {
type: String,
required: true,
},
personalAccessTokensHelpLink: {
type: String,
required: true,
},
registryHostUrlWithPort: {
type: String,
required: true,
},
},
computed: {
dockerBuildCommand() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `docker build -t ${this.repositoryUrl} .`;
},
dockerPushCommand() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `docker push ${this.repositoryUrl}`;
},
dockerLoginCommand() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `docker login ${this.registryHostUrlWithPort}`;
},
noContainerImagesText() {
return sprintf(
s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
docLinkEnd: '</a>',
},
false,
);
},
notLoggedInToRegistryText() {
return sprintf(
s__(`ContainerRegistry|If you are not already logged in, you need to authenticate to
the Container Registry by using your GitLab username and password. If you have
%{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a
%{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd}
instead of a password.`),
{
twofaDocLinkStart: `<a href="${this.twoFactorAuthHelpLink}" target="_blank">`,
twofaDocLinkEnd: '</a>',
personalAccessTokensDocLinkStart: `<a href="${this.personalAccessTokensHelpLink}" target="_blank">`,
personalAccessTokensDocLinkEnd: '</a>',
},
false,
);
},
},
};
</script>
<template>
<gl-empty-state
:title="s__('ContainerRegistry|There are no container images stored for this project')"
:svg-path="noContainersImage"
class="container-message"
>
<template #description>
<p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
<h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
<p class="js-not-logged-in-to-registry-text" v-html="notLoggedInToRegistryText"></p>
<div class="input-group append-bottom-10">
<input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerLoginCommand"
:title="s__('ContainerRegistry|Copy login command')"
class="input-group-text"
/>
</span>
</div>
<p></p>
<p>
{{
s__(
'ContainerRegistry|You can add an image to this registry with the following commands:',
)
}}
</p>
<div class="input-group append-bottom-10">
<input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerBuildCommand"
:title="s__('ContainerRegistry|Copy build command')"
class="input-group-text"
/>
</span>
</div>
<div class="input-group">
<input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerPushCommand"
:title="s__('ContainerRegistry|Copy push command')"
class="input-group-text"
/>
</span>
</div>
</template>
</gl-empty-state>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import { GlButton, GlFormCheckbox, GlTooltipDirective, GlModal } from '@gitlab/ui';
import Tracking from '~/tracking';
import { n__, s__, sprintf } from '~/locale';
import createFlash from '~/flash';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import Icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { FETCH_REGISTRY_ERROR_MESSAGE, DELETE_REGISTRY_ERROR_MESSAGE } from '../constants';
export default {
components: {
ClipboardButton,
TablePagination,
GlFormCheckbox,
GlButton,
Icon,
GlModal,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin, Tracking.mixin()],
props: {
repo: {
type: Object,
required: true,
},
canDeleteRepo: {
type: Boolean,
default: false,
required: false,
},
},
data() {
return {
selectedItems: [],
itemsToBeDeleted: [],
modalId: `confirm-image-deletion-modal-${this.repo.id}`,
selectAllChecked: false,
modalDescription: '',
};
},
computed: {
...mapGetters(['isDeleteDisabled']),
bulkDeletePath() {
return this.repo.tagsPath ? this.repo.tagsPath.replace('?format=json', '/bulk_destroy') : '';
},
shouldRenderPagination() {
return this.repo.pagination.total > this.repo.pagination.perPage;
},
modalAction() {
return n__(
'ContainerRegistry|Remove tag',
'ContainerRegistry|Remove tags',
this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length,
);
},
isMultiDelete() {
return this.itemsToBeDeleted.length > 1;
},
tracking() {
return {
property: this.repo.name,
label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
};
},
},
methods: {
...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']),
setModalDescription(itemIndex = -1) {
if (itemIndex === -1) {
this.modalDescription = sprintf(
s__(`ContainerRegistry|You are about to remove <b>%{count}</b> tags. Are you sure?`),
{ count: this.itemsToBeDeleted.length },
);
} else {
const { tag } = this.repo.list[itemIndex];
this.modalDescription = sprintf(
s__(`ContainerRegistry|You are about to remove <b>%{title}</b>. Are you sure?`),
{ title: `${this.repo.name}:${tag}` },
);
}
},
layers(item) {
return item.layers ? n__('%d layer', '%d layers', item.layers) : '';
},
formatSize(size) {
return numberToHumanSize(size);
},
deleteSingleItem(index) {
this.setModalDescription(index);
this.itemsToBeDeleted = [index];
this.track('click_button');
this.$refs.deleteModal.show();
},
deleteMultipleItems() {
this.itemsToBeDeleted = [...this.selectedItems];
if (this.selectedItems.length === 1) {
this.setModalDescription(this.itemsToBeDeleted[0]);
} else if (this.selectedItems.length > 1) {
this.setModalDescription();
}
this.track('click_button');
this.$refs.deleteModal.show();
},
handleSingleDelete(itemToDelete) {
this.itemsToBeDeleted = [];
this.deleteItem(itemToDelete)
.then(() => this.fetchList({ repo: this.repo }))
.catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE));
},
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = [];
this.selectedItems = [];
if (this.bulkDeletePath) {
this.multiDeleteItems({
path: this.bulkDeletePath,
items: itemsToBeDeleted.map(x => this.repo.list[x].tag),
})
.then(() => this.fetchList({ repo: this.repo }))
.catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE));
} else {
createFlash(DELETE_REGISTRY_ERROR_MESSAGE);
}
},
onPageChange(pageNumber) {
this.fetchList({ repo: this.repo, page: pageNumber }).catch(() =>
createFlash(FETCH_REGISTRY_ERROR_MESSAGE),
);
},
onSelectAllChange() {
if (this.selectAllChecked) {
this.deselectAll();
} else {
this.selectAll();
}
},
selectAll() {
this.selectedItems = this.repo.list.map((x, index) => index);
this.selectAllChecked = true;
},
deselectAll() {
this.selectedItems = [];
this.selectAllChecked = false;
},
updateselectedItems(index) {
const delIndex = this.selectedItems.findIndex(x => x === index);
if (delIndex > -1) {
this.selectedItems.splice(delIndex, 1);
this.selectAllChecked = false;
} else {
this.selectedItems.push(index);
if (this.selectedItems.length === this.repo.list.length) {
this.selectAllChecked = true;
}
}
},
canDeleteRow(item) {
return item && item.canDelete && !this.isDeleteDisabled;
},
onDeletionConfirmed() {
this.track('confirm_delete');
if (this.isMultiDelete) {
this.handleMultipleDelete();
} else {
const index = this.itemsToBeDeleted[0];
this.handleSingleDelete(this.repo.list[index]);
}
},
},
};
</script>
<template>
<div>
<table class="table tags">
<thead>
<tr>
<th>
<gl-form-checkbox
v-if="canDeleteRepo"
class="js-select-all-checkbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
/>
</th>
<th>{{ s__('ContainerRegistry|Tag') }}</th>
<th ref="imageId">{{ s__('ContainerRegistry|Image ID') }}</th>
<th>{{ s__('ContainerRegistry|Size') }}</th>
<th>{{ s__('ContainerRegistry|Last Updated') }}</th>
<th>
<gl-button
v-if="canDeleteRepo"
ref="bulkDeleteButton"
v-gl-tooltip
:disabled="!selectedItems || selectedItems.length === 0"
class="float-right"
variant="danger"
:title="s__('ContainerRegistry|Remove selected tags')"
:aria-label="s__('ContainerRegistry|Remove selected tags')"
@click="deleteMultipleItems()"
>
<icon name="remove" />
</gl-button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in repo.list" :key="item.tag" class="registry-image-row">
<td class="check">
<gl-form-checkbox
v-if="canDeleteRow(item)"
class="js-select-checkbox"
:checked="selectedItems && selectedItems.includes(index)"
@change="updateselectedItems(index)"
/>
</td>
<td class="monospace">
{{ item.tag }}
<clipboard-button
v-if="item.location"
:title="item.location"
:text="item.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</td>
<td>
<span v-gl-tooltip.bottom class="monospace" :title="item.revision">{{
item.shortRevision
}}</span>
</td>
<td>
{{ formatSize(item.size) }}
<template v-if="item.size && item.layers"
>&middot;</template
>
{{ layers(item) }}
</td>
<td>
<span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{
timeFormatted(item.createdAt)
}}</span>
</td>
<td class="content action-buttons">
<gl-button
v-if="canDeleteRow(item)"
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
variant="danger"
class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon"
@click="deleteSingleItem(index)"
>
<icon name="remove" />
</gl-button>
</td>
</tr>
</tbody>
</table>
<table-pagination
v-if="shouldRenderPagination"
:change="onPageChange"
:page-info="repo.pagination"
class="js-registry-pagination"
/>
<gl-modal
ref="deleteModal"
:modal-id="modalId"
ok-variant="danger"
@ok="onDeletionConfirmed"
@cancel="track('cancel_delete')"
>
<template v-slot:modal-title>{{ modalAction }}</template>
<template v-slot:modal-ok>{{ modalAction }}</template>
<p v-html="modalDescription"></p>
</gl-modal>
</div>
</template>
import { __ } from '~/locale';
export const FETCH_REGISTRY_ERROR_MESSAGE = __(
'Something went wrong while fetching the registry list.',
);
export const FETCH_REPOS_ERROR_MESSAGE = __('Something went wrong while fetching the projects.');
export const DELETE_REPO_ERROR_MESSAGE = __('Something went wrong on our end.');
export const DELETE_REGISTRY_ERROR_MESSAGE = __('Something went wrong on our end.');
import Vue from 'vue';
import registryApp from './components/app.vue';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
export default () => {
const el = document.getElementById('js-vue-registry-images');
if (!el) {
return null;
}
return new Vue({
el,
components: {
registryApp,
},
data() {
const { dataset } = el;
return {
registryData: {
endpoint: dataset.endpoint,
characterError: Boolean(dataset.characterError),
helpPagePath: dataset.helpPagePath,
noContainersImage: dataset.noContainersImage,
containersErrorImage: dataset.containersErrorImage,
repositoryUrl: dataset.repositoryUrl,
isGroupPage: dataset.isGroupPage,
personalAccessTokensHelpLink: dataset.personalAccessTokensHelpLink,
registryHostUrlWithPort: dataset.registryHostUrlWithPort,
twoFactorAuthHelpLink: dataset.twoFactorAuthHelpLink,
},
};
},
render(createElement) {
return createElement('registry-app', {
props: {
...this.registryData,
},
});
},
});
};
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import * as types from './mutation_types';
import { FETCH_REPOS_ERROR_MESSAGE, FETCH_REGISTRY_ERROR_MESSAGE } from '../constants';
export const fetchRepos = ({ commit, state }) => {
commit(types.TOGGLE_MAIN_LOADING);
return axios
.get(state.endpoint)
.then(({ data }) => {
commit(types.TOGGLE_MAIN_LOADING);
commit(types.SET_REPOS_LIST, data);
})
.catch(() => {
commit(types.TOGGLE_MAIN_LOADING);
createFlash(FETCH_REPOS_ERROR_MESSAGE);
});
};
export const fetchList = ({ commit }, { repo, page }) => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
return axios
.get(repo.tagsPath, { params: { page } })
.then(response => {
const { headers, data } = response;
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
commit(types.SET_REGISTRY_LIST, { repo, resp: data, headers });
})
.catch(() => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
createFlash(FETCH_REGISTRY_ERROR_MESSAGE);
});
};
export const deleteItem = (_, item) => axios.delete(item.destroyPath);
export const multiDeleteItems = (_, { path, items }) =>
axios.delete(path, { params: { ids: items } });
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
export const setIsDeleteDisabled = ({ commit }, data) => commit(types.SET_IS_DELETE_DISABLED, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const isLoading = state => state.isLoading;
export const repos = state => state.repos;
export const isDeleteDisabled = state => state.isDeleteDisabled;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import createState from './state';
Vue.use(Vuex);
export default new Vuex.Store({
state: createState(),
actions,
getters,
mutations,
});
export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT';
export const SET_IS_DELETE_DISABLED = 'SET_IS_DELETE_DISABLED';
export const SET_REPOS_LIST = 'SET_REPOS_LIST';
export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING';
export const SET_REGISTRY_LIST = 'SET_REGISTRY_LIST';
export const TOGGLE_REGISTRY_LIST_LOADING = 'TOGGLE_REGISTRY_LIST_LOADING';
import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export default {
[types.SET_MAIN_ENDPOINT](state, endpoint) {
state.endpoint = endpoint;
},
[types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) {
state.isDeleteDisabled = isDeleteDisabled;
},
[types.SET_REPOS_LIST](state, list) {
state.repos = list.map(el => ({
canDelete: Boolean(el.destroy_path),
destroyPath: el.destroy_path,
id: el.id,
isLoading: false,
list: [],
location: el.location,
name: el.path,
tagsPath: el.tags_path,
projectId: el.project_id,
}));
},
[types.TOGGLE_MAIN_LOADING](state) {
state.isLoading = !state.isLoading;
},
[types.SET_REGISTRY_LIST](state, { repo, resp, headers }) {
const listToUpdate = state.repos.find(el => el.id === repo.id);
const normalizedHeaders = normalizeHeaders(headers);
const pagination = parseIntPagination(normalizedHeaders);
listToUpdate.pagination = pagination;
listToUpdate.list = resp.map(element => ({
tag: element.name,
revision: element.revision,
shortRevision: element.short_revision,
size: element.total_size,
layers: element.layers,
location: element.location,
createdAt: element.created_at,
destroyPath: element.destroy_path,
canDelete: Boolean(element.destroy_path),
}));
},
[types.TOGGLE_REGISTRY_LIST_LOADING](state, list) {
const listToUpdate = state.repos.find(el => el.id === list.id);
listToUpdate.isLoading = !listToUpdate.isLoading;
},
};
export default () => ({
isLoading: false,
endpoint: '', // initial endpoint to fetch the repos list
isDeleteDisabled: false, // controls the delete buttons in the registry
/**
* Each object in `repos` has the following strucure:
* {
* name: String,
* isLoading: Boolean,
* tagsPath: String // endpoint to request the list
* destroyPath: String // endpoit to delete the repo
* list: Array // List of the registry images
* }
*
* Each registry image inside `list` has the following structure:
* {
* tag: String,
* revision: String
* shortRevision: String
* size: Number
* layers: Number
* createdAt: String
* destroyPath: String // endpoit to delete each image
* }
*/
repos: [],
});
......@@ -106,17 +106,17 @@ export default {
<div class="title hide-collapsed">
{{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }}
<button
<a
v-if="isEditable"
class="float-right lock-edit"
type="button"
href="#"
data-track-event="click_edit_button"
data-track-label="right_sidebar"
data-track-property="lock_issue"
@click.prevent="toggleForm"
>
{{ __('Edit') }}
</button>
</a>
</div>
<div class="value sidebar-item-value hide-collapsed">
......
......@@ -127,7 +127,7 @@ export default {
<div v-if="hasMoreParticipants" class="participants-more hide-collapsed">
<button
type="button"
class="btn-transparent btn-blank js-toggle-participants-button"
class="btn-transparent btn-link js-toggle-participants-button"
@click="toggleMoreParticipants"
>
{{ toggleLabel }}
......
......@@ -447,6 +447,7 @@
font-weight: normal;
border-radius: 0;
border-color: transparent;
border-width: 0;
&:hover,
&:active,
......
......@@ -187,7 +187,6 @@
.btn-link {
color: inherit;
outline: none;
}
.issuable-header-text {
......@@ -261,15 +260,10 @@
color: rgba($gray-normal, 0.2);
}
.lock-edit, // uses same style, different js behaviour
.confidential-edit,
.lock-edit,
.edit-link {
@extend .btn-blank;
color: $gl-text-color;
&:hover {
text-decoration: underline;
color: $blue-800;
}
@extend .btn-link;
}
}
......@@ -689,7 +683,6 @@
}
.btn-link {
outline: none;
padding: 0;
}
......
......@@ -21,7 +21,7 @@ module Boards
before_action :validate_id_list, only: [:bulk_move]
before_action :can_move_issues?, only: [:bulk_move]
before_action do
push_frontend_feature_flag(:board_search_optimization, board.group)
push_frontend_feature_flag(:board_search_optimization, board.group, default_enabled: true)
end
def index
......
......@@ -4,6 +4,10 @@ module SnippetsActions
extend ActiveSupport::Concern
include SendsBlob
included do
before_action :redirect_if_binary, only: [:edit, :update]
end
def edit
# We need to load some info from the existing blob
snippet.content = blob.data
......@@ -67,4 +71,8 @@ module SnippetsActions
flash.now[:alert] = repository_errors.first if repository_errors.present?
recaptcha_check_with_fallback(repository_errors.empty?) { render :edit }
end
def redirect_if_binary
redirect_to gitlab_snippet_path(snippet) if blob&.binary?
end
end
......@@ -4,7 +4,6 @@ module Groups
class RepositoriesController < Groups::ApplicationController
before_action :verify_container_registry_enabled!
before_action :authorize_read_container_image!
before_action :feature_flag_group_container_registry_browser!
def index
respond_to do |format|
......@@ -17,12 +16,8 @@ module Groups
serializer = ContainerRepositoriesSerializer
.new(current_user: current_user)
if Feature.enabled?(:vue_container_registry_explorer, group)
render json: serializer.with_pagination(request, response)
.represent_read_only(@images)
else
render json: serializer.represent_read_only(@images)
end
render json: serializer.with_pagination(request, response)
.represent_read_only(@images)
end
end
end
......@@ -34,10 +29,6 @@ module Groups
private
def feature_flag_group_container_registry_browser!
render_404 unless Feature.enabled?(:group_container_registry_browser, group)
end
def verify_container_registry_enabled!
render_404 unless Gitlab.config.registry.enabled
end
......
......@@ -17,11 +17,7 @@ module Projects
serializer = ContainerRepositoriesSerializer
.new(project: project, current_user: current_user)
if Feature.enabled?(:vue_container_registry_explorer, project.group)
render json: serializer.with_pagination(request, response).represent(@images)
else
render json: serializer.represent(@images)
end
render json: serializer.with_pagination(request, response).represent(@images)
end
end
end
......
......@@ -22,8 +22,7 @@ module GroupsHelper
def group_container_registry_nav?
Gitlab.config.registry.enabled &&
can?(current_user, :read_container_image, @group) &&
Feature.enabled?(:group_container_registry_browser, @group)
can?(current_user, :read_container_image, @group)
end
def group_sidebar_links
......
......@@ -111,4 +111,8 @@ module HasRepository
def web_url(only_path: nil)
raise NotImplementedError
end
def repository_size_checker
raise NotImplementedError
end
end
......@@ -16,7 +16,7 @@ module AwardEmojis
award = awardable.award_emoji.create(name: name, user: current_user)
if award.persisted?
TodoService.new.new_award_emoji(todoable, current_user) if todoable
after_create(award)
success(award: award)
else
error(award.errors.full_messages, award: award)
......@@ -25,6 +25,10 @@ module AwardEmojis
private
def after_create(award)
TodoService.new.new_award_emoji(todoable, current_user) if todoable
end
def todoable
strong_memoize(:todoable) do
case awardable
......@@ -40,3 +44,5 @@ module AwardEmojis
end
end
end
AwardEmojis::AddService.prepend_if_ee('EE::AwardEmojis::AddService')
......@@ -14,8 +14,16 @@ module AwardEmojis
end
award = awards.destroy_all.first # rubocop: disable DestroyAll
after_destroy(award)
success(award: award)
end
private
def after_destroy(award)
end
end
end
AwardEmojis::DestroyService.prepend_if_ee('EE::AwardEmojis::DestroyService')
......@@ -133,7 +133,7 @@ module Boards
def can_attempt_search_optimization?
params[:search].present? &&
Feature.enabled?(:board_search_optimization, board_group, default_enabled: false)
Feature.enabled?(:board_search_optimization, board_group, default_enabled: true)
end
end
end
......
......@@ -59,8 +59,6 @@ module Issues
end
def store_first_mentioned_in_commit_at(issue, merge_request)
return unless Feature.enabled?(:store_first_mentioned_in_commit_on_issue_close, issue.project, default_enabled: true)
metrics = issue.metrics
return if metrics.nil? || metrics.first_mentioned_in_commit_at
......
......@@ -4,23 +4,14 @@
%section
.row.registry-placeholder.prepend-bottom-10
.col-12
- if Feature.enabled?(:vue_container_registry_explorer, @group)
#js-container-registry{ data: { endpoint: group_container_registries_path(@group),
"help_page_path" => help_page_path('user/packages/container_registry/index'),
"two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'),
"personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"is_admin": current_user&.admin,
is_group_page: true,
character_error: @character_error.to_s } }
- else
#js-vue-registry-images{ data: { endpoint: group_container_registries_path(@group, format: :json),
"help_page_path" => help_page_path('user/packages/container_registry/index'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"repository_url" => "",
is_group_page: true,
character_error: @character_error.to_s } }
#js-container-registry{ data: { endpoint: group_container_registries_path(@group),
"help_page_path" => help_page_path('user/packages/container_registry/index'),
"two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'),
"personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"is_admin": current_user&.admin,
is_group_page: true,
character_error: @character_error.to_s } }
.js-success-pipeline-modal{ 'data-commit-cookie': suggest_pipeline_commit_cookie_name, 'data-pipelines-path': project_pipelines_path(@project) }
.js-success-pipeline-modal{ data: { 'commit-cookie': suggest_pipeline_commit_cookie_name,
'go-to-pipelines-path': project_pipelines_path(@project),
'human-access': @project.team.human_max_access(current_user&.id) } }
......@@ -4,28 +4,17 @@
%section
.row.registry-placeholder.prepend-bottom-10
.col-12
- if Feature.enabled?(:vue_container_registry_explorer, @project.group)
#js-container-registry{ data: { endpoint: project_container_registry_index_path(@project),
settings_path: project_settings_ci_cd_path(@project),
expiration_policy: @project.container_expiration_policy.to_json,
"help_page_path" => help_page_path('user/packages/container_registry/index'),
"two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'),
"personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"repository_url" => escape_once(@project.container_registry_url),
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"is_admin": current_user&.admin,
character_error: @character_error.to_s } }
- else
#js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json),
"help_page_path" => help_page_path('user/packages/container_registry/index'),
"two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'),
"personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"repository_url" => escape_once(@project.container_registry_url),
"registry_host_url_with_port" => escape_once(registry_config.host_port),
character_error: @character_error.to_s } }
#js-container-registry{ data: { endpoint: project_container_registry_index_path(@project),
settings_path: project_settings_ci_cd_path(@project),
expiration_policy: @project.container_expiration_policy.to_json,
"help_page_path" => help_page_path('user/packages/container_registry/index'),
"two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'),
"personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"repository_url" => escape_once(@project.container_registry_url),
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"is_admin": current_user&.admin,
character_error: @character_error.to_s } }
---
title: Resolve Snippet actions with binary data
merge_request: 28191
author:
type: fixed
---
title: Enable container registry at the group level
merge_request: 27814
author:
type: added
---
title: Use CTE optimization for searching board issues
merge_request: 28430
author:
type: fixed
......@@ -253,6 +253,85 @@ LoadModule proxy_http_module modules/mod_proxy_http.so
[This snippet](https://gitlab.com/gitlab-org/security-products/dast/snippets/1894732) contains a complete `httpd.conf` file
configured to act as a remote proxy and add the `Gitlab-DAST-Permission` header.
### API scan
Using an API specification as a scan's target is a useful way to seed URLs for scanning an API.
Vulnerability rules in an API scan are different than those in a normal website scan.
#### Specification format
API scans support OpenAPI V2 and OpenAPI V3 specifications. You can define these specifications using `JSON` or `YAML`.
#### Import API specification from a URL
If your API specification is accessible at a URL, you can pass that URL in directly as the target.
The specification doesn't have to be hosted on the same host as the API being tested.
```yml
include:
- template: DAST.gitlab-ci.yml
variables:
DAST_API_SPECIFICATION: http://my.api/api-specification.yml
```
#### Import API specification from a file
If your API specification is in your repository, you can provide the specification's filename directly as the target. The specification file is expected to be in the `/zap/wrk` directory.
```yml
dast:
script:
- mkdir -p /zap/wrk
- cp api-specification.yml /zap/wrk/api-specification.yml
- /analyze -t $DAST_WEBSITE
variables:
GIT_STRATEGY: fetch
DAST_API_SPECIFICATION: api-specification.yml
```
#### Full scan
API scans support full scanning, which can be enabled by using the `DAST_FULL_SCAN_ENABLED` environment variable. Domain validation isn't supported for full API scans.
#### Host override
Specifications often define a host, which contains a domain name and a port. The host referenced may be different than the host of the API's review instance.
This can cause incorrect URLs to be imported, or a scan on an incorrect host. Use the `DAST_API_HOST_OVERRIDE` environment variable to override these values.
For example, with a OpenAPI V3 specification containing:
```yml
servers:
- url: https://api.host.com
```
If the test version of the API is running at `https://api-test.host.com`, then the following DAST configuration can be used:
```yml
include:
- template: DAST.gitlab-ci.yml
variables:
DAST_API_SPECIFICATION: http://api-test.host.com/api-specification.yml
DAST_API_HOST_OVERRIDE: api-test.host.com
```
Note that `DAST_API_HOST_OVERRIDE` is only applied to specifications imported by URL.
#### Authentication using headers
Tokens in request headers are often used as a way to authenticate API requests. You can achieve this by using the `DAST_REQUEST_HEADERS` environment variable. Headers are applied to every request DAST makes.
```yml
include:
- template: DAST.gitlab-ci.yml
variables:
DAST_API_SPECIFICATION: http://api-test.api.com/api-specification.yml
DAST_REQUEST_HEADERS: "Authorization: Bearer my.token"
```
### Customizing the DAST settings
The DAST settings can be changed through environment variables by using the
......@@ -300,17 +379,21 @@ DAST can be [configured](#customizing-the-dast-settings) using environment varia
| Environment variable | Required | Description |
|-----------------------------| ----------|--------------------------------------------------------------------------------|
| `DAST_WEBSITE` | yes | The URL of the website to scan. |
| `DAST_AUTH_URL` | no | The authentication URL of the website to scan. |
| `DAST_WEBSITE` | no| The URL of the website to scan. `DAST_API_SPECIFICATION` must be specified if this is omitted. |
| `DAST_API_SPECIFICATION` | no | The API specification to import. `DAST_WEBSITE` must be specified if this is omitted. |
| `DAST_AUTH_URL` | no | The authentication URL of the website to scan. Not supported for API scans. |
| `DAST_USERNAME` | no | The username to authenticate to in the website. |
| `DAST_PASSWORD` | no | The password to authenticate to in the website. |
| `DAST_USERNAME_FIELD` | no | The name of username field at the sign-in HTML form. |
| `DAST_PASSWORD_FIELD` | no | The name of password field at the sign-in HTML form. |
| `DAST_AUTH_EXCLUDE_URLS` | no | The URLs to skip during the authenticated scan; comma-separated, no spaces in between. |
| `DAST_AUTH_EXCLUDE_URLS` | no | The URLs to skip during the authenticated scan; comma-separated, no spaces in between. Not supported for API scans. |
| `DAST_TARGET_AVAILABILITY_TIMEOUT` | no | Time limit in seconds to wait for target availability. Scan is attempted nevertheless if it runs out. Integer. Defaults to `60`. |
| `DAST_FULL_SCAN_ENABLED` | no | Switches the tool to execute [ZAP Full Scan](https://github.com/zaproxy/zaproxy/wiki/ZAP-Full-Scan) instead of [ZAP Baseline Scan](https://github.com/zaproxy/zaproxy/wiki/ZAP-Baseline-Scan). Boolean. `true`, `True`, or `1` are considered as true value, otherwise false. Defaults to `false`. |
| `DAST_FULL_SCAN_DOMAIN_VALIDATION_REQUIRED` | no | Requires [domain validation](#domain-validation) when running DAST full scans. Boolean. `true`, `True`, or `1` are considered as true value, otherwise false. Defaults to `false`. |
| `DAST_FULL_SCAN_DOMAIN_VALIDATION_REQUIRED` | no | Requires [domain validation](#domain-validation) when running DAST full scans. Boolean. `true`, `True`, or `1` are considered as true value, otherwise false. Defaults to `false`. Not supported for API scans. |
| `DAST_AUTO_UPDATE_ADDONS` | no | Set to `false` to pin the versions of ZAProxy add-ons to those provided with the DAST image. Defaults to `true`. |
| `DAST_API_HOST_OVERRIDE` | no | Used to override domains defined in API specification files. |
| `DAST_EXCLUDE_RULES` | no | Set to a comma-separated list of Vulnerability Rule IDs to exclude them from scans. Rule IDs are numbers and can be found from the DAST log or on the [ZAP project](https://github.com/zaproxy/zaproxy/blob/master/docs/scanners.md). For example, `HTTP Parameter Override` has a rule ID of `10026`. |
| `DAST_REQUEST_HEADERS` | no | Set to a comma-separated list of request header names and values. For example, `Cache-control: no-cache,User-Agent: DAST/1.0` |
### DAST command-line options
......
......@@ -316,6 +316,53 @@ For example, to unlink the `MyOrg` account, the following **Disconnect** button
| Issuer | How GitLab identifies itself to the identity provider. Also known as a "Relying party trust identifier". |
| Certificate fingerprint | Used to confirm that communications over SAML are secure by checking that the server is signing communications with the correct certificate. Also known as a certificate thumbprint. |
## Configuring on a self-managed GitLab instance
For self-managed GitLab instances we strongly recommend using the
[instance-wide SAML OmniAuth Provider](../../../integration/saml.md) instead.
Group SAML SSO helps if you need to allow access via multiple SAML identity providers, but as a multi-tenant solution is less suited to cases where you administer your own GitLab instance.
To proceed with configuring Group SAML SSO instead, you'll need to enable the `group_saml` OmniAuth provider. This can be done from:
- `gitlab.rb` for GitLab [Omnibus installations](#omnibus-installations).
- `gitlab/config/gitlab.yml` for [source installations](#source-installations).
### Limitations
Group SAML on a self-managed instance is limited when compared to the recommended
[instance-wide SAML](../../../integration/saml.md). The recommended solution allows you to take advantage of:
- [LDAP compatibility](../../../administration/auth/ldap.md).
- [LDAP group Sync](../../../administration/auth/how_to_configure_ldap_gitlab_ee/index.md#group-sync).
- [Required groups](../../../integration/saml.md#required-groups-starter-only).
- [Admin groups](../../../integration/saml.md#admin-groups-starter-only).
- [Auditor groups](../../../integration/saml.md#auditor-groups-starter-only).
### Omnibus installations
1. Make sure GitLab is
[configured with HTTPS](../../../install/installation.md#using-https).
1. Enable OmniAuth and the `group_saml` provider in `gitlab.rb`:
```ruby
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_providers'] = [{ name: 'group_saml' }]
```
### Source installations
1. Make sure GitLab is
[configured with HTTPS](../../../install/installation.md#using-https).
1. Enable OmniAuth and the `group_saml` provider in `gitlab/config/gitlab.yml`:
```yaml
omniauth:
enabled: true
providers:
- { name: 'group_saml' }
```
## Troubleshooting
This section contains possible solutions for problems you might encounter.
......
# frozen_string_literal: true
module Gitlab
# Centralized class for repository size related calculations.
class RepositorySizeChecker
attr_reader :limit
def initialize(current_size_proc:, limit:, enabled: true)
@current_size_proc = current_size_proc
@limit = limit
@enabled = enabled && limit != 0
end
def current_size
@current_size ||= @current_size_proc.call
end
def enabled?
@enabled
end
def above_size_limit?
return false unless enabled?
current_size > limit
end
# @param change_size [int] in bytes
def changes_will_exceed_size_limit?(change_size)
return false unless enabled?
change_size > limit || exceeded_size(change_size) > 0
end
# @param change_size [int] in bytes
def exceeded_size(change_size = 0)
current_size + change_size - limit
end
def error_message
@error_message_object ||= Gitlab::RepositorySizeErrorMessage.new(self)
end
end
end
# frozen_string_literal: true
module Gitlab
class RepositorySizeErrorMessage
include ActiveSupport::NumberHelper
delegate :current_size, :limit, :exceeded_size, to: :@checker
# @param checher [RepositorySizeChecker]
def initialize(checker)
@checker = checker
end
def commit_error
"Your changes could not be committed, #{base_message}"
end
def merge_error
"This merge request cannot be merged, #{base_message}"
end
def push_error(change_size = 0)
"Your push has been rejected, #{base_message(change_size)}. #{more_info_message}"
end
def new_changes_error
"Your push to this repository would cause it to exceed the size limit of #{formatted(limit)} so it has been rejected. #{more_info_message}"
end
def more_info_message
'Please contact your GitLab administrator for more information.'
end
def above_size_limit_message
"The size of this repository (#{formatted(current_size)}) exceeds the limit of #{formatted(limit)} by #{formatted(exceeded_size)}. You won't be able to push new code to this project. #{more_info_message}"
end
private
def base_message(change_size = 0)
"because this repository has exceeded its size limit of #{formatted(limit)} by #{formatted(exceeded_size(change_size))}"
end
def formatted(number)
number_to_human_size(number, delimiter: ',', precision: 2)
end
end
end
......@@ -11,8 +11,8 @@ module Gitlab
# Validates the given url according to the constraints specified by arguments.
#
# ports - Raises error if the given URL port does is not between given ports.
# allow_localhost - Raises error if URL resolves to a localhost IP address and argument is true.
# allow_local_network - Raises error if URL resolves to a link-local address and argument is true.
# allow_localhost - Raises error if URL resolves to a localhost IP address and argument is false.
# allow_local_network - Raises error if URL resolves to a link-local address and argument is false.
# ascii_only - Raises error if URL has unicode characters and argument is true.
# enforce_user - Raises error if URL user doesn't start with alphanumeric characters and argument is true.
# enforce_sanitization - Raises error if URL includes any HTML/CSS/JS tags and argument is true.
......
......@@ -10,7 +10,6 @@ module Sentry
Error = Class.new(StandardError)
MissingKeysError = Class.new(StandardError)
ResponseInvalidSizeError = Class.new(StandardError)
attr_accessor :url, :token
......
......@@ -4,6 +4,7 @@ module Sentry
class Client
module Issue
BadRequestError = Class.new(StandardError)
ResponseInvalidSizeError = Class.new(StandardError)
SENTRY_API_SORT_VALUE_MAP = {
# <accepted_by_client> => <accepted_by_sentry_api>
......
......@@ -5395,9 +5395,6 @@ msgstr[1] ""
msgid "ContainerRegistry|Retention policy has been Enabled"
msgstr ""
msgid "ContainerRegistry|Size"
msgstr ""
msgid "ContainerRegistry|Something went wrong while deleting the image."
msgstr ""
......@@ -5479,18 +5476,9 @@ msgstr ""
msgid "ContainerRegistry|You are about to remove %{item}. Are you sure?"
msgstr ""
msgid "ContainerRegistry|You are about to remove <b>%{count}</b> tags. Are you sure?"
msgstr ""
msgid "ContainerRegistry|You are about to remove <b>%{title}</b>. Are you sure?"
msgstr ""
msgid "ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted."
msgstr ""
msgid "ContainerRegistry|You are about to remove repository <b>%{title}</b>. Once you confirm, this repository will be permanently deleted."
msgstr ""
msgid "ContainerRegistry|You can add an image to this registry with the following commands:"
msgstr ""
......@@ -10747,9 +10735,6 @@ msgstr ""
msgid "Ignored"
msgstr ""
msgid "Image %{imageName} was scheduled for deletion from the registry."
msgstr ""
msgid "Image: %{image}"
msgstr ""
......@@ -12051,6 +12036,9 @@ msgstr ""
msgid "Live preview"
msgstr ""
msgid "Load more vulnerabilities"
msgstr ""
msgid "Loading"
msgstr ""
......@@ -18678,12 +18666,6 @@ msgstr ""
msgid "Something went wrong while fetching the packages list."
msgstr ""
msgid "Something went wrong while fetching the projects."
msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
msgid "Something went wrong while initializing the OpenAPI viewer"
msgstr ""
......
......@@ -15,7 +15,7 @@ describe Groups::Registry::RepositoriesController do
end
shared_examples 'renders a list of repositories' do
context 'when container registry is enabled' do
context 'when user has access to registry' do
it 'show index page' do
expect(Gitlab::Tracking).not_to receive(:event)
......@@ -63,21 +63,7 @@ describe Groups::Registry::RepositoriesController do
end
end
context 'container registry is disabled' do
before do
stub_container_registry_config(enabled: false)
end
it 'renders not found' do
get :index, params: {
group_id: group
}
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'user do not have acces to container registry' do
context 'user does not have access to container registry' do
before do
sign_out(user)
sign_in(guest)
......@@ -90,22 +76,6 @@ describe Groups::Registry::RepositoriesController do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with :vue_container_registry_explorer feature flag disabled' do
before do
stub_feature_flags(vue_container_registry_explorer: { enabled: false, thing: group })
end
it 'has the correct response schema' do
get :index, params: {
group_id: group,
format: :json
}
expect(response).to match_response_schema('registry/repositories')
expect(response).not_to include_pagination_headers
end
end
end
context 'GET #index' do
......
......@@ -85,22 +85,6 @@ describe Projects::Registry::RepositoriesController do
end
end
end
context 'with :vue_container_registry_explorer feature flag disabled' do
before do
stub_feature_flags(vue_container_registry_explorer: { enabled: false, thing: project.group })
stub_container_registry_tags(repository: project.full_path,
tags: %w[rc1 latest])
end
it 'json has a list of projects' do
go_to_index(format: :json)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('registry/repositories')
expect(response).not_to include_pagination_headers
end
end
end
describe 'GET #index' do
......
......@@ -116,7 +116,7 @@ describe Projects::SnippetsController do
end
context 'when the snippet is public' do
it 'rejects the shippet' do
it 'rejects the snippet' do
expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }
.not_to change { Snippet.count }
expect(response).to render_template(:new)
......@@ -164,6 +164,7 @@ describe Projects::SnippetsController do
describe 'PUT #update' do
let(:project) { create :project, :public }
let(:visibility_level) { Snippet::PUBLIC }
let(:snippet) { create :project_snippet, author: user, project: project, visibility_level: visibility_level }
def update_snippet(snippet_params = {}, additional_params = {})
......@@ -174,13 +175,27 @@ describe Projects::SnippetsController do
put :update, params: {
namespace_id: project.namespace.to_param,
project_id: project,
id: snippet.id,
id: snippet,
project_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params)
}.merge(additional_params)
snippet.reload
end
it_behaves_like 'updating snippet checks blob is binary' do
let_it_be(:title) { 'Foo' }
let(:params) do
{
namespace_id: project.namespace.to_param,
project_id: project,
id: snippet.id,
project_snippet: { title: title }
}
end
subject { put :update, params: params }
end
context 'when the snippet is spam' do
before do
allow_next_instance_of(Spam::AkismetService) do |instance|
......@@ -198,9 +213,7 @@ describe Projects::SnippetsController do
end
context 'when the snippet is public' do
let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the shippet' do
it 'rejects the snippet' do
expect { update_snippet(title: 'Foo') }
.not_to change { snippet.reload.title }
end
......@@ -245,7 +258,7 @@ describe Projects::SnippetsController do
context 'when the private snippet is made public' do
let(:visibility_level) { Snippet::PRIVATE }
it 'rejects the shippet' do
it 'rejects the snippet' do
expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }
.not_to change { snippet.reload.title }
end
......@@ -581,4 +594,19 @@ describe Projects::SnippetsController do
end
end
end
describe 'GET #edit' do
it_behaves_like 'editing snippet checks blob is binary' do
let(:snippet) { create(:project_snippet, :private, project: project, author: user) }
let(:params) do
{
namespace_id: project.namespace,
project_id: project,
id: snippet
}
end
subject { get :edit, params: params }
end
end
end
......@@ -308,7 +308,7 @@ describe SnippetsController do
end
context 'when the snippet is public' do
it 'rejects the shippet' do
it 'rejects the snippet' do
expect { create_snippet(visibility_level: Snippet::PUBLIC) }
.not_to change { Snippet.count }
end
......@@ -354,6 +354,7 @@ describe SnippetsController do
describe 'PUT #update' do
let(:project) { create :project }
let(:visibility_level) { Snippet::PUBLIC }
let(:snippet) { create :personal_snippet, author: user, project: project, visibility_level: visibility_level }
def update_snippet(snippet_params = {}, additional_params = {})
......@@ -367,6 +368,12 @@ describe SnippetsController do
snippet.reload
end
it_behaves_like 'updating snippet checks blob is binary' do
let_it_be(:title) { 'Foo' }
subject { put :update, params: { id: snippet, personal_snippet: { title: title } } }
end
context 'when the snippet is spam' do
before do
allow_next_instance_of(Spam::AkismetService) do |instance|
......@@ -429,9 +436,7 @@ describe SnippetsController do
end
context 'when the snippet is public' do
let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the shippet' do
it 'rejects the snippet' do
expect { update_snippet(title: 'Foo') }
.not_to change { snippet.reload.title }
end
......@@ -793,4 +798,12 @@ describe SnippetsController do
end
end
end
describe 'GET #edit' do
it_behaves_like 'editing snippet checks blob is binary' do
let_it_be(:snippet) { create(:personal_snippet, :public, :repository, author: user) }
subject { get :edit, params: { id: snippet } }
end
end
end
......@@ -17,167 +17,107 @@ describe 'Container Registry', :js do
stub_container_registry_tags(repository: :any, tags: [])
end
describe 'Registry explorer is off' do
before do
stub_feature_flags(vue_container_registry_explorer: { enabled: false, thing: project.group })
end
it 'has a page title set' do
visit_container_registry
expect(page).to have_title _('Container Registry')
end
it 'has a page title set' do
context 'when there are no image repositories' do
it 'list page has no container title' do
visit_container_registry
expect(page).to have_title _('Container Registry')
expect(page).to have_content _('There are no container images stored for this project')
end
context 'when there are no image repositories' do
it 'user visits container registry main page' do
visit_container_registry
it 'list page has quickstart' do
visit_container_registry
expect(page).to have_content _('no container images')
end
expect(page).to have_content _('Quick Start')
end
end
context 'when there are image repositories' do
before do
stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true)
project.container_repositories << container_repository
end
it 'user wants to see multi-level container repository' do
visit_container_registry
expect(page).to have_content 'my/image'
end
it 'user removes entire container repository', :sidekiq_might_not_need_inline do
visit_container_registry
expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true)
click_on(class: 'js-remove-repo')
expect(find('.modal .modal-title')).to have_content _('Remove repository')
find('.modal .modal-footer .btn-danger').click
end
it 'user removes a specific tag from container repository' do
visit_container_registry
find('.js-toggle-repo').click
wait_for_requests
service = double('service')
expect(service).to receive(:execute).with(container_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service }
click_on(class: 'js-delete-registry-row', visible: false)
expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click
end
context 'when there are image repositories' do
before do
stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true)
project.container_repositories << container_repository
end
end
describe 'Registry explorer is on' do
it 'has a page title set' do
it 'list page has a list of images' do
visit_container_registry
expect(page).to have_title _('Container Registry')
expect(page).to have_content 'my/image'
end
context 'when there are no image repositories' do
it 'list page has no container title' do
visit_container_registry
it 'user removes entire container repository', :sidekiq_might_not_need_inline do
visit_container_registry
expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true)
expect(page).to have_content _('There are no container images stored for this project')
end
find('[title="Remove repository"]').click
expect(find('.modal .modal-title')).to have_content _('Remove repository')
find('.modal .modal-footer .btn-danger').click
end
it 'list page has quickstart' do
visit_container_registry
it 'navigates to repo details' do
visit_container_registry_details('my/image')
expect(page).to have_content _('Quick Start')
end
expect(page).to have_content 'latest'
end
context 'when there are image repositories' do
describe 'image repo details' do
before do
stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true)
project.container_repositories << container_repository
stub_container_registry_tags(repository: %r{my/image}, tags: ('1'..'20').to_a, with_manifest: true)
visit_container_registry_details 'my/image'
end
it 'list page has a list of images' do
visit_container_registry
expect(page).to have_content 'my/image'
it 'shows the details breadcrumb' do
expect(find('.breadcrumbs')).to have_link 'my/image'
end
it 'user removes entire container repository', :sidekiq_might_not_need_inline do
visit_container_registry
expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true)
find('[title="Remove repository"]').click
expect(find('.modal .modal-title')).to have_content _('Remove repository')
find('.modal .modal-footer .btn-danger').click
it 'shows the image title' do
expect(page).to have_content 'my/image tags'
end
it 'navigates to repo details' do
visit_container_registry_details('my/image')
it 'user removes a specific tag from container repository' do
service = double('service')
expect(service).to receive(:execute).with(container_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['1']) { service }
expect(page).to have_content 'latest'
first('.js-delete-registry').click
expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click
end
describe 'image repo details' do
before do
stub_container_registry_tags(repository: %r{my/image}, tags: ('1'..'20').to_a, with_manifest: true)
visit_container_registry_details 'my/image'
end
it 'shows the details breadcrumb' do
expect(find('.breadcrumbs')).to have_link 'my/image'
end
it 'shows the image title' do
expect(page).to have_content 'my/image tags'
end
it 'user removes a specific tag from container repository' do
service = double('service')
expect(service).to receive(:execute).with(container_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['1']) { service }
first('.js-delete-registry').click
expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click
end
it('pagination navigate to the second page') do
visit_second_page
expect(page).to have_content '20'
end
it('pagination navigate to the second page') do
visit_second_page
expect(page).to have_content '20'
end
end
end
context 'when there are more than 10 images' do
before do
create_list(:container_repository, 12, project: project)
project.container_repositories << container_repository
visit_container_registry
end
context 'when there are more than 10 images' do
before do
create_list(:container_repository, 12, project: project)
project.container_repositories << container_repository
visit_container_registry
end
it 'shows pagination' do
expect(page).to have_css '.gl-pagination'
end
it 'shows pagination' do
expect(page).to have_css '.gl-pagination'
end
it 'pagination goes to second page' do
visit_second_page
expect(page).to have_content 'my/image'
end
it 'pagination goes to second page' do
visit_second_page
expect(page).to have_content 'my/image'
end
it 'pagination is preserved after navigating back from details' do
visit_second_page
click_link 'my/image'
breadcrumb = find '.breadcrumbs'
breadcrumb.click_link 'Container Registry'
expect(page).to have_content 'my/image'
end
it 'pagination is preserved after navigating back from details' do
visit_second_page
click_link 'my/image'
breadcrumb = find '.breadcrumbs'
breadcrumb.click_link 'Container Registry'
expect(page).to have_content 'my/image'
end
end
......
import Sortablejs from 'sortablejs';
export default Sortablejs;
export const Sortable = Sortablejs;
export class MultiDrag {}
const modalProps = {
goToPipelinesPath: 'some_pipeline_path',
commitCookie: 'some_cookie',
humanAccess: 'maintainer',
};
export default modalProps;
......@@ -2,19 +2,20 @@ import pipelineTourSuccess from '~/blob/pipeline_tour_success_modal.vue';
import { shallowMount } from '@vue/test-utils';
import Cookies from 'js-cookie';
import { GlSprintf, GlModal } from '@gitlab/ui';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import modalProps from './pipeline_tour_success_mock_data';
describe('PipelineTourSuccessModal', () => {
let wrapper;
let cookieSpy;
const goToPipelinesPath = 'some_pipeline_path';
const commitCookie = 'some_cookie';
let trackingSpy;
beforeEach(() => {
document.body.dataset.page = 'projects:blob:show';
trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
wrapper = shallowMount(pipelineTourSuccess, {
propsData: {
goToPipelinesPath,
commitCookie,
},
propsData: modalProps,
});
cookieSpy = jest.spyOn(Cookies, 'remove');
......@@ -22,6 +23,7 @@ describe('PipelineTourSuccessModal', () => {
afterEach(() => {
wrapper.destroy();
unmockTracking();
});
it('has expected structure', () => {
......@@ -35,6 +37,15 @@ describe('PipelineTourSuccessModal', () => {
it('calls to remove cookie', () => {
wrapper.vm.disableModalFromRenderingAgain();
expect(cookieSpy).toHaveBeenCalledWith(commitCookie);
expect(cookieSpy).toHaveBeenCalledWith(modalProps.commitCookie);
});
describe('tracking', () => {
it('send event for basic view of popover', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, undefined, {
label: 'congratulate_first_pipeline',
property: modalProps.humanAccess,
});
});
});
});
......@@ -2,9 +2,11 @@
/* global ListAssignee */
/* global ListLabel */
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises';
import eventHub from '~/boards/eventhub';
import '~/boards/models/label';
......@@ -13,22 +15,41 @@ import '~/boards/models/list';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card.vue';
import issueCardInner from '~/boards/components/issue_card_inner.vue';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { listObj, boardsMockInterceptor, setMockEndpoints } from './mock_data';
describe('Board card', () => {
let vm;
let wrapper;
let mock;
let list;
const findIssueCardInner = () => wrapper.find(issueCardInner);
const findUserAvatarLink = () => wrapper.find(userAvatarLink);
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
const mountComponent = propsData => {
wrapper = shallowMount(boardCard, {
stubs: {
issueCardInner,
},
store,
propsData: {
list,
issue: list.issues[0],
issueLinkBase: '/',
disabled: false,
index: 0,
rootPath: '/',
...propsData,
},
});
};
beforeEach(done => {
mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
setMockEndpoints();
const setupData = () => {
list = new List(listObj);
boardsStore.create();
boardsStore.detail.issue = {};
const BoardCardComp = Vue.extend(boardCard);
const list = new List(listObj);
const label1 = new ListLabel({
id: 3,
title: 'testing 123',
......@@ -36,178 +57,155 @@ describe('Board card', () => {
text_color: 'white',
description: 'test',
});
setTimeout(() => {
return waitForPromises().then(() => {
list.issues[0].labels.push(label1);
});
};
vm = new BoardCardComp({
store,
propsData: {
list,
issue: list.issues[0],
issueLinkBase: '/',
disabled: false,
index: 0,
rootPath: '/',
},
}).$mount();
done();
}, 0);
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
setMockEndpoints();
return setupData();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
list = null;
mock.restore();
});
it('returns false when detailIssue is empty', () => {
expect(vm.issueDetailVisible).toBe(false);
it('when details issue is empty does not show the element', () => {
mountComponent();
expect(wrapper.classes()).not.toContain('is-active');
});
it('returns true when detailIssue is equal to card issue', () => {
boardsStore.detail.issue = vm.issue;
it('when detailIssue is equal to card issue shows the element', () => {
[boardsStore.detail.issue] = list.issues;
mountComponent();
expect(vm.issueDetailVisible).toBe(true);
expect(wrapper.classes()).toContain('is-active');
});
it("returns false when multiSelect doesn't contain issue", () => {
expect(vm.multiSelectVisible).toBe(false);
it('when multiSelect does not contain issue removes multi select class', () => {
mountComponent();
expect(wrapper.classes()).not.toContain('multi-select');
});
it('returns true when multiSelect contains issue', () => {
boardsStore.multiSelect.list = [vm.issue];
it('when multiSelect contain issue add multi select class', () => {
boardsStore.multiSelect.list = [list.issues[0]];
mountComponent();
expect(vm.multiSelectVisible).toBe(true);
expect(wrapper.classes()).toContain('multi-select');
});
it('adds user-can-drag class if not disabled', () => {
expect(vm.$el.classList.contains('user-can-drag')).toBe(true);
mountComponent();
expect(wrapper.classes()).toContain('user-can-drag');
});
it('does not add user-can-drag class disabled', done => {
vm.disabled = true;
it('does not add user-can-drag class disabled', () => {
mountComponent({ disabled: true });
setTimeout(() => {
expect(vm.$el.classList.contains('user-can-drag')).toBe(false);
done();
}, 0);
expect(wrapper.classes()).not.toContain('user-can-drag');
});
it('does not add disabled class', () => {
expect(vm.$el.classList.contains('is-disabled')).toBe(false);
mountComponent();
expect(wrapper.classes()).not.toContain('is-disabled');
});
it('adds disabled class is disabled is true', done => {
vm.disabled = true;
it('adds disabled class is disabled is true', () => {
mountComponent({ disabled: true });
setTimeout(() => {
expect(vm.$el.classList.contains('is-disabled')).toBe(true);
done();
}, 0);
expect(wrapper.classes()).toContain('is-disabled');
});
describe('mouse events', () => {
const triggerEvent = (eventName, el = vm.$el) => {
const event = document.createEvent('MouseEvents');
event.initMouseEvent(
eventName,
true,
true,
window,
1,
0,
0,
0,
0,
false,
false,
false,
false,
0,
null,
);
el.dispatchEvent(event);
};
it('sets showDetail to true on mousedown', () => {
triggerEvent('mousedown');
expect(vm.showDetail).toBe(true);
mountComponent();
wrapper.trigger('mousedown');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.showDetail).toBe(true);
});
});
it('sets showDetail to false on mousemove', () => {
triggerEvent('mousedown');
expect(vm.showDetail).toBe(true);
triggerEvent('mousemove');
expect(vm.showDetail).toBe(false);
mountComponent();
wrapper.trigger('mousedown');
return wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.vm.showDetail).toBe(true);
wrapper.trigger('mousemove');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.vm.showDetail).toBe(false);
});
});
it('does not set detail issue if showDetail is false', () => {
mountComponent();
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if link is clicked', () => {
triggerEvent('mouseup', vm.$el.querySelector('a'));
mountComponent();
findIssueCardInner()
.find('a')
.trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if img is clicked', done => {
vm.issue.assignees = [
new ListAssignee({
id: 1,
name: 'testing 123',
username: 'test',
avatar: 'test_image',
}),
];
Vue.nextTick(() => {
triggerEvent('mouseup', vm.$el.querySelector('img'));
it('does not set detail issue if img is clicked', () => {
mountComponent({
issue: {
...list.issues[0],
assignees: [
new ListAssignee({
id: 1,
name: 'testing 123',
username: 'test',
avatar: 'test_image',
}),
],
},
});
expect(boardsStore.detail.issue).toEqual({});
findUserAvatarLink().trigger('mouseup');
done();
});
expect(boardsStore.detail.issue).toEqual({});
});
it('does not set detail issue if showDetail is false after mouseup', () => {
triggerEvent('mouseup');
mountComponent();
wrapper.trigger('mouseup');
expect(boardsStore.detail.issue).toEqual({});
});
it('sets detail issue to card issue on mouse up', () => {
spyOn(eventHub, '$emit');
triggerEvent('mousedown');
triggerEvent('mouseup');
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', vm.issue, undefined);
expect(boardsStore.detail.list).toEqual(vm.list);
});
mountComponent();
it('adds active class if detail issue is set', done => {
vm.detailIssue.issue = vm.issue;
wrapper.trigger('mousedown');
wrapper.trigger('mouseup');
Vue.nextTick()
.then(() => {
expect(vm.$el.classList.contains('is-active')).toBe(true);
})
.then(done)
.catch(done.fail);
expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, undefined);
expect(boardsStore.detail.list).toEqual(wrapper.vm.list);
});
it('resets detail issue to empty if already set', () => {
spyOn(eventHub, '$emit');
boardsStore.detail.issue = vm.issue;
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
[boardsStore.detail.issue] = list.issues;
mountComponent();
triggerEvent('mousedown');
triggerEvent('mouseup');
wrapper.trigger('mousedown');
wrapper.trigger('mouseup');
expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', undefined);
});
......
......@@ -9,7 +9,9 @@ import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/issue';
import '~/boards/models/list';
import { ListType } from '~/boards/constants';
import boardsStore from '~/boards/stores/boards_store';
import waitForPromises from 'helpers/wait_for_promises';
import { listObj, listObjDuplicate, boardsMockInterceptor } from './mock_data';
describe('List model', () => {
......@@ -20,22 +22,35 @@ describe('List model', () => {
mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
boardsStore.create();
boardsStore.setEndpoints({
listsEndpoint: '/test/-/boards/1/lists',
});
list = new List(listObj);
return waitForPromises();
});
afterEach(() => {
mock.restore();
});
it('gets issues when created', done => {
setTimeout(() => {
expect(list.issues.length).toBe(1);
done();
}, 0);
describe('list type', () => {
const notExpandableList = ['blank'];
const table = Object.keys(ListType).map(k => {
const value = ListType[k];
return [value, !notExpandableList.includes(value)];
});
it.each(table)(`when list_type is %s boards isExpandable is %p`, (type, result) => {
expect(new List({ id: 1, list_type: type }).isExpandable).toBe(result);
});
});
it('saves list and returns ID', done => {
it('gets issues when created', () => {
expect(list.issues.length).toBe(1);
});
it('saves list and returns ID', () => {
list = new List({
title: 'test',
label: {
......@@ -45,50 +60,40 @@ describe('List model', () => {
text_color: 'white',
},
});
list.save();
setTimeout(() => {
return list.save().then(() => {
expect(list.id).toBe(listObj.id);
expect(list.type).toBe('label');
expect(list.position).toBe(0);
expect(list.label.color).toBe('red');
expect(list.label.textColor).toBe('white');
done();
}, 0);
});
});
it('destroys the list', done => {
it('destroys the list', () => {
boardsStore.addList(listObj);
list = boardsStore.findList('id', listObj.id);
expect(boardsStore.state.lists.length).toBe(1);
list.destroy();
setTimeout(() => {
return waitForPromises().then(() => {
expect(boardsStore.state.lists.length).toBe(0);
done();
}, 0);
});
});
it('gets issue from list', done => {
setTimeout(() => {
const issue = list.findIssue(1);
it('gets issue from list', () => {
const issue = list.findIssue(1);
expect(issue).toBeDefined();
done();
}, 0);
expect(issue).toBeDefined();
});
it('removes issue', done => {
setTimeout(() => {
const issue = list.findIssue(1);
it('removes issue', () => {
const issue = list.findIssue(1);
expect(list.issues.length).toBe(1);
list.removeIssue(issue);
expect(list.issues.length).toBe(1);
list.removeIssue(issue);
expect(list.issues.length).toBe(0);
done();
}, 0);
expect(list.issues.length).toBe(0);
});
it('sends service request to update issue label', () => {
......@@ -105,7 +110,7 @@ describe('List model', () => {
list.issues.push(issue);
listDup.issues.push(issue);
spyOn(boardsStore, 'moveIssue').and.callThrough();
jest.spyOn(boardsStore, 'moveIssue');
listDup.updateIssueLabel(issue, list);
......@@ -120,7 +125,8 @@ describe('List model', () => {
describe('page number', () => {
beforeEach(() => {
spyOn(list, 'getIssues');
jest.spyOn(list, 'getIssues').mockImplementation(() => {});
list.issues = [];
});
it('increase page number if current issue count is more than the page size', () => {
......@@ -167,7 +173,7 @@ describe('List model', () => {
describe('newIssue', () => {
beforeEach(() => {
spyOn(boardsStore, 'newIssue').and.returnValue(
jest.spyOn(boardsStore, 'newIssue').mockReturnValue(
Promise.resolve({
data: {
id: 42,
......@@ -178,6 +184,7 @@ describe('List model', () => {
},
}),
);
list.issues = [];
});
it('adds new issue to top of list', done => {
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Group Empty state to match the default snapshot 1`] = `
<div
class="row container-message empty-state"
>
<div
class="col-12"
>
<div
class="svg-250 svg-content"
>
<img
alt="There are no container images available in this group"
class=""
src="imageUrl"
/>
</div>
</div>
<div
class="col-12"
>
<div
class="text-content"
>
<h4
class="center"
style=""
>
There are no container images available in this group
</h4>
<p
class="center"
style=""
>
<p
class="js-no-container-images-text"
>
With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here.
<a
href="help"
target="_blank"
>
More Information
</a>
</p>
</p>
<div
class="text-center"
>
<!---->
<!---->
</div>
</div>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Project Empty state to match the default snapshot 1`] = `
<div
class="row container-message empty-state"
>
<div
class="col-12"
>
<div
class="svg-250 svg-content"
>
<img
alt="There are no container images stored for this project"
class=""
src="imageUrl"
/>
</div>
</div>
<div
class="col-12"
>
<div
class="text-content"
>
<h4
class="center"
style=""
>
There are no container images stored for this project
</h4>
<p
class="center"
style=""
>
<p
class="js-no-container-images-text"
>
With the Container Registry, every project can have its own space to store its Docker images.
<a
href="help"
target="_blank"
>
More Information
</a>
</p>
<h5>
Quick Start
</h5>
<p
class="js-not-logged-in-to-registry-text"
>
If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have
<a
href="help_link"
target="_blank"
>
Two-Factor Authentication
</a>
enabled, use a
<a
href="personal_token"
target="_blank"
>
Personal Access Token
</a>
instead of a password.
</p>
<div
class="input-group append-bottom-10"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<button
class="btn input-group-text btn-secondary btn-md btn-default"
data-clipboard-text="docker login host"
title="Copy login command"
type="button"
>
<!---->
<svg
class="gl-icon s16"
>
<use
href="#copy-to-clipboard"
/>
</svg>
</button>
</span>
</div>
<p />
<p>
You can add an image to this registry with the following commands:
</p>
<div
class="input-group append-bottom-10"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<button
class="btn input-group-text btn-secondary btn-md btn-default"
data-clipboard-text="docker build -t url ."
title="Copy build command"
type="button"
>
<!---->
<svg
class="gl-icon s16"
>
<use
href="#copy-to-clipboard"
/>
</svg>
</button>
</span>
</div>
<div
class="input-group"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<button
class="btn input-group-text btn-secondary btn-md btn-default"
data-clipboard-text="docker push url"
title="Copy push command"
type="button"
>
<!---->
<svg
class="gl-icon s16"
>
<use
href="#copy-to-clipboard"
/>
</svg>
</button>
</span>
</div>
</p>
<div
class="text-center"
>
<!---->
<!---->
</div>
</div>
</div>
</div>
`;
import { mount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import registry from '~/registry/list/components/app.vue';
import { reposServerResponse, parsedReposServerResponse } from '../mock_data';
describe('Registry List', () => {
let wrapper;
const findCollapsibleContainer = () => wrapper.findAll({ name: 'CollapsibeContainerRegisty' });
const findProjectEmptyState = () => wrapper.find({ name: 'ProjectEmptyState' });
const findGroupEmptyState = () => wrapper.find({ name: 'GroupEmptyState' });
const findSpinner = () => wrapper.find('.gl-spinner');
const findCharacterErrorText = () => wrapper.find('.js-character-error-text');
const propsData = {
endpoint: `${TEST_HOST}/foo`,
helpPagePath: 'foo',
noContainersImage: 'foo',
containersErrorImage: 'foo',
repositoryUrl: 'foo',
registryHostUrlWithPort: 'foo',
personalAccessTokensHelpLink: 'foo',
twoFactorAuthHelpLink: 'foo',
};
const setMainEndpoint = jest.fn();
const fetchRepos = jest.fn();
const setIsDeleteDisabled = jest.fn();
const methods = {
setMainEndpoint,
fetchRepos,
setIsDeleteDisabled,
};
beforeEach(() => {
wrapper = mount(registry, {
propsData,
computed: {
repos() {
return parsedReposServerResponse;
},
},
methods,
});
});
afterEach(() => {
wrapper.destroy();
});
describe('with data', () => {
it('should render a list of CollapsibeContainerRegisty', () => {
const containers = findCollapsibleContainer();
expect(wrapper.vm.repos.length).toEqual(reposServerResponse.length);
expect(containers.length).toEqual(reposServerResponse.length);
});
});
describe('without data', () => {
beforeEach(() => {
wrapper = mount(registry, {
propsData,
computed: {
repos() {
return [];
},
},
methods,
});
});
it('should render project empty message', () => {
const projectEmptyState = findProjectEmptyState();
expect(projectEmptyState.exists()).toBe(true);
});
});
describe('while loading data', () => {
beforeEach(() => {
wrapper = mount(registry, {
propsData,
computed: {
repos() {
return [];
},
isLoading() {
return true;
},
},
methods,
});
});
it('should render a loading spinner', () => {
const spinner = findSpinner();
expect(spinner.exists()).toBe(true);
});
});
describe('invalid characters in path', () => {
beforeEach(() => {
wrapper = mount(registry, {
propsData: {
...propsData,
characterError: true,
},
computed: {
repos() {
return [];
},
},
methods,
});
});
it('should render invalid characters error message', () => {
const characterErrorText = findCharacterErrorText();
expect(characterErrorText.text()).toEqual(
'We are having trouble connecting to Docker, which could be due to an issue with your project name or path. More Information',
);
});
});
describe('with groupId set', () => {
const isGroupPage = true;
beforeEach(() => {
wrapper = mount(registry, {
propsData: {
...propsData,
endpoint: '',
isGroupPage,
},
methods,
});
});
it('call the right vuex setters', () => {
expect(methods.setMainEndpoint).toHaveBeenLastCalledWith('');
expect(methods.setIsDeleteDisabled).toHaveBeenLastCalledWith(true);
});
it('should render groups empty message', () => {
const groupEmptyState = findGroupEmptyState(wrapper);
expect(groupEmptyState.exists()).toBe(true);
});
});
});
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import createFlash from '~/flash';
import Tracking from '~/tracking';
import collapsibleComponent from '~/registry/list/components/collapsible_container.vue';
import * as getters from '~/registry/list/stores/getters';
import { repoPropsData } from '../mock_data';
jest.mock('~/flash.js');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('collapsible registry container', () => {
let wrapper;
let store;
const findDeleteBtn = () => wrapper.find('.js-remove-repo');
const findContainerImageTags = () => wrapper.find('.container-image-tags');
const findToggleRepos = () => wrapper.findAll('.js-toggle-repo');
const findDeleteModal = () => wrapper.find({ ref: 'deleteModal' });
const mountWithStore = config =>
mount(collapsibleComponent, {
...config,
store,
localVue,
});
beforeEach(() => {
createFlash.mockClear();
store = new Vuex.Store({
state: {
isDeleteDisabled: false,
},
getters,
});
wrapper = mountWithStore({
propsData: {
repo: repoPropsData,
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('toggle', () => {
beforeEach(() => {
const fetchList = jest.fn();
wrapper.setMethods({ fetchList });
return wrapper.vm.$nextTick();
});
const expectIsClosed = () => {
const container = findContainerImageTags();
expect(container.exists()).toBe(false);
expect(wrapper.vm.iconName).toEqual('angle-right');
};
it('should be closed by default', () => {
expectIsClosed();
});
it('should be open when user clicks on closed repo', () => {
const toggleRepos = findToggleRepos();
toggleRepos.at(0).trigger('click');
return wrapper.vm.$nextTick().then(() => {
const container = findContainerImageTags();
expect(container.exists()).toBe(true);
expect(wrapper.vm.fetchList).toHaveBeenCalled();
});
});
it('should be closed when the user clicks on an opened repo', () => {
const toggleRepos = findToggleRepos();
toggleRepos.at(0).trigger('click');
return wrapper.vm.$nextTick().then(() => {
toggleRepos.at(0).trigger('click');
wrapper.vm.$nextTick(() => {
expectIsClosed();
});
});
});
});
describe('delete repo', () => {
beforeEach(() => {
const deleteItem = jest.fn().mockResolvedValue();
const fetchRepos = jest.fn().mockResolvedValue();
wrapper.setMethods({ deleteItem, fetchRepos });
});
it('should be possible to delete a repo', () => {
const deleteBtn = findDeleteBtn();
expect(deleteBtn.exists()).toBe(true);
});
it('should call deleteItem when confirming deletion', () => {
wrapper.vm.handleDeleteRepository();
expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(wrapper.vm.repo);
});
it('should show a flash with a success notice', () =>
wrapper.vm.handleDeleteRepository().then(() => {
expect(wrapper.vm.deleteImageConfirmationMessage).toContain(wrapper.vm.repo.name);
expect(createFlash).toHaveBeenCalledWith(
wrapper.vm.deleteImageConfirmationMessage,
'notice',
);
}));
it('should show an error when there is API error', () => {
const deleteItem = jest.fn().mockRejectedValue('error');
wrapper.setMethods({ deleteItem });
return wrapper.vm.handleDeleteRepository().then(() => {
expect(createFlash).toHaveBeenCalled();
});
});
});
describe('disabled delete', () => {
beforeEach(() => {
store = new Vuex.Store({
state: {
isDeleteDisabled: true,
},
getters,
});
wrapper = mountWithStore({
propsData: {
repo: repoPropsData,
},
});
});
it('should not render delete button', () => {
const deleteBtn = findDeleteBtn();
expect(deleteBtn.exists()).toBe(false);
});
});
describe('tracking', () => {
const testTrackingCall = action => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
label: 'registry_repository_delete',
});
};
beforeEach(() => {
jest.spyOn(Tracking, 'event');
wrapper.vm.deleteItem = jest.fn().mockResolvedValue();
wrapper.vm.fetchRepos = jest.fn();
});
it('send an event when delete button is clicked', () => {
const deleteBtn = findDeleteBtn();
deleteBtn.trigger('click');
testTrackingCall('click_button');
});
it('send an event when cancel is pressed on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('cancel');
testTrackingCall('cancel_delete');
});
it('send an event when confirm is clicked on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('ok');
testTrackingCall('confirm_delete');
});
});
});
import { mount } from '@vue/test-utils';
import groupEmptyState from '~/registry/list/components/group_empty_state.vue';
describe('Registry Group Empty state', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(groupEmptyState, {
propsData: {
noContainersImage: 'imageUrl',
helpPagePath: 'help',
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
import { mount } from '@vue/test-utils';
import projectEmptyState from '~/registry/list/components/project_empty_state.vue';
describe('Registry Project Empty state', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(projectEmptyState, {
propsData: {
noContainersImage: 'imageUrl',
helpPagePath: 'help',
repositoryUrl: 'url',
twoFactorAuthHelpLink: 'help_link',
personalAccessTokensHelpLink: 'personal_token',
registryHostUrlWithPort: 'host',
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
export const defaultState = {
isLoading: false,
endpoint: '',
repos: [],
};
export const reposServerResponse = [
{
destroy_path: 'path',
id: '123',
location: 'location',
path: 'foo',
tags_path: 'tags_path',
},
{
destroy_path: 'path_',
id: '456',
location: 'location_',
path: 'bar',
tags_path: 'tags_path_',
},
];
export const registryServerResponse = [
{
name: 'centos7',
short_revision: 'b118ab5b0',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
total_size: 679,
layers: 19,
location: 'location',
created_at: 1505828744434,
destroy_path: 'path_',
},
{
name: 'centos6',
short_revision: 'b118ab5b0',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
total_size: 679,
layers: 19,
location: 'location',
created_at: 1505828744434,
},
];
export const parsedReposServerResponse = [
{
canDelete: true,
destroyPath: reposServerResponse[0].destroy_path,
id: reposServerResponse[0].id,
isLoading: false,
list: [],
location: reposServerResponse[0].location,
name: reposServerResponse[0].path,
tagsPath: reposServerResponse[0].tags_path,
},
{
canDelete: true,
destroyPath: reposServerResponse[1].destroy_path,
id: reposServerResponse[1].id,
isLoading: false,
list: [],
location: reposServerResponse[1].location,
name: reposServerResponse[1].path,
tagsPath: reposServerResponse[1].tags_path,
},
];
export const parsedRegistryServerResponse = [
{
tag: registryServerResponse[0].name,
revision: registryServerResponse[0].revision,
shortRevision: registryServerResponse[0].short_revision,
size: registryServerResponse[0].total_size,
layers: registryServerResponse[0].layers,
location: registryServerResponse[0].location,
createdAt: registryServerResponse[0].created_at,
destroyPath: registryServerResponse[0].destroy_path,
canDelete: true,
},
{
tag: registryServerResponse[1].name,
revision: registryServerResponse[1].revision,
shortRevision: registryServerResponse[1].short_revision,
size: registryServerResponse[1].total_size,
layers: registryServerResponse[1].layers,
location: registryServerResponse[1].location,
createdAt: registryServerResponse[1].created_at,
destroyPath: registryServerResponse[1].destroy_path,
canDelete: false,
},
];
export const repoPropsData = {
canDelete: true,
destroyPath: 'path',
id: '123',
isLoading: false,
list: [
{
tag: 'centos6',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
shortRevision: 'b118ab5b0',
size: 19,
layers: 10,
location: 'location',
createdAt: 1505828744434,
destroyPath: 'path',
canDelete: true,
},
{
tag: 'test-image',
revision: 'b969de599faea2b3d9b6605a8b0897261c571acaa36db1bdc7349b5775b4e0b4',
shortRevision: 'b969de599',
size: 19,
layers: 10,
location: 'location-2',
createdAt: 1505828744434,
destroyPath: 'path-2',
canDelete: true,
},
],
location: 'location',
name: 'foo',
tagsPath: 'path',
pagination: {
perPage: 5,
page: 1,
total: 13,
totalPages: 1,
nextPage: null,
previousPage: null,
},
};
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
import * as actions from '~/registry/list/stores/actions';
import * as types from '~/registry/list/stores/mutation_types';
import createFlash from '~/flash';
import {
reposServerResponse,
registryServerResponse,
parsedReposServerResponse,
} from '../mock_data';
jest.mock('~/flash.js');
describe('Actions Registry Store', () => {
let mock;
let state;
beforeEach(() => {
mock = new MockAdapter(axios);
state = {
endpoint: `${TEST_HOST}/endpoint.json`,
};
});
afterEach(() => {
mock.restore();
});
describe('fetchRepos', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {});
});
it('should set received repos', done => {
testAction(
actions.fetchRepos,
null,
state,
[
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.SET_REPOS_LIST, payload: reposServerResponse },
],
[],
done,
);
});
it('should create flash on API error', done => {
testAction(
actions.fetchRepos,
null,
{
endpoint: null,
},
[{ type: types.TOGGLE_MAIN_LOADING }, { type: types.TOGGLE_MAIN_LOADING }],
[],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
});
});
describe('fetchList', () => {
let repo;
beforeEach(() => {
state.repos = parsedReposServerResponse;
[, repo] = state.repos;
});
it('should set received list', done => {
mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
testAction(
actions.fetchList,
{ repo },
state,
[
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
{
type: types.SET_REGISTRY_LIST,
payload: {
repo,
resp: registryServerResponse,
headers: expect.anything(),
},
},
],
[],
done,
);
});
it('should create flash on API error', done => {
mock.onGet(repo.tagsPath).replyOnce(400);
const updatedRepo = {
...repo,
tagsPath: null,
};
testAction(
actions.fetchList,
{
repo: updatedRepo,
},
state,
[
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: updatedRepo },
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: updatedRepo },
],
[],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
});
});
describe('setMainEndpoint', () => {
it('should commit set main endpoint', done => {
testAction(
actions.setMainEndpoint,
'endpoint',
state,
[{ type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' }],
[],
done,
);
});
});
describe('setIsDeleteDisabled', () => {
it('should commit set is delete disabled', done => {
testAction(
actions.setIsDeleteDisabled,
true,
state,
[{ type: types.SET_IS_DELETE_DISABLED, payload: true }],
[],
done,
);
});
});
describe('toggleLoading', () => {
it('should commit toggle main loading', done => {
testAction(
actions.toggleLoading,
null,
state,
[{ type: types.TOGGLE_MAIN_LOADING }],
[],
done,
);
});
});
describe('deleteItem and multiDeleteItems', () => {
let deleted;
const destroyPath = `${TEST_HOST}/mygroup/myproject/container_registry/1.json`;
const expectDelete = done => {
expect(mock.history.delete.length).toBe(1);
expect(deleted).toBe(true);
done();
};
beforeEach(() => {
deleted = false;
mock.onDelete(destroyPath).replyOnce(() => {
deleted = true;
return [200];
});
});
it('deleteItem should perform DELETE request on destroyPath', done => {
testAction(
actions.deleteItem,
{
destroyPath,
},
state,
)
.then(() => {
expectDelete(done);
})
.catch(done.fail);
});
it('multiDeleteItems should perform DELETE request on path', done => {
testAction(actions.multiDeleteItems, { path: destroyPath, items: [1] }, state)
.then(() => {
expectDelete(done);
})
.catch(done.fail);
});
});
});
import * as getters from '~/registry/list/stores/getters';
describe('Getters Registry Store', () => {
let state;
beforeEach(() => {
state = {
isLoading: false,
endpoint: '/root/empty-project/container_registry.json',
isDeleteDisabled: false,
repos: [
{
canDelete: true,
destroyPath: 'bar',
id: '134',
isLoading: false,
list: [],
location: 'foo',
name: 'gitlab-org/omnibus-gitlab/foo',
tagsPath: 'foo',
},
{
canDelete: true,
destroyPath: 'bar',
id: '123',
isLoading: false,
list: [],
location: 'foo',
name: 'gitlab-org/omnibus-gitlab',
tagsPath: 'foo',
},
],
};
});
describe('isLoading', () => {
it('should return the isLoading property', () => {
expect(getters.isLoading(state)).toEqual(state.isLoading);
});
});
describe('repos', () => {
it('should return the repos', () => {
expect(getters.repos(state)).toEqual(state.repos);
});
});
describe('isDeleteDisabled', () => {
it('should return isDeleteDisabled', () => {
expect(getters.isDeleteDisabled(state)).toEqual(state.isDeleteDisabled);
});
});
});
import mutations from '~/registry/list/stores/mutations';
import * as types from '~/registry/list/stores/mutation_types';
import {
defaultState,
reposServerResponse,
registryServerResponse,
parsedReposServerResponse,
parsedRegistryServerResponse,
} from '../mock_data';
describe('Mutations Registry Store', () => {
let mockState;
beforeEach(() => {
mockState = defaultState;
});
describe('SET_MAIN_ENDPOINT', () => {
it('should set the main endpoint', () => {
const expectedState = Object.assign({}, mockState, { endpoint: 'foo' });
mutations[types.SET_MAIN_ENDPOINT](mockState, 'foo');
expect(mockState.endpoint).toEqual(expectedState.endpoint);
});
});
describe('SET_IS_DELETE_DISABLED', () => {
it('should set the is delete disabled', () => {
const expectedState = Object.assign({}, mockState, { isDeleteDisabled: true });
mutations[types.SET_IS_DELETE_DISABLED](mockState, true);
expect(mockState.isDeleteDisabled).toEqual(expectedState.isDeleteDisabled);
});
});
describe('SET_REPOS_LIST', () => {
it('should set a parsed repository list', () => {
mutations[types.SET_REPOS_LIST](mockState, reposServerResponse);
expect(mockState.repos).toEqual(parsedReposServerResponse);
});
});
describe('TOGGLE_MAIN_LOADING', () => {
it('should set a parsed repository list', () => {
mutations[types.TOGGLE_MAIN_LOADING](mockState);
expect(mockState.isLoading).toEqual(true);
});
});
describe('SET_REGISTRY_LIST', () => {
it('should set a list of registries in a specific repository', () => {
mutations[types.SET_REPOS_LIST](mockState, reposServerResponse);
mutations[types.SET_REGISTRY_LIST](mockState, {
repo: mockState.repos[0],
resp: registryServerResponse,
headers: {
'x-per-page': 2,
'x-page': 1,
'x-total': 10,
},
});
expect(mockState.repos[0].list).toEqual(parsedRegistryServerResponse);
expect(mockState.repos[0].pagination).toEqual({
perPage: 2,
page: 1,
total: 10,
totalPages: NaN,
nextPage: NaN,
previousPage: NaN,
});
});
});
describe('TOGGLE_REGISTRY_LIST_LOADING', () => {
it('should toggle isLoading property for a specific repository', () => {
mutations[types.SET_REPOS_LIST](mockState, reposServerResponse);
mutations[types.SET_REGISTRY_LIST](mockState, {
repo: mockState.repos[0],
resp: registryServerResponse,
headers: {
'x-per-page': 2,
'x-page': 1,
'x-total': 10,
},
});
mutations[types.TOGGLE_REGISTRY_LIST_LOADING](mockState, mockState.repos[0]);
expect(mockState.repos[0].isLoading).toEqual(true);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::RepositorySizeChecker do
let(:current_size) { 0 }
let(:limit) { 50 }
let(:enabled) { true }
subject do
described_class.new(
current_size_proc: -> { current_size },
limit: limit,
enabled: enabled
)
end
describe '#enabled?' do
context 'when enabled' do
it 'returns true' do
expect(subject.enabled?).to be_truthy
end
end
context 'when limit is zero' do
let(:limit) { 0 }
it 'returns false' do
expect(subject.enabled?).to be_falsey
end
end
end
describe '#changes_will_exceed_size_limit?' do
let(:current_size) { 49 }
it 'returns true when changes go over' do
expect(subject.changes_will_exceed_size_limit?(2)).to be_truthy
end
it 'returns false when changes do not go over' do
expect(subject.changes_will_exceed_size_limit?(1)).to be_falsey
end
end
describe '#above_size_limit?' do
context 'when size is above the limit' do
let(:current_size) { 100 }
it 'returns true' do
expect(subject.above_size_limit?).to be_truthy
end
end
it 'returns false when not over the limit' do
expect(subject.above_size_limit?).to be_falsey
end
end
describe '#exceeded_size' do
context 'when current size is below or equal to the limit' do
let(:current_size) { 50 }
it 'returns zero' do
expect(subject.exceeded_size).to eq(0)
end
end
context 'when current size is over the limit' do
let(:current_size) { 51 }
it 'returns zero' do
expect(subject.exceeded_size).to eq(1)
end
end
context 'when change size will be over the limit' do
let(:current_size) { 50 }
it 'returns zero' do
expect(subject.exceeded_size(1)).to eq(1)
end
end
context 'when change size will not be over the limit' do
let(:current_size) { 49 }
it 'returns zero' do
expect(subject.exceeded_size(1)).to eq(0)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::RepositorySizeErrorMessage do
let(:checker) do
Gitlab::RepositorySizeChecker.new(
current_size_proc: -> { 15.megabytes },
limit: 10.megabytes
)
end
let(:message) { checker.error_message }
let(:base_message) { 'because this repository has exceeded its size limit of 10 MB by 5 MB' }
describe 'error messages' do
describe '#commit_error' do
it 'returns the correct message' do
expect(message.commit_error).to eq("Your changes could not be committed, #{base_message}")
end
end
describe '#merge_error' do
it 'returns the correct message' do
expect(message.merge_error).to eq("This merge request cannot be merged, #{base_message}")
end
end
describe '#push_error' do
context 'with exceeded_limit value' do
let(:rejection_message) do
'because this repository has exceeded its size limit of 10 MB by 15 MB'
end
it 'returns the correct message' do
expect(message.push_error(10.megabytes))
.to eq("Your push has been rejected, #{rejection_message}. #{message.more_info_message}")
end
end
context 'without exceeded_limit value' do
it 'returns the correct message' do
expect(message.push_error)
.to eq("Your push has been rejected, #{base_message}. #{message.more_info_message}")
end
end
end
describe '#new_changes_error' do
it 'returns the correct message' do
expect(message.new_changes_error).to eq("Your push to this repository would cause it to exceed the size limit of 10 MB so it has been rejected. #{message.more_info_message}")
end
end
end
end
......@@ -163,19 +163,6 @@ describe Issues::CloseService do
expect(issue.metrics.first_mentioned_in_commit_at).to be_nil
end
end
context 'when `store_first_mentioned_in_commit_on_issue_close` feature flag is off' do
before do
stub_feature_flags(store_first_mentioned_in_commit_on_issue_close: { enabled: false, thing: issue.project })
end
it 'does not update the metrics' do
subject
expect(described_class).not_to receive(:store_first_mentioned_in_commit_at)
expect(issue.metrics.first_mentioned_in_commit_at).to be_nil
end
end
end
end
......
# frozen_string_literal: true
RSpec.shared_examples 'editing snippet checks blob is binary' do
before do
sign_in(user)
allow_next_instance_of(Blob) do |blob|
allow(blob).to receive(:binary?).and_return(binary)
end
subject
end
context 'when blob is text' do
let(:binary) { false }
it 'responds with status 200' do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:edit)
end
end
context 'when blob is binary' do
let(:binary) { true }
it 'redirects away' do
expect(response).to redirect_to(gitlab_snippet_path(snippet))
end
end
end
RSpec.shared_examples 'updating snippet checks blob is binary' do
before do
sign_in(user)
allow_next_instance_of(Blob) do |blob|
allow(blob).to receive(:binary?).and_return(binary)
end
subject
end
context 'when blob is text' do
let(:binary) { false }
it 'updates successfully' do
expect(snippet.reload.title).to eq title
expect(response).to redirect_to(gitlab_snippet_path(snippet))
end
end
context 'when blob is binary' do
let(:binary) { true }
it 'redirects away without updating' do
expect(response).to redirect_to(gitlab_snippet_path(snippet))
expect(snippet.reload.title).not_to eq title
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment