Commit 82fa8a3d authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 232655bf
......@@ -10,6 +10,11 @@ import {
} from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import projectQuery from '../queries/project_boards.query.graphql';
import groupQuery from '../queries/group_boards.query.graphql';
import boardsStore from '../stores/boards_store';
import BoardForm from './board_form.vue';
......@@ -88,8 +93,9 @@ export default {
},
data() {
return {
loading: true,
hasScrollFade: false,
loadingBoards: 0,
loadingRecentBoards: false,
scrollFadeInitialized: false,
boards: [],
recentBoards: [],
......@@ -102,6 +108,12 @@ export default {
};
},
computed: {
parentType() {
return this.groupId ? 'group' : 'project';
},
loading() {
return this.loadingRecentBoards && this.loadingBoards;
},
currentPage() {
return this.state.currentPage;
},
......@@ -147,49 +159,71 @@ export default {
return;
}
const recentBoardsPromise = new Promise((resolve, reject) =>
boardsStore
.recentBoards()
.then(resolve)
.catch(err => {
/**
* If user is unauthorized we'd still want to resolve the
* request to display all boards.
*/
if (err.response.status === httpStatusCodes.UNAUTHORIZED) {
resolve({ data: [] }); // recent boards are empty
return;
}
reject(err);
}),
);
this.$apollo.addSmartQuery('boards', {
variables() {
return { fullPath: this.state.endpoints.fullPath };
},
query() {
return this.groupId ? groupQuery : projectQuery;
},
loadingKey: 'loadingBoards',
update(data) {
if (!data?.[this.parentType]) {
return [];
}
return data[this.parentType].boards.edges.map(({ node }) => ({
id: getIdFromGraphQLId(node.id),
name: node.name,
}));
},
});
Promise.all([boardsStore.allBoards(), recentBoardsPromise])
.then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data])
.then(([allBoardsJson, recentBoardsJson]) => {
this.loading = false;
this.boards = allBoardsJson;
this.recentBoards = recentBoardsJson;
this.loadingRecentBoards = true;
boardsStore
.recentBoards()
.then(res => {
this.recentBoards = res.data;
})
.catch(err => {
/**
* If user is unauthorized we'd still want to resolve the
* request to display all boards.
*/
if (err?.response?.status === httpStatusCodes.UNAUTHORIZED) {
this.recentBoards = []; // recent boards are empty
return;
}
throw err;
})
.then(() => this.$nextTick()) // Wait for boards list in DOM
.then(() => {
this.setScrollFade();
})
.catch(() => {
this.loading = false;
.catch(() => {})
.finally(() => {
this.loadingRecentBoards = false;
});
},
isScrolledUp() {
const { content } = this.$refs;
if (!content) {
return false;
}
const currentPosition = this.contentClientHeight + content.scrollTop;
return content && currentPosition < this.maxPosition;
return currentPosition < this.maxPosition;
},
initScrollFade() {
this.scrollFadeInitialized = true;
const { content } = this.$refs;
if (!content) {
return;
}
this.scrollFadeInitialized = true;
this.contentClientHeight = content.clientHeight;
this.maxPosition = content.scrollHeight;
},
......
......@@ -98,6 +98,7 @@ export default () => {
listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
fullPath: $boardApp.dataset.fullPath,
});
boardsStore.rootPath = this.boardsEndpoint;
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import BoardsSelector from '~/boards/components/boards_selector.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => {
const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
return new Vue({
......@@ -9,6 +17,7 @@ export default () => {
components: {
BoardsSelector,
},
apolloProvider,
data() {
const { dataset } = boardsSwitcherElement;
......
fragment BoardFragment on Board {
id,
name
}
#import "ee_else_ce/boards/queries/board.fragment.graphql"
query group_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
boards {
edges {
node {
...BoardFragment
}
}
}
}
}
#import "ee_else_ce/boards/queries/board.fragment.graphql"
query project_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
boards {
edges {
node {
...BoardFragment
}
}
}
}
}
......@@ -45,7 +45,14 @@ const boardsStore = {
},
multiSelect: { list: [] },
setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
setEndpoints({
boardsEndpoint,
listsEndpoint,
bulkUpdatePath,
boardId,
recentBoardsEndpoint,
fullPath,
}) {
const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
this.state.endpoints = {
boardsEndpoint,
......@@ -53,6 +60,7 @@ const boardsStore = {
listsEndpoint,
listsEndpointGenerate,
bulkUpdatePath,
fullPath,
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
};
},
......@@ -542,10 +550,6 @@ const boardsStore = {
return axios.post(endpoint);
},
allBoards() {
return axios.get(this.generateBoardsPath());
},
recentBoards() {
return axios.get(this.state.endpoints.recentBoardsEndpoint);
},
......
......@@ -165,6 +165,16 @@ export default {
showContainerRegistryPublicNote() {
return this.visibilityLevel === visibilityOptions.PUBLIC;
},
repositoryHelpText() {
if (this.visibilityLevel === visibilityOptions.PRIVATE) {
return s__('ProjectSettings|View and edit files in this project');
}
return s__(
'ProjectSettings|View and edit files in this project. Non-project members will only have read access',
);
},
},
watch: {
......@@ -225,6 +235,7 @@ export default {
<div>
<div class="project-visibility-setting">
<project-setting-row
ref="project-visibility-settings"
:help-path="visibilityHelpPath"
:label="s__('ProjectSettings|Project visibility')"
>
......@@ -270,6 +281,7 @@ export default {
</div>
<div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings">
<project-setting-row
ref="issues-settings"
:label="s__('ProjectSettings|Issues')"
:help-text="s__('ProjectSettings|Lightweight issue tracking system for this project')"
>
......@@ -280,8 +292,9 @@ export default {
/>
</project-setting-row>
<project-setting-row
ref="repository-settings"
:label="s__('ProjectSettings|Repository')"
:help-text="s__('ProjectSettings|View and edit files in this project')"
:help-text="repositoryHelpText"
>
<project-feature-setting
v-model="repositoryAccessLevel"
......@@ -291,6 +304,7 @@ export default {
</project-setting-row>
<div class="project-feature-setting-group">
<project-setting-row
ref="merge-request-settings"
:label="s__('ProjectSettings|Merge requests')"
:help-text="s__('ProjectSettings|Submit changes to be merged upstream')"
>
......@@ -302,6 +316,7 @@ export default {
/>
</project-setting-row>
<project-setting-row
ref="fork-settings"
:label="s__('ProjectSettings|Forks')"
:help-text="
s__('ProjectSettings|Allow users to make copies of your repository to a new project')
......@@ -315,6 +330,7 @@ export default {
/>
</project-setting-row>
<project-setting-row
ref="pipeline-settings"
:label="s__('ProjectSettings|Pipelines')"
:help-text="s__('ProjectSettings|Build, test, and deploy your changes')"
>
......@@ -327,6 +343,7 @@ export default {
</project-setting-row>
<project-setting-row
v-if="registryAvailable"
ref="container-registry-settings"
:help-path="registryHelpPath"
:label="s__('ProjectSettings|Container registry')"
:help-text="
......@@ -348,6 +365,7 @@ export default {
</project-setting-row>
<project-setting-row
v-if="lfsAvailable"
ref="git-lfs-settings"
:help-path="lfsHelpPath"
:label="s__('ProjectSettings|Git Large File Storage')"
:help-text="
......@@ -362,6 +380,7 @@ export default {
</project-setting-row>
<project-setting-row
v-if="packagesAvailable"
ref="package-settings"
:help-path="packagesHelpPath"
:label="s__('ProjectSettings|Packages')"
:help-text="
......@@ -376,6 +395,7 @@ export default {
</project-setting-row>
</div>
<project-setting-row
ref="wiki-settings"
:label="s__('ProjectSettings|Wiki')"
:help-text="s__('ProjectSettings|Pages for project documentation')"
>
......@@ -386,6 +406,7 @@ export default {
/>
</project-setting-row>
<project-setting-row
ref="snippet-settings"
:label="s__('ProjectSettings|Snippets')"
:help-text="s__('ProjectSettings|Share code pastes with others out of Git repository')"
>
......@@ -397,6 +418,7 @@ export default {
</project-setting-row>
<project-setting-row
v-if="pagesAvailable && pagesAccessControlEnabled"
ref="pages-settings"
:help-path="pagesHelpPath"
:label="s__('ProjectSettings|Pages')"
:help-text="
......@@ -410,7 +432,7 @@ export default {
/>
</project-setting-row>
</div>
<project-setting-row v-if="canDisableEmails" class="mb-3">
<project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
<label class="js-emails-disabled">
<input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" />
<input v-model="emailsDisabled" type="checkbox" />
......
<script>
import { GlFormInput } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import setupCollapsibleInputs from '~/snippet/collapsible_input';
export default {
components: {
GlFormInput,
MarkdownField,
},
props: {
description: {
type: String,
default: '',
required: false,
},
markdownPreviewPath: {
type: String,
required: true,
},
markdownDocsPath: {
type: String,
required: true,
},
},
data() {
return {
text: this.description,
};
},
mounted() {
setupCollapsibleInputs();
},
};
</script>
<template>
<div class="form-group js-description-input">
<label>{{ s__('Snippets|Description (optional)') }}</label>
<div class="js-collapsible-input">
<div class="js-collapsed" :class="{ 'd-none': text }">
<gl-form-input
class="form-control"
:placeholder="
s__(
'Snippets|Optionally add a description about what your snippet does or how to use it…',
)
"
data-qa-selector="description_placeholder"
/>
</div>
<markdown-field
class="js-expanded"
:class="{ 'd-none': !text }"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
>
<textarea
id="snippet-description"
slot="textarea"
v-model="text"
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
dir="auto"
data-supports-quick-actions="false"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
>
</textarea>
</markdown-field>
</div>
</div>
</template>
......@@ -212,6 +212,8 @@ export default {
return new MRWidgetService(this.getServiceEndpoints(store));
},
checkStatus(cb, isRebased) {
if (document.visibilityState !== 'visible') return Promise.resolve();
return this.service
.checkStatus()
.then(({ data }) => {
......
......@@ -13,6 +13,7 @@ module BoardsHelper
disabled: (!can?(current_user, :create_non_backlog_issues, board)).to_s,
issue_link_base: build_issue_link_base,
root_path: root_path,
full_path: full_path,
bulk_update_path: @bulk_issues_path,
default_avatar: image_path(default_avatar),
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
......@@ -20,6 +21,14 @@ module BoardsHelper
}
end
def full_path
if board.group_board?
@group.full_path
else
@project.full_path
end
end
def build_issue_link_base
if board.group_board?
"#{group_path(@board.group)}/:project_path/issues"
......
......@@ -313,6 +313,7 @@ class ProjectPolicy < BasePolicy
enable :daily_statistics
enable :admin_operations
enable :read_deploy_token
enable :create_deploy_token
end
rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror
......
---
title: Update project's permission settings description to reflect actual permissions
merge_request: 25523
author:
type: other
---
title: Added Blob Description Edit component in Vue
merge_request: 26762
author:
type: added
---
title: Update charts documentation and common_metrics.yml to enable data formatting
merge_request: 26048
author:
type: added
---
title: Optimize event counters query performance in usage data
merge_request: 26444
author:
type: performance
---
title: Add api endpoint to create deploy tokens
merge_request: 25270
author:
type: added
---
title: Enable client-side GRPC keepalive for Gitaly
merge_request: 26536
author:
type: changed
---
title: Replace undefined severity with unknown severity for vulnerabilities
merge_request: 26305
author:
type: other
......@@ -59,5 +59,7 @@ Rails.application.configure do
config.active_record.migration_error = false
config.active_record.verbose_query_logs = false
config.action_view.cache_template_loading = true
config.middleware.delete BetterErrors::Middleware
end
end
......@@ -17,6 +17,8 @@ panel_groups:
- title: "Latency"
type: "area-chart"
y_label: "Latency (ms)"
y_axis:
format: milliseconds
weight: 1
metrics:
- id: response_metrics_nginx_ingress_latency_pod_average
......@@ -26,6 +28,8 @@ panel_groups:
- title: "HTTP Error Rate"
type: "area-chart"
y_label: "HTTP Errors (%)"
y_axis:
format: percentHundred
weight: 1
metrics:
- id: response_metrics_nginx_ingress_http_error_rate
......@@ -138,6 +142,8 @@ panel_groups:
- title: "HTTP Error Rate (Errors / Sec)"
type: "area-chart"
y_label: "HTTP 500 Errors / Sec"
y_axis:
precision: 0
weight: 1
metrics:
- id: response_metrics_nginx_http_error_rate
......@@ -150,6 +156,8 @@ panel_groups:
- title: "Memory Usage (Total)"
type: "area-chart"
y_label: "Total Memory Used (GB)"
y_axis:
format: "gibibytes"
weight: 4
metrics:
- id: system_metrics_kubernetes_container_memory_total
......@@ -168,6 +176,8 @@ panel_groups:
- title: "Memory Usage (Pod average)"
type: "line-chart"
y_label: "Memory Used per Pod (MB)"
y_axis:
format: "mebibytes"
weight: 2
metrics:
- id: system_metrics_kubernetes_container_memory_average
......@@ -177,6 +187,8 @@ panel_groups:
- title: "Canary: Memory Usage (Pod Average)"
type: "line-chart"
y_label: "Memory Used per Pod (MB)"
y_axis:
format: "mebibytes"
weight: 2
metrics:
- id: system_metrics_kubernetes_container_memory_average_canary
......@@ -206,6 +218,8 @@ panel_groups:
- title: "Knative function invocations"
type: "area-chart"
y_label: "Invocations"
y_axis:
precision: 0
weight: 1
metrics:
- id: system_metrics_knative_function_invocation_count
......
# frozen_string_literal: true
class AddIndexOnAuthorIdAndCreatedAtToEvents < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :events, [:author_id, :created_at]
end
def down
remove_concurrent_index :events, [:author_id, :created_at]
end
end
# frozen_string_literal: true
class AddIndexOnAuthorIdAndIdAndCreatedAtToIssues < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :issues, [:author_id, :id, :created_at]
end
def down
remove_concurrent_index :issues, [:author_id, :id, :created_at]
end
end
# frozen_string_literal: true
class UpdateVulnerabilitySeverityColumn < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
BATCH_SIZE = 1_000
INTERVAL = 2.minutes
def up
# create temporary index for undefined vulnerabilities
add_concurrent_index(:vulnerabilities, :id, where: 'severity = 0', name: 'undefined_vulnerability')
return unless Gitlab.ee?
migration = Gitlab::BackgroundMigration::RemoveUndefinedVulnerabilitySeverityLevel
migration_name = migration.to_s.demodulize
relation = migration::Vulnerability.undefined_severity
queue_background_migration_jobs_by_range_at_intervals(relation,
migration_name,
INTERVAL,
batch_size: BATCH_SIZE)
end
def down
# no-op
# This migration can not be reversed because we can not know which records had undefined severity
end
end
......@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_03_09_105539) do
ActiveRecord::Schema.define(version: 2020_03_06_170531) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
......@@ -1616,6 +1616,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
t.string "target_type"
t.bigint "group_id"
t.index ["action"], name: "index_events_on_action"
t.index ["author_id", "created_at"], name: "index_events_on_author_id_and_created_at"
t.index ["author_id", "project_id"], name: "index_events_on_author_id_and_project_id"
t.index ["created_at", "author_id"], name: "analytics_index_events_on_created_at_and_author_id"
t.index ["group_id"], name: "index_events_on_group_id_partial", where: "(group_id IS NOT NULL)"
......@@ -2206,6 +2207,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
t.integer "duplicated_to_id"
t.integer "promoted_to_epic_id"
t.integer "health_status", limit: 2
t.index ["author_id", "id", "created_at"], name: "index_issues_on_author_id_and_id_and_created_at"
t.index ["author_id"], name: "index_issues_on_author_id"
t.index ["closed_by_id"], name: "index_issues_on_closed_by_id"
t.index ["confidential"], name: "index_issues_on_confidential"
......@@ -4454,6 +4456,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
t.index ["dismissed_by_id"], name: "index_vulnerabilities_on_dismissed_by_id"
t.index ["due_date_sourcing_milestone_id"], name: "index_vulnerabilities_on_due_date_sourcing_milestone_id"
t.index ["epic_id"], name: "index_vulnerabilities_on_epic_id"
t.index ["id"], name: "undefined_vulnerability", where: "(severity = 0)"
t.index ["last_edited_by_id"], name: "index_vulnerabilities_on_last_edited_by_id"
t.index ["milestone_id"], name: "index_vulnerabilities_on_milestone_id"
t.index ["project_id"], name: "index_vulnerabilities_on_project_id"
......
......@@ -72,6 +72,43 @@ Example response:
]
```
### Create a project deploy token
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/21811) in GitLab 12.9.
Creates a new deploy token for a project.
```
POST /projects/:id/deploy_tokens
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `name` | string | yes | New deploy token's name |
| `expires_at` | datetime | no | Expiration date for the deploy token. Does not expire if no value is provided. |
| `username` | string | no | Username for deploy token. Default is `gitlab+deploy-token-{n}` |
| `scopes` | array of strings | yes | Indicates the deploy token scopes. Must be at least one of `read_repository` or `read_registry`. |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" --data '{"name": "My deploy token", "expires_at": "2021-01-01", "username": "custom-user", "scopes": ["read_repository"]}' "https://gitlab.example.com/api/v4/projects/5/deploy_tokens/"
```
Example response:
```json
{
"id": 1,
"name": "My deploy token",
"username": "custom-user",
"expires_at": "2021-01-01T00:00:00.000Z",
"token": "jMRvtPNxrn3crTAGukpZ",
"scopes": [
"read_repository"
]
}
```
## Group deploy tokens
These endpoints require group maintainer access or higher.
......
......@@ -12,7 +12,10 @@ The requirement for adding a new metric is to make each query to have an unique
- group: Response metrics (NGINX Ingress)
metrics:
- title: "Throughput"
y_label: "Requests / Sec"
y_axis:
name: "Requests / Sec"
format: "number"
precision: 2
queries:
- id: response_metrics_nginx_ingress_throughput_status_code
query_range: 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)'
......
......@@ -52,8 +52,6 @@ Here's a list of the AWS services we will use, with links to pricing information
will apply. If you want to run it on a dedicated or reserved instance,
consult the [EC2 pricing page](https://aws.amazon.com/ec2/pricing/) for more
information on the cost.
- **EBS**: We will also use an EBS volume to store the Git data. See the
[Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/).
- **S3**: We will use S3 to store backups, artifacts, LFS objects, etc. See the
[Amazon S3 pricing](https://aws.amazon.com/s3/pricing/).
- **ELB**: A Classic Load Balancer will be used to route requests to the
......@@ -524,7 +522,7 @@ Let's create an EC2 instance where we'll install Gitaly:
1. Click **Review and launch** followed by **Launch** if you're happy with your settings.
1. Finally, acknowledge that you have access to the selected private key file or create a new one. Click **Launch Instances**.
> **Optional:** Instead of storing configuration _and_ repository data on the root volume, you can also choose to add an additional EBS volume for repository storage. Follow the same guidance as above.
> **Optional:** Instead of storing configuration _and_ repository data on the root volume, you can also choose to add an additional EBS volume for repository storage. Follow the same guidance as above. See the [Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/).
Now that we have our EC2 instance ready, follow the [documentation to install GitLab and set up Gitaly on its own server](../../administration/gitaly/index.md#running-gitaly-on-its-own-server).
......
......@@ -7,9 +7,9 @@ type: howto
GitLab can be configured to require confirmation of a user's email address when
the user signs up. When this setting is enabled:
- For GitLab 12.1 and earlier, the user is unable to sign in until they confirm their
- For GitLab 12.7 and earlier, the user is unable to sign in until they confirm their
email address.
- For GitLab 12.2 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
- For GitLab 12.8 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
After 30 days, they will be unable to log in and access GitLab features.
In **Admin Area > Settings** (`/admin/application_settings/general`), go to the section
......
......@@ -39,9 +39,9 @@ email domains to prevent malicious users from creating accounts.
You can send confirmation emails during sign-up and require that users confirm
their email address. If this setting is selected:
- For GitLab 12.1 and earlier, the user is unable to sign in until they confirm their
- For GitLab 12.7 and earlier, the user is unable to sign in until they confirm their
email address.
- For GitLab 12.2 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
- For GitLab 12.8 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
After 30 days, they will be unable to log in and access GitLab features.
![Email confirmation](img/email_confirmation_v12_7.png)
......
......@@ -356,6 +356,31 @@ dast:
The DAST job does not require the project's repository to be present when running, so by default
[`GIT_STRATEGY`](../../../ci/yaml/README.md#git-strategy) is set to `none`.
## Running DAST in an offline air-gapped installation
DAST can be executed on an offline air-gapped GitLab Ultimate installation using the following process:
1. Host the DAST image `registry.gitlab.com/gitlab-org/security-products/dast:latest` in your local
Docker container registry.
1. Add the following configuration to your `.gitlab-ci.yml` file. You must replace `image` to refer
to the DAST Docker image hosted on your local Docker container registry:
```yaml
include:
- template: DAST.gitlab-ci.yml
dast:
image: registry.example.com/namespace/dast:latest
script:
- export DAST_WEBSITE=${DAST_WEBSITE:-$(cat environment_url.txt)}
- /analyze -t $DAST_WEBSITE --auto-update-addons false -z"-silent"
```
The option `--auto-update-addons false` instructs ZAP not to update add-ons.
The option `-z` passes the quoted `-silent` parameter to ZAP. The `-silent` parameter ensures ZAP
does not make any unsolicited requests including checking for updates.
## Reports
The DAST job can emit various reports.
......
......@@ -203,14 +203,17 @@ For example:
panel_groups:
- group: 'Group Title'
panels:
- type: area-chart
title: "Chart Title"
y_label: "Y-Axis"
metrics:
- id: metric_of_ages
query_range: 'http_requests_total'
label: "Instance: {{instance}}, method: {{method}}"
unit: "count"
- type: area-chart
title: "Chart Title"
y_label: "Y-Axis"
y_axis:
format: number
precision: 0
metrics:
- id: my_metric_id
query_range: 'http_requests_total'
label: "Instance: {{instance}}, method: {{method}}"
unit: "count"
```
The above sample dashboard would display a single area chart. Each file should
......@@ -276,9 +279,18 @@ The following tables outline the details of expected properties.
| `type` | enum | no, defaults to `area-chart` | Specifies the chart type to use, can be: `area-chart`, `line-chart` or `anomaly-chart`. |
| `title` | string | yes | Heading for the panel. |
| `y_label` | string | no, but highly encouraged | Y-Axis label for the panel. |
| `y_axis` | string | no | Y-Axis configuration for the panel. |
| `weight` | number | no, defaults to order in file | Order to appear within the grouping. Lower number means higher priority, which will be higher on the page. Numbers do not need to be consecutive. |
| `metrics` | array | yes | The metrics which should be displayed in the panel. Any number of metrics can be displayed when `type` is `area-chart` or `line-chart`, whereas only 3 can be displayed when `type` is `anomaly-chart`. |
**Axis (`panels[].y_axis`) properties:**
| Property | Type | Required | Description |
| ----------- | ------ | ------------------------- | -------------------------------------------------------------------- |
| `name` | string | no, but highly encouraged | Y-Axis label for the panel, it will replace `y_label` if set. |
| `format` | string | no, defaults to `number` | Unit format used. See the [full list of units](prometheus_units.md). |
| `precision` | number | no, defaults to `2` | Number of decimals to display in the number. |
**Metrics (`metrics`) properties:**
| Property | Type | Required | Description |
......@@ -297,7 +309,7 @@ When a static label is used and a query returns multiple time series, then all t
```yaml
metrics:
- id: metric_of_ages
- id: my_metric_id
query_range: 'http_requests_total'
label: "Time Series"
unit: "count"
......@@ -311,7 +323,7 @@ For labels to be more explicit, using variables that reflect time series labels
```yaml
metrics:
- id: metric_of_ages
- id: my_metric_id
query_range: 'http_requests_total'
label: "Instance: {{instance}}, method: {{method}}"
unit: "count"
......@@ -325,7 +337,7 @@ There is also a shorthand value for dynamic dashboard labels that make use of on
```yaml
metrics:
- id: metric_of_ages
- id: my_metric_id
query_range: 'http_requests_total'
label: "Method"
unit: "count"
......@@ -351,6 +363,9 @@ panel_groups:
- type: area-chart # or line-chart
title: 'Area Chart Title'
y_label: "Y-Axis"
y_axis:
format: number
precision: 0
metrics:
- id: area_http_requests_total
query_range: 'http_requests_total'
......
# Unit formats reference
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/201999) in GitLab 12.9.
You can select units to format your charts by adding `format` to your
[axis configuration](prometheus.md#dashboard-yaml-properties).
## Numbers
For generic data, numbers are formatted according to the current locale.
Formats: `number`
**Examples:**
| Data | Displayed |
| --------- | --------- |
| `10` | 1 |
| `1000` | 1,000 |
| `1000000` | 1,000,000 |
## Percentage
For percentage data, format numbers in the chart with a `%` symbol.
Formats supported: `percent`, `percentHundred`
**Examples:**
| Format | Data | Displayed |
| ---------------- | ----- | --------- |
| `percent` | `0.5` | 50% |
| `percent` | `1` | 100% |
| `percent` | `2` | 200% |
| `percentHundred` | `50` | 50% |
| `percentHundred` | `100` | 100% |
| `percentHundred` | `200` | 200% |
## Duration
For time durations, format numbers in the chart with a time unit symbol.
Formats supported: `milliseconds`, `seconds`
**Examples:**
| Format | Data | Displayed |
| -------------- | ------ | --------- |
| `milliseconds` | `10` | 10ms |
| `milliseconds` | `500` | 100ms |
| `milliseconds` | `1000` | 1000ms |
| `seconds` | `10` | 10s |
| `seconds` | `500` | 500s |
| `seconds` | `1000` | 1000s |
## Digital (Metric)
Converts a number of bytes using metric prefixes. It scales to
use the unit that's the best fit.
Formats supported:
- `decimalBytes`
- `kilobytes`
- `megabytes`
- `gigabytes`
- `terabytes`
- `petabytes`
**Examples:**
| Format | Data | Displayed |
| -------------- | --------- | --------- |
| `decimalBytes` | `1` | 1B |
| `decimalBytes` | `1000` | 1kB |
| `decimalBytes` | `1000000` | 1MB |
| `kilobytes` | `1` | 1kB |
| `kilobytes` | `1000` | 1MB |
| `kilobytes` | `1000000` | 1GB |
| `megabytes` | `1` | 1MB |
| `megabytes` | `1000` | 1GB |
| `megabytes` | `1000000` | 1TB |
## Digital (IEC)
Converts a number of bytes using binary prefixes. It scales to
use the unit that's the best fit.
Formats supported:
- `bytes`
- `kibibytes`
- `mebibytes`
- `gibibytes`
- `tebibytes`
- `pebibytes`
**Examples:**
| Format | Data | Displayed |
| ----------- | ------------- | --------- |
| `bytes` | `1` | 1B |
| `bytes` | `1024` | 1KiB |
| `bytes` | `1024 * 1024` | 1MiB |
| `kibibytes` | `1` | 1KiB |
| `kibibytes` | `1024` | 1MiB |
| `kibibytes` | `1024 * 1024` | 1GiB |
| `mebibytes` | `1` | 1MiB |
| `mebibytes` | `1024` | 1GiB |
| `mebibytes` | `1024 * 1024` | 1TiB |
......@@ -4,6 +4,17 @@ module API
class DeployTokens < Grape::API
include PaginationParams
helpers do
def scope_params
scopes = params.delete(:scopes)
result_hash = {}
result_hash[:read_registry] = scopes.include?('read_registry')
result_hash[:read_repository] = scopes.include?('read_repository')
result_hash
end
end
desc 'Return all deploy tokens' do
detail 'This feature was introduced in GitLab 12.9.'
success Entities::DeployToken
......@@ -33,6 +44,27 @@ module API
present paginate(user_project.deploy_tokens), with: Entities::DeployToken
end
params do
requires :name, type: String, desc: "New deploy token's name"
requires :expires_at, type: DateTime, desc: 'Expiration date for the deploy token. Does not expire if no value is provided.'
requires :username, type: String, desc: 'Username for deploy token. Default is `gitlab+deploy-token-{n}`'
requires :scopes, type: Array[String], values: ::DeployToken::AVAILABLE_SCOPES.map(&:to_s),
desc: 'Indicates the deploy token scopes. Must be at least one of "read_repository" or "read_registry".'
end
desc 'Create a project deploy token' do
detail 'This feature was introduced in GitLab 12.9'
success Entities::DeployTokenWithToken
end
post ':id/deploy_tokens' do
authorize!(:create_deploy_token, user_project)
deploy_token = ::Projects::DeployTokens::CreateService.new(
user_project, current_user, scope_params.merge(declared(params, include_missing: false, include_parent_namespaces: false))
).execute
present deploy_token, with: Entities::DeployTokenWithToken
end
end
params do
......
# frozen_string_literal: true
module API
module Entities
class DeployTokenWithToken < Entities::DeployToken
expose :token
end
end
end
# frozen_string_literal: true
# rubocop:disable Style/Documentation
module Gitlab
module BackgroundMigration
class RemoveUndefinedVulnerabilitySeverityLevel
def perform(start_id, stop_id)
end
end
end
end
Gitlab::BackgroundMigration::RemoveUndefinedVulnerabilitySeverityLevel.prepend_if_ee('EE::Gitlab::BackgroundMigration::RemoveUndefinedVulnerabilitySeverityLevel')
......@@ -28,7 +28,7 @@ module Gitlab
class BatchCounter
FALLBACK = -1
MIN_REQUIRED_BATCH_SIZE = 2_000
MIN_REQUIRED_BATCH_SIZE = 1_250
MAX_ALLOWED_LOOPS = 10_000
SLEEP_TIME_IN_SECONDS = 0.01 # 10 msec sleep
# Each query should take <<500ms https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22705
......
......@@ -42,7 +42,7 @@ module Gitlab
klass = stub_class(name)
addr = stub_address(storage)
creds = stub_creds(storage)
klass.new(addr, creds, interceptors: interceptors)
klass.new(addr, creds, interceptors: interceptors, channel_args: channel_args)
end
end
end
......@@ -54,6 +54,16 @@ module Gitlab
end
private_class_method :interceptors
def self.channel_args
# These values match the go Gitaly client
# https://gitlab.com/gitlab-org/gitaly/-/blob/bf9f52bc/client/dial.go#L78
{
'grpc.keepalive_time_ms': 20000,
'grpc.keepalive_permit_without_calls': 1
}
end
private_class_method :channel_args
def self.stub_cert_paths
cert_paths = Dir["#{OpenSSL::X509::DEFAULT_CERT_DIR}/*"]
cert_paths << OpenSSL::X509::DEFAULT_CERT_FILE if File.exist? OpenSSL::X509::DEFAULT_CERT_FILE
......
......@@ -15422,6 +15422,9 @@ msgstr ""
msgid "ProjectSettings|View and edit files in this project"
msgstr ""
msgid "ProjectSettings|View and edit files in this project. Non-project members will only have read access"
msgstr ""
msgid "ProjectSettings|When conflicts arise the user is given the option to rebase"
msgstr ""
......@@ -18174,6 +18177,9 @@ msgstr ""
msgid "Snippets|Optionally add a description about what your snippet does or how to use it..."
msgstr ""
msgid "Snippets|Optionally add a description about what your snippet does or how to use it…"
msgstr ""
msgid "Snowplow"
msgstr ""
......
......@@ -25,7 +25,9 @@
"items": {
"type": "string"
}
},
"token": {
"type": "string"
}
},
"additionalProperties": false
}
}
\ No newline at end of file
{
"type": "object",
"required": [],
"properties": {
"name": { "type": "string" },
"precision": { "type": "number" },
"format": { "type": "string" }
},
"additionalProperties": false
}
......@@ -9,6 +9,7 @@
"title": { "type": "string" },
"type": { "type": "string" },
"y_label": { "type": "string" },
"y_axis": { "$ref": "axis.json" },
"weight": { "type": "number" },
"metrics": {
"type": "array",
......
......@@ -3,6 +3,8 @@ import axios from '~/lib/utils/axios_utils';
import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
import ClassSpecHelper from '../../helpers/class_spec_helper';
jest.mock('sql.js');
describe('BalsamiqViewer', () => {
const mockArrayBuffer = new ArrayBuffer(10);
let balsamiqViewer;
......@@ -34,22 +36,22 @@ describe('BalsamiqViewer', () => {
});
it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => {
spyOn(axios, 'get').and.returnValue(requestSuccess);
spyOn(bv, 'renderFile').and.stub();
jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
jest.spyOn(bv, 'renderFile').mockReturnValue();
bv.loadFile(endpoint);
expect(axios.get).toHaveBeenCalledWith(
endpoint,
jasmine.objectContaining({
expect.objectContaining({
responseType: 'arraybuffer',
}),
);
});
it('should call `renderFile` on request success', done => {
spyOn(axios, 'get').and.returnValue(requestSuccess);
spyOn(bv, 'renderFile').and.callFake(() => {});
jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
bv.loadFile(endpoint)
.then(() => {
......@@ -60,8 +62,8 @@ describe('BalsamiqViewer', () => {
});
it('should not call `renderFile` on request failure', done => {
spyOn(axios, 'get').and.returnValue(Promise.reject());
spyOn(bv, 'renderFile');
jest.spyOn(axios, 'get').mockReturnValue(Promise.reject());
jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
bv.loadFile(endpoint)
.then(() => {
......@@ -80,19 +82,21 @@ describe('BalsamiqViewer', () => {
let previews;
beforeEach(() => {
viewer = jasmine.createSpyObj('viewer', ['appendChild']);
viewer = {
appendChild: jest.fn(),
};
previews = [document.createElement('ul'), document.createElement('ul')];
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', [
'initDatabase',
'getPreviews',
'renderPreview',
]);
balsamiqViewer = {
initDatabase: jest.fn(),
getPreviews: jest.fn(),
renderPreview: jest.fn(),
};
balsamiqViewer.viewer = viewer;
balsamiqViewer.getPreviews.and.returnValue(previews);
balsamiqViewer.renderPreview.and.callFake(preview => preview);
viewer.appendChild.and.callFake(containerElement => {
balsamiqViewer.getPreviews.mockReturnValue(previews);
balsamiqViewer.renderPreview.mockImplementation(preview => preview);
viewer.appendChild.mockImplementation(containerElement => {
container = containerElement;
});
......@@ -108,7 +112,7 @@ describe('BalsamiqViewer', () => {
});
it('should call .renderPreview for each preview', () => {
const allArgs = balsamiqViewer.renderPreview.calls.allArgs();
const allArgs = balsamiqViewer.renderPreview.mock.calls;
expect(allArgs.length).toBe(2);
......@@ -132,19 +136,15 @@ describe('BalsamiqViewer', () => {
});
describe('initDatabase', () => {
let database;
let uint8Array;
let data;
beforeEach(() => {
uint8Array = {};
database = {};
data = 'data';
balsamiqViewer = {};
spyOn(window, 'Uint8Array').and.returnValue(uint8Array);
spyOn(sqljs, 'Database').and.returnValue(database);
window.Uint8Array = jest.fn();
window.Uint8Array.mockReturnValue(uint8Array);
BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data);
});
......@@ -158,7 +158,7 @@ describe('BalsamiqViewer', () => {
});
it('should set .database', () => {
expect(balsamiqViewer.database).toBe(database);
expect(balsamiqViewer.database).not.toBe(null);
});
});
......@@ -168,15 +168,17 @@ describe('BalsamiqViewer', () => {
let getPreviews;
beforeEach(() => {
database = jasmine.createSpyObj('database', ['exec']);
database = {
exec: jest.fn(),
};
thumbnails = [{ values: [0, 1, 2] }];
balsamiqViewer = {
database,
};
spyOn(BalsamiqViewer, 'parsePreview').and.callFake(preview => preview.toString());
database.exec.and.returnValue(thumbnails);
jest.spyOn(BalsamiqViewer, 'parsePreview').mockImplementation(preview => preview.toString());
database.exec.mockReturnValue(thumbnails);
getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer);
});
......@@ -186,7 +188,7 @@ describe('BalsamiqViewer', () => {
});
it('should call .parsePreview for each value', () => {
const allArgs = BalsamiqViewer.parsePreview.calls.allArgs();
const allArgs = BalsamiqViewer.parsePreview.mock.calls;
expect(allArgs.length).toBe(3);
......@@ -207,7 +209,9 @@ describe('BalsamiqViewer', () => {
let getResource;
beforeEach(() => {
database = jasmine.createSpyObj('database', ['exec']);
database = {
exec: jest.fn(),
};
resourceID = 4;
resource = ['resource'];
......@@ -215,7 +219,7 @@ describe('BalsamiqViewer', () => {
database,
};
database.exec.and.returnValue(resource);
database.exec.mockReturnValue(resource);
getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID);
});
......@@ -241,14 +245,18 @@ describe('BalsamiqViewer', () => {
innerHTML = '<a>innerHTML</a>';
previewElement = {
outerHTML: '<p>outerHTML</p>',
classList: jasmine.createSpyObj('classList', ['add']),
classList: {
add: jest.fn(),
},
};
preview = {};
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderTemplate']);
balsamiqViewer = {
renderTemplate: jest.fn(),
};
spyOn(document, 'createElement').and.returnValue(previewElement);
balsamiqViewer.renderTemplate.and.returnValue(innerHTML);
jest.spyOn(document, 'createElement').mockReturnValue(previewElement);
balsamiqViewer.renderTemplate.mockReturnValue(innerHTML);
renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview);
});
......@@ -290,10 +298,12 @@ describe('BalsamiqViewer', () => {
</div>
`;
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getResource']);
balsamiqViewer = {
getResource: jest.fn(),
};
spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name);
balsamiqViewer.getResource.and.returnValue(resource);
jest.spyOn(BalsamiqViewer, 'parseTitle').mockReturnValue(name);
balsamiqViewer.getResource.mockReturnValue(resource);
renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview);
});
......@@ -306,7 +316,7 @@ describe('BalsamiqViewer', () => {
expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource);
});
it('should return the template string', function() {
it('should return the template string', () => {
expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, ''));
});
});
......@@ -318,7 +328,7 @@ describe('BalsamiqViewer', () => {
beforeEach(() => {
preview = ['{}', '{ "id": 1 }'];
spyOn(JSON, 'parse').and.callThrough();
jest.spyOn(JSON, 'parse');
parsePreview = BalsamiqViewer.parsePreview(preview);
});
......@@ -337,7 +347,7 @@ describe('BalsamiqViewer', () => {
beforeEach(() => {
title = { values: [['{}', '{}', '{"name":"name"}']] };
spyOn(JSON, 'parse').and.callThrough();
jest.spyOn(JSON, 'parse');
parseTitle = BalsamiqViewer.parseTitle(title);
});
......
......@@ -440,23 +440,6 @@ describe('boardsStore', () => {
});
});
describe('allBoards', () => {
const url = `${endpoints.boardsEndpoint}.json`;
it('makes a request to fetch all boards', () => {
axiosMock.onGet(url).replyOnce(200, dummyResponse);
const expectedResponse = expect.objectContaining({ data: dummyResponse });
return expect(boardsStore.allBoards()).resolves.toEqual(expectedResponse);
});
it('fails for error response', () => {
axiosMock.onGet(url).replyOnce(500);
return expect(boardsStore.allBoards()).rejects.toThrow();
});
});
describe('recentBoards', () => {
const url = `${endpoints.recentBoardsEndpoint}.json`;
......
import Vue from 'vue';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { GlDropdown } from '@gitlab/ui';
import { GlDropdown, GlLoadingIcon } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector.vue';
import boardsStore from '~/boards/stores/boards_store';
......@@ -8,7 +8,8 @@ import boardsStore from '~/boards/stores/boards_store';
const throttleDuration = 1;
function boardGenerator(n) {
return new Array(n).fill().map((board, id) => {
return new Array(n).fill().map((board, index) => {
const id = `${index}`;
const name = `board${id}`;
return {
......@@ -34,8 +35,17 @@ describe('BoardsSelector', () => {
const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
const getDropdownHeaders = () => wrapper.findAll('.dropdown-bold-header');
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
const $apollo = {
queries: {
boards: {
loading: false,
},
},
};
boardsStore.setEndpoints({
boardsEndpoint: '',
recentBoardsEndpoint: '',
......@@ -45,7 +55,13 @@ describe('BoardsSelector', () => {
});
allBoardsResponse = Promise.resolve({
data: boards,
data: {
group: {
boards: {
edges: boards.map(board => ({ node: board })),
},
},
},
});
recentBoardsResponse = Promise.resolve({
data: recentBoards,
......@@ -54,8 +70,7 @@ describe('BoardsSelector', () => {
boardsStore.allBoards = jest.fn(() => allBoardsResponse);
boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
const Component = Vue.extend(BoardsSelector);
wrapper = mount(Component, {
wrapper = mount(BoardsSelector, {
propsData: {
throttleDuration,
currentBoard: {
......@@ -77,13 +92,18 @@ describe('BoardsSelector', () => {
scopedIssueBoardFeatureEnabled: true,
weights: [],
},
mocks: { $apollo },
attachToDocument: true,
});
wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
wrapper.setData({
[options.loadingKey]: true,
});
});
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
wrapper.find(GlDropdown).vm.$emit('show');
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => Vue.nextTick());
});
afterEach(() => {
......@@ -91,64 +111,99 @@ describe('BoardsSelector', () => {
wrapper = null;
});
describe('filtering', () => {
it('shows all boards without filtering', () => {
expect(getDropdownItems().length).toBe(boards.length + recentBoards.length);
describe('loading', () => {
// we are testing loading state, so don't resolve responses until after the tests
afterEach(() => {
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
it('shows only matching boards when filtering', () => {
const filterTerm = 'board1';
const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
it('shows loading spinner', () => {
expect(getDropdownHeaders()).toHaveLength(0);
expect(getDropdownItems()).toHaveLength(0);
expect(getLoadingIcon().exists()).toBe(true);
});
});
fillSearchBox(filterTerm);
describe('loaded', () => {
beforeEach(() => {
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
return Vue.nextTick().then(() => {
expect(getDropdownItems().length).toBe(expectedCount);
});
it('hides loading spinner', () => {
expect(getLoadingIcon().exists()).toBe(false);
});
it('shows message if there are no matching boards', () => {
fillSearchBox('does not exist');
describe('filtering', () => {
beforeEach(() => {
wrapper.setData({
boards,
});
return Vue.nextTick().then(() => {
expect(getDropdownItems().length).toBe(0);
expect(wrapper.text().includes('No matching boards found')).toBe(true);
return nextTick();
});
});
});
describe('recent boards section', () => {
it('shows only when boards are greater than 10', () => {
const expectedCount = 2; // Recent + All
it('shows all boards without filtering', () => {
expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
});
expect(getDropdownHeaders().length).toBe(expectedCount);
});
it('shows only matching boards when filtering', () => {
const filterTerm = 'board1';
const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
it('does not show when boards are less than 10', () => {
wrapper.setData({
boards: boards.slice(0, 5),
fillSearchBox(filterTerm);
return nextTick().then(() => {
expect(getDropdownItems()).toHaveLength(expectedCount);
});
});
return Vue.nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0);
it('shows message if there are no matching boards', () => {
fillSearchBox('does not exist');
return nextTick().then(() => {
expect(getDropdownItems()).toHaveLength(0);
expect(wrapper.text().includes('No matching boards found')).toBe(true);
});
});
});
it('does not show when recentBoards api returns empty array', () => {
wrapper.setData({
recentBoards: [],
describe('recent boards section', () => {
it('shows only when boards are greater than 10', () => {
wrapper.setData({
boards,
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(2);
});
});
return Vue.nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0);
it('does not show when boards are less than 10', () => {
wrapper.setData({
boards: boards.slice(0, 5),
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
it('does not show when recentBoards api returns empty array', () => {
wrapper.setData({
recentBoards: [],
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
});
it('does not show when search is active', () => {
fillSearchBox('Random string');
it('does not show when search is active', () => {
fillSearchBox('Random string');
return Vue.nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0);
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
});
});
......
import { mount, shallowMount } from '@vue/test-utils';
import projectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue';
import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
describe('Project Feature Settings', () => {
const defaultProps = {
name: 'Test',
options: [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]],
value: 1,
disabledInput: false,
};
let wrapper;
const mountComponent = customProps => {
const propsData = { ...defaultProps, ...customProps };
return shallowMount(projectFeatureSetting, { propsData });
};
beforeEach(() => {
wrapper = mountComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('Hidden name input', () => {
it('should set the hidden name input if the name exists', () => {
expect(wrapper.find({ name: 'Test' }).props().value).toBe(1);
});
it('should not set the hidden name input if the name does not exist', () => {
wrapper.setProps({ name: null });
return wrapper.vm.$nextTick(() => {
expect(wrapper.find({ name: 'Test' }).exists()).toBe(false);
});
});
});
describe('Feature toggle', () => {
it('should enable the feature toggle if the value is not 0', () => {
expect(wrapper.find(projectFeatureToggle).props().value).toBe(true);
});
it('should enable the feature toggle if the value is less than 0', () => {
wrapper.setProps({ value: -1 });
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(projectFeatureToggle).props().value).toBe(true);
});
});
it('should disable the feature toggle if the value is 0', () => {
wrapper.setProps({ value: 0 });
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(projectFeatureToggle).props().value).toBe(false);
});
});
it('should disable the feature toggle if disabledInput is set', () => {
wrapper.setProps({ disabledInput: true });
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(projectFeatureToggle).props().disabledInput).toBe(true);
});
});
it('should emit a change event when the feature toggle changes', () => {
// Needs to be fully mounted to be able to trigger the click event on the internal button
wrapper = mount(projectFeatureSetting, { propsData: defaultProps });
expect(wrapper.emitted().change).toBeUndefined();
wrapper
.find(projectFeatureToggle)
.find('button')
.trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted().change.length).toBe(1);
expect(wrapper.emitted().change[0]).toEqual([0]);
});
});
});
describe('Project repo select', () => {
it.each`
disabledInput | value | options | isDisabled
${true} | ${0} | ${[[1, 1]]} | ${true}
${true} | ${1} | ${[[1, 1], [2, 2], [3, 3]]} | ${true}
${false} | ${0} | ${[[1, 1], [2, 2], [3, 3]]} | ${true}
${false} | ${1} | ${[[1, 1]]} | ${true}
${false} | ${1} | ${[[1, 1], [2, 2], [3, 3]]} | ${false}
`(
'should set disabled to $isDisabled when disabledInput is $disabledInput, the value is $value and options are $options',
({ disabledInput, value, options, isDisabled }) => {
wrapper.setProps({ disabledInput, value, options });
return wrapper.vm.$nextTick(() => {
if (isDisabled) {
expect(wrapper.find('select').attributes().disabled).toEqual('disabled');
} else {
expect(wrapper.find('select').attributes().disabled).toBeUndefined();
}
});
},
);
it('should emit the change when a new option is selected', () => {
expect(wrapper.emitted().change).toBeUndefined();
wrapper
.findAll('option')
.at(1)
.trigger('change');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted().change.length).toBe(1);
expect(wrapper.emitted().change[0]).toEqual([2]);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import projectSettingRow from '~/pages/projects/shared/permissions/components/project_setting_row.vue';
describe('Project Setting Row', () => {
let wrapper;
const mountComponent = (customProps = {}) => {
const propsData = { ...customProps };
return shallowMount(projectSettingRow, { propsData });
};
beforeEach(() => {
wrapper = mountComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('should show the label if it is set', () => {
wrapper.setProps({ label: 'Test label' });
return wrapper.vm.$nextTick(() => {
expect(wrapper.find('label').text()).toEqual('Test label');
});
});
it('should hide the label if it is not set', () => {
expect(wrapper.find('label').exists()).toBe(false);
});
it('should show the help icon with the correct help path if it is set', () => {
wrapper.setProps({ label: 'Test label', helpPath: '/123' });
return wrapper.vm.$nextTick(() => {
const link = wrapper.find('a');
expect(link.exists()).toBe(true);
expect(link.attributes().href).toEqual('/123');
});
});
it('should hide the help icon if no help path is set', () => {
wrapper.setProps({ label: 'Test label' });
return wrapper.vm.$nextTick(() => {
expect(wrapper.find('a').exists()).toBe(false);
});
});
it('should show the help text if it is set', () => {
wrapper.setProps({ helpText: 'Test text' });
return wrapper.vm.$nextTick(() => {
expect(wrapper.find('span').text()).toEqual('Test text');
});
});
it('should hide the help text if it is set', () => {
expect(wrapper.find('span').exists()).toBe(false);
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Snippet Description Edit component rendering matches the snapshot 1`] = `
<div
class="form-group js-description-input"
>
<label>
Description (optional)
</label>
<div
class="js-collapsible-input"
>
<div
class="js-collapsed d-none"
>
<gl-form-input-stub
class="form-control"
data-qa-selector="description_placeholder"
placeholder="Optionally add a description about what your snippet does or how to use it…"
/>
</div>
<markdown-field-stub
addspacingclasses="true"
canattachfile="true"
class="js-expanded"
enableautocomplete="true"
helppagepath=""
markdowndocspath="help/"
markdownpreviewpath="foo/"
note="[object Object]"
quickactionsdocspath=""
textareavalue=""
>
<textarea
aria-label="Description"
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
data-supports-quick-actions="false"
dir="auto"
id="snippet-description"
placeholder="Write a comment or drag your files here…"
/>
</markdown-field-stub>
</div>
</div>
`;
import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
import { shallowMount } from '@vue/test-utils';
describe('Snippet Description Edit component', () => {
let wrapper;
const defaultDescription = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const markdownPreviewPath = 'foo/';
const markdownDocsPath = 'help/';
function createComponent(description = defaultDescription) {
wrapper = shallowMount(SnippetDescriptionEdit, {
propsData: {
description,
markdownPreviewPath,
markdownDocsPath,
},
});
}
function isHidden(sel) {
return wrapper.find(sel).classes('d-none');
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('rendering', () => {
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the field expanded when description exists', () => {
expect(wrapper.find('.js-collapsed').classes('d-none')).toBe(true);
expect(wrapper.find('.js-expanded').classes('d-none')).toBe(false);
expect(isHidden('.js-collapsed')).toBe(true);
expect(isHidden('.js-expanded')).toBe(false);
});
it('renders the field collapsed if there is no description yet', () => {
createComponent('');
expect(isHidden('.js-collapsed')).toBe(false);
expect(isHidden('.js-expanded')).toBe(true);
});
});
});
......@@ -259,16 +259,40 @@ describe('mrWidgetOptions', () => {
describe('methods', () => {
describe('checkStatus', () => {
it('should tell service to check status', () => {
let cb;
let isCbExecuted;
beforeEach(() => {
jest.spyOn(vm.service, 'checkStatus').mockReturnValue(returnPromise(mockData));
jest.spyOn(vm.mr, 'setData').mockImplementation(() => {});
jest.spyOn(vm, 'handleNotification').mockImplementation(() => {});
let isCbExecuted = false;
const cb = () => {
isCbExecuted = false;
cb = () => {
isCbExecuted = true;
};
});
it('should not tell service to check status if document is not visible', () => {
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
configurable: true,
});
vm.checkStatus(cb);
return vm.$nextTick().then(() => {
expect(vm.service.checkStatus).not.toHaveBeenCalled();
expect(vm.mr.setData).not.toHaveBeenCalled();
expect(vm.handleNotification).not.toHaveBeenCalled();
expect(isCbExecuted).toBeFalsy();
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
configurable: true,
});
});
});
it('should tell service to check status if document is visible', () => {
vm.checkStatus(cb);
return vm.$nextTick().then(() => {
......
......@@ -52,7 +52,7 @@ describe ProjectPolicy do
admin_snippet admin_project_member admin_note admin_wiki admin_project
admin_commit_status admin_build admin_container_image
admin_pipeline admin_environment admin_deployment destroy_release add_cluster
daily_statistics read_deploy_token
daily_statistics read_deploy_token create_deploy_token
]
end
......
......@@ -133,4 +133,57 @@ describe API::DeployTokens do
end
end
end
describe 'POST /projects/:id/deploy_tokens' do
let(:params) do
{
name: 'Foo',
expires_at: 1.year.from_now,
scopes: [
'read_repository'
],
username: 'Bar'
}
end
subject do
post api("/projects/#{project.id}/deploy_tokens", user), params: params
response
end
context 'when unauthenticated' do
let(:user) { nil }
it { is_expected.to have_gitlab_http_status(:not_found) }
end
context 'when authenticated as non-admin user' do
before do
project.add_developer(user)
end
it { is_expected.to have_gitlab_http_status(:forbidden) }
end
context 'when authenticated as maintainer' do
before do
project.add_maintainer(user)
end
it 'creates the deploy token' do
expect { subject }.to change { DeployToken.count }.by(1)
expect(response).to have_gitlab_http_status(:created)
expect(response).to match_response_schema('public_api/v4/deploy_token')
end
context 'with an invalid scope' do
before do
params[:scopes] = %w[read_repository all_access]
end
it { is_expected.to have_gitlab_http_status(:bad_request) }
end
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