diff options
author | Douwe Maan <douwe@selenight.nl> | 2017-03-07 16:39:52 -0600 |
---|---|---|
committer | Douwe Maan <douwe@selenight.nl> | 2017-03-07 16:39:52 -0600 |
commit | 3420c6cc4ef53d78db276bde06dbd9ab47165fb3 (patch) | |
tree | 2e34fd77be6424157243b67911f5c53d3a585692 | |
parent | 9895d6707d51140b3cc75e925cfd775c6bd93f83 (diff) | |
parent | 7f2819b778b055278a7fafe9c782d12d09dbd2ea (diff) | |
download | gitlab-ce-3420c6cc4ef53d78db276bde06dbd9ab47165fb3.tar.gz |
Merge branch 'master' into orderable-issues
280 files changed, 8444 insertions, 1251 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index e075de055e3..42e094bdfc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 8.17.3 (2017-03-07) + +- Fix the redirect to custom home page URL. !9518 +- Fix broken migration when upgrading straight to 8.17.1. !9613 +- Make projects dropdown only show projects you are a member of. !9614 +- Fix creating a file in an empty repo using the API. !9632 +- Don't copy tooltip when copying GFM. +- Fix cherry-picking or reverting through an MR. + ## 8.17.2 (2017-03-01) - Expire all webpack assets after 8.17.1 included a badly compiled asset. !9602 @@ -18,25 +18,26 @@ gem 'pg', '~> 0.18.2', group: :postgres gem 'rugged', '~> 0.24.0' # Authentication libraries -gem 'devise', '~> 4.2' -gem 'doorkeeper', '~> 4.2.0' -gem 'omniauth', '~> 1.4.2' -gem 'omniauth-auth0', '~> 1.4.1' -gem 'omniauth-azure-oauth2', '~> 0.0.6' -gem 'omniauth-cas3', '~> 1.1.2' -gem 'omniauth-facebook', '~> 4.0.0' -gem 'omniauth-github', '~> 1.1.1' -gem 'omniauth-gitlab', '~> 1.0.2' +gem 'devise', '~> 4.2' +gem 'doorkeeper', '~> 4.2.0' +gem 'doorkeeper-openid_connect', '~> 1.1.0' +gem 'omniauth', '~> 1.4.2' +gem 'omniauth-auth0', '~> 1.4.1' +gem 'omniauth-azure-oauth2', '~> 0.0.6' +gem 'omniauth-cas3', '~> 1.1.2' +gem 'omniauth-facebook', '~> 4.0.0' +gem 'omniauth-github', '~> 1.1.1' +gem 'omniauth-gitlab', '~> 1.0.2' gem 'omniauth-google-oauth2', '~> 0.4.1' -gem 'omniauth-kerberos', '~> 0.3.0', group: :kerberos +gem 'omniauth-kerberos', '~> 0.3.0', group: :kerberos gem 'omniauth-oauth2-generic', '~> 0.2.2' -gem 'omniauth-saml', '~> 1.7.0' -gem 'omniauth-shibboleth', '~> 1.2.0' -gem 'omniauth-twitter', '~> 1.2.0' -gem 'omniauth_crowd', '~> 2.2.0' -gem 'omniauth-authentiq', '~> 0.3.0' -gem 'rack-oauth2', '~> 1.2.1' -gem 'jwt', '~> 1.5.6' +gem 'omniauth-saml', '~> 1.7.0' +gem 'omniauth-shibboleth', '~> 1.2.0' +gem 'omniauth-twitter', '~> 1.2.0' +gem 'omniauth_crowd', '~> 2.2.0' +gem 'omniauth-authentiq', '~> 0.3.0' +gem 'rack-oauth2', '~> 1.2.1' +gem 'jwt', '~> 1.5.6' # Spam and anti-bot protection gem 'recaptcha', '~> 3.0', require: 'recaptcha/rails' @@ -68,9 +69,9 @@ gem 'gollum-rugged_adapter', '~> 0.4.2', require: false gem 'github-linguist', '~> 4.7.0', require: 'linguist' # API -gem 'grape', '~> 0.19.0' +gem 'grape', '~> 0.19.0' gem 'grape-entity', '~> 0.6.0' -gem 'rack-cors', '~> 0.4.0', require: 'rack/cors' +gem 'rack-cors', '~> 0.4.0', require: 'rack/cors' # Pagination gem 'kaminari', '~> 0.17.0' @@ -102,19 +103,19 @@ gem 'unf', '~> 0.1.4' gem 'seed-fu', '~> 2.3.5' # Markdown and HTML processing -gem 'html-pipeline', '~> 1.11.0' -gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie' -gem 'gitlab-markup', '~> 1.5.1' -gem 'redcarpet', '~> 3.4' -gem 'RedCloth', '~> 4.3.2' -gem 'rdoc', '~> 4.2' -gem 'org-ruby', '~> 0.9.12' -gem 'creole', '~> 0.5.0' -gem 'wikicloth', '0.8.1' -gem 'asciidoctor', '~> 1.5.2' +gem 'html-pipeline', '~> 1.11.0' +gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie' +gem 'gitlab-markup', '~> 1.5.1' +gem 'redcarpet', '~> 3.4' +gem 'RedCloth', '~> 4.3.2' +gem 'rdoc', '~> 4.2' +gem 'org-ruby', '~> 0.9.12' +gem 'creole', '~> 0.5.0' +gem 'wikicloth', '0.8.1' +gem 'asciidoctor', '~> 1.5.2' gem 'asciidoctor-plantuml', '0.0.7' -gem 'rouge', '~> 2.0' -gem 'truncato', '~> 0.7.8' +gem 'rouge', '~> 2.0' +gem 'truncato', '~> 0.7.8' # See https://groups.google.com/forum/#!topic/ruby-security-ann/aSbgDiwb24s # and https://groups.google.com/forum/#!topic/ruby-security-ann/Dy7YiKb_pMM @@ -229,18 +230,18 @@ gem 'sass-rails', '~> 5.0.6' gem 'coffee-rails', '~> 4.1.0' gem 'uglifier', '~> 2.7.2' -gem 'addressable', '~> 2.3.8' -gem 'bootstrap-sass', '~> 3.3.0' +gem 'addressable', '~> 2.3.8' +gem 'bootstrap-sass', '~> 3.3.0' gem 'font-awesome-rails', '~> 4.7' -gem 'gemojione', '~> 3.0' -gem 'gon', '~> 6.1.0' +gem 'gemojione', '~> 3.0' +gem 'gon', '~> 6.1.0' gem 'jquery-atwho-rails', '~> 1.3.2' -gem 'jquery-rails', '~> 4.1.0' -gem 'request_store', '~> 1.3' -gem 'select2-rails', '~> 3.5.9' -gem 'virtus', '~> 1.0.1' -gem 'net-ssh', '~> 3.0.1' -gem 'base32', '~> 0.3.0' +gem 'jquery-rails', '~> 4.1.0' +gem 'request_store', '~> 1.3' +gem 'select2-rails', '~> 3.5.9' +gem 'virtus', '~> 1.0.1' +gem 'net-ssh', '~> 3.0.1' +gem 'base32', '~> 0.3.0' # Sentry integration gem 'sentry-raven', '~> 2.0.0' @@ -278,13 +279,13 @@ group :development, :test do gem 'awesome_print', '~> 1.2.0', require: false gem 'fuubar', '~> 2.0.0' - gem 'database_cleaner', '~> 1.5.0' + gem 'database_cleaner', '~> 1.5.0' gem 'factory_girl_rails', '~> 4.7.0' - gem 'rspec-rails', '~> 3.5.0' - gem 'rspec-retry', '~> 0.4.5' - gem 'spinach-rails', '~> 0.2.1' + gem 'rspec-rails', '~> 3.5.0' + gem 'rspec-retry', '~> 0.4.5' + gem 'spinach-rails', '~> 0.2.1' gem 'spinach-rerun-reporter', '~> 0.0.2' - gem 'rspec_profiling', '~> 0.0.5' + gem 'rspec_profiling', '~> 0.0.5' # Prevent occasions where minitest is not bundled in packaged versions of ruby (see #3826) gem 'minitest', '~> 5.7.0' @@ -292,13 +293,13 @@ group :development, :test do # Generate Fake data gem 'ffaker', '~> 2.4' - gem 'capybara', '~> 2.6.2' + gem 'capybara', '~> 2.6.2' gem 'capybara-screenshot', '~> 1.0.0' - gem 'poltergeist', '~> 1.9.0' + gem 'poltergeist', '~> 1.9.0' - gem 'spring', '~> 1.7.0' - gem 'spring-commands-rspec', '~> 1.0.4' - gem 'spring-commands-spinach', '~> 1.1.0' + gem 'spring', '~> 1.7.0' + gem 'spring-commands-rspec', '~> 1.0.4' + gem 'spring-commands-spinach', '~> 1.1.0' gem 'rubocop', '~> 0.47.1', require: false gem 'rubocop-rspec', '~> 1.12.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index d4131a3dede..62388628eaa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -78,6 +78,7 @@ GEM better_errors (1.0.1) coderay (>= 1.0.0) erubis (>= 2.6.6) + bindata (2.3.5) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) bootstrap-sass (3.3.6) @@ -167,6 +168,9 @@ GEM unf (>= 0.0.5, < 1.0.0) doorkeeper (4.2.0) railties (>= 4.2) + doorkeeper-openid_connect (1.1.2) + doorkeeper (~> 4.0) + json-jwt (~> 1.6) dropzonejs-rails (0.7.2) rails (> 3.1) email_reply_trimmer (0.1.6) @@ -376,6 +380,12 @@ GEM railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (1.8.6) + json-jwt (1.7.1) + activesupport + bindata + multi_json (>= 1.3) + securecompare + url_safe_base64 json-schema (2.6.2) addressable (~> 2.3.8) jwt (1.5.6) @@ -684,6 +694,7 @@ GEM scss_lint (0.47.1) rake (>= 0.9, < 11) sass (~> 3.4.15) + securecompare (1.0.0) seed-fu (2.3.6) activerecord (>= 3.1) activesupport (>= 3.1) @@ -789,6 +800,7 @@ GEM get_process_mem (~> 0) unicorn (>= 4, < 6) uniform_notifier (1.10.0) + url_safe_base64 (0.2.2) validates_hostname (1.0.6) activerecord (>= 3.0) activesupport (>= 3.0) @@ -866,6 +878,7 @@ DEPENDENCIES devise-two-factor (~> 3.0.0) diffy (~> 3.1.0) doorkeeper (~> 4.2.0) + doorkeeper-openid_connect (~> 1.1.0) dropzonejs-rails (~> 0.7.1) email_reply_trimmer (~> 0.1) email_spec (~> 1.6.0) diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 31f10f89245..546bdc9c8d7 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -1,3 +1,4 @@ +import PrometheusGraph from './monitoring/prometheus_graph'; // TODO: Maybe Make this a bundle /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ /* global UsernameValidator */ /* global ActiveTabMemoizer */ @@ -286,7 +287,7 @@ const UserCallout = require('./user_callout'); case 'search:show': new Search(); break; - case 'projects:protected_branches:index': + case 'projects:repository:show': new gl.ProtectedBranchCreate(); new gl.ProtectedBranchEditList(); break; @@ -297,6 +298,8 @@ const UserCallout = require('./user_callout'); case 'ci:lints:show': new gl.CILintEditor(); break; + case 'projects:environments:metrics': + new PrometheusGraph(); case 'users:show': new UserCallout(); break; diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js new file mode 100644 index 00000000000..9384fe3f276 --- /dev/null +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -0,0 +1,333 @@ +/* eslint-disable no-new*/ +import d3 from 'd3'; +import _ from 'underscore'; +import statusCodes from '~/lib/utils/http_status'; +import '~/lib/utils/common_utils'; +import Flash from '~/flash'; + +const prometheusGraphsContainer = '.prometheus-graph'; +const metricsEndpoint = 'metrics.json'; +const timeFormat = d3.time.format('%H:%M'); +const dayFormat = d3.time.format('%b %e, %a'); +const bisectDate = d3.bisector(d => d.time).left; +const extraAddedWidthParent = 100; + +class PrometheusGraph { + + constructor() { + this.margin = { top: 80, right: 180, bottom: 80, left: 100 }; + this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 }; + const parentContainerWidth = $(prometheusGraphsContainer).parent().width() + + extraAddedWidthParent; + this.originalWidth = parentContainerWidth; + this.originalHeight = 400; + this.width = parentContainerWidth - this.margin.left - this.margin.right; + this.height = 400 - this.margin.top - this.margin.bottom; + this.backOffRequestCounter = 0; + this.configureGraph(); + this.init(); + } + + createGraph() { + const self = this; + _.each(this.data, (value, key) => { + if (value.length > 0 && (key === 'cpu_values' || key === 'memory_values')) { + self.plotValues(value, key); + } + }); + } + + init() { + const self = this; + this.getData().then((metricsResponse) => { + if (metricsResponse === {}) { + new Flash('Empty metrics', 'alert'); + } else { + self.transformData(metricsResponse); + self.createGraph(); + } + }); + } + + plotValues(valuesToPlot, key) { + const x = d3.time.scale() + .range([0, this.width]); + + const y = d3.scale.linear() + .range([this.height, 0]); + + const prometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; + + const graphSpecifics = this.graphSpecificProperties[key]; + + const chart = d3.select(prometheusGraphContainer) + .attr('width', this.width + this.margin.left + this.margin.right) + .attr('height', this.height + this.margin.bottom + this.margin.top) + .append('g') + .attr('transform', `translate(${this.margin.left},${this.margin.top})`); + + const axisLabelContainer = d3.select(prometheusGraphContainer) + .attr('width', this.originalWidth + this.marginLabelContainer.left + this.marginLabelContainer.right) + .attr('height', this.originalHeight + this.marginLabelContainer.bottom + this.marginLabelContainer.top) + .append('g') + .attr('transform', `translate(${this.marginLabelContainer.left},${this.marginLabelContainer.top})`); + + x.domain(d3.extent(valuesToPlot, d => d.time)); + y.domain([0, d3.max(valuesToPlot.map(metricValue => metricValue.value))]); + + const xAxis = d3.svg.axis() + .scale(x) + .ticks(this.commonGraphProperties.axis_no_ticks) + .orient('bottom'); + + const yAxis = d3.svg.axis() + .scale(y) + .ticks(this.commonGraphProperties.axis_no_ticks) + .tickSize(-this.width) + .orient('left'); + + this.createAxisLabelContainers(axisLabelContainer, key); + + chart.append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0,${this.height})`) + .call(xAxis); + + chart.append('g') + .attr('class', 'y-axis') + .call(yAxis); + + const area = d3.svg.area() + .x(d => x(d.time)) + .y0(this.height) + .y1(d => y(d.value)) + .interpolate('linear'); + + const line = d3.svg.line() + .x(d => x(d.time)) + .y(d => y(d.value)); + + chart.append('path') + .datum(valuesToPlot) + .attr('d', area) + .attr('class', 'metric-area') + .attr('fill', graphSpecifics.area_fill_color); + + chart.append('path') + .datum(valuesToPlot) + .attr('class', 'metric-line') + .attr('stroke', graphSpecifics.line_color) + .attr('fill', 'none') + .attr('stroke-width', this.commonGraphProperties.area_stroke_width) + .attr('d', line); + + // Overlay area for the mouseover events + chart.append('rect') + .attr('class', 'prometheus-graph-overlay') + .attr('width', this.width) + .attr('height', this.height) + .on('mousemove', this.handleMouseOverGraph.bind(this, x, y, valuesToPlot, chart, prometheusGraphContainer, key)); + } + + // The legends from the metric + createAxisLabelContainers(axisLabelContainer, key) { + const graphSpecifics = this.graphSpecificProperties[key]; + + axisLabelContainer.append('line') + .attr('class', 'label-x-axis-line') + .attr('stroke', '#000000') + .attr('stroke-width', '1') + .attr({ + x1: 0, + y1: this.originalHeight - this.marginLabelContainer.top, + x2: this.originalWidth - this.margin.right, + y2: this.originalHeight - this.marginLabelContainer.top, + }); + + axisLabelContainer.append('line') + .attr('class', 'label-y-axis-line') + .attr('stroke', '#000000') + .attr('stroke-width', '1') + .attr({ + x1: 0, + y1: 0, + x2: 0, + y2: this.originalHeight - this.marginLabelContainer.top, + }); + + axisLabelContainer.append('text') + .attr('class', 'label-axis-text') + .attr('text-anchor', 'middle') + .attr('transform', `translate(15, ${(this.originalHeight - this.marginLabelContainer.top) / 2}) rotate(-90)`) + .text(graphSpecifics.graph_legend_title); + + axisLabelContainer.append('rect') + .attr('class', 'rect-axis-text') + .attr('x', (this.originalWidth / 2) - this.margin.right) + .attr('y', this.originalHeight - this.marginLabelContainer.top - 20) + .attr('width', 30) + .attr('height', 80); + + axisLabelContainer.append('text') + .attr('class', 'label-axis-text') + .attr('x', (this.originalWidth / 2) - this.margin.right) + .attr('y', this.originalHeight - this.marginLabelContainer.top) + .attr('dy', '.35em') + .text('Time'); + + // Legends + + // Metric Usage + axisLabelContainer.append('rect') + .attr('x', this.originalWidth - 170) + .attr('y', (this.originalHeight / 2) - 80) + .style('fill', graphSpecifics.area_fill_color) + .attr('width', 20) + .attr('height', 35); + + axisLabelContainer.append('text') + .attr('class', 'label-axis-text') + .attr('x', this.originalWidth - 140) + .attr('y', (this.originalHeight / 2) - 65) + .text(graphSpecifics.graph_legend_title); + + axisLabelContainer.append('text') + .attr('class', 'text-metric-usage') + .attr('x', this.originalWidth - 140) + .attr('y', (this.originalHeight / 2) - 50); + } + + handleMouseOverGraph(x, y, valuesToPlot, chart, prometheusGraphContainer, key) { + const rectOverlay = document.querySelector(`${prometheusGraphContainer} .prometheus-graph-overlay`); + const timeValueFromOverlay = x.invert(d3.mouse(rectOverlay)[0]); + const timeValueIndex = bisectDate(valuesToPlot, timeValueFromOverlay, 1); + const d0 = valuesToPlot[timeValueIndex - 1]; + const d1 = valuesToPlot[timeValueIndex]; + const currentData = timeValueFromOverlay - d0.time > d1.time - timeValueFromOverlay ? d1 : d0; + const maxValueMetric = y(d3.max(valuesToPlot.map(metricValue => metricValue.value))); + const currentTimeCoordinate = x(currentData.time); + const graphSpecifics = this.graphSpecificProperties[key]; + // Remove the current selectors + d3.selectAll(`${prometheusGraphContainer} .selected-metric-line`).remove(); + d3.selectAll(`${prometheusGraphContainer} .circle-metric`).remove(); + d3.selectAll(`${prometheusGraphContainer} .rect-text-metric`).remove(); + d3.selectAll(`${prometheusGraphContainer} .text-metric`).remove(); + + chart.append('line') + .attr('class', 'selected-metric-line') + .attr({ + x1: currentTimeCoordinate, + y1: y(0), + x2: currentTimeCoordinate, + y2: maxValueMetric, + }); + + chart.append('circle') + .attr('class', 'circle-metric') + .attr('fill', graphSpecifics.line_color) + .attr('cx', currentTimeCoordinate) + .attr('cy', y(currentData.value)) + .attr('r', this.commonGraphProperties.circle_radius_metric); + + // The little box with text + const rectTextMetric = chart.append('g') + .attr('class', 'rect-text-metric') + .attr('translate', `(${currentTimeCoordinate}, ${y(currentData.value)})`); + + rectTextMetric.append('rect') + .attr('class', 'rect-metric') + .attr('x', currentTimeCoordinate + 10) + .attr('y', maxValueMetric) + .attr('width', this.commonGraphProperties.rect_text_width) + .attr('height', this.commonGraphProperties.rect_text_height); + + rectTextMetric.append('text') + .attr('class', 'text-metric') + .attr('x', currentTimeCoordinate + 35) + .attr('y', maxValueMetric + 35) + .text(timeFormat(currentData.time)); + + rectTextMetric.append('text') + .attr('class', 'text-metric-date') + .attr('x', currentTimeCoordinate + 15) + .attr('y', maxValueMetric + 15) + .text(dayFormat(currentData.time)); + + // Update the text + d3.select(`${prometheusGraphContainer} .text-metric-usage`) + .text(currentData.value.substring(0, 8)); + } + + configureGraph() { + this.graphSpecificProperties = { + cpu_values: { + area_fill_color: '#edf3fc', + line_color: '#5b99f7', + graph_legend_title: 'CPU Usage (Cores)', + }, + memory_values: { + area_fill_color: '#fca326', + line_color: '#fc6d26', + graph_legend_title: 'Memory Usage (MB)', + }, + }; + + this.commonGraphProperties = { + area_stroke_width: 2, + median_total_characters: 8, + circle_radius_metric: 5, + rect_text_width: 90, + rect_text_height: 40, + axis_no_ticks: 3, + }; + } + + getData() { + const maxNumberOfRequests = 3; + return gl.utils.backOff((next, stop) => { + $.ajax({ + url: metricsEndpoint, + dataType: 'json', + }) + .done((data, statusText, resp) => { + if (resp.status === statusCodes.NO_CONTENT) { + this.backOffRequestCounter = this.backOffRequestCounter += 1; + if (this.backOffRequestCounter < maxNumberOfRequests) { + next(); + } else { + stop({ + status: resp.status, + metrics: data, + }); + } + } else { + stop({ + status: resp.status, + metrics: data, + }); + } + }).fail(stop); + }) + .then((resp) => { + if (resp.status === statusCodes.NO_CONTENT) { + return {}; + } + return resp.metrics; + }) + .catch(() => new Flash('An error occurred while fetching metrics.', 'alert')); + } + + transformData(metricsResponse) { + const metricTypes = {}; + _.each(metricsResponse.metrics, (value, key) => { + const metricValues = value[0].values; + metricTypes[key] = _.map(metricValues, metric => ({ + time: new Date(metric[0] * 1000), + value: metric[1], + })); + }); + this.data = metricTypes; + } +} + +export default PrometheusGraph; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 0ba00cea8b5..499398ecf85 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -4,6 +4,21 @@ &.reset-filters { padding: 7px; } + + &.update-issues-btn { + float: right; + margin-right: 0; + + @media (max-width: $screen-xs-max) { + float: none; + } + } +} + +.filters-section { + @media (max-width: $screen-xs-max) { + display: inline-block; + } } @media (min-width: $screen-sm-min) { @@ -34,6 +49,11 @@ display: block; margin: 0 0 10px; } + + .dropdown-menu-toggle, + .update-issues-btn .btn { + width: 100%; + } } .filtered-search-container { @@ -111,7 +131,15 @@ overflow: auto; } -@media (max-width: $screen-xs-min) { +@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { + .issues-details-filters { + .dropdown-menu-toggle { + width: 100px; + } + } +} + +@media (max-width: $screen-xs-max) { .issues-details-filters { padding: 0 0 10px; background-color: $white-light; @@ -205,4 +233,4 @@ .filter-dropdown-loading { padding: 8px 16px; -}
\ No newline at end of file +} diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 55ed4b7b06c..7adbb0a4188 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -229,44 +229,6 @@ ul.content-list { } } -// Table list -.table-list { - display: table; - width: 100%; - - .table-list-row { - display: table-row; - } - - .table-list-cell { - display: table-cell; - vertical-align: top; - padding: 10px 16px; - border-bottom: 1px solid $gray-darker; - - &.avatar-cell { - width: 36px; - padding-right: 0; - - img { - margin-right: 0; - } - } - } - - &.table-wide { - .table-list-cell { - &:last-of-type { - padding-right: 0; - } - - &:first-of-type { - padding-left: 0; - } - } - } -} - .panel > .content-list > li { padding: $gl-padding-top $gl-padding; } diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 8e2c56a8488..eb73f7cc794 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -100,8 +100,7 @@ @media (max-width: $screen-sm-max) { .issues-filters { - .milestone-filter, - .labels-filter { + .milestone-filter { display: none; } } diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss index efe93724013..9d8d08dff88 100644 --- a/app/assets/stylesheets/framework/panels.scss +++ b/app/assets/stylesheets/framework/panels.scss @@ -48,11 +48,3 @@ line-height: inherit; } } - -.panel-default { - .table-list-row:last-child { - .table-list-cell { - border-bottom: 0; - } - } -} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index c3d45d708c1..2029b6893ef 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -78,6 +78,7 @@ padding: 5px 10px; background-color: $gray-light; border-bottom: 1px solid $gray-darker; + border-top: 1px solid $gray-darker; font-size: 14px; &:first-child { @@ -117,10 +118,37 @@ } } +.commit.flex-list { + display: flex; +} + +.avatar-cell { + width: 46px; + padding-left: 10px; + + img { + margin-right: 0; + } +} + +.commit-detail { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-grow: 1; + padding-left: 10px; + + .merge-request-branches & { + flex-direction: column; + } +} + +.commit-content { + padding-right: 10px; +} + .commit-actions { @media (min-width: $screen-sm-min) { - width: 300px; - text-align: right; font-size: 0; } diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 77e09e66340..0e2b8dba780 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -143,3 +143,71 @@ } } } + +.prometheus-graph { + text { + fill: $stat-graph-axis-fill; + } +} + +.x-axis path, +.y-axis path, +.label-x-axis-line, +.label-y-axis-line { + fill: none; + stroke-width: 1; + shape-rendering: crispEdges; +} + +.x-axis path, +.y-axis path { + stroke: $stat-graph-axis-fill; +} + +.label-x-axis-line, +.label-y-axis-line { + stroke: $border-color; +} + +.y-axis { + line { + stroke: $stat-graph-axis-fill; + stroke-width: 1; + } +} + +.metric-area { + opacity: 0.8; +} + +.prometheus-graph-overlay { + fill: none; + opacity: 0.0; + pointer-events: all; +} + +.rect-text-metric { + fill: $white-light; + stroke-width: 1; + stroke: $black; +} + +.rect-axis-text { + fill: $white-light; +} + +.text-metric, +.text-median-metric, +.text-metric-usage, +.text-metric-date { + fill: $black; +} + +.text-metric-date { + font-weight: 200; +} + +.selected-metric-line { + stroke: $black; + stroke-width: 1; +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index f41eeb8ca45..d3496e19dde 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -240,8 +240,7 @@ .commit { margin: 0; - padding-top: 2px; - padding-bottom: 2px; + padding: 10px 0; list-style: none; &:hover { @@ -409,7 +408,7 @@ } .panel-footer { - padding: 5px 10px; + padding: 0; .btn { min-width: auto; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 00f5f2645b3..dc79de19d48 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -331,6 +331,10 @@ ul.notes { &:hover { color: $gl-link-color; + } + + &:focus, + &:hover { text-decoration: none; } } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 69eea1b2217..20eabc83142 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -115,7 +115,7 @@ .table.ci-table { - &.builds-page tr { + &.builds-page tbody tr { height: 71px; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 09b85db7d45..4914933430f 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -746,6 +746,8 @@ pre.light-well { } .protected-branches-list { + margin-bottom: 30px; + a { color: $gl-text-color; diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index a28a87ed4f8..3889deee21a 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -24,3 +24,14 @@ .service-settings .control-label { padding-top: 0; } + +.token-token-container { + #impersonation-token-token { + width: 80%; + display: inline; + } + + .btn-clipboard { + margin-left: 5px; + } +} diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss new file mode 100644 index 00000000000..b97a29cd1a0 --- /dev/null +++ b/app/assets/stylesheets/pages/settings_ci_cd.scss @@ -0,0 +1,12 @@ +.triggers-container { + .label-container { + display: inline-block; + margin-left: 10px; + } +} + +.trigger-actions { + .btn { + margin-left: 10px; + } +} diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 8d1063fc26f..fc4da4c495f 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -139,18 +139,10 @@ .blob-commit-info { list-style: none; background: $gray-light; - padding: 6px 0; + padding: 16px 16px 16px 6px; border: 1px solid $border-color; border-bottom: none; margin: 0; - - .table-list-cell { - border-bottom: none; - } - - .commit-actions { - width: 260px; - } } #modal-remove-blob > .modal-dialog { width: 850px; } diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 62f62e99a97..9c9f420c1e0 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -2,7 +2,7 @@ class Admin::ApplicationsController < Admin::ApplicationController include OauthApplications before_action :set_application, only: [:show, :edit, :update, :destroy] - before_action :load_scopes, only: [:new, :edit] + before_action :load_scopes, only: [:new, :create, :edit, :update] def index @applications = Doorkeeper::Application.where("owner_id IS NULL") diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb new file mode 100644 index 00000000000..07c8bf714fc --- /dev/null +++ b/app/controllers/admin/impersonation_tokens_controller.rb @@ -0,0 +1,53 @@ +class Admin::ImpersonationTokensController < Admin::ApplicationController + before_action :user + + def index + set_index_vars + end + + def create + @impersonation_token = finder.build(impersonation_token_params) + + if @impersonation_token.save + flash[:impersonation_token] = @impersonation_token.token + redirect_to admin_user_impersonation_tokens_path, notice: "A new impersonation token has been created." + else + set_index_vars + render :index + end + end + + def revoke + @impersonation_token = finder.find(params[:id]) + + if @impersonation_token.revoke! + flash[:notice] = "Revoked impersonation token #{@impersonation_token.name}!" + else + flash[:alert] = "Could not revoke impersonation token #{@impersonation_token.name}." + end + + redirect_to admin_user_impersonation_tokens_path + end + + private + + def user + @user ||= User.find_by!(username: params[:user_id]) + end + + def finder(options = {}) + PersonalAccessTokensFinder.new({ user: user, impersonation: true }.merge(options)) + end + + def impersonation_token_params + params.require(:personal_access_token).permit(:name, :expires_at, :impersonation, scopes: []) + end + + def set_index_vars + @scopes = Gitlab::Auth::API_SCOPES + + @impersonation_token ||= finder.build + @inactive_impersonation_tokens = finder(state: 'inactive').execute + @active_impersonation_tokens = finder(state: 'active').execute.order(:expires_at) + end +end diff --git a/app/controllers/concerns/repository_settings_redirect.rb b/app/controllers/concerns/repository_settings_redirect.rb new file mode 100644 index 00000000000..0854c73a02f --- /dev/null +++ b/app/controllers/concerns/repository_settings_redirect.rb @@ -0,0 +1,7 @@ +module RepositorySettingsRedirect + extend ActiveSupport::Concern + + def redirect_to_repository_settings(project) + redirect_to namespace_project_settings_repository_path(project.namespace, project) + end +end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index c721dca58d9..05190103767 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,8 +1,8 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController - before_action :authenticate_resource_owner! - layout 'profile' + # Overriden from Doorkeeper::AuthorizationsController to + # include the call to session.delete def new if pre_auth.authorizable? if skip_authorization? || matching_token? @@ -16,44 +16,4 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController render "doorkeeper/authorizations/error" end end - - # TODO: Handle raise invalid authorization - def create - redirect_or_render authorization.authorize - end - - def destroy - redirect_or_render authorization.deny - end - - private - - def matching_token? - Doorkeeper::AccessToken.matching_token_for(pre_auth.client, - current_resource_owner.id, - pre_auth.scopes) - end - - def redirect_or_render(auth) - if auth.redirectable? - redirect_to auth.redirect_uri - else - render json: auth.body, status: auth.status - end - end - - def pre_auth - @pre_auth ||= - Doorkeeper::OAuth::PreAuthorization.new(Doorkeeper.configuration, - server.client_via_uid, - params) - end - - def authorization - @authorization ||= strategy.request - end - - def strategy - @strategy ||= server.authorization_request(pre_auth.response_type) - end end diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 6e007f17913..0abe7ea3c9b 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -4,7 +4,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController end def create - @personal_access_token = current_user.personal_access_tokens.generate(personal_access_token_params) + @personal_access_token = finder.build(personal_access_token_params) if @personal_access_token.save flash[:personal_access_token] = @personal_access_token.token @@ -16,7 +16,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController end def revoke - @personal_access_token = current_user.personal_access_tokens.find(params[:id]) + @personal_access_token = finder.find(params[:id]) if @personal_access_token.revoke! flash[:notice] = "Revoked personal access token #{@personal_access_token.name}!" @@ -29,14 +29,19 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController private + def finder(options = {}) + PersonalAccessTokensFinder.new({ user: current_user, impersonation: false }.merge(options)) + end + def personal_access_token_params params.require(:personal_access_token).permit(:name, :expires_at, scopes: []) end def set_index_vars - @personal_access_token ||= current_user.personal_access_tokens.build - @scopes = Gitlab::Auth::SCOPES - @active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at) - @inactive_personal_access_tokens = current_user.personal_access_tokens.inactive + @scopes = Gitlab::Auth::API_SCOPES + + @personal_access_token = finder.build + @inactive_personal_access_tokens = finder(state: 'inactive').execute + @active_personal_access_tokens = finder(state: 'active').execute.order(:expires_at) end end diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index b094491e006..1502b734f37 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -1,4 +1,5 @@ class Projects::DeployKeysController < Projects::ApplicationController + include RepositorySettingsRedirect respond_to :html # Authorize @@ -7,51 +8,36 @@ class Projects::DeployKeysController < Projects::ApplicationController layout "project_settings" def index - @key = DeployKey.new - set_index_vars + redirect_to_repository_settings(@project) end def new - redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) + redirect_to_repository_settings(@project) end def create @key = DeployKey.new(deploy_key_params.merge(user: current_user)) - set_index_vars - if @key.valid? && @project.deploy_keys << @key - redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) - else - render "index" + unless @key.valid? && @project.deploy_keys << @key + flash[:alert] = @key.errors.full_messages.join(', ').html_safe end + redirect_to_repository_settings(@project) end def enable Projects::EnableDeployKeyService.new(@project, current_user, params).execute - redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) + redirect_to_repository_settings(@project) end def disable @project.deploy_keys_projects.find_by(deploy_key_id: params[:id]).destroy - redirect_back_or_default(default: { action: 'index' }) + redirect_to_repository_settings(@project) end protected - def set_index_vars - @enabled_keys ||= @project.deploy_keys - - @available_keys ||= current_user.accessible_deploy_keys - @enabled_keys - @available_project_keys ||= current_user.project_deploy_keys - @enabled_keys - @available_public_keys ||= DeployKey.are_public - @enabled_keys - - # Public keys that are already used by another accessible project are already - # in @available_project_keys. - @available_public_keys -= @available_project_keys - end - def deploy_key_params params.require(:deploy_key).permit(:key, :title, :can_push) end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index fed75396d6e..fa37963dfd4 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -5,7 +5,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :authorize_create_deployment!, only: [:stop] before_action :authorize_update_environment!, only: [:edit, :update] before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize] - before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize] + before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics] before_action :verify_api_request!, only: :terminal_websocket_authorize def index @@ -109,6 +109,19 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end + def metrics + # Currently, this acts as a hint to load the metrics details into the cache + # if they aren't there already + @metrics = environment.metrics || {} + + respond_to do |format| + format.html + format.json do + render json: @metrics, status: @metrics.any? ? :ok : :no_content + end + end + end + private def verify_api_request! diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index ee8c30058a1..a8cb07eb67a 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -1,26 +1,22 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController + include RepositorySettingsRedirect # Authorize before_action :require_non_empty_project before_action :authorize_admin_project! before_action :load_protected_branch, only: [:show, :update, :destroy] - before_action :load_protected_branches, only: [:index] layout "project_settings" def index - @protected_branch = @project.protected_branches.new - load_gon_index + redirect_to_repository_settings(@project) end def create @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute - if @protected_branch.persisted? - redirect_to namespace_project_protected_branches_path(@project.namespace, @project) - else - load_protected_branches - load_gon_index - render :index + unless @protected_branch.persisted? + flash[:alert] = @protected_branches.errors.full_messages.join(', ').html_safe end + redirect_to_repository_settings(@project) end def show @@ -45,7 +41,7 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController @protected_branch.destroy respond_to do |format| - format.html { redirect_to namespace_project_protected_branches_path } + format.html { redirect_to_repository_settings(@project) } format.js { head :ok } end end @@ -61,24 +57,4 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController merge_access_levels_attributes: [:access_level, :id], push_access_levels_attributes: [:access_level, :id]) end - - def load_protected_branches - @protected_branches = @project.protected_branches.order(:name).page(params[:page]) - end - - def access_levels_options - { - push_access_levels: { - roles: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } }, - }, - merge_access_levels: { - roles: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } } - } - } - end - - def load_gon_index - params = { open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } } - gon.push(params.merge(access_levels_options)) - end end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb new file mode 100644 index 00000000000..b6ce4abca45 --- /dev/null +++ b/app/controllers/projects/settings/repository_controller.rb @@ -0,0 +1,50 @@ +module Projects + module Settings + class RepositoryController < Projects::ApplicationController + before_action :authorize_admin_project! + + def show + @deploy_keys = DeployKeysPresenter + .new(@project, current_user: current_user) + + define_protected_branches + end + + private + + def define_protected_branches + load_protected_branches + @protected_branch = @project.protected_branches.new + load_gon_index + end + + def load_protected_branches + @protected_branches = @project.protected_branches.order(:name).page(params[:page]) + end + + def access_levels_options + { + push_access_levels: { + roles: ProtectedBranch::PushAccessLevel.human_access_levels.map do |id, text| + { id: id, text: text, before_divider: true } + end + }, + merge_access_levels: { + roles: ProtectedBranch::MergeAccessLevel.human_access_levels.map do |id, text| + { id: id, text: text, before_divider: true } + end + } + } + end + + def open_branches + branches = @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } + { open_branches: branches } + end + + def load_gon_index + gon.push(open_branches.merge(access_levels_options)) + end + end + end +end diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb index b2c11ea4156..c47198c5eb6 100644 --- a/app/controllers/projects/triggers_controller.rb +++ b/app/controllers/projects/triggers_controller.rb @@ -1,5 +1,8 @@ class Projects::TriggersController < Projects::ApplicationController before_action :authorize_admin_build! + before_action :authorize_manage_trigger!, except: [:index, :create] + before_action :authorize_admin_trigger!, only: [:edit, :update] + before_action :trigger, only: [:take_ownership, :edit, :update, :destroy] layout 'project_settings' @@ -8,27 +11,67 @@ class Projects::TriggersController < Projects::ApplicationController end def create - @trigger = project.triggers.new - @trigger.save + @trigger = project.triggers.create(create_params.merge(owner: current_user)) if @trigger.valid? - redirect_to namespace_project_variables_path(project.namespace, project), notice: 'Trigger was created successfully.' + flash[:notice] = 'Trigger was created successfully.' else - @triggers = project.triggers.select(&:persisted?) - render action: "show" + flash[:alert] = 'You could not create a new trigger.' + end + + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) + end + + def take_ownership + if trigger.update(owner: current_user) + flash[:notice] = 'Trigger was re-assigned.' + else + flash[:alert] = 'You could not take ownership of trigger.' + end + + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) + end + + def edit + end + + def update + if trigger.update(update_params) + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project), notice: 'Trigger was successfully updated.' + else + render action: "edit" end end def destroy - trigger.destroy - flash[:alert] = "Trigger removed" + if trigger.destroy + flash[:notice] = "Trigger removed." + else + flash[:alert] = "Could not remove the trigger." + end redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) end private + def authorize_manage_trigger! + access_denied! unless can?(current_user, :manage_trigger, trigger) + end + + def authorize_admin_trigger! + access_denied! unless can?(current_user, :admin_trigger, trigger) + end + def trigger - @trigger ||= project.triggers.find(params[:id]) + @trigger ||= project.triggers.find(params[:id]) || render_404 + end + + def create_params + params.require(:trigger).permit(:description) + end + + def update_params + params.require(:trigger).permit(:description) end end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 509f4f412ca..f1bfd574f04 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -14,6 +14,8 @@ class UploadsController < ApplicationController end disposition = uploader.image? ? 'inline' : 'attachment' + + expires_in 0.seconds, must_revalidate: true, private: true send_file uploader.file.path, disposition: disposition end diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb new file mode 100644 index 00000000000..760166b453f --- /dev/null +++ b/app/finders/personal_access_tokens_finder.rb @@ -0,0 +1,45 @@ +class PersonalAccessTokensFinder + attr_accessor :params + + delegate :build, :find, :find_by, to: :execute + + def initialize(params = {}) + @params = params + end + + def execute + tokens = PersonalAccessToken.all + tokens = by_user(tokens) + tokens = by_impersonation(tokens) + by_state(tokens) + end + + private + + def by_user(tokens) + return tokens unless @params[:user] + tokens.where(user: @params[:user]) + end + + def by_impersonation(tokens) + case @params[:impersonation] + when true + tokens.with_impersonation + when false + tokens.without_impersonation + else + tokens + end + end + + def by_state(tokens) + case @params[:state] + when 'active' + tokens.active + when 'inactive' + tokens.inactive + else + tokens + end + end +end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 4b025669f69..ca326dd0627 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -81,8 +81,8 @@ module ApplicationSettingsHelper end def repository_storages_options_for_select - options = Gitlab.config.repositories.storages.map do |name, path| - ["#{name} - #{path}", name] + options = Gitlab.config.repositories.storages.map do |name, storage| + ["#{name} - #{storage['path']}", name] end options_for_select(options, @application_setting.repository_storages) diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 94f3b480178..2c2c408b035 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -48,6 +48,8 @@ module CiStatusHelper 'icon_status_created' when 'skipped' 'icon_status_skipped' + when 'manual' + 'icon_status_manual' else 'icon_status_canceled' end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 362046c0270..5605393c0c3 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -162,7 +162,12 @@ module EventsHelper def event_note(text, options = {}) text = first_line_in_markdown(text, 150, options) - sanitize(text, tags: %w(a img b pre code p span)) + + sanitize( + text, + tags: %w(a img b pre code p span), + attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + ['style'] + ) end def event_commit_title(message) diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index f16a63e2178..e9b7cbbad6a 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -74,6 +74,10 @@ module GitlabRoutingHelper namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args) end + def environment_metrics_path(environment, *args) + metrics_namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args) + end + def issue_path(entity, *args) namespace_project_issue_path(entity.project.namespace, entity.project, entity, *args) end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 8ad3851fb9a..18734f1411f 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -50,7 +50,7 @@ module SortingHelper end def sort_title_priority - 'Priority' + 'Label priority' end def sort_title_oldest_updated diff --git a/app/models/chat_team.rb b/app/models/chat_team.rb index 7952141a0d6..c52b6f15913 100644 --- a/app/models/chat_team.rb +++ b/app/models/chat_team.rb @@ -1,5 +1,6 @@ class ChatTeam < ActiveRecord::Base validates :team_id, presence: true + validates :namespace, uniqueness: true belongs_to :namespace end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index d69643967a1..3722047251d 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -517,6 +517,27 @@ module Ci ] end + def steps + [Gitlab::Ci::Build::Step.from_commands(self), + Gitlab::Ci::Build::Step.from_after_script(self)].compact + end + + def image + Gitlab::Ci::Build::Image.from_image(self) + end + + def services + Gitlab::Ci::Build::Image.from_services(self) + end + + def artifacts + [options[:artifacts]] + end + + def cache + [options[:cache]] + end + def credentials Gitlab::Ci::Build::Credentials::Factory.new(self).create! end @@ -543,10 +564,35 @@ module Ci @unscoped_project ||= Project.unscoped.find_by(id: gl_project_id) end + CI_REGISTRY_USER = 'gitlab-ci-token'.freeze + def predefined_variables variables = [ { key: 'CI', value: 'true', public: true }, { key: 'GITLAB_CI', value: 'true', public: true }, + { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, + { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, + { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true }, + { key: 'CI_JOB_ID', value: id.to_s, public: true }, + { key: 'CI_JOB_NAME', value: name, public: true }, + { key: 'CI_JOB_STAGE', value: stage, public: true }, + { key: 'CI_JOB_TOKEN', value: token, public: false }, + { key: 'CI_COMMIT_SHA', value: sha, public: true }, + { key: 'CI_COMMIT_REF_NAME', value: ref, public: true }, + { key: 'CI_COMMIT_REF_SLUG', value: ref_slug, public: true }, + { key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER, public: true }, + { key: 'CI_REGISTRY_PASSWORD', value: token, public: false }, + { key: 'CI_REPOSITORY_URL', value: repo_url, public: false } + ] + + variables << { key: "CI_COMMIT_TAG", value: ref, public: true } if tag? + variables << { key: "CI_PIPELINE_TRIGGERED", value: 'true', public: true } if trigger_request + variables << { key: "CI_JOB_MANUAL", value: 'true', public: true } if action? + variables.concat(legacy_variables) + end + + def legacy_variables + variables = [ { key: 'CI_BUILD_ID', value: id.to_s, public: true }, { key: 'CI_BUILD_TOKEN', value: token, public: false }, { key: 'CI_BUILD_REF', value: sha, public: true }, @@ -554,14 +600,12 @@ module Ci { key: 'CI_BUILD_REF_NAME', value: ref, public: true }, { key: 'CI_BUILD_REF_SLUG', value: ref_slug, public: true }, { key: 'CI_BUILD_NAME', value: name, public: true }, - { key: 'CI_BUILD_STAGE', value: stage, public: true }, - { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, - { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, - { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true } + { key: 'CI_BUILD_STAGE', value: stage, public: true } ] - variables << { key: 'CI_BUILD_TAG', value: ref, public: true } if tag? - variables << { key: 'CI_BUILD_TRIGGERED', value: 'true', public: true } if trigger_request - variables << { key: 'CI_BUILD_MANUAL', value: 'true', public: true } if action? + + variables << { key: "CI_BUILD_TAG", value: ref, public: true } if tag? + variables << { key: "CI_BUILD_TRIGGERED", value: 'true', public: true } if trigger_request + variables << { key: "CI_BUILD_MANUAL", value: 'true', public: true } if action? variables end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 8aa45b2f02e..90473d41c04 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -29,8 +29,12 @@ module Ci token[0...4] end - def can_show_token?(user) - owner.blank? || owner == user + def legacy? + self.owner_id.blank? + end + + def can_access_project? + self.owner_id.blank? || Ability.allowed?(self.owner, :create_build, project) end end end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index b819947c9e6..5101cc7e687 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -7,7 +7,7 @@ module HasStatus STARTED_STATUSES = %w[running success failed skipped manual].freeze ACTIVE_STATUSES = %w[pending running].freeze COMPLETED_STATUSES = %w[success failed canceled skipped].freeze - ORDERED_STATUSES = %w[manual failed pending running canceled success skipped].freeze + ORDERED_STATUSES = %w[failed pending running manual canceled success skipped created].freeze class_methods do def status_sql diff --git a/app/models/environment.rb b/app/models/environment.rb index 1a21b5e52b5..bf33010fd21 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -145,6 +145,14 @@ class Environment < ActiveRecord::Base project.deployment_service.terminals(self) if has_terminals? end + def has_metrics? + project.monitoring_service.present? && available? && last_deployment.present? + end + + def metrics + project.monitoring_service.metrics(self) if has_metrics? + end + # An environment name is not necessarily suitable for use in URLs, DNS # or other third-party contexts, so provide a slugified version. A slug has # the following properties: diff --git a/app/models/oauth_access_grant.rb b/app/models/oauth_access_grant.rb new file mode 100644 index 00000000000..3a997406565 --- /dev/null +++ b/app/models/oauth_access_grant.rb @@ -0,0 +1,4 @@ +class OauthAccessGrant < Doorkeeper::AccessGrant + belongs_to :resource_owner, class_name: 'User' + belongs_to :application, class_name: 'Doorkeeper::Application' +end diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb index 116fb71ac08..b85f5dbaf2e 100644 --- a/app/models/oauth_access_token.rb +++ b/app/models/oauth_access_token.rb @@ -1,4 +1,4 @@ -class OauthAccessToken < ActiveRecord::Base +class OauthAccessToken < Doorkeeper::AccessToken belongs_to :resource_owner, class_name: 'User' belongs_to :application, class_name: 'Doorkeeper::Application' end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 10a34c42fd8..e8b000ddad6 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -1,4 +1,5 @@ class PersonalAccessToken < ActiveRecord::Base + include Expirable include TokenAuthenticatable add_authentication_token_field :token @@ -6,17 +7,30 @@ class PersonalAccessToken < ActiveRecord::Base belongs_to :user - scope :active, -> { where(revoked: false).where("expires_at >= NOW() OR expires_at IS NULL") } + before_save :ensure_token + + scope :active, -> { where("revoked = false AND (expires_at >= NOW() OR expires_at IS NULL)") } scope :inactive, -> { where("revoked = true OR expires_at < NOW()") } + scope :with_impersonation, -> { where(impersonation: true) } + scope :without_impersonation, -> { where(impersonation: false) } - def self.generate(params) - personal_access_token = self.new(params) - personal_access_token.ensure_token - personal_access_token - end + validates :scopes, presence: true + validate :validate_api_scopes def revoke! self.revoked = true self.save end + + def active? + !revoked? && !expired? + end + + protected + + def validate_api_scopes + unless scopes.all? { |scope| Gitlab::Auth::API_SCOPES.include?(scope.to_sym) } + errors.add :scopes, "can only contain API scopes" + end + end end diff --git a/app/models/project.rb b/app/models/project.rb index 7d211784c3c..8c2dadf4659 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -113,6 +113,7 @@ class Project < ActiveRecord::Base has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project has_one :external_wiki_service, dependent: :destroy has_one :kubernetes_service, dependent: :destroy, inverse_of: :project + has_one :prometheus_service, dependent: :destroy, inverse_of: :project has_one :mock_ci_service, dependent: :destroy has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id" @@ -392,7 +393,7 @@ class Project < ActiveRecord::Base end def repository_storage_path - Gitlab.config.repositories.storages[repository_storage] + Gitlab.config.repositories.storages[repository_storage]['path'] end def team @@ -771,6 +772,14 @@ class Project < ActiveRecord::Base @deployment_service ||= deployment_services.reorder(nil).find_by(active: true) end + def monitoring_services + services.where(category: :monitoring) + end + + def monitoring_service + @monitoring_service ||= monitoring_services.reorder(nil).find_by(active: true) + end + def jira_tracker? issues_tracker.to_param == 'jira' end diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb new file mode 100644 index 00000000000..ea585721e8f --- /dev/null +++ b/app/models/project_services/monitoring_service.rb @@ -0,0 +1,16 @@ +# Base class for monitoring services +# +# These services integrate with a deployment solution like Prometheus +# to provide additional features for environments. +class MonitoringService < Service + default_value_for :category, 'monitoring' + + def self.supported_events + %w() + end + + # Environments have a number of metrics + def metrics(environment) + raise NotImplementedError + end +end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb new file mode 100644 index 00000000000..375966b9efc --- /dev/null +++ b/app/models/project_services/prometheus_service.rb @@ -0,0 +1,93 @@ +class PrometheusService < MonitoringService + include ReactiveCaching + + self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } + self.reactive_cache_lease_timeout = 30.seconds + self.reactive_cache_refresh_interval = 30.seconds + self.reactive_cache_lifetime = 1.minute + + # Access to prometheus is directly through the API + prop_accessor :api_url + + with_options presence: true, if: :activated? do + validates :api_url, url: true + end + + after_save :clear_reactive_cache! + + def initialize_properties + if properties.nil? + self.properties = {} + end + end + + def title + 'Prometheus' + end + + def description + 'Prometheus monitoring' + end + + def help + 'Retrieves `container_cpu_usage_seconds_total` and `container_memory_usage_bytes` from the configured Prometheus server. An `environment` label is required on each metric to identify the Environment.' + end + + def self.to_param + 'prometheus' + end + + def fields + [ + { + type: 'text', + name: 'api_url', + title: 'API URL', + placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/' + } + ] + end + + # Check we can connect to the Prometheus API + def test(*args) + client.ping + + { success: true, result: 'Checked API endpoint' } + rescue Gitlab::PrometheusError => err + { success: false, result: err } + end + + def metrics(environment) + with_reactive_cache(environment.slug) do |data| + data + end + end + + # Cache metrics for specific environment + def calculate_reactive_cache(environment_slug) + return unless active? && project && !project.pending_delete? + + memory_query = %{sum(container_memory_usage_bytes{container_name="app",environment="#{environment_slug}"})/1024/1024} + cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name="app",environment="#{environment_slug}"}[2m]))} + + { + success: true, + metrics: { + # Memory used in MB + memory_values: client.query_range(memory_query, start: 8.hours.ago), + memory_current: client.query(memory_query), + # CPU Usage rate in cores. + cpu_values: client.query_range(cpu_query, start: 8.hours.ago), + cpu_current: client.query(cpu_query) + }, + last_update: Time.now.utc + } + + rescue Gitlab::PrometheusError => err + { success: false, result: err.message } + end + + def client + @prometheus ||= Gitlab::Prometheus.new(api_url: api_url) + end +end diff --git a/app/models/repository.rb b/app/models/repository.rb index e7cc8d6e083..2a12b36a84d 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -50,10 +50,6 @@ class Repository end end - def self.storages - Gitlab.config.repositories.storages - end - def initialize(path_with_namespace, project) @path_with_namespace = path_with_namespace @project = project diff --git a/app/models/service.rb b/app/models/service.rb index 3ef4cbead10..2f75a2e4e7f 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -232,6 +232,7 @@ class Service < ActiveRecord::Base mattermost pipelines_email pivotaltracker + prometheus pushover redmine slack_slash_commands diff --git a/app/models/user.rb b/app/models/user.rb index bd57904a2cd..76fb4cd470e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -325,8 +325,7 @@ class User < ActiveRecord::Base end def find_by_personal_access_token(token_string) - personal_access_token = PersonalAccessToken.active.find_by_token(token_string) if token_string - personal_access_token&.user + PersonalAccessTokensFinder.new(state: 'active').find_by(token: token_string)&.user end # Returns a user for the given SSH key. diff --git a/app/policies/ci/trigger_policy.rb b/app/policies/ci/trigger_policy.rb new file mode 100644 index 00000000000..c90c9ac0583 --- /dev/null +++ b/app/policies/ci/trigger_policy.rb @@ -0,0 +1,13 @@ +module Ci + class TriggerPolicy < BasePolicy + def rules + delegate! @subject.project + + if can?(:admin_build) + can! :admin_trigger if @subject.owner.blank? || + @subject.owner == @user + can! :manage_trigger + end + end + end +end diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb new file mode 100644 index 00000000000..86ac513b3c0 --- /dev/null +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -0,0 +1,60 @@ +module Projects + module Settings + class DeployKeysPresenter < Gitlab::View::Presenter::Simple + presents :project + delegate :size, to: :enabled_keys, prefix: true + delegate :size, to: :available_project_keys, prefix: true + delegate :size, to: :available_public_keys, prefix: true + + def new_key + @key ||= DeployKey.new + end + + def enabled_keys + @enabled_keys ||= project.deploy_keys + end + + def any_keys_enabled? + enabled_keys.any? + end + + def available_keys + @available_keys ||= current_user.accessible_deploy_keys - enabled_keys + end + + def available_project_keys + @available_project_keys ||= current_user.project_deploy_keys - enabled_keys + end + + def any_available_project_keys_enabled? + available_project_keys.any? + end + + def key_available?(deploy_key) + available_keys.include?(deploy_key) + end + + def available_public_keys + return @available_public_keys if defined?(@available_public_keys) + + @available_public_keys ||= DeployKey.are_public - enabled_keys + + # Public keys that are already used by another accessible project are already + # in @available_project_keys. + @available_public_keys -= available_project_keys + end + + def any_available_public_keys_enabled? + available_public_keys.any? + end + + def to_partial_path + 'projects/deploy_keys/index' + end + + def form_partial_path + 'projects/deploy_keys/form' + end + end + end +end diff --git a/app/services/ci/register_build_service.rb b/app/services/ci/register_job_service.rb index 5b52a0425de..0ab9042bf24 100644 --- a/app/services/ci/register_build_service.rb +++ b/app/services/ci/register_job_service.rb @@ -1,7 +1,7 @@ module Ci # This class responsible for assigning # proper pending build to runner on runner API request - class RegisterBuildService + class RegisterJobService include Gitlab::CurrentSettings attr_reader :runner diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index dbe2fda27b5..bc7431c89a8 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -99,6 +99,8 @@ class GitPushService < BaseService UpdateMergeRequestsWorker .perform_async(@project.id, current_user.id, params[:oldrev], params[:newrev], params[:ref]) + SystemHookPushWorker.perform_async(build_push_data.dup, :push_hooks) + EventCreateService.new.push(@project, current_user, build_push_data) @project.execute_hooks(build_push_data.dup, :push_hooks) @project.execute_services(build_push_data.dup, :push_hooks) diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml new file mode 100644 index 00000000000..1378dde52ab --- /dev/null +++ b/app/views/admin/impersonation_tokens/index.html.haml @@ -0,0 +1,8 @@ +- page_title "Impersonation Tokens", @user.name, "Users" += render 'admin/users/head' + +.row.prepend-top-default + .col-lg-12 + = render "shared/personal_access_tokens_form", path: admin_user_impersonation_tokens_path, impersonation: true, token: @impersonation_token, scopes: @scopes + + = render "shared/personal_access_tokens_table", impersonation: true, active_tokens: @active_impersonation_tokens, inactive_tokens: @inactive_impersonation_tokens diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index 9984e733956..d20be373564 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -21,4 +21,6 @@ = link_to "SSH keys", keys_admin_user_path(@user) = nav_link(controller: :identities) do = link_to "Identities", admin_user_identities_path(@user) + = nav_link(controller: :impersonation_tokens) do + = link_to "Impersonation Tokens", admin_user_impersonation_tokens_path(@user) .append-bottom-default diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index a196561f381..82aa51f9778 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -27,6 +27,7 @@ = hidden_field_tag :state, @pre_auth.state = hidden_field_tag :response_type, @pre_auth.response_type = hidden_field_tag :scope, @pre_auth.scope + = hidden_field_tag :nonce, @pre_auth.nonce = submit_tag "Authorize", class: "btn btn-success wide pull-left" = form_tag oauth_authorization_path, method: :delete do = hidden_field_tag :client_id, @pre_auth.client.uid @@ -34,4 +35,5 @@ = hidden_field_tag :state, @pre_auth.state = hidden_field_tag :response_type, @pre_auth.response_type = hidden_field_tag :scope, @pre_auth.scope + = hidden_field_tag :nonce, @pre_auth.nonce = submit_tag "Deny", class: "btn btn-danger prepend-left-10" diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index 665725f6862..6f2777d1be6 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -4,18 +4,14 @@ %span Members - if can_edit - = nav_link(controller: :deploy_keys) do - = link_to namespace_project_deploy_keys_path(@project.namespace, @project), title: 'Deploy Keys' do + = nav_link(controller: :repository) do + = link_to namespace_project_settings_repository_path(@project.namespace, @project), title: 'Repository' do %span - Deploy Keys + Repository = nav_link(controller: :integrations) do = link_to namespace_project_settings_integrations_path(@project.namespace, @project), title: 'Integrations' do %span Integrations - = nav_link(controller: :protected_branches) do - = link_to namespace_project_protected_branches_path(@project.namespace, @project), title: 'Protected Branches' do - %span - Protected Branches - if @project.feature_available?(:builds, current_user) = nav_link(controller: :ci_cd) do diff --git a/app/views/profiles/personal_access_tokens/_form.html.haml b/app/views/profiles/personal_access_tokens/_form.html.haml deleted file mode 100644 index 3f6efa33953..00000000000 --- a/app/views/profiles/personal_access_tokens/_form.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -- personal_access_token = local_assigns.fetch(:personal_access_token) -- scopes = local_assigns.fetch(:scopes) - -= form_for [:profile, personal_access_token], method: :post, html: { class: 'js-requires-input' } do |f| - - = form_errors(personal_access_token) - - .form-group - = f.label :name, class: 'label-light' - = f.text_field :name, class: "form-control", required: true - - .form-group - = f.label :expires_at, class: 'label-light' - = f.text_field :expires_at, class: "datepicker form-control" - - .form-group - = f.label :scopes, class: 'label-light' - = render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: personal_access_token, scopes: scopes - - .prepend-top-default - = f.submit 'Create Personal Access Token', class: "btn btn-create" diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 903b957c26b..0645ecad496 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -24,80 +24,11 @@ %hr - %h5.prepend-top-0 - Add a Personal Access Token - %p.profile-settings-content - Pick a name for the application, and we'll give you a unique token. - - = render "form", personal_access_token: @personal_access_token, scopes: @scopes - - %hr - - %h5 Active Personal Access Tokens (#{@active_personal_access_tokens.length}) - - - if @active_personal_access_tokens.present? - .table-responsive - %table.table.active-personal-access-tokens - %thead - %tr - %th Name - %th Created - %th Expires - %th Scopes - %th - %tbody - - @active_personal_access_tokens.each do |token| - %tr - %td= token.name - %td= token.created_at.to_date.to_s(:medium) - %td - - if token.expires_at.present? - = token.expires_at.to_date.to_s(:medium) - - else - %span.personal-access-tokens-never-expires-label Never - %td= token.scopes.present? ? token.scopes.join(", ") : "<no scopes selected>" - %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to revoke this token? This action cannot be undone." } - - - else - .settings-message.text-center - You don't have any active tokens yet. - - %hr - - %h5 Inactive Personal Access Tokens (#{@inactive_personal_access_tokens.length}) - - - if @inactive_personal_access_tokens.present? - .table-responsive - %table.table.inactive-personal-access-tokens - %thead - %tr - %th Name - %th Created - %tbody - - @inactive_personal_access_tokens.each do |token| - %tr - %td= token.name - %td= token.created_at.to_date.to_s(:medium) - - - else - .settings-message.text-center - There are no inactive tokens. + = render "shared/personal_access_tokens_form", path: profile_personal_access_tokens_path, impersonation: false, token: @personal_access_token, scopes: @scopes + = render "shared/personal_access_tokens_table", impersonation: false, active_tokens: @active_personal_access_tokens, inactive_tokens: @inactive_personal_access_tokens :javascript - var $dateField = $('#personal_access_token_expires_at'); - var date = $dateField.val(); - - new Pikaday({ - field: $dateField.get(0), - theme: 'gitlab-theme', - format: 'yyyy-mm-dd', - minDate: new Date(), - onSelect: function(dateText) { - $dateField.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); - } - }); - $("#created-personal-access-token").click(function() { this.select(); }); diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 41a7191302d..24ff74ecb3b 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -18,7 +18,7 @@ - else = link_to title, '#' -%ul.blob-commit-info.table-list.hidden-xs +%ul.blob-commit-info.hidden-xs - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path) = render blob_commit, project: @project, ref: @ref diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 002e3d345dc..6ab9a80e083 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -9,33 +9,34 @@ - cache_key.push(commit.status(ref)) if commit.status(ref) = cache(cache_key, expires_in: 1.day) do - %li.commit.table-list-row.js-toggle-container{ id: "commit-#{commit.short_id}" } + %li.commit.flex-list.js-toggle-container{ id: "commit-#{commit.short_id}" } - .table-list-cell.avatar-cell.hidden-xs + .avatar-cell.hidden-xs = author_avatar(commit, size: 36) - .table-list-cell.commit-content - = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message item-title" - %span.commit-row-message.visible-xs-inline - · - = commit.short_id - - if commit.status(ref) - .visible-xs-inline - = render_commit_status(commit, ref: ref) - - if commit.description? - %a.text-expander.hidden-xs.js-toggle-button ... + .commit-detail + .commit-content + = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message item-title" + %span.commit-row-message.visible-xs-inline + · + = commit.short_id + - if commit.status(ref) + .visible-xs-inline + = render_commit_status(commit, ref: ref) + - if commit.description? + %a.text-expander.hidden-xs.js-toggle-button ... - - if commit.description? - %pre.commit-row-description.js-toggle-content - = preserve(markdown(commit.description, pipeline: :single_line, author: commit.author)) - .commiter - = commit_author_link(commit, avatar: false, size: 24) - committed - #{time_ago_with_tooltip(commit.committed_date)} + - if commit.description? + %pre.commit-row-description.js-toggle-content + = preserve(markdown(commit.description, pipeline: :single_line, author: commit.author)) + .commiter + = commit_author_link(commit, avatar: false, size: 24) + committed + #{time_ago_with_tooltip(commit.committed_date)} - .table-list-cell.commit-actions.hidden-xs - - if commit.status(ref) - = render_commit_status(commit, ref: ref) - = clipboard_button(clipboard_text: commit.id, title: "Copy commit SHA to clipboard") - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" - = link_to_browse_code(project, commit) + .commit-actions.flex-row.hidden-xs + - if commit.status(ref) + = render_commit_status(commit, ref: ref) + = clipboard_button(clipboard_text: commit.id, title: "Copy commit SHA to clipboard") + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" + = link_to_browse_code(project, commit) diff --git a/app/views/projects/commits/_commit_list.html.haml b/app/views/projects/commits/_commit_list.html.haml index 64d93e4141c..6f5835cb9be 100644 --- a/app/views/projects/commits/_commit_list.html.haml +++ b/app/views/projects/commits/_commit_list.html.haml @@ -11,4 +11,4 @@ %li.warning-row.unstyled #{number_with_delimiter(hidden)} additional commits have been omitted to prevent performance issues. - else - %ul.content-list.table-list= render commits, project: @project, ref: @ref + %ul.content-list= render commits, project: @project, ref: @ref diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 904cdb5767f..88c7d7bc44b 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -4,7 +4,7 @@ - commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits| %li.commit-header #{day.strftime('%d %b, %Y')} #{pluralize(commits.count, 'commit')} %li.commits-row - %ul.content-list.commit-list.table-list.table-wide + %ul.content-list.commit-list = render commits, project: project, ref: ref - if hidden > 0 diff --git a/app/views/projects/deploy_keys/_deploy_key.html.haml b/app/views/projects/deploy_keys/_deploy_key.html.haml index d1e3cb14022..ec8fc4c9ee8 100644 --- a/app/views/projects/deploy_keys/_deploy_key.html.haml +++ b/app/views/projects/deploy_keys/_deploy_key.html.haml @@ -18,7 +18,7 @@ %span.key-created-at created #{time_ago_with_tooltip(deploy_key.created_at)} .visible-xs-block.visible-sm-block - - if @available_keys.include?(deploy_key) + - if @deploy_keys.key_available?(deploy_key) = link_to enable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: "btn btn-sm prepend-left-10", method: :put do Enable - else diff --git a/app/views/projects/deploy_keys/_form.html.haml b/app/views/projects/deploy_keys/_form.html.haml index c91bb9c255a..1421da72418 100644 --- a/app/views/projects/deploy_keys/_form.html.haml +++ b/app/views/projects/deploy_keys/_form.html.haml @@ -1,5 +1,5 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input" } do |f| - = form_errors(@key) += form_for [@project.namespace.becomes(Namespace), @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input" } do |f| + = form_errors(@deploy_keys.new_key) .form-group = f.label :title, class: "label-light" = f.text_field :title, class: 'form-control', autofocus: true, required: true diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml new file mode 100644 index 00000000000..0cbe9b3275a --- /dev/null +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -0,0 +1,34 @@ +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + Deploy Keys + %p + Deploy keys allow read-only access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one. + .col-lg-9 + %h5.prepend-top-0 + Create a new deploy key for this project + = render @deploy_keys.form_partial_path + .col-lg-9.col-lg-offset-3 + %hr + .col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys + %h5.prepend-top-0 + Enabled deploy keys for this project (#{@deploy_keys.enabled_keys_size}) + - if @deploy_keys.any_keys_enabled? + %ul.well-list + = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.enabled_keys, as: :deploy_key + - else + .settings-message.text-center + No deploy keys found. Create one with the form above. + %h5.prepend-top-default + Deploy keys from projects you have access to (#{@deploy_keys.available_project_keys_size}) + - if @deploy_keys.any_available_project_keys_enabled? + %ul.well-list + = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_project_keys, as: :deploy_key + - else + .settings-message.text-center + No deploy keys from your projects could be found. Create one with the form above or add existing one below. + - if @deploy_keys.any_available_public_keys_enabled? + %h5.prepend-top-default + Public deploy keys available to any project (#{@deploy_keys.available_public_keys_size}) + %ul.well-list + = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_public_keys, as: :deploy_key diff --git a/app/views/projects/deploy_keys/index.html.haml b/app/views/projects/deploy_keys/index.html.haml deleted file mode 100644 index 04fbb37d93f..00000000000 --- a/app/views/projects/deploy_keys/index.html.haml +++ /dev/null @@ -1,36 +0,0 @@ -- page_title "Deploy Keys" - -.row.prepend-top-default - .col-lg-3.profile-settings-sidebar - %h4.prepend-top-0 - = page_title - %p - Deploy keys allow read-only access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one. - .col-lg-9 - %h5.prepend-top-0 - Create a new deploy key for this project - = render "form" - .col-lg-9.col-lg-offset-3 - %hr - .col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys - %h5.prepend-top-0 - Enabled deploy keys for this project (#{@enabled_keys.size}) - - if @enabled_keys.any? - %ul.well-list - = render @enabled_keys - - else - .settings-message.text-center - No deploy keys found. Create one with the form above or add existing one below. - %h5.prepend-top-default - Deploy keys from projects you have access to (#{@available_project_keys.size}) - - if @available_project_keys.any? - %ul.well-list - = render @available_project_keys - - else - .settings-message.text-center - No deploy keys from your projects could be found. Create one with the form above or add existing one below. - - if @available_public_keys.any? - %h5.prepend-top-default - Public deploy keys available to any project (#{@available_public_keys.size}) - %ul.well-list - = render @available_public_keys diff --git a/app/views/projects/environments/_metrics_button.html.haml b/app/views/projects/environments/_metrics_button.html.haml new file mode 100644 index 00000000000..acbac1869fd --- /dev/null +++ b/app/views/projects/environments/_metrics_button.html.haml @@ -0,0 +1,6 @@ +- environment = local_assigns.fetch(:environment) + +- return unless environment.has_metrics? && can?(current_user, :read_environment, environment) + += link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do + = icon('area-chart') diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml new file mode 100644 index 00000000000..f8e94ca98ae --- /dev/null +++ b/app/views/projects/environments/metrics.html.haml @@ -0,0 +1,21 @@ +- @no_container = true +- page_title "Metrics for environment", @environment.name += render "projects/pipelines/head" + +%div{ class: container_class } + .top-area + .row + .col-sm-6 + %h3.page-title + Environment: + = @environment.name + + .col-sm-6 + .nav-controls + = render 'projects/deployments/actions', deployment: @environment.last_deployment + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'memory_values' } diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 7036325fff8..29a98f23b88 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -8,6 +8,7 @@ %h3.page-title= @environment.name .col-md-3 .nav-controls + = render 'projects/environments/metrics_button', environment: @environment = render 'projects/environments/terminal_button', environment: @environment = render 'projects/environments/external_url', environment: @environment - if can?(current_user, :update_environment, @environment) diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml index 466ec1475d8..ad14b4e583e 100644 --- a/app/views/projects/merge_requests/_new_compare.html.haml +++ b/app/views/projects/merge_requests/_new_compare.html.haml @@ -21,7 +21,7 @@ selected: f.object.source_project_id .merge-request-select.dropdown = f.hidden_field :source_branch - = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" } + = dropdown_toggle local_assigns.fetch(f.object.source_branch, "Select source branch"), { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" } .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch = dropdown_title("Select source branch") = dropdown_filter("Search branches") @@ -30,7 +30,7 @@ branches: @merge_request.source_branches, selected: f.object.source_branch .panel-footer - = icon('spinner spin', class: 'js-source-loading') + .text-center= icon('spinner spin', class: 'js-source-loading') %ul.list-unstyled.mr_source_commit .col-md-6 @@ -60,7 +60,7 @@ branches: @merge_request.target_branches, selected: f.object.target_branch .panel-footer - = icon('spinner spin', class: "js-target-loading") + .text-center= icon('spinner spin', class: "js-target-loading") %ul.list-unstyled.mr_target_commit - if @merge_request.errors.any? diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml index e8f17f000dc..1298376ac25 100644 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ b/app/views/projects/merge_requests/widget/_heading.html.haml @@ -1,6 +1,6 @@ - if @pipeline .mr-widget-heading - - %w[success success_with_warnings skipped canceled failed running pending].each do |status| + - %w[success success_with_warnings skipped manual canceled failed running pending].each do |status| .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } %div{ class: "ci-status-icon ci-status-icon-#{status}" } = link_to namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'icon-link' do diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index f0ccc4e00fd..bc426f1dc0c 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -27,6 +27,8 @@ = render 'projects/merge_requests/widget/open/build_failed' - elsif !@merge_request.mergeable_discussions_state? = render 'projects/merge_requests/widget/open/unresolved_discussions' + - elsif @pipeline&.blocked? + = render 'projects/merge_requests/widget/open/manual' - elsif @merge_request.can_be_merged? || resolved_conflicts = render 'projects/merge_requests/widget/open/accept' diff --git a/app/views/projects/merge_requests/widget/open/_manual.html.haml b/app/views/projects/merge_requests/widget/open/_manual.html.haml new file mode 100644 index 00000000000..9078b7e21dd --- /dev/null +++ b/app/views/projects/merge_requests/widget/open/_manual.html.haml @@ -0,0 +1,4 @@ +%h4 + Pipeline blocked +%p + The pipeline for this merge request requires a manual action to proceed. diff --git a/app/views/projects/pipelines/_stage.html.haml b/app/views/projects/pipelines/_stage.html.haml index a0b14a7274a..3feb99cfcd7 100644 --- a/app/views/projects/pipelines/_stage.html.haml +++ b/app/views/projects/pipelines/_stage.html.haml @@ -1,3 +1,5 @@ -- @stage.statuses.latest.each do |status| - %li - = render 'ci/status/dropdown_graph_badge', subject: status +- grouped_statuses = @stage.statuses.latest_ordered.group_by(&:status) +- HasStatus::ORDERED_STATUSES.each do |ordered_status| + - grouped_statuses.fetch(ordered_status, []).each do |status| + %li + = render 'ci/status/dropdown_graph_badge', subject: status diff --git a/app/views/projects/protected_branches/_branches_list.html.haml b/app/views/projects/protected_branches/_branches_list.html.haml index 04b19a8c5a7..cf0db943865 100644 --- a/app/views/projects/protected_branches/_branches_list.html.haml +++ b/app/views/projects/protected_branches/_branches_list.html.haml @@ -23,6 +23,6 @@ - if can_admin_project %th %tbody - = render partial: @protected_branches, locals: { can_admin_project: can_admin_project } + = render partial: 'projects/protected_branches/protected_branch', collection: @protected_branches, locals: { can_admin_project: can_admin_project} = paginate @protected_branches, theme: 'gitlab' diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index e95a3b1b4c3..b8e885b4d9a 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -10,7 +10,7 @@ = f.label :name, class: 'col-md-2 text-right' do Branch: .col-md-10 - = render partial: "dropdown", locals: { f: f } + = render partial: "projects/protected_branches/dropdown", locals: { f: f } .help-block = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches') such as diff --git a/app/views/projects/protected_branches/index.html.haml b/app/views/projects/protected_branches/_index.html.haml index b3b419bd92d..2d8c519c025 100644 --- a/app/views/projects/protected_branches/index.html.haml +++ b/app/views/projects/protected_branches/_index.html.haml @@ -1,11 +1,10 @@ -- page_title "Protected branches" - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('protected_branches') .row.prepend-top-default.append-bottom-default .col-lg-3 %h4.prepend-top-0 - = page_title + Protected Branches %p Keep stable branches secure and force developers to use merge requests. %p.prepend-top-20 By default, protected branches are designed to: @@ -17,6 +16,6 @@ %p.append-bottom-0 Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}. .col-lg-9 - if can? current_user, :admin_project, @project - = render 'create_protected_branch' + = render 'projects/protected_branches/create_protected_branch' - = render "branches_list" + = render "projects/protected_branches/branches_list" diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index 0193800dedf..b2a6b8469a3 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -14,7 +14,7 @@ - else (branch was removed from repository) - = render partial: 'update_protected_branch', locals: { protected_branch: protected_branch } + = render partial: 'projects/protected_branches/update_protected_branch', locals: { protected_branch: protected_branch } - if can_admin_project %td diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml new file mode 100644 index 00000000000..95d821f6135 --- /dev/null +++ b/app/views/projects/settings/repository/show.html.haml @@ -0,0 +1,4 @@ +- page_title "Repository" + += render @deploy_keys += render "projects/protected_branches/index" diff --git a/app/views/projects/triggers/_content.html.haml b/app/views/projects/triggers/_content.html.haml new file mode 100644 index 00000000000..ea32eac2ae2 --- /dev/null +++ b/app/views/projects/triggers/_content.html.haml @@ -0,0 +1,14 @@ +%h4.prepend-top-0 + Triggers +%p.prepend-top-20 + Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will + impersonate their associated user including their access to projects and their project + permissions. +%p.prepend-top-20 + Triggers with the + %span.label.label-primary legacy + label do not have an associated user and only have access to the current project. +%p.append-bottom-0 + = succeed '.' do + Learn more in the + = link_to 'triggers documentation', help_page_path('ci/triggers/README'), target: '_blank' diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml new file mode 100644 index 00000000000..5f708b3a2ed --- /dev/null +++ b/app/views/projects/triggers/_form.html.haml @@ -0,0 +1,11 @@ += form_for [@project.namespace.becomes(Namespace), @project, @trigger], html: { class: 'gl-show-field-errors' } do |f| + = form_errors(@trigger) + + - if @trigger.token + .form-group + %label.label-light Token + %p.form-control-static= @trigger.token + .form-group + = f.label :key, "Description", class: "label-light" + = f.text_field :description, class: "form-control", required: true, title: 'Trigger description is required.', placeholder: "Trigger description" + = f.submit btn_text, class: "btn btn-save" diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index 33883facf9b..cc74e50a5e3 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -1,35 +1,31 @@ -.row.prepend-top-default.append-bottom-default +.row.prepend-top-default.append-bottom-default.triggers-container .col-lg-3 - %h4.prepend-top-0 - Triggers - %p.prepend-top-20 - Triggers can force a specific branch or tag to get rebuilt with an API call. - %p.append-bottom-0 - = succeed '.' do - Learn more in the - = link_to 'triggers documentation', help_page_path('ci/triggers/README'), target: '_blank' + = render "projects/triggers/content" .col-lg-9 .panel.panel-default .panel-heading %h4.panel-title Manage your project's triggers .panel-body + = render "projects/triggers/form", btn_text: "Add trigger" + %hr - if @triggers.any? - .table-responsive + .table-responsive.triggers-list %table.table %thead %th %strong Token %th + %strong Description + %th + %strong Owner + %th %strong Last used %th = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger - else %p.settings-message.text-center.append-bottom-default - No triggers have been created yet. Add one using the button below. - - = form_for @trigger, url: url_for(controller: '/projects/triggers', action: 'create') do |f| - = f.submit "Add trigger", class: 'btn btn-success' + No triggers have been created yet. Add one using the form above. .panel-footer diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index 112b51712ef..ed68e0ed56d 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -1,12 +1,42 @@ %tr %td - %span.monospace= trigger.token + - if can?(current_user, :admin_trigger, trigger) + %span= trigger.token + = clipboard_button(clipboard_text: trigger.token, title: "Copy trigger token to clipboard") + - else + %span= trigger.short_token + + .label-container + - if trigger.legacy? + %span.label.label-primary.has-tooltip{ title: "Trigger makes use of deprecated functionality" } legacy + - if !trigger.can_access_project? + %span.label.label-danger.has-tooltip{ title: "Trigger user has insufficient permissions to project" } invalid + + %td + - if trigger.description? && trigger.description.length > 15 + %span.has-tooltip{ title: trigger.description }= truncate(trigger.description, length: 15) + - else + = trigger.description + + %td + - if trigger.owner + .trigger-owner.sr-only= trigger.owner.name + = user_avatar(user: trigger.owner, size: 20) %td - - if trigger.last_trigger_request - #{time_ago_in_words(trigger.last_trigger_request.created_at)} ago + - if trigger.last_used + #{time_ago_in_words(trigger.last_used)} ago - else Never - %td.text-right - = link_to 'Revoke', namespace_project_trigger_path(@project.namespace, @project, trigger), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-warning btn-sm" + %td.text-right.trigger-actions + - take_ownership_confirmation = "By taking ownership you will bind this trigger to your user account. With this the trigger will have access to all your projects as if it was you. Are you sure?" + - revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?" + - if trigger.owner != current_user && can?(current_user, :manage_trigger, trigger) + = link_to 'Take ownership', take_ownership_namespace_project_trigger_path(@project.namespace, @project, trigger), data: { confirm: take_ownership_confirmation }, method: :post, class: "btn btn-default btn-sm btn-trigger-take-ownership" + - if can?(current_user, :admin_trigger, trigger) + = link_to edit_namespace_project_trigger_path(@project.namespace, @project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do + %i.fa.fa-pencil + - if can?(current_user, :manage_trigger, trigger) + = link_to namespace_project_trigger_path(@project.namespace, @project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do + %i.fa.fa-trash diff --git a/app/views/projects/triggers/edit.html.haml b/app/views/projects/triggers/edit.html.haml new file mode 100644 index 00000000000..c35df322b9d --- /dev/null +++ b/app/views/projects/triggers/edit.html.haml @@ -0,0 +1,9 @@ +- page_title "Trigger" + +.row.prepend-top-default.append-bottom-default + .col-lg-3 + = render "content" + .col-lg-9 + %h4.prepend-top-0 + Update trigger + = render "form", btn_text: "Save trigger" diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 22004ecacbc..02133d09cdf 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -11,7 +11,7 @@ .results.prepend-top-10 - if @scope == 'commits' - %ul.content-list.commit-list.table-list.table-wide + %ul.content-list.commit-list = render partial: "search/results/commit", collection: @search_objects - else .search-results diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml new file mode 100644 index 00000000000..af4cc90f4a7 --- /dev/null +++ b/app/views/shared/_personal_access_tokens_form.html.haml @@ -0,0 +1,39 @@ +- type = impersonation ? "Impersonation" : "Personal Access" + +%h5.prepend-top-0 + Add a #{type} Token +%p.profile-settings-content + Pick a name for the application, and we'll give you a unique #{type} Token. + += form_for token, url: path, method: :post, html: { class: 'js-requires-input' } do |f| + + = form_errors(token) + + .form-group + = f.label :name, class: 'label-light' + = f.text_field :name, class: "form-control", required: true + + .form-group + = f.label :expires_at, class: 'label-light' + = f.text_field :expires_at, class: "datepicker form-control" + + .form-group + = f.label :scopes, class: 'label-light' + = render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: token, scopes: scopes + + .prepend-top-default + = f.submit "Create #{type} Token", class: "btn btn-create" + +:javascript + var $dateField = $('.datepicker'); + var date = $dateField.val(); + + new Pikaday({ + field: $dateField.get(0), + theme: 'gitlab-theme', + format: 'yyyy-mm-dd', + minDate: new Date(), + onSelect: function(dateText) { + $dateField.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); + } + }); diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/_personal_access_tokens_table.html.haml new file mode 100644 index 00000000000..67a49815478 --- /dev/null +++ b/app/views/shared/_personal_access_tokens_table.html.haml @@ -0,0 +1,60 @@ +- type = impersonation ? "Impersonation" : "Personal Access" +%hr + +%h5 Active #{type} Tokens (#{active_tokens.length}) +- if impersonation + %p.profile-settings-content + To see all the user's personal access tokens you must impersonate them first. + +- if active_tokens.present? + .table-responsive + %table.table.active-tokens + %thead + %tr + %th Name + %th Created + %th Expires + %th Scopes + - if impersonation + %th Token + %th + %tbody + - active_tokens.each do |token| + %tr + %td= token.name + %td= token.created_at.to_date.to_s(:medium) + %td + - if token.expires? + %span{ class: ('text-warning' if token.expires_soon?) } + In #{distance_of_time_in_words_to_now(token.expires_at)} + - else + %span.token-never-expires-label Never + %td= token.scopes.present? ? token.scopes.join(", ") : "<no scopes selected>" + - if impersonation + %td.token-token-container + = text_field_tag 'impersonation-token-token', token.token, readonly: true, class: "form-control" + = clipboard_button(clipboard_text: token.token) + - path = impersonation ? revoke_admin_user_impersonation_token_path(token.user, token) : revoke_profile_personal_access_token_path(token) + %td= link_to "Revoke", path, method: :put, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to revoke this #{type} Token? This action cannot be undone." } +- else + .settings-message.text-center + This user has no active #{type} Tokens. + +%hr + +%h5 Inactive #{type} Tokens (#{inactive_tokens.length}) +- if inactive_tokens.present? + .table-responsive + %table.table.inactive-tokens + %thead + %tr + %th Name + %th Created + %tbody + - inactive_tokens.each do |token| + %tr + %td= token.name + %td= token.created_at.to_date.to_s(:medium) +- else + .settings-message.text-center + This user has no inactive #{type} Tokens. diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 62f09cc2dc1..5e7ce399b9a 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -112,7 +112,7 @@ = hidden_field_tag 'update[issuable_ids]', [] = hidden_field_tag :state_event, params[:state_event] - .filter-item.inline + .filter-item.inline.update-issues-btn = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save" :javascript diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 2fff6b0105d..2cd87895c55 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -3,8 +3,8 @@ class PostReceive include DedicatedSidekiqQueue def perform(repo_path, identifier, changes) - if path = Gitlab.config.repositories.storages.find { |p| repo_path.start_with?(p[1].to_s) } - repo_path.gsub!(path[1].to_s, "") + if repository_storage = Gitlab.config.repositories.storages.find { |p| repo_path.start_with?(p[1]['path'].to_s) } + repo_path.gsub!(repository_storage[1]['path'].to_s, "") else log("Check gitlab.yml config for correct repositories.storages values. No repository storage path matches \"#{repo_path}\"") end diff --git a/app/workers/system_hook_push_worker.rb b/app/workers/system_hook_push_worker.rb new file mode 100644 index 00000000000..e43bbe35de9 --- /dev/null +++ b/app/workers/system_hook_push_worker.rb @@ -0,0 +1,8 @@ +class SystemHookPushWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def perform(push_data, hook_id) + SystemHooksService.new.execute_hooks(push_data, hook_id) + end +end diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index acc4d858136..89ae17cef37 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -10,8 +10,5 @@ class UpdateMergeRequestsWorker return unless user MergeRequests::RefreshService.new(project, user).execute(oldrev, newrev, ref) - - push_data = Gitlab::DataBuilder::Push.build(project, user, oldrev, newrev, ref, []) - SystemHooksService.new.execute_hooks(push_data, :push_hooks) end end diff --git a/changelogs/unreleased/18962-update-issues-button-jumps.yml b/changelogs/unreleased/18962-update-issues-button-jumps.yml new file mode 100644 index 00000000000..7be136ac4ff --- /dev/null +++ b/changelogs/unreleased/18962-update-issues-button-jumps.yml @@ -0,0 +1,4 @@ +--- +title: Align bulk update issues button to the right +merge_request: +author: diff --git a/changelogs/unreleased/25367-add-impersonation-token.yml b/changelogs/unreleased/25367-add-impersonation-token.yml new file mode 100644 index 00000000000..4a30f960036 --- /dev/null +++ b/changelogs/unreleased/25367-add-impersonation-token.yml @@ -0,0 +1,4 @@ +--- +title: Manage user personal access tokens through api and add impersonation tokens +merge_request: 9099 +author: Simon Vocella diff --git a/changelogs/unreleased/26732-combine-deploy-keys-and-push-rules-and-mirror-repository-and-protect-branches-settings-pages.yml b/changelogs/unreleased/26732-combine-deploy-keys-and-push-rules-and-mirror-repository-and-protect-branches-settings-pages.yml new file mode 100644 index 00000000000..6fc4615dab8 --- /dev/null +++ b/changelogs/unreleased/26732-combine-deploy-keys-and-push-rules-and-mirror-repository-and-protect-branches-settings-pages.yml @@ -0,0 +1,5 @@ +--- +title: Combined deploy keys, push rules, protect branches and mirror repository settings options into a single one called + Repository +merge_request: +author: diff --git a/changelogs/unreleased/26790-label-color-todos.yml b/changelogs/unreleased/26790-label-color-todos.yml new file mode 100644 index 00000000000..74084473d81 --- /dev/null +++ b/changelogs/unreleased/26790-label-color-todos.yml @@ -0,0 +1,4 @@ +--- +title: fix background color for labels mention in todo +merge_request: 9155 +author: mhasbini diff --git a/changelogs/unreleased/27568-refactor-very-slow-dropdown-asignee-spec.yml b/changelogs/unreleased/27568-refactor-very-slow-dropdown-asignee-spec.yml new file mode 100644 index 00000000000..5c738af7704 --- /dev/null +++ b/changelogs/unreleased/27568-refactor-very-slow-dropdown-asignee-spec.yml @@ -0,0 +1,4 @@ +--- +title: Refactor dropdown_assignee_spec +merge_request: 9711 +author: George Andrinopoulos diff --git a/changelogs/unreleased/27936-make-all-uploads-require-revalidation-on-each-browser-fetch.yml b/changelogs/unreleased/27936-make-all-uploads-require-revalidation-on-each-browser-fetch.yml new file mode 100644 index 00000000000..adc129d8dca --- /dev/null +++ b/changelogs/unreleased/27936-make-all-uploads-require-revalidation-on-each-browser-fetch.yml @@ -0,0 +1,4 @@ +--- +title: Uploaded files which content can change now require revalidation on each page load +merge_request: 9453 +author: diff --git a/changelogs/unreleased/28447-hybrid-repository-storages.yml b/changelogs/unreleased/28447-hybrid-repository-storages.yml new file mode 100644 index 00000000000..00dfc5781b9 --- /dev/null +++ b/changelogs/unreleased/28447-hybrid-repository-storages.yml @@ -0,0 +1,4 @@ +--- +title: Update storage settings to allow extra values per repository storage +merge_request: 9597 +author: diff --git a/changelogs/unreleased/28609-fix-redirect-to-home-page-url.yml b/changelogs/unreleased/28609-fix-redirect-to-home-page-url.yml deleted file mode 100644 index baf832d4495..00000000000 --- a/changelogs/unreleased/28609-fix-redirect-to-home-page-url.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix the redirect to custom home page URL -merge_request: 9518 -author: diff --git a/changelogs/unreleased/28835-jobs-head.yml b/changelogs/unreleased/28835-jobs-head.yml new file mode 100644 index 00000000000..1580cfb19ba --- /dev/null +++ b/changelogs/unreleased/28835-jobs-head.yml @@ -0,0 +1,4 @@ +--- +title: Fix jobs table header height +merge_request: +author: diff --git a/changelogs/unreleased/28850-fix-broken-migration.yml b/changelogs/unreleased/28850-fix-broken-migration.yml deleted file mode 100644 index 7f59a7708bc..00000000000 --- a/changelogs/unreleased/28850-fix-broken-migration.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix broken migration when upgrading straight to 8.17.1 -merge_request: 9613 -author: diff --git a/changelogs/unreleased/add-pipeline-triggers.yml b/changelogs/unreleased/add-pipeline-triggers.yml new file mode 100644 index 00000000000..81b11da0bb2 --- /dev/null +++ b/changelogs/unreleased/add-pipeline-triggers.yml @@ -0,0 +1,4 @@ +--- +title: Add pipeline trigger API with user permissions +merge_request: 9277 +author: diff --git a/changelogs/unreleased/dm-dont-copy-toolip.yml b/changelogs/unreleased/dm-dont-copy-toolip.yml deleted file mode 100644 index 2b134da66ab..00000000000 --- a/changelogs/unreleased/dm-dont-copy-toolip.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Don't copy tooltip when copying GFM -merge_request: -author: diff --git a/changelogs/unreleased/dm-fix-api-create-file-on-empty-repo.yml b/changelogs/unreleased/dm-fix-api-create-file-on-empty-repo.yml deleted file mode 100644 index 7ac25c0a83e..00000000000 --- a/changelogs/unreleased/dm-fix-api-create-file-on-empty-repo.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix creating a file in an empty repo using the API -merge_request: 9632 -author: diff --git a/changelogs/unreleased/dm-fix-cherry-pick.yml b/changelogs/unreleased/dm-fix-cherry-pick.yml deleted file mode 100644 index e924b821d7e..00000000000 --- a/changelogs/unreleased/dm-fix-cherry-pick.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix cherry-picking or reverting through an MR -merge_request: -author: diff --git a/changelogs/unreleased/feature-openid-connect.yml b/changelogs/unreleased/feature-openid-connect.yml new file mode 100644 index 00000000000..e84eb7aff86 --- /dev/null +++ b/changelogs/unreleased/feature-openid-connect.yml @@ -0,0 +1,4 @@ +--- +title: Implement OpenID Connect identity provider +merge_request: 8018 +author: Markus Koller diff --git a/changelogs/unreleased/feature-runner-jobs-v4-api.yml b/changelogs/unreleased/feature-runner-jobs-v4-api.yml new file mode 100644 index 00000000000..b24ea65266d --- /dev/null +++ b/changelogs/unreleased/feature-runner-jobs-v4-api.yml @@ -0,0 +1,4 @@ +--- +title: Add Runner's jobs v4 API +merge_request: 9273 +author: diff --git a/changelogs/unreleased/feature-syshook_commits.yml b/changelogs/unreleased/feature-syshook_commits.yml new file mode 100644 index 00000000000..1305f5cd414 --- /dev/null +++ b/changelogs/unreleased/feature-syshook_commits.yml @@ -0,0 +1,4 @@ +--- +title: Added commit array to Syshook json +merge_request: 9685 +author: Gabriele Pongelli diff --git a/changelogs/unreleased/fix-gb-deprecate-ci-config-types.yml b/changelogs/unreleased/fix-gb-deprecate-ci-config-types.yml new file mode 100644 index 00000000000..605b5f01d0e --- /dev/null +++ b/changelogs/unreleased/fix-gb-deprecate-ci-config-types.yml @@ -0,0 +1,4 @@ +--- +title: Deprecate usage of `types` configuration entry to describe CI/CD stages +merge_request: 9766 +author: diff --git a/changelogs/unreleased/priority-to-label-priority.yml b/changelogs/unreleased/priority-to-label-priority.yml new file mode 100644 index 00000000000..2d9c58bfd9b --- /dev/null +++ b/changelogs/unreleased/priority-to-label-priority.yml @@ -0,0 +1,4 @@ +--- +title: Rename priority sorting option to label priority +merge_request: +author: diff --git a/changelogs/unreleased/rfr-20170307-change-default-project-number-limit.yml b/changelogs/unreleased/rfr-20170307-change-default-project-number-limit.yml new file mode 100644 index 00000000000..e799dd3b48d --- /dev/null +++ b/changelogs/unreleased/rfr-20170307-change-default-project-number-limit.yml @@ -0,0 +1,4 @@ +--- +title: Change project count limit from 10 to 100000 +merge_request: +author: diff --git a/changelogs/unreleased/sort-builds-in-stage-dropdown.yml b/changelogs/unreleased/sort-builds-in-stage-dropdown.yml new file mode 100644 index 00000000000..646f25125b1 --- /dev/null +++ b/changelogs/unreleased/sort-builds-in-stage-dropdown.yml @@ -0,0 +1,4 @@ +--- +title: Sort builds in stage dropdown +merge_request: +author: diff --git a/changelogs/unreleased/use-v3-api-on-frontend.yml b/changelogs/unreleased/use-v3-api-on-frontend.yml deleted file mode 100644 index 467ad3c8276..00000000000 --- a/changelogs/unreleased/use-v3-api-on-frontend.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Make projects dropdown only show projects you are a member of -merge_request: 9614 -author: diff --git a/changelogs/unreleased/zj-variables-build-job.yml b/changelogs/unreleased/zj-variables-build-job.yml new file mode 100644 index 00000000000..1cb0919f824 --- /dev/null +++ b/changelogs/unreleased/zj-variables-build-job.yml @@ -0,0 +1,4 @@ +--- +title: Rename job environment variables to new terminology +merge_request: 9756 +author: diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index be34a4000fa..720df0cac2d 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -461,7 +461,8 @@ production: &base # gitlab-shell invokes Dir.pwd inside the repository path and that results # real path not the symlink. storages: # You must have at least a `default` storage path. - default: /home/git/repositories/ + default: + path: /home/git/repositories/ ## Backup settings backup: @@ -574,7 +575,8 @@ test: path: tmp/tests/gitlab-satellites/ repositories: storages: - default: tmp/tests/repositories/ + default: + path: tmp/tests/repositories/ backup: path: tmp/tests/backups gitlab_shell: diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 933844e4ea6..b45d0e23080 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -83,7 +83,7 @@ class Settings < Settingslogic def base_url(config) custom_port = on_standard_port?(config) ? nil : ":#{config.port}" - + [ config.protocol, "://", @@ -186,7 +186,7 @@ Settings['issues_tracker'] ||= {} # GitLab # Settings['gitlab'] ||= Settingslogic.new({}) -Settings.gitlab['default_projects_limit'] ||= 10 +Settings.gitlab['default_projects_limit'] ||= 100000 Settings.gitlab['default_branch_protection'] ||= 2 Settings.gitlab['default_can_create_group'] = true if Settings.gitlab['default_can_create_group'].nil? Settings.gitlab['host'] ||= ENV['GITLAB_HOST'] || 'localhost' @@ -366,8 +366,13 @@ Settings.gitlab_shell['ssh_path_prefix'] ||= Settings.send(:build_gitlab_shell_s # Settings['repositories'] ||= Settingslogic.new({}) Settings.repositories['storages'] ||= {} -# Setting gitlab_shell.repos_path is DEPRECATED and WILL BE REMOVED in version 9.0 -Settings.repositories.storages['default'] ||= Settings.gitlab_shell['repos_path'] || Settings.gitlab['user_home'] + '/repositories/' +unless Settings.repositories.storages['default'] + Settings.repositories.storages['default'] ||= {} + # We set the path only if the default storage doesn't exist, in case it exists + # but follows the pre-9.0 configuration structure. `6_validations.rb` initializer + # will validate all storages and throw a relevant error to the user if necessary. + Settings.repositories.storages['default']['path'] ||= Settings.gitlab['user_home'] + '/repositories/' +end # # The repository_downloads_path is used to remove outdated repository @@ -376,11 +381,11 @@ Settings.repositories.storages['default'] ||= Settings.gitlab_shell['repos_path' # data-integrity issue. In this case, we sets it to the default # repository_downloads_path value. # -repositories_storages_path = Settings.repositories.storages.values +repositories_storages = Settings.repositories.storages.values repository_downloads_path = Settings.gitlab['repository_downloads_path'].to_s.gsub(/\/$/, '') repository_downloads_full_path = File.expand_path(repository_downloads_path, Settings.gitlab['user_home']) -if repository_downloads_path.blank? || repositories_storages_path.any? { |path| [repository_downloads_path, repository_downloads_full_path].include?(path.gsub(/\/$/, '')) } +if repository_downloads_path.blank? || repositories_storages.any? { |rs| [repository_downloads_path, repository_downloads_full_path].include?(rs['path'].gsub(/\/$/, '')) } Settings.gitlab['repository_downloads_path'] = File.join(Settings.shared['path'], 'cache/archive') end diff --git a/config/initializers/6_validations.rb b/config/initializers/6_validations.rb index d92f64e1647..abe570f430c 100644 --- a/config/initializers/6_validations.rb +++ b/config/initializers/6_validations.rb @@ -4,8 +4,8 @@ end def find_parent_path(name, path) parent = Pathname.new(path).realpath.parent - Gitlab.config.repositories.storages.detect do |n, p| - name != n && Pathname.new(p).realpath == parent + Gitlab.config.repositories.storages.detect do |n, rs| + name != n && Pathname.new(rs['path']).realpath == parent end end @@ -16,10 +16,22 @@ end def validate_storages storage_validation_error('No repository storage path defined') if Gitlab.config.repositories.storages.empty? - Gitlab.config.repositories.storages.each do |name, path| + Gitlab.config.repositories.storages.each do |name, repository_storage| storage_validation_error("\"#{name}\" is not a valid storage name") unless storage_name_valid?(name) - parent_name, _parent_path = find_parent_path(name, path) + if repository_storage.is_a?(String) + error = "#{name} is not a valid storage, because it has no `path` key. " \ + "It may be configured as:\n\n#{name}:\n path: #{repository_storage}\n\n" \ + "Refer to gitlab.yml.example for an updated example" + + storage_validation_error(error) + end + + if !repository_storage.is_a?(Hash) || repository_storage['path'].nil? + storage_validation_error("#{name} is not a valid storage, because it has no `path` key. Refer to gitlab.yml.example for an updated example") + end + + parent_name, _parent_path = find_parent_path(name, repository_storage['path']) if parent_name storage_validation_error("#{name} is a nested path of #{parent_name}. Nested paths are not supported for repository storages") end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 88cd0f5f652..a5636765774 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -6,9 +6,14 @@ Doorkeeper.configure do # This block will be called to check whether the resource owner is authenticated or not. resource_owner_authenticator do # Put your resource owner authentication logic here. - # Ensure user is redirected to redirect_uri after login - session[:user_return_to] = request.fullpath - current_user || redirect_to(new_user_session_url) + if current_user + current_user + else + # Ensure user is redirected to redirect_uri after login + session[:user_return_to] = request.fullpath + redirect_to(new_user_session_url) + nil + end end resource_owner_from_credentials do |routes| diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb new file mode 100644 index 00000000000..700ca25b884 --- /dev/null +++ b/config/initializers/doorkeeper_openid_connect.rb @@ -0,0 +1,36 @@ +Doorkeeper::OpenidConnect.configure do + issuer Gitlab.config.gitlab.url + + jws_private_key Rails.application.secrets.jws_private_key + + resource_owner_from_access_token do |access_token| + User.active.find_by(id: access_token.resource_owner_id) + end + + auth_time_from_resource_owner do |user| + user.current_sign_in_at + end + + reauthenticate_resource_owner do |user, return_to| + store_location_for user, return_to + sign_out user + redirect_to new_user_session_url + end + + subject do |user| + # hash the user's ID with the Rails secret_key_base to avoid revealing it + Digest::SHA256.hexdigest "#{user.id}-#{Rails.application.secrets.secret_key_base}" + end + + claims do + with_options scope: :openid do |o| + o.claim(:name) { |user| user.name } + o.claim(:nickname) { |user| user.username } + o.claim(:email) { |user| user.public_email } + o.claim(:email_verified) { |user| true if user.public_email? } + o.claim(:website) { |user| user.full_website_url if user.website_url? } + o.claim(:profile) { |user| Rails.application.routes.url_helpers.user_url user } + o.claim(:picture) { |user| user.avatar_url } + end + end +end diff --git a/config/initializers/rspec_profiling.rb b/config/initializers/rspec_profiling.rb index 0ef9f51e5cf..ac353d14499 100644 --- a/config/initializers/rspec_profiling.rb +++ b/config/initializers/rspec_profiling.rb @@ -1,22 +1,41 @@ -module RspecProfilingConnection - def establish_connection - ::RspecProfiling::Collectors::PSQL::Result.establish_connection(ENV['RSPEC_PROFILING_POSTGRES_URL']) +module RspecProfilingExt + module PSQL + def establish_connection + ::RspecProfiling::Collectors::PSQL::Result.establish_connection(ENV['RSPEC_PROFILING_POSTGRES_URL']) + end end -end -module RspecProfilingGitBranchCi - def branch - ENV['CI_BUILD_REF_NAME'] || super + module Git + def branch + ENV['CI_BUILD_REF_NAME'] || super + end + end + + module Run + def example_finished(*args) + super + rescue => err + return if @already_logged_example_finished_error + + $stderr.puts "rspec_profiling couldn't collect an example: #{err}. Further warnings suppressed." + @already_logged_example_finished_error = true + end + + alias_method :example_passed, :example_finished + alias_method :example_failed, :example_finished end end if Rails.env.test? RspecProfiling.configure do |config| if ENV['RSPEC_PROFILING_POSTGRES_URL'] - RspecProfiling::Collectors::PSQL.prepend(RspecProfilingConnection) + RspecProfiling::Collectors::PSQL.prepend(RspecProfilingExt::PSQL) config.collector = RspecProfiling::Collectors::PSQL end end - RspecProfiling::VCS::Git.prepend(RspecProfilingGitBranchCi) if ENV.has_key?('CI') + if ENV.has_key?('CI') + RspecProfiling::VCS::Git.prepend(RspecProfilingExt::Git) + RspecProfiling::Run.prepend(RspecProfilingExt::Run) + end end diff --git a/config/initializers/secret_token.rb b/config/initializers/secret_token.rb index 291fa6c0abc..f9c1d2165d3 100644 --- a/config/initializers/secret_token.rb +++ b/config/initializers/secret_token.rb @@ -24,7 +24,8 @@ def create_tokens defaults = { secret_key_base: file_secret_key || generate_new_secure_token, otp_key_base: env_secret_key || file_secret_key || generate_new_secure_token, - db_key_base: generate_new_secure_token + db_key_base: generate_new_secure_token, + jws_private_key: generate_new_rsa_private_key } missing_secrets = set_missing_keys(defaults) @@ -41,6 +42,10 @@ def generate_new_secure_token SecureRandom.hex(64) end +def generate_new_rsa_private_key + OpenSSL::PKey::RSA.new(2048).to_pem +end + def warn_missing_secret(secret) warn "Missing Rails.application.secrets.#{secret} for #{Rails.env} environment. The secret will be generated and stored in config/secrets.yml." end diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index 1d728282d90..14d49885fb3 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -60,6 +60,7 @@ en: scopes: api: Access your API read_user: Read user information + openid: Authenticate using OpenID Connect flash: applications: diff --git a/config/routes.rb b/config/routes.rb index 06293316937..1a851da6203 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,8 @@ Rails.application.routes.draw do authorizations: 'oauth/authorizations' end + use_doorkeeper_openid_connect + # Autocomplete get '/autocomplete/users' => 'autocomplete#users' get '/autocomplete/users/:id' => 'autocomplete#user' diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 8e99239f350..486ce3c5c87 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -2,6 +2,11 @@ namespace :admin do resources :users, constraints: { id: /[a-zA-Z.\/0-9_\-]+/ } do resources :keys, only: [:show, :destroy] resources :identities, except: [:show] + resources :impersonation_tokens, only: [:index, :create] do + member do + put :revoke + end + end member do get :projects diff --git a/config/routes/project.rb b/config/routes/project.rb index f5cc99b6867..44b8ae7aedd 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -135,7 +135,11 @@ constraints(ProjectUrlConstrainer.new) do resources :protected_branches, only: [:index, :show, :create, :update, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex } resources :variables, only: [:index, :show, :update, :create, :destroy] - resources :triggers, only: [:index, :create, :destroy] + resources :triggers, only: [:index, :create, :edit, :update, :destroy] do + member do + post :take_ownership + end + end resources :pipelines, only: [:index, :new, :create, :show] do collection do @@ -155,6 +159,7 @@ constraints(ProjectUrlConstrainer.new) do member do post :stop get :terminal + get :metrics get '/terminal.ws/authorize', to: 'environments#terminal_websocket_authorize', constraints: { format: nil } end @@ -324,6 +329,7 @@ constraints(ProjectUrlConstrainer.new) do resource :members, only: [:show] resource :ci_cd, only: [:show], controller: 'ci_cd' resource :integrations, only: [:show] + resource :repository, only: [:show], controller: :repository end # Since both wiki and repository routing contains wildcard characters diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 824f99e687e..9d2066a6490 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -52,3 +52,4 @@ - [cronjob, 1] - [default, 1] - [pages, 1] + - [system_hook_push, 1] diff --git a/db/migrate/20140502125220_migrate_repo_size.rb b/db/migrate/20140502125220_migrate_repo_size.rb index e8de7ccf3db..66203486d53 100644 --- a/db/migrate/20140502125220_migrate_repo_size.rb +++ b/db/migrate/20140502125220_migrate_repo_size.rb @@ -8,7 +8,7 @@ class MigrateRepoSize < ActiveRecord::Migration project_data.each do |project| id = project['id'] namespace_path = project['namespace_path'] || '' - repos_path = Gitlab.config.gitlab_shell['repos_path'] || Gitlab.config.repositories.storages.default + repos_path = Gitlab.config.gitlab_shell['repos_path'] || Gitlab.config.repositories.storages.default['path'] path = File.join(repos_path, namespace_path, project['project_path'] + '.git') begin diff --git a/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb b/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb index 63f7392e54f..7a8ed99c68f 100644 --- a/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb +++ b/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb @@ -1,9 +1,15 @@ class AddIndexOnRequestedAtToMembers < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers + DOWNTIME = false + disable_ddl_transaction! - def change + def up add_concurrent_index :members, :requested_at end + + def down + remove_index :members, :requested_at if index_exists? :members, :requested_at + end end diff --git a/db/migrate/20160620115026_add_index_on_runners_locked.rb b/db/migrate/20160620115026_add_index_on_runners_locked.rb index dfa5110dea4..6ca486c63d1 100644 --- a/db/migrate/20160620115026_add_index_on_runners_locked.rb +++ b/db/migrate/20160620115026_add_index_on_runners_locked.rb @@ -4,9 +4,15 @@ class AddIndexOnRunnersLocked < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers + DOWNTIME = false + disable_ddl_transaction! - def change + def up add_concurrent_index :ci_runners, :locked end + + def down + remove_index :ci_runners, :locked if index_exists? :ci_runners, :locked + end end diff --git a/db/migrate/20160715134306_add_index_for_pipeline_user_id.rb b/db/migrate/20160715134306_add_index_for_pipeline_user_id.rb index 7c991c6d998..a05a4c679e3 100644 --- a/db/migrate/20160715134306_add_index_for_pipeline_user_id.rb +++ b/db/migrate/20160715134306_add_index_for_pipeline_user_id.rb @@ -1,9 +1,15 @@ class AddIndexForPipelineUserId < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers + DOWNTIME = false + disable_ddl_transaction! - def change + def up add_concurrent_index :ci_commits, :user_id end + + def down + remove_index :ci_commits, :user_id if index_exists? :ci_commits, :user_id + end end diff --git a/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb b/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb index a853de3abfb..3f074723b4a 100644 --- a/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb +++ b/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb @@ -5,8 +5,15 @@ class AddDeletedAtToNamespaces < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_column :namespaces, :deleted_at, :datetime + add_concurrent_index :namespaces, :deleted_at end + + def down + remove_index :namespaces, :deleted_at if index_exists? :namespaces, :deleted_at + + remove_column :namespaces, :deleted_at + end end diff --git a/db/migrate/20160808085602_add_index_for_build_token.rb b/db/migrate/20160808085602_add_index_for_build_token.rb index 10ef42afce1..6c5d7268e72 100644 --- a/db/migrate/20160808085602_add_index_for_build_token.rb +++ b/db/migrate/20160808085602_add_index_for_build_token.rb @@ -6,7 +6,11 @@ class AddIndexForBuildToken < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_index :ci_builds, :token, unique: true end + + def down + remove_index :ci_builds, :token, unique: true if index_exists? :ci_builds, :token, unique: true + end end diff --git a/db/migrate/20160819221631_add_index_to_note_discussion_id.rb b/db/migrate/20160819221631_add_index_to_note_discussion_id.rb index b6e8bb18e7b..8f693e97a58 100644 --- a/db/migrate/20160819221631_add_index_to_note_discussion_id.rb +++ b/db/migrate/20160819221631_add_index_to_note_discussion_id.rb @@ -8,7 +8,11 @@ class AddIndexToNoteDiscussionId < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_index :notes, :discussion_id end + + def down + remove_index :notes, :discussion_id if index_exists? :notes, :discussion_id + end end diff --git a/db/migrate/20160819232256_add_incoming_email_token_to_users.rb b/db/migrate/20160819232256_add_incoming_email_token_to_users.rb index f2cf956adc9..bcad3416d04 100644 --- a/db/migrate/20160819232256_add_incoming_email_token_to_users.rb +++ b/db/migrate/20160819232256_add_incoming_email_token_to_users.rb @@ -9,8 +9,15 @@ class AddIncomingEmailTokenToUsers < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_column :users, :incoming_email_token, :string + add_concurrent_index :users, :incoming_email_token end + + def down + remove_index :users, :incoming_email_token if index_exists? :users, :incoming_email_token + + remove_column :users, :incoming_email_token + end end diff --git a/db/migrate/20160919145149_add_group_id_to_labels.rb b/db/migrate/20160919145149_add_group_id_to_labels.rb index d10f3a6d104..828b6afddb1 100644 --- a/db/migrate/20160919145149_add_group_id_to_labels.rb +++ b/db/migrate/20160919145149_add_group_id_to_labels.rb @@ -5,9 +5,15 @@ class AddGroupIdToLabels < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_column :labels, :group_id, :integer add_foreign_key :labels, :namespaces, column: :group_id, on_delete: :cascade # rubocop: disable Migration/AddConcurrentForeignKey add_concurrent_index :labels, :group_id end + + def down + remove_index :labels, :group_id if index_exists? :labels, :group_id + remove_foreign_key :labels, :namespaces, column: :group_id + remove_column :labels, :group_id + end end diff --git a/db/migrate/20160920160832_add_index_to_labels_title.rb b/db/migrate/20160920160832_add_index_to_labels_title.rb index b5de552b98c..19f7b1076a7 100644 --- a/db/migrate/20160920160832_add_index_to_labels_title.rb +++ b/db/migrate/20160920160832_add_index_to_labels_title.rb @@ -5,7 +5,11 @@ class AddIndexToLabelsTitle < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_index :labels, :title end + + def down + remove_index :labels, :title if index_exists? :labels, :title + end end diff --git a/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb b/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb index 2abfe47b776..ad3eb4a26f9 100644 --- a/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb +++ b/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb @@ -25,9 +25,15 @@ class AddPipelineIdToMergeRequestMetrics < ActiveRecord::Migration # comments: # disable_ddl_transaction! - def change + def up add_column :merge_request_metrics, :pipeline_id, :integer - add_concurrent_index :merge_request_metrics, :pipeline_id add_foreign_key :merge_request_metrics, :ci_commits, column: :pipeline_id, on_delete: :cascade # rubocop: disable Migration/AddConcurrentForeignKey + add_concurrent_index :merge_request_metrics, :pipeline_id + end + + def down + remove_index :merge_request_metrics, :pipeline_id if index_exists? :merge_request_metrics, :pipeline_id + remove_foreign_key :merge_request_metrics, :ci_commits, column: :pipeline_id + remove_column :merge_request_metrics, :pipeline_id end end diff --git a/db/migrate/20161106185620_add_project_import_data_project_index.rb b/db/migrate/20161106185620_add_project_import_data_project_index.rb index 750a6a8c51e..94b8ddd46f5 100644 --- a/db/migrate/20161106185620_add_project_import_data_project_index.rb +++ b/db/migrate/20161106185620_add_project_import_data_project_index.rb @@ -6,7 +6,11 @@ class AddProjectImportDataProjectIndex < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_index :project_import_data, :project_id end + + def down + remove_index :project_import_data, :project_id if index_exists? :project_import_data, :project_id + end end diff --git a/db/migrate/20161124111395_add_index_to_parent_id.rb b/db/migrate/20161124111395_add_index_to_parent_id.rb index eab74c01dfd..73f9d92bb22 100644 --- a/db/migrate/20161124111395_add_index_to_parent_id.rb +++ b/db/migrate/20161124111395_add_index_to_parent_id.rb @@ -8,7 +8,11 @@ class AddIndexToParentId < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_index(:namespaces, [:parent_id, :id], unique: true) end + + def down + remove_index :namespaces, [:parent_id, :id] if index_exists? :namespaces, [:parent_id, :id] + end end diff --git a/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb b/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb index 3e1f6b1627d..e5292cfba07 100644 --- a/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb +++ b/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb @@ -12,7 +12,7 @@ class MigrateProcessCommitWorkerJobs < ActiveRecord::Migration end def repository_storage_path - Gitlab.config.repositories.storages[repository_storage] + Gitlab.config.repositories.storages[repository_storage]['path'] end def repository_path diff --git a/db/migrate/20161202152035_add_index_to_routes.rb b/db/migrate/20161202152035_add_index_to_routes.rb index 4a51337bda6..6d6c8906204 100644 --- a/db/migrate/20161202152035_add_index_to_routes.rb +++ b/db/migrate/20161202152035_add_index_to_routes.rb @@ -9,8 +9,13 @@ class AddIndexToRoutes < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_index(:routes, :path, unique: true) add_concurrent_index(:routes, [:source_type, :source_id], unique: true) end + + def down + remove_index(:routes, :path) if index_exists? :routes, :path + remove_index(:routes, [:source_type, :source_id]) if index_exists? :routes, [:source_type, :source_id] + end end diff --git a/db/migrate/20161209153400_add_unique_index_for_environment_slug.rb b/db/migrate/20161209153400_add_unique_index_for_environment_slug.rb index e9fcef1cd45..d7ef1aa83d9 100644 --- a/db/migrate/20161209153400_add_unique_index_for_environment_slug.rb +++ b/db/migrate/20161209153400_add_unique_index_for_environment_slug.rb @@ -9,7 +9,11 @@ class AddUniqueIndexForEnvironmentSlug < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_index :environments, [:project_id, :slug], unique: true end + + def down + remove_index :environments, [:project_id, :slug], unique: true if index_exists? :environments, [:project_id, :slug] + end end diff --git a/db/migrate/20161209165216_create_doorkeeper_openid_connect_tables.rb b/db/migrate/20161209165216_create_doorkeeper_openid_connect_tables.rb new file mode 100644 index 00000000000..e63d5927f86 --- /dev/null +++ b/db/migrate/20161209165216_create_doorkeeper_openid_connect_tables.rb @@ -0,0 +1,37 @@ +class CreateDoorkeeperOpenidConnectTables < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + create_table :oauth_openid_requests do |t| + t.integer :access_grant_id, null: false + t.string :nonce, null: false + end + + if Gitlab::Database.postgresql? + # add foreign key without validation to avoid downtime on PostgreSQL, + # also see db/post_migrate/20170209140523_validate_foreign_keys_on_oauth_openid_requests.rb + execute %q{ + ALTER TABLE "oauth_openid_requests" + ADD CONSTRAINT "fk_oauth_openid_requests_oauth_access_grants_access_grant_id" + FOREIGN KEY ("access_grant_id") + REFERENCES "oauth_access_grants" ("id") + NOT VALID; + } + else + execute %q{ + ALTER TABLE oauth_openid_requests + ADD CONSTRAINT fk_oauth_openid_requests_oauth_access_grants_access_grant_id + FOREIGN KEY (access_grant_id) + REFERENCES oauth_access_grants (id); + } + end + end + + def down + drop_table :oauth_openid_requests + end +end diff --git a/db/migrate/20161220141214_remove_dot_git_from_group_names.rb b/db/migrate/20161220141214_remove_dot_git_from_group_names.rb index 241afc6b097..8fb1f9d5e73 100644 --- a/db/migrate/20161220141214_remove_dot_git_from_group_names.rb +++ b/db/migrate/20161220141214_remove_dot_git_from_group_names.rb @@ -60,7 +60,7 @@ class RemoveDotGitFromGroupNames < ActiveRecord::Migration def move_namespace(group_id, path_was, path) repository_storage_paths = select_all("SELECT distinct(repository_storage) FROM projects WHERE namespace_id = #{group_id}").map do |row| - Gitlab.config.repositories.storages[row['repository_storage']] + Gitlab.config.repositories.storages[row['repository_storage']]['path'] end.compact # Move the namespace directory in all storages paths used by member projects diff --git a/db/migrate/20161226122833_remove_dot_git_from_usernames.rb b/db/migrate/20161226122833_remove_dot_git_from_usernames.rb index a0ce927161f..61dcc8c54f5 100644 --- a/db/migrate/20161226122833_remove_dot_git_from_usernames.rb +++ b/db/migrate/20161226122833_remove_dot_git_from_usernames.rb @@ -71,7 +71,7 @@ class RemoveDotGitFromUsernames < ActiveRecord::Migration route_exists = route_exists?(path) Gitlab.config.repositories.storages.each_value do |storage| - if route_exists || path_exists?(path, storage) + if route_exists || path_exists?(path, storage['path']) counter += 1 path = "#{base}#{counter}" @@ -84,7 +84,7 @@ class RemoveDotGitFromUsernames < ActiveRecord::Migration def move_namespace(namespace_id, path_was, path) repository_storage_paths = select_all("SELECT distinct(repository_storage) FROM projects WHERE namespace_id = #{namespace_id}").map do |row| - Gitlab.config.repositories.storages[row['repository_storage']] + Gitlab.config.repositories.storages[row['repository_storage']]['path'] end.compact # Move the namespace directory in all storages paths used by member projects diff --git a/db/migrate/20161228124936_change_expires_at_to_date_in_personal_access_tokens.rb b/db/migrate/20161228124936_change_expires_at_to_date_in_personal_access_tokens.rb new file mode 100644 index 00000000000..af1bac897cc --- /dev/null +++ b/db/migrate/20161228124936_change_expires_at_to_date_in_personal_access_tokens.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class ChangeExpiresAtToDateInPersonalAccessTokens < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = true + DOWNTIME_REASON = 'This migration requires downtime because it alters expires_at column from datetime to date' + + def up + change_column :personal_access_tokens, :expires_at, :date + end + + def down + change_column :personal_access_tokens, :expires_at, :datetime + end +end diff --git a/db/migrate/20161228135550_add_impersonation_to_personal_access_tokens.rb b/db/migrate/20161228135550_add_impersonation_to_personal_access_tokens.rb new file mode 100644 index 00000000000..ea9caceaa2c --- /dev/null +++ b/db/migrate/20161228135550_add_impersonation_to_personal_access_tokens.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddImpersonationToPersonalAccessTokens < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def up + add_column_with_default :personal_access_tokens, :impersonation, :boolean, default: false, allow_null: false + end + + def down + remove_column :personal_access_tokens, :impersonation + end +end diff --git a/db/migrate/20170204181513_add_index_to_labels_for_type_and_project.rb b/db/migrate/20170204181513_add_index_to_labels_for_type_and_project.rb index 8f944930807..31ef458c44f 100644 --- a/db/migrate/20170204181513_add_index_to_labels_for_type_and_project.rb +++ b/db/migrate/20170204181513_add_index_to_labels_for_type_and_project.rb @@ -5,7 +5,11 @@ class AddIndexToLabelsForTypeAndProject < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_index :labels, [:type, :project_id] end + + def down + remove_index :labels, [:type, :project_id] if index_exists? :labels, [:type, :project_id] + end end diff --git a/db/migrate/20170210062829_add_index_to_labels_for_title_and_project.rb b/db/migrate/20170210062829_add_index_to_labels_for_title_and_project.rb index f922ed209aa..70fb0ef12f9 100644 --- a/db/migrate/20170210062829_add_index_to_labels_for_title_and_project.rb +++ b/db/migrate/20170210062829_add_index_to_labels_for_title_and_project.rb @@ -5,8 +5,13 @@ class AddIndexToLabelsForTitleAndProject < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_index :labels, :title add_concurrent_index :labels, :project_id end + + def down + remove_index :labels, :title if index_exists? :labels, :title + remove_index :labels, :project_id if index_exists? :labels, :project_id + end end diff --git a/db/migrate/20170210075922_add_index_to_ci_trigger_requests_for_commit_id.rb b/db/migrate/20170210075922_add_index_to_ci_trigger_requests_for_commit_id.rb index 61e49c14fc0..07d4f8af27f 100644 --- a/db/migrate/20170210075922_add_index_to_ci_trigger_requests_for_commit_id.rb +++ b/db/migrate/20170210075922_add_index_to_ci_trigger_requests_for_commit_id.rb @@ -5,7 +5,11 @@ class AddIndexToCiTriggerRequestsForCommitId < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_index :ci_trigger_requests, :commit_id end + + def down + remove_index :ci_trigger_requests, :commit_id if index_exists? :ci_trigger_requests, :commit_id + end end diff --git a/db/migrate/20170210103609_add_index_to_user_agent_detail.rb b/db/migrate/20170210103609_add_index_to_user_agent_detail.rb index c01753cfbd2..2d8329b7862 100644 --- a/db/migrate/20170210103609_add_index_to_user_agent_detail.rb +++ b/db/migrate/20170210103609_add_index_to_user_agent_detail.rb @@ -8,7 +8,11 @@ class AddIndexToUserAgentDetail < ActiveRecord::Migration disable_ddl_transaction! - def change - add_concurrent_index(:user_agent_details, [:subject_id, :subject_type]) + def up + add_concurrent_index :user_agent_details, [:subject_id, :subject_type] + end + + def down + remove_index :user_agent_details, [:subject_id, :subject_type] if index_exists? :user_agent_details, [:subject_id, :subject_type] end end diff --git a/db/migrate/20170216135621_add_index_for_latest_successful_pipeline.rb b/db/migrate/20170216135621_add_index_for_latest_successful_pipeline.rb index 7b1e687977b..65adc90c2c1 100644 --- a/db/migrate/20170216135621_add_index_for_latest_successful_pipeline.rb +++ b/db/migrate/20170216135621_add_index_for_latest_successful_pipeline.rb @@ -4,7 +4,11 @@ class AddIndexForLatestSuccessfulPipeline < ActiveRecord::Migration disable_ddl_transaction! - def change - add_concurrent_index(:ci_commits, [:gl_project_id, :ref, :status]) + def up + add_concurrent_index :ci_commits, [:gl_project_id, :ref, :status] + end + + def down + remove_index :ci_commits, [:gl_project_id, :ref, :status] if index_exists? :ci_commits, [:gl_project_id, :ref, :status] end end diff --git a/db/post_migrate/20170209140523_validate_foreign_keys_on_oauth_openid_requests.rb b/db/post_migrate/20170209140523_validate_foreign_keys_on_oauth_openid_requests.rb new file mode 100644 index 00000000000..e206f9af636 --- /dev/null +++ b/db/post_migrate/20170209140523_validate_foreign_keys_on_oauth_openid_requests.rb @@ -0,0 +1,20 @@ +class ValidateForeignKeysOnOauthOpenidRequests < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + if Gitlab::Database.postgresql? + execute %q{ + ALTER TABLE "oauth_openid_requests" + VALIDATE CONSTRAINT "fk_oauth_openid_requests_oauth_access_grants_access_grant_id"; + } + end + end + + def down + # noop + end +end diff --git a/db/schema.rb b/db/schema.rb index efb9eaa8d00..3ec5461f600 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -61,6 +61,7 @@ ActiveRecord::Schema.define(version: 20170306170512) do t.boolean "shared_runners_enabled", default: true, null: false t.integer "max_artifacts_size", default: 100, null: false t.string "runners_registration_token" + t.integer "max_pages_size", default: 100, null: false t.boolean "require_two_factor_authentication", default: false t.integer "two_factor_grace_period", default: 48 t.boolean "metrics_enabled", default: false @@ -109,7 +110,6 @@ ActiveRecord::Schema.define(version: 20170306170512) do t.boolean "html_emails_enabled", default: true t.string "plantuml_url" t.boolean "plantuml_enabled" - t.integer "max_pages_size", default: 100, null: false t.integer "terminal_max_session_time", default: 0, null: false t.string "default_artifacts_expire_in", default: "0", null: false t.integer "unique_ips_limit_per_user" @@ -773,8 +773,8 @@ ActiveRecord::Schema.define(version: 20170306170512) do t.integer "visibility_level", default: 20, null: false t.boolean "request_access_enabled", default: false, null: false t.datetime "deleted_at" - t.boolean "lfs_enabled" t.text "description_html" + t.boolean "lfs_enabled" t.integer "parent_id" end @@ -880,6 +880,11 @@ ActiveRecord::Schema.define(version: 20170306170512) do add_index "oauth_applications", ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type", using: :btree add_index "oauth_applications", ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree + create_table "oauth_openid_requests", force: :cascade do |t| + t.integer "access_grant_id", null: false + t.string "nonce", null: false + end + create_table "pages_domains", force: :cascade do |t| t.integer "project_id" t.text "certificate" @@ -896,10 +901,11 @@ ActiveRecord::Schema.define(version: 20170306170512) do t.string "token", null: false t.string "name", null: false t.boolean "revoked", default: false - t.datetime "expires_at" + t.date "expires_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "scopes", default: "--- []\n", null: false + t.boolean "impersonation", default: false, null: false end add_index "personal_access_tokens", ["token"], name: "index_personal_access_tokens_on_token", unique: true, using: :btree @@ -1376,6 +1382,7 @@ ActiveRecord::Schema.define(version: 20170306170512) do add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade + add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", name: "fk_oauth_openid_requests_oauth_access_grants_access_grant_id" add_foreign_key "personal_access_tokens", "users" add_foreign_key "project_authorizations", "projects", on_delete: :cascade add_foreign_key "project_authorizations", "users", on_delete: :cascade diff --git a/doc/administration/repository_storage_paths.md b/doc/administration/repository_storage_paths.md index d6aa6101026..55a45119525 100644 --- a/doc/administration/repository_storage_paths.md +++ b/doc/administration/repository_storage_paths.md @@ -52,9 +52,12 @@ respectively. # Paths where repositories can be stored. Give the canonicalized absolute pathname. # NOTE: REPOS PATHS MUST NOT CONTAIN ANY SYMLINK!!! storages: # You must have at least a 'default' storage path. - default: /home/git/repositories - nfs: /mnt/nfs/repositories - cephfs: /mnt/cephfs/repositories + default: + path: /home/git/repositories + nfs: + path: /mnt/nfs/repositories + cephfs: + path: /mnt/cephfs/repositories ``` 1. [Restart GitLab] for the changes to take effect. @@ -75,9 +78,9 @@ working, you can remove the `repos_path` line. ```ruby git_data_dirs({ - "default" => "/var/opt/gitlab/git-data", - "nfs" => "/mnt/nfs/git-data", - "cephfs" => "/mnt/cephfs/git-data" + "default" => { "path" => "/var/opt/gitlab/git-data" }, + "nfs" => { "path" => "/mnt/nfs/git-data" }, + "cephfs" => { "path" => "/mnt/cephfs/git-data" } }) ``` diff --git a/doc/api/README.md b/doc/api/README.md index 45048294298..58d090b8f5e 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -221,6 +221,14 @@ GET /projects?private_token=9koXpg98eAheJpvBs5tK&sudo=23 curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "SUDO: 23" "https://gitlab.example.com/api/v4/projects" ``` +## Impersonation Tokens + +Impersonation Tokens are a type of Personal Access Token that can only be created by an admin for a specific user. These can be used by automated tools +to authenticate with the API as a specific user, as a better alternative to using the user's password or private token directly, which may change over time, +and to using the [Sudo](#sudo) feature, which requires the tool to know an admin's password or private token, which can change over time as well and are extremely powerful. + +For more information about the usage please refer to the [Users](users.md) page + ## Pagination Sometimes the returned result will span across many pages. When listing diff --git a/doc/api/award_emoji.md b/doc/api/award_emoji.md index 3470f8ce497..f57928d3c93 100644 --- a/doc/api/award_emoji.md +++ b/doc/api/award_emoji.md @@ -14,17 +14,17 @@ requests, snippets, and notes/comments. Issues, merge requests, snippets, and no Gets a list of all award emoji ``` -GET /projects/:id/issues/:issue_id/award_emoji -GET /projects/:id/merge_requests/:merge_request_id/award_emoji +GET /projects/:id/issues/:issue_iid/award_emoji +GET /projects/:id/merge_requests/:merge_request_iid/award_emoji GET /projects/:id/snippets/:snippet_id/award_emoji ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `awardable_id` | integer | yes | The ID of an awardable | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `awardable_id` | integer | yes | The ID (`iid` for merge requests/issues, `id` for snippets) of an awardable | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/issues/80/award_emoji @@ -74,18 +74,18 @@ Example Response: Gets a single award emoji from an issue, snippet, or merge request. ``` -GET /projects/:id/issues/:issue_id/award_emoji/:award_id -GET /projects/:id/merge_requests/:merge_request_id/award_emoji/:award_id +GET /projects/:id/issues/:issue_iid/award_emoji/:award_id +GET /projects/:id/merge_requests/:merge_request_iid/award_emoji/:award_id GET /projects/:id/snippets/:snippet_id/award_emoji/:award_id ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `awardable_id` | integer | yes | The ID of an awardable | -| `award_id` | integer | yes | The ID of the award emoji | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `awardable_id` | integer | yes | The ID (`iid` for merge requests/issues, `id` for snippets) of an awardable | +| `award_id` | integer | yes | The ID of the award emoji | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/issues/80/award_emoji/1 @@ -117,18 +117,18 @@ Example Response: This end point creates an award emoji on the specified resource ``` -POST /projects/:id/issues/:issue_id/award_emoji -POST /projects/:id/merge_requests/:merge_request_id/award_emoji +POST /projects/:id/issues/:issue_iid/award_emoji +POST /projects/:id/merge_requests/:merge_request_iid/award_emoji POST /projects/:id/snippets/:snippet_id/award_emoji ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `awardable_id` | integer | yes | The ID of an awardable | -| `name` | string | yes | The name of the emoji, without colons | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `awardable_id` | integer | yes | The ID (`iid` for merge requests/issues, `id` for snippets) of an awardable | +| `name` | string | yes | The name of the emoji, without colons | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/issues/80/award_emoji?name=blowfish @@ -161,18 +161,18 @@ Sometimes its just not meant to be, and you'll have to remove your award. Only a admins or the author of the award. ``` -DELETE /projects/:id/issues/:issue_id/award_emoji/:award_id -DELETE /projects/:id/merge_requests/:merge_request_id/award_emoji/:award_id +DELETE /projects/:id/issues/:issue_iid/award_emoji/:award_id +DELETE /projects/:id/merge_requests/:merge_request_iid/award_emoji/:award_id DELETE /projects/:id/snippets/:snippet_id/award_emoji/:award_id ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of an issue | -| `award_id` | integer | yes | The ID of a award_emoji | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of an issue | +| `award_id` | integer | yes | The ID of a award_emoji | ```bash curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/issues/80/award_emoji/344 @@ -188,16 +188,16 @@ easily adapted for notes on a Merge Request. ### List a note's award emoji ``` -GET /projects/:id/issues/:issue_id/notes/:note_id/award_emoji +GET /projects/:id/issues/:issue_iid/notes/:note_id/award_emoji ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of an issue | -| `note_id` | integer | yes | The ID of an note | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of an issue | +| `note_id` | integer | yes | The ID of an note | ```bash @@ -230,17 +230,17 @@ Example Response: ### Get single note's award emoji ``` -GET /projects/:id/issues/:issue_id/notes/:note_id/award_emoji/:award_id +GET /projects/:id/issues/:issue_iid/notes/:note_id/award_emoji/:award_id ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of an issue | -| `note_id` | integer | yes | The ID of a note | -| `award_id` | integer | yes | The ID of the award emoji | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of an issue | +| `note_id` | integer | yes | The ID of a note | +| `award_id` | integer | yes | The ID of the award emoji | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/issues/80/notes/1/award_emoji/2 @@ -270,17 +270,17 @@ Example Response: ### Award a new emoji on a note ``` -POST /projects/:id/issues/:issue_id/notes/:note_id/award_emoji +POST /projects/:id/issues/:issue_iid/notes/:note_id/award_emoji ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of an issue | -| `note_id` | integer | yes | The ID of a note | -| `name` | string | yes | The name of the emoji, without colons | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of an issue | +| `note_id` | integer | yes | The ID of a note | +| `name` | string | yes | The name of the emoji, without colons | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/issues/80/notes/1/award_emoji?name=rocket @@ -313,17 +313,17 @@ Sometimes its just not meant to be, and you'll have to remove your award. Only a admins or the author of the award. ``` -DELETE /projects/:id/issues/:issue_id/notes/:note_id/award_emoji/:award_id +DELETE /projects/:id/issues/:issue_iid/notes/:note_id/award_emoji/:award_id ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of an issue | -| `note_id` | integer | yes | The ID of a note | -| `award_id` | integer | yes | The ID of a award_emoji | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of an issue | +| `note_id` | integer | yes | The ID of a note | +| `award_id` | integer | yes | The ID of a award_emoji | ```bash curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/issues/80/award_emoji/345 diff --git a/doc/api/issues.md b/doc/api/issues.md index 4047ff14af2..e25841926f8 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -261,13 +261,13 @@ Example response: Get a single project issue. ``` -GET /projects/:id/issues/:issue_id +GET /projects/:id/issues/:issue_iid ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id`| integer | yes | The ID of a project's issue | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues/41 @@ -385,22 +385,22 @@ Updates an existing project issue. This call is also used to mark an issue as closed. ``` -PUT /projects/:id/issues/:issue_id +PUT /projects/:id/issues/:issue_iid ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | -| `title` | string | no | The title of an issue | -| `description` | string | no | The description of an issue | -| `confidential` | boolean | no | Updates an issue to be confidential | -| `assignee_id` | integer | no | The ID of a user to assign the issue to | -| `milestone_id` | integer | no | The ID of a milestone to assign the issue to | -| `labels` | string | no | Comma-separated label names for an issue | -| `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it | -| `updated_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | -| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | +| `title` | string | no | The title of an issue | +| `description` | string | no | The description of an issue | +| `confidential` | boolean | no | Updates an issue to be confidential | +| `assignee_id` | integer | no | The ID of a user to assign the issue to | +| `milestone_id` | integer | no | The ID of a milestone to assign the issue to | +| `labels` | string | no | Comma-separated label names for an issue | +| `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it | +| `updated_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | +| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | ```bash curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues/85?state_event=close @@ -444,13 +444,13 @@ Example response: Only for admins and project owners. Soft deletes the issue in question. ``` -DELETE /projects/:id/issues/:issue_id +DELETE /projects/:id/issues/:issue_iid ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | ```bash curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues/85 @@ -466,14 +466,14 @@ If a given label and/or milestone with the same name also exists in the target project, it will then be assigned to the issue that is being moved. ``` -POST /projects/:id/issues/:issue_id/move +POST /projects/:id/issues/:issue_iid/move ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | -| `to_project_id` | integer | yes | The ID of the new project | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | +| `to_project_id` | integer | yes | The ID of the new project | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues/85/move @@ -522,13 +522,13 @@ If the user is already subscribed to the issue, the status code `304` is returned. ``` -POST /projects/:id/issues/:issue_id/subscribe +POST /projects/:id/issues/:issue_iid/subscribe ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/subscribe @@ -577,13 +577,13 @@ from it. If the user is not subscribed to the issue, the status code `304` is returned. ``` -POST /projects/:id/issues/:issue_id/unsubscribe +POST /projects/:id/issues/:issue_iid/unsubscribe ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/unsubscribe @@ -596,13 +596,13 @@ there already exists a todo for the user on that issue, status code `304` is returned. ``` -POST /projects/:id/issues/:issue_id/todo +POST /projects/:id/issues/:issue_iid/todo ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/todo @@ -687,14 +687,14 @@ Example response: Sets an estimated time of work for this issue. ``` -POST /projects/:id/issues/:issue_id/time_estimate +POST /projects/:id/issues/:issue_iid/time_estimate ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | -| `duration` | string | yes | The duration in human format. e.g: 3h30m | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | +| `duration` | string | yes | The duration in human format. e.g: 3h30m | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/time_estimate?duration=3h30m @@ -716,13 +716,13 @@ Example response: Resets the estimated time for this issue to 0 seconds. ``` -POST /projects/:id/issues/:issue_id/reset_time_estimate +POST /projects/:id/issues/:issue_iid/reset_time_estimate ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/reset_time_estimate @@ -744,14 +744,14 @@ Example response: Adds spent time for this issue ``` -POST /projects/:id/issues/:issue_id/add_spent_time +POST /projects/:id/issues/:issue_iid/add_spent_time ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | -| `duration` | string | yes | The duration in human format. e.g: 3h30m | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | +| `duration` | string | yes | The duration in human format. e.g: 3h30m | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/add_spent_time?duration=1h @@ -773,13 +773,13 @@ Example response: Resets the total spent time for this issue to 0 seconds. ``` -POST /projects/:id/issues/:issue_id/reset_spent_time +POST /projects/:id/issues/:issue_iid/reset_spent_time ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/reset_spent_time @@ -799,13 +799,13 @@ Example response: ## Get time tracking stats ``` -GET /projects/:id/issues/:issue_id/time_stats +GET /projects/:id/issues/:issue_iid/time_stats ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_id` | integer | yes | The ID of a project's issue | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `issue_iid` | integer | yes | The internal ID of a project's issue | ```bash curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/time_stats diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 09d23cd2ff6..2e0545da1c4 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -82,13 +82,13 @@ Parameters: Shows information about a single merge request. ``` -GET /projects/:id/merge_requests/:merge_request_id +GET /projects/:id/merge_requests/:merge_request_iid ``` Parameters: - `id` (required) - The ID of a project -- `merge_request_id` (required) - The ID of MR +- `merge_request_iid` (required) - The internal ID of the merge request ```json { @@ -150,13 +150,13 @@ Parameters: Get a list of merge request commits. ``` -GET /projects/:id/merge_requests/:merge_request_id/commits +GET /projects/:id/merge_requests/:merge_request_iid/commits ``` Parameters: - `id` (required) - The ID of a project -- `merge_request_id` (required) - The ID of MR +- `merge_request_iid` (required) - The internal ID of the merge request ```json @@ -187,13 +187,13 @@ Parameters: Shows information about the merge request including its files and changes. ``` -GET /projects/:id/merge_requests/:merge_request_id/changes +GET /projects/:id/merge_requests/:merge_request_iid/changes ``` Parameters: - `id` (required) - The ID of a project -- `merge_request_id` (required) - The ID of MR +- `merge_request_iid` (required) - The internal ID of the merge request ```json { @@ -269,18 +269,18 @@ Creates a new merge request. POST /projects/:id/merge_requests ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | string | yes | The ID of a project | -| `source_branch` | string | yes | The source branch | -| `target_branch` | string | yes | The target branch | -| `title` | string | yes | Title of MR | -| `assignee_id` | integer | no | Assignee user ID | -| `description` | string | no | Description of MR | -| `target_project_id` | integer | no | The target project (numeric id) | -| `labels` | string | no | Labels for MR as a comma-separated list | -| `milestone_id` | integer | no | The ID of a milestone | -| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | yes | The ID of a project | +| `source_branch` | string | yes | The source branch | +| `target_branch` | string | yes | The target branch | +| `title` | string | yes | Title of MR | +| `assignee_id` | integer | no | Assignee user ID | +| `description` | string | no | Description of MR | +| `target_project_id` | integer | no | The target project (numeric id) | +| `labels` | string | no | Labels for MR as a comma-separated list | +| `milestone_id` | integer | no | The ID of a milestone | +| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | ```json { @@ -342,21 +342,21 @@ POST /projects/:id/merge_requests Updates an existing merge request. You can change the target branch, title, or even close the MR. ``` -PUT /projects/:id/merge_requests/:merge_request_id +PUT /projects/:id/merge_requests/:merge_request_iid ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | string | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of a merge request | -| `target_branch` | string | no | The target branch | -| `title` | string | no | Title of MR | -| `assignee_id` | integer | no | Assignee user ID | -| `description` | string | no | Description of MR | -| `state_event` | string | no | New state (close/reopen) | -| `labels` | string | no | Labels for MR as a comma-separated list | -| `milestone_id` | integer | no | The ID of a milestone | -| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | string | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The ID of a merge request | +| `target_branch` | string | no | The target branch | +| `title` | string | no | Title of MR | +| `assignee_id` | integer | no | Assignee user ID | +| `description` | string | no | Description of MR | +| `state_event` | string | no | New state (close/reopen) | +| `labels` | string | no | Labels for MR as a comma-separated list | +| `milestone_id` | integer | no | The ID of a milestone | +| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | Must include at least one non-required attribute from above. @@ -419,13 +419,13 @@ Must include at least one non-required attribute from above. Only for admins and project owners. Soft deletes the merge request in question. ``` -DELETE /projects/:id/merge_requests/:merge_request_id +DELETE /projects/:id/merge_requests/:merge_request_iid ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of a project's merge request | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | ```bash curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/merge_requests/85 @@ -445,13 +445,13 @@ If the `sha` parameter is passed and does not match the HEAD of the source - you If you don't have permissions to accept this merge request - you'll get a `401` ``` -PUT /projects/:id/merge_requests/:merge_request_id/merge +PUT /projects/:id/merge_requests/:merge_request_iid/merge ``` Parameters: - `id` (required) - The ID of a project -- `merge_request_id` (required) - ID of MR +- `merge_request_iid` (required) - Internal ID of MR - `merge_commit_message` (optional) - Custom merge commit message - `should_remove_source_branch` (optional) - if `true` removes the source branch - `merge_when_pipeline_succeeds` (optional) - if `true` the MR is merged when the pipeline succeeds @@ -520,12 +520,12 @@ If the merge request is already merged or closed - you get `405` and error messa In case the merge request is not set to be merged when the pipeline succeeds, you'll also get a `406` error. ``` -PUT /projects/:id/merge_requests/:merge_request_id/cancel_merge_when_pipeline_succeeds +PUT /projects/:id/merge_requests/:merge_request_iid/cancel_merge_when_pipeline_succeeds ``` Parameters: - `id` (required) - The ID of a project -- `merge_request_id` (required) - ID of MR +- `merge_request_iid` (required) - Internal ID of MR ```json { @@ -591,13 +591,13 @@ Comments are done via the [notes](notes.md) resource. Get all the issues that would be closed by merging the provided merge request. ``` -GET /projects/:id/merge_requests/:merge_request_id/closes_issues +GET /projects/:id/merge_requests/:merge_request_iid/closes_issues ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of the merge request | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/76/merge_requests/1/closes_issues @@ -666,13 +666,13 @@ Subscribes the authenticated user to a merge request to receive notification. If status code `304` is returned. ``` -POST /projects/:id/merge_requests/:merge_request_id/subscribe +POST /projects/:id/merge_requests/:merge_request_iid/subscribe ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of the merge request | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/17/subscribe @@ -740,13 +740,13 @@ notifications from that merge request. If the user is not subscribed to the merge request, the status code `304` is returned. ``` -POST /projects/:id/merge_requests/:merge_request_id/unsubscribe +POST /projects/:id/merge_requests/:merge_request_iid/unsubscribe ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of the merge request | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/17/unsubscribe @@ -814,13 +814,13 @@ If there already exists a todo for the user on that merge request, status code `304` is returned. ``` -POST /projects/:id/merge_requests/:merge_request_id/todo +POST /projects/:id/merge_requests/:merge_request_iid/todo ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of the merge request | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/27/todo @@ -914,13 +914,13 @@ Example response: Get a list of merge request diff versions. ``` -GET /projects/:id/merge_requests/:merge_request_id/versions +GET /projects/:id/merge_requests/:merge_request_iid/versions ``` -| Attribute | Type | Required | Description | -| --------- | ------- | -------- | --------------------- | -| `id` | String | yes | The ID of the project | -| `merge_request_id` | integer | yes | The ID of the merge request | +| Attribute | Type | Required | Description | +| --------- | ------- | -------- | --------------------- | +| `id` | String | yes | The ID of the project | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/merge_requests/1/versions @@ -955,14 +955,14 @@ Example response: Get a single merge request diff version. ``` -GET /projects/:id/merge_requests/:merge_request_id/versions/:version_id +GET /projects/:id/merge_requests/:merge_request_iid/versions/:version_id ``` -| Attribute | Type | Required | Description | -| --------- | ------- | -------- | --------------------- | -| `id` | String | yes | The ID of the project | -| `merge_request_id` | integer | yes | The ID of the merge request | -| `version_id` | integer | yes | The ID of the merge request diff version | +| Attribute | Type | Required | Description | +| --------- | ------- | -------- | --------------------- | +| `id` | String | yes | The ID of the project | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | +| `version_id` | integer | yes | The ID of the merge request diff version | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/merge_requests/1/versions/1 @@ -1022,14 +1022,14 @@ Example response: Sets an estimated time of work for this merge request. ``` -POST /projects/:id/merge_requests/:merge_request_id/time_estimate +POST /projects/:id/merge_requests/:merge_request_iid/time_estimate ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of a project's merge request | -| `duration` | string | yes | The duration in human format. e.g: 3h30m | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | +| `duration` | string | yes | The duration in human format. e.g: 3h30m | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/93/time_estimate?duration=3h30m @@ -1051,13 +1051,13 @@ Example response: Resets the estimated time for this merge request to 0 seconds. ``` -POST /projects/:id/merge_requests/:merge_request_id/reset_time_estimate +POST /projects/:id/merge_requests/:merge_request_iid/reset_time_estimate ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of a project's merge_request | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The internal ID of a project's merge_request | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/93/reset_time_estimate @@ -1079,14 +1079,14 @@ Example response: Adds spent time for this merge request ``` -POST /projects/:id/merge_requests/:merge_request_id/add_spent_time +POST /projects/:id/merge_requests/:merge_request_iid/add_spent_time ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of a project's merge request | -| `duration` | string | yes | The duration in human format. e.g: 3h30m | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | +| `duration` | string | yes | The duration in human format. e.g: 3h30m | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/93/add_spent_time?duration=1h @@ -1108,13 +1108,13 @@ Example response: Resets the total spent time for this merge request to 0 seconds. ``` -POST /projects/:id/merge_requests/:merge_request_id/reset_spent_time +POST /projects/:id/merge_requests/:merge_request_iid/reset_spent_time ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of a project's merge_request | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The internal ID of a project's merge_request | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/93/reset_spent_time @@ -1134,13 +1134,13 @@ Example response: ## Get time tracking stats ``` -GET /projects/:id/merge_requests/:merge_request_id/time_stats +GET /projects/:id/merge_requests/:merge_request_iid/time_stats ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `merge_request_id` | integer | yes | The ID of a project's merge request | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `merge_request_iid` | integer | yes | The internal ID of the merge request | ```bash curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/93/time_stats diff --git a/doc/api/settings.md b/doc/api/settings.md index 38a37cd920c..ad975e2e325 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -20,7 +20,7 @@ Example response: ```json { - "default_projects_limit" : 10, + "default_projects_limit" : 100000, "signup_enabled" : true, "id" : 1, "default_branch_protection" : 2, @@ -60,7 +60,7 @@ PUT /application/settings | Attribute | Type | Required | Description | | --------- | ---- | :------: | ----------- | -| `default_projects_limit` | integer | no | Project limit per user. Default is `10` | +| `default_projects_limit` | integer | no | Project limit per user. Default is `100000` | | `signup_enabled` | boolean | no | Enable registration. Default is `true`. | | `signin_enabled` | boolean | no | Enable login via a GitLab account. Default is `true`. | | `gravatar_enabled` | boolean | no | Enable Gravatar | @@ -98,7 +98,7 @@ Example response: ```json { "id": 1, - "default_projects_limit": 10, + "default_projects_limit": 100000, "signup_enabled": true, "signin_enabled": true, "gravatar_enabled": true, diff --git a/doc/api/users.md b/doc/api/users.md index 95f6bcfccb6..14b5c6c713e 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -827,3 +827,99 @@ Example response: } ] ``` + +## Retrieve user impersonation tokens + +It retrieves every impersonation token of the user. Note that only administrators can do this. +This function takes pagination parameters `page` and `per_page` to restrict the list of impersonation tokens. + +``` +GET /users/:user_id/impersonation_tokens +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `user_id` | integer | yes | The ID of the user | +| `state` | string | no | filter tokens based on state (all, active, inactive) | + +Example response: +```json +[ + { + "id": 1, + "name": "mytoken", + "revoked": false, + "expires_at": "2017-01-04", + "scopes": ['api'], + "active": true, + "impersonation": true, + "token": "9koXpg98eAheJpvBs5tK" + } +] +``` + +## Show a user's impersonation token + +It shows a user's impersonation token. Note that only administrators can do this. + +``` +GET /users/:user_id/impersonation_tokens/:impersonation_token_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `user_id` | integer | yes | The ID of the user | +| `impersonation_token_id` | integer | yes | The ID of the impersonation token | + +## Create a impersonation token + +It creates a new impersonation token. Note that only administrators can do this. +You are only able to create impersonation tokens to impersonate the user and perform +both API calls and Git reads and writes. The user will not see these tokens in his profile +settings page. + +``` +POST /users/:user_id/impersonation_tokens +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `user_id` | integer | yes | The ID of the user | +| `name` | string | yes | The name of the impersonation token | +| `expires_at` | date | no | The expiration date of the impersonation token | +| `scopes` | array | no | The array of scopes of the impersonation token (api, read_user) | + +Example response: +```json +{ + "id": 1, + "name": "mytoken", + "revoked": false, + "expires_at": "2017-01-04", + "scopes": ['api'], + "active": true, + "impersonation": true, + "token": "9koXpg98eAheJpvBs5tK" +} +``` + +## Revoke an impersonation token + +It revokes an impersonation token. Note that only administrators can revoke impersonation tokens. + +``` +DELETE /users/:user_id/impersonation_tokens/:impersonation_token_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `user_id` | integer | yes | The ID of the user | +| `impersonation_token_id` | integer | yes | The ID of the impersonation token | diff --git a/doc/api/v3_to_v4.md b/doc/api/v3_to_v4.md index 5af775860ca..f42a5e9158b 100644 --- a/doc/api/v3_to_v4.md +++ b/doc/api/v3_to_v4.md @@ -69,4 +69,5 @@ changes are in V4: - `POST /projects/:id/trigger/builds` to `POST /projects/:id/trigger/pipeline` - Require description when creating a new trigger `POST /projects/:id/triggers` - Simplify project payload exposed on Environment endpoints [!9675](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9675) - +- API uses merge request `IID`s (internal ID, as in the web UI) rather than `ID`s. This affects the merge requests, award emoji, todos, and time tracking APIs. [!9530](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9530) +- API uses issue `IID`s (internal ID, as in the web UI) rather than `ID`s. This affects the issues, award emoji, todos, and time tracking APIs. [!9530](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9530) diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index a9e25187b88..4c3e7c4e86e 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -35,17 +35,28 @@ version of Runner required. | **CI_SERVER_NAME** | all | all | The name of CI server that is used to coordinate jobs | | **CI_SERVER_VERSION** | all | all | GitLab version that is used to schedule jobs | | **CI_SERVER_REVISION** | all | all | GitLab revision that is used to schedule jobs | -| **CI_BUILD_ID** | all | all | The unique id of the current job that GitLab CI uses internally | -| **CI_BUILD_REF** | all | all | The commit revision for which project is built | -| **CI_BUILD_TAG** | all | 0.5 | The commit tag name. Present only when building tags. | -| **CI_BUILD_NAME** | all | 0.5 | The name of the job as defined in `.gitlab-ci.yml` | -| **CI_BUILD_STAGE** | all | 0.5 | The name of the stage as defined in `.gitlab-ci.yml` | -| **CI_BUILD_REF_NAME** | all | all | The branch or tag name for which project is built | -| **CI_BUILD_REF_SLUG** | 8.15 | all | `$CI_BUILD_REF_NAME` lowercased, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. | -| **CI_BUILD_REPO** | all | all | The URL to clone the Git repository | -| **CI_BUILD_TRIGGERED** | all | 0.5 | The flag to indicate that job was [triggered] | -| **CI_BUILD_MANUAL** | 8.12 | all | The flag to indicate that job was manually started | -| **CI_BUILD_TOKEN** | all | 1.2 | Token used for authenticating with the GitLab Container Registry | +| **CI_BUILD_ID** | all | all | The unique id of the current job that GitLab CI uses internally. Deprecated, use CI_JOB_ID | +| **CI_JOB_ID** | 9.0 | all | The unique id of the current job that GitLab CI uses internally | +| **CI_BUILD_REF** | all | all | The commit revision for which project is built. Deprecated, use CI_COMMIT_REF | +| **CI_COMMIT_SHA** | 9.0 | all | The commit revision for which project is built | +| **CI_BUILD_TAG** | all | 0.5 | The commit tag name. Present only when building tags. Deprecated, use CI_COMMIT_TAG | +| **CI_COMMIT_TAG** | 9.0 | 0.5 | The commit tag name. Present only when building tags. | +| **CI_BUILD_NAME** | all | 0.5 | The name of the job as defined in `.gitlab-ci.yml`. Deprecated, use CI_JOB_NAME | +| **CI_JOB_NAME** | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` | +| **CI_BUILD_STAGE** | all | 0.5 | The name of the stage as defined in `.gitlab-ci.yml`. Deprecated, use CI_JOB_STAGE | +| **CI_JOB_STAGE** | 9.0 | 0.5 | The name of the stage as defined in `.gitlab-ci.yml` | +| **CI_BUILD_REF_NAME** | all | all | The branch or tag name for which project is built. Deprecated, use CI_COMMIT_REF_NAME | +| **CI_COMMIT_REF_NAME** | 9.0 | all | The branch or tag name for which project is built | +| **CI_BUILD_REF_SLUG** | 8.15 | all | `$CI_COMMIT_REF_NAME` lowercased, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. Deprecated, use CI_COMMIT_REF_SLUG | +| **CI_COMMIT_REF_SLUG** | 9.0 | all | `$CI_COMMIT_REF_NAME` lowercased, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. | +| **CI_BUILD_REPO** | all | all | The URL to clone the Git repository. Deprecated, use CI_REPOSITORY | +| **CI_REPOSITORY_URL** | 9.0 | all | The URL to clone the Git repository | +| **CI_BUILD_TRIGGERED** | all | 0.5 | The flag to indicate that job was [triggered]. Deprecated, use CI_PIPELINE_TRIGGERED | +| **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] | +| **CI_BUILD_MANUAL** | 8.12 | all | The flag to indicate that job was manually started. Deprecated, use CI_JOB_MANUAL | +| **CI_JOB_MANUAL** | 8.12 | all | The flag to indicate that job was manually started | +| **CI_BUILD_TOKEN** | all | 1.2 | Token used for authenticating with the GitLab Container Registry. Deprecated, use CI_JOB_TOKEN | +| **CI_JOB_TOKEN** | 9.0 | 1.2 | Token used for authenticating with the GitLab Container Registry | | **CI_PIPELINE_ID** | 8.10 | 0.5 | The unique id of the current pipeline that GitLab CI uses internally | | **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally | | **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built | @@ -66,21 +77,22 @@ version of Runner required. | **RESTORE_CACHE_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to restore the cache running a job | | **GITLAB_USER_ID** | 8.12 | all | The id of the user who started the job | | **GITLAB_USER_EMAIL** | 8.12 | all | The email of the user who started the job | - +| **CI_REGISTRY_USER** | 9.0 | all | The username to use to push containers to the GitLab Container Registry | +| **CI_REGISTRY_PASSWORD** | 9.0 | all | The password to use to push containers to the GitLab Container Registry | Example values: ```bash -export CI_BUILD_ID="50" -export CI_BUILD_REF="1ecfd275763eff1d6b4844ea3168962458c9f27a" -export CI_BUILD_REF_NAME="master" -export CI_BUILD_REPO="https://gitab-ci-token:abcde-1234ABCD5678ef@example.com/gitlab-org/gitlab-ce.git" -export CI_BUILD_TAG="1.0.0" -export CI_BUILD_NAME="spec:other" -export CI_BUILD_STAGE="test" -export CI_BUILD_MANUAL="true" -export CI_BUILD_TRIGGERED="true" -export CI_BUILD_TOKEN="abcde-1234ABCD5678ef" +export CI_JOB_ID="50" +export CI_COMMIT_SHA="1ecfd275763eff1d6b4844ea3168962458c9f27a" +export CI_COMMIT_REF_NAME="master" +export CI_REPOSITORY="https://gitab-ci-token:abcde-1234ABCD5678ef@example.com/gitlab-org/gitlab-ce.git" +export CI_COMMIT_TAG="1.0.0" +export CI_JOB_NAME="spec:other" +export CI_JOB_STAGE="test" +export CI_JOB_MANUAL="true" +export CI_JOB_TRIGGERED="true" +export CI_JOB_TOKEN="abcde-1234ABCD5678ef" export CI_PIPELINE_ID="1000" export CI_PROJECT_ID="34" export CI_PROJECT_DIR="/builds/gitlab-org/gitlab-ce" @@ -99,8 +111,30 @@ export CI_SERVER_REVISION="70606bf" export CI_SERVER_VERSION="8.9.0" export GITLAB_USER_ID="42" export GITLAB_USER_EMAIL="user@example.com" +export CI_REGISTRY_USER="gitlab-ci-token" +export CI_REGISTRY_PASSWORD="longalfanumstring" ``` +## 9.0 Renaming + +To follow conventions of naming across GitLab, and to futher move away from the +`build` term and toward `job` CI variables have been renamed for the 9.0 +release. + +| 8.X name | 9.0 name | +|----------|----------| +| CI_BUILD_ID | CI_JOB_ID | +| CI_BUILD_REF | CI_COMMIT_SHA | +| CI_BUILD_TAG | CI_COMMIT_TAG | +| CI_BUILD_REF_NAME | CI_COMMIT_REF_NAME | +| CI_BUILD_REF_SLUG | CI_COMMIT_REF_SLUG | +| CI_BUILD_NAME | CI_JOB_NAME | +| CI_BUILD_STAGE | CI_JOB_STAGE | +| CI_BUILD_REPO | CI_REPOSITORY | +| CI_BUILD_TRIGGERED | CI_PIPELINE_TRIGGERED | +| CI_BUILD_MANUAL | CI_JOB_MANUAL | +| CI_BUILD_TOKEN | CI_JOB_TOKEN | + ## `.gitlab-ci.yaml` defined variables >**Note:** diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index b25ccd4376e..49fa8761e5e 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -70,7 +70,7 @@ There are a few reserved `keywords` that **cannot** be used as job names: | image | no | Use docker image, covered in [Use Docker](../docker/README.md) | | services | no | Use docker services, covered in [Use Docker](../docker/README.md) | | stages | no | Define build stages | -| types | no | Alias for `stages` | +| types | no | Alias for `stages` (deprecated) | | before_script | no | Define commands that run before each job's script | | after_script | no | Define commands that run after each job's script | | variables | no | Define build variables | @@ -130,6 +130,8 @@ There are also two edge cases worth mentioning: ### types +> Deprecated, and will be removed in 10.0. Use [stages](#stages) instead. + Alias for [stages](#stages). ### variables diff --git a/doc/integration/README.md b/doc/integration/README.md index 22bdf33443d..e56e58498a6 100644 --- a/doc/integration/README.md +++ b/doc/integration/README.md @@ -12,6 +12,7 @@ See the documentation below for details on how to configure these services. - [SAML](saml.md) Configure GitLab as a SAML 2.0 Service Provider - [CAS](cas.md) Configure GitLab to sign in using CAS - [OAuth2 provider](oauth_provider.md) OAuth2 application creation +- [OpenID Connect](openid_connect_provider.md) Use GitLab as an identity provider - [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages - [reCAPTCHA](recaptcha.md) Configure GitLab to use Google reCAPTCHA for new users - [Akismet](akismet.md) Configure Akismet to stop spam diff --git a/doc/integration/openid_connect_provider.md b/doc/integration/openid_connect_provider.md new file mode 100644 index 00000000000..56f367d841e --- /dev/null +++ b/doc/integration/openid_connect_provider.md @@ -0,0 +1,47 @@ +# GitLab as OpenID Connect identity provider + +This document is about using GitLab as an OpenID Connect identity provider +to sign in to other services. + +## Introduction to OpenID Connect + +[OpenID Connect] \(OIC) is a simple identity layer on top of the +OAuth 2.0 protocol. It allows clients to verify the identity of the end-user +based on the authentication performed by GitLab, as well as to obtain +basic profile information about the end-user in an interoperable and +REST-like manner. OIC performs many of the same tasks as OpenID 2.0, +but does so in a way that is API-friendly, and usable by native and +mobile applications. + +On the client side, you can use [omniauth-openid-connect] for Rails +applications, or any of the other available [client implementations]. + +GitLab's implementation uses the [doorkeeper-openid_connect] gem, refer +to its README for more details about which parts of the specifications +are supported. + +## Enabling OpenID Connect for OAuth applications + +Refer to the [OAuth guide] for basic information on how to set up OAuth +applications in GitLab. To enable OIC for an application, all you have to do +is select the `openid` scope in the application settings. + +Currently the following user information is shared with clients: + +| Claim | Type | Description | +|:-----------------|:----------|:------------| +| `sub` | `string` | An opaque token that uniquely identifies the user +| `auth_time` | `integer` | The timestamp for the user's last authentication +| `name` | `string` | The user's full name +| `nickname` | `string` | The user's GitLab username +| `email` | `string` | The user's public email address +| `email_verified` | `boolean` | Whether the user's public email address was verified +| `website` | `string` | URL for the user's website +| `profile` | `string` | URL for the user's GitLab profile +| `picture` | `string` | URL for the user's GitLab avatar + +[OpenID Connect]: http://openid.net/connect/ "OpenID Connect website" +[doorkeeper-openid_connect]: https://github.com/doorkeeper-gem/doorkeeper-openid_connect "Doorkeeper::OpenidConnect website" +[OAuth guide]: oauth_provider.md "GitLab as OAuth2 authentication service provider" +[omniauth-openid-connect]: https://github.com/jjbohn/omniauth-openid-connect/ "OmniAuth::OpenIDConnect website" +[client implementations]: http://openid.net/developers/libraries#connect "List of available client implementations" diff --git a/doc/update/8.17-to-9.0.md b/doc/update/8.17-to-9.0.md index 7b934ecd87a..4cc8be752c4 100644 --- a/doc/update/8.17-to-9.0.md +++ b/doc/update/8.17-to-9.0.md @@ -1,3 +1,66 @@ +#### Configuration changes for repository storages + +This version introduces a new configuration structure for repository storages. +Update your current configuration as follows, replacing with your storages names and paths: + +**For installations from source** + +1. Update your `gitlab.yml`, from + + ```yaml + repositories: + storages: # You must have at least a 'default' storage path. + default: /home/git/repositories + nfs: /mnt/nfs/repositories + cephfs: /mnt/cephfs/repositories + ``` + + to + + ```yaml + repositories: + storages: # You must have at least a 'default' storage path. + default: + path: /home/git/repositories + nfs: + path: /mnt/nfs/repositories + cephfs: + path: /mnt/cephfs/repositories + ``` + +**For Omnibus installations** + +1. Upate your `/etc/gitlab/gitlab.rb`, from + + ```ruby + git_data_dirs({ + "default" => "/var/opt/gitlab/git-data", + "nfs" => "/mnt/nfs/git-data", + "cephfs" => "/mnt/cephfs/git-data" + }) + ``` + + to + + ```ruby + git_data_dirs({ + "default" => { "path" => "/var/opt/gitlab/git-data" }, + "nfs" => { "path" => "/mnt/nfs/git-data" }, + "cephfs" => { "path" => "/mnt/cephfs/git-data" } + }) + ``` + +#### Git configuration + +Configure Git to generate packfile bitmaps (introduced in Git 2.0) on +the GitLab server during `git gc`. + +```sh +cd /home/git/gitlab + +sudo -u git -H git config --global repack.writeBitmaps true +``` + #### Nginx configuration Ensure you're still up-to-date with the latest NGINX configuration changes: @@ -12,7 +75,7 @@ git diff origin/8-17-stable:lib/support/nginx/gitlab-ssl origin/9-0-stable:lib/s git diff origin/8-17-stable:lib/support/nginx/gitlab origin/9-0-stable:lib/support/nginx/gitlab ``` -If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx +If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx configuration as GitLab application no longer handles setting it. If you are using Apache instead of NGINX please see the updated [Apache templates]. diff --git a/features/project/active_tab.feature b/features/project/active_tab.feature index 8570c637b36..ba04b03c3cc 100644 --- a/features/project/active_tab.feature +++ b/features/project/active_tab.feature @@ -56,10 +56,10 @@ Feature: Project Active Tab And no other sub navs should be active And the active main tab should be Settings - Scenario: On Project Settings/Deploy Keys + Scenario: On Project Settings/Repository Given I visit my project's settings page - And I click the "Deploy Keys" tab - Then the active sub nav should be Deploy Keys + And I click the "Repository" tab + Then the active sub nav should be Repository And no other sub navs should be active And the active main tab should be Settings diff --git a/features/steps/project/active_tab.rb b/features/steps/project/active_tab.rb index d29b22d42ec..f901f4889dd 100644 --- a/features/steps/project/active_tab.rb +++ b/features/steps/project/active_tab.rb @@ -31,8 +31,10 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps click_link('Integrations') end - step 'I click the "Deploy Keys" tab' do - click_link('Deploy Keys') + step 'I click the "Repository" tab' do + page.within '.layout-nav .controls' do + click_link('Repository') + end end step 'I click the "Pages" tab' do @@ -53,8 +55,8 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps ensure_active_sub_nav('Integrations') end - step 'the active sub nav should be Deploy Keys' do - ensure_active_sub_nav('Deploy Keys') + step 'the active sub nav should be Repository' do + ensure_active_sub_nav('Repository') end step 'the active sub nav should be Pages' do diff --git a/features/steps/project/deploy_keys.rb b/features/steps/project/deploy_keys.rb index edf78f62f9a..580a19494c2 100644 --- a/features/steps/project/deploy_keys.rb +++ b/features/steps/project/deploy_keys.rb @@ -36,7 +36,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I should be on deploy keys page' do - expect(current_path).to eq namespace_project_deploy_keys_path(@project.namespace, @project) + expect(current_path).to eq namespace_project_settings_repository_path(@project.namespace, @project) end step 'I should see newly created deploy key' do diff --git a/lib/api/api.rb b/lib/api/api.rb index 8dbe8875fe8..1bf20f76ad6 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -5,6 +5,8 @@ module API version %w(v3 v4), using: :path version 'v3', using: :path do + helpers ::API::V3::Helpers + mount ::API::V3::AwardEmoji mount ::API::V3::Boards mount ::API::V3::Branches diff --git a/lib/api/award_emoji.rb b/lib/api/award_emoji.rb index 07a1bcdbe18..f9e0c2c4e16 100644 --- a/lib/api/award_emoji.rb +++ b/lib/api/award_emoji.rb @@ -3,12 +3,16 @@ module API include PaginationParams before { authenticate! } - AWARDABLES = %w[issue merge_request snippet].freeze + AWARDABLES = [ + { type: 'issue', find_by: :iid }, + { type: 'merge_request', find_by: :iid }, + { type: 'snippet', find_by: :id } + ].freeze resource :projects do - AWARDABLES.each do |awardable_type| - awardable_string = awardable_type.pluralize - awardable_id_string = "#{awardable_type}_id" + AWARDABLES.each do |awardable_params| + awardable_string = awardable_params[:type].pluralize + awardable_id_string = "#{awardable_params[:type]}_#{awardable_params[:find_by]}" params do requires :id, type: String, desc: 'The ID of a project' @@ -104,10 +108,10 @@ module API note_id = params.delete(:note_id) awardable.notes.find(note_id) - elsif params.include?(:issue_id) - user_project.issues.find(params[:issue_id]) - elsif params.include?(:merge_request_id) - user_project.merge_requests.find(params[:merge_request_id]) + elsif params.include?(:issue_iid) + user_project.issues.find_by!(iid: params[:issue_iid]) + elsif params.include?(:merge_request_iid) + user_project.merge_requests.find_by!(iid: params[:merge_request_iid]) else user_project.snippets.find(params[:snippet_id]) end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 2230aa0706b..0a12ee72d49 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -705,5 +705,99 @@ module API expose :id, :message, :starts_at, :ends_at, :color, :font expose :active?, as: :active end + + class PersonalAccessToken < Grape::Entity + expose :id, :name, :revoked, :created_at, :scopes + expose :active?, as: :active + expose :expires_at do |personal_access_token| + personal_access_token.expires_at ? personal_access_token.expires_at.strftime("%Y-%m-%d") : nil + end + end + + class PersonalAccessTokenWithToken < PersonalAccessToken + expose :token + end + + class ImpersonationToken < PersonalAccessTokenWithToken + expose :impersonation + end + + module JobRequest + class JobInfo < Grape::Entity + expose :name, :stage + expose :project_id, :project_name + end + + class GitInfo < Grape::Entity + expose :repo_url, :ref, :sha, :before_sha + expose :ref_type do |model| + if model.tag + 'tag' + else + 'branch' + end + end + end + + class RunnerInfo < Grape::Entity + expose :timeout + end + + class Step < Grape::Entity + expose :name, :script, :timeout, :when, :allow_failure + end + + class Image < Grape::Entity + expose :name + end + + class Artifacts < Grape::Entity + expose :name, :untracked, :paths, :when, :expire_in + end + + class Cache < Grape::Entity + expose :key, :untracked, :paths + end + + class Credentials < Grape::Entity + expose :type, :url, :username, :password + end + + class ArtifactFile < Grape::Entity + expose :filename, :size + end + + class Dependency < Grape::Entity + expose :id, :name + expose :artifacts_file, using: ArtifactFile, if: ->(job, _) { job.artifacts? } + end + + class Response < Grape::Entity + expose :id + expose :token + expose :allow_git_fetch + + expose :job_info, using: JobInfo do |model| + model + end + + expose :git_info, using: GitInfo do |model| + model + end + + expose :runner_info, using: RunnerInfo do |model| + model + end + + expose :variables + expose :steps, using: Step + expose :image, using: Image + expose :services, using: Image + expose :artifacts, using: Artifacts + expose :cache, using: Cache + expose :credentials, using: Credentials + expose :depends_on_builds, as: :dependencies, using: Dependency + end + end end end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index f325f0a3050..a9b364da9e1 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -82,16 +82,16 @@ module API label || not_found!('Label') end - def find_project_issue(id) - IssuesFinder.new(current_user, project_id: user_project.id).find(id) + def find_project_issue(iid) + IssuesFinder.new(current_user, project_id: user_project.id).find_by!(iid: iid) end - def find_project_merge_request(id) - MergeRequestsFinder.new(current_user, project_id: user_project.id).find(id) + def find_project_merge_request(iid) + MergeRequestsFinder.new(current_user, project_id: user_project.id).find_by!(iid: iid) end - def find_merge_request_with_access(id, access_level = :read_merge_request) - merge_request = user_project.merge_requests.find(id) + def find_merge_request_with_access(iid, access_level = :read_merge_request) + merge_request = user_project.merge_requests.find_by!(iid: iid) authorize! access_level, merge_request merge_request end diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index 080a6274957..2135a787b11 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -9,11 +9,11 @@ module API # In addition, they may have a '.git' extension and multiple namespaces # # Transform all these cases to 'namespace/project' - def clean_project_path(project_path, storage_paths = Repository.storages.values) + def clean_project_path(project_path, storages = Gitlab.config.repositories.storages.values) project_path = project_path.sub(/\.git\z/, '') - storage_paths.each do |storage_path| - storage_path = File.expand_path(storage_path) + storages.each do |storage| + storage_path = File.expand_path(storage['path']) if project_path.start_with?(storage_path) project_path = project_path.sub(storage_path, '') diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb index 119ca81b883..ec2bcaed929 100644 --- a/lib/api/helpers/runner.rb +++ b/lib/api/helpers/runner.rb @@ -1,6 +1,10 @@ module API module Helpers module Runner + JOB_TOKEN_HEADER = 'HTTP_JOB_TOKEN'.freeze + JOB_TOKEN_PARAM = :token + UPDATE_RUNNER_EVERY = 10 * 60 + def runner_registration_token_valid? ActiveSupport::SecurityUtils.variable_size_secure_compare(params[:token], current_application_settings.runners_registration_token) @@ -18,6 +22,56 @@ module API def current_runner @runner ||= ::Ci::Runner.find_by_token(params[:token].to_s) end + + def update_runner_info + return unless update_runner? + + current_runner.contacted_at = Time.now + current_runner.assign_attributes(get_runner_version_from_params) + current_runner.save if current_runner.changed? + end + + def update_runner? + # Use a random threshold to prevent beating DB updates. + # It generates a distribution between [40m, 80m]. + # + contacted_at_max_age = UPDATE_RUNNER_EVERY + Random.rand(UPDATE_RUNNER_EVERY) + + current_runner.contacted_at.nil? || + (Time.now - current_runner.contacted_at) >= contacted_at_max_age + end + + def job_not_found! + if headers['User-Agent'].to_s =~ /gitlab(-ci-multi)?-runner \d+\.\d+\.\d+(~beta\.\d+\.g[0-9a-f]+)? / + no_content! + else + not_found! + end + end + + def validate_job!(job) + not_found! unless job + + yield if block_given? + + forbidden!('Project has been deleted!') unless job.project + forbidden!('Job has been erased!') if job.erased? + end + + def authenticate_job!(job) + validate_job!(job) do + forbidden! unless job_token_valid?(job) + end + end + + def job_token_valid?(job) + token = (params[JOB_TOKEN_PARAM] || env[JOB_TOKEN_HEADER]).to_s + token && job.valid_token?(token) + end + + def max_artifacts_size + current_application_settings.max_artifacts_size.megabytes.to_i + end end end end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index bda74069ad5..4a9f2b26fb2 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -102,10 +102,10 @@ module API success Entities::Issue end params do - requires :issue_id, type: Integer, desc: 'The ID of a project issue' + requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' end - get ":id/issues/:issue_id" do - issue = find_project_issue(params[:issue_id]) + get ":id/issues/:issue_iid" do + issue = find_project_issue(params[:issue_iid]) present issue, with: Entities::Issue, current_user: current_user, project: user_project end @@ -152,7 +152,7 @@ module API success Entities::Issue end params do - requires :issue_id, type: Integer, desc: 'The ID of a project issue' + requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' optional :title, type: String, desc: 'The title of an issue' optional :updated_at, type: DateTime, desc: 'Date time when the issue was updated. Available only for admins and project owners.' @@ -161,8 +161,8 @@ module API at_least_one_of :title, :description, :assignee_id, :milestone_id, :labels, :created_at, :due_date, :confidential, :state_event end - put ':id/issues/:issue_id' do - issue = user_project.issues.find(params.delete(:issue_id)) + put ':id/issues/:issue_iid' do + issue = user_project.issues.find_by!(iid: params.delete(:issue_iid)) authorize! :update_issue, issue # Setting created_at time only allowed for admins and project owners @@ -189,11 +189,11 @@ module API success Entities::Issue end params do - requires :issue_id, type: Integer, desc: 'The ID of a project issue' + requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' requires :to_project_id, type: Integer, desc: 'The ID of the new project' end - post ':id/issues/:issue_id/move' do - issue = user_project.issues.find_by(id: params[:issue_id]) + post ':id/issues/:issue_iid/move' do + issue = user_project.issues.find_by(iid: params[:issue_iid]) not_found!('Issue') unless issue new_project = Project.find_by(id: params[:to_project_id]) @@ -209,10 +209,10 @@ module API desc 'Delete a project issue' params do - requires :issue_id, type: Integer, desc: 'The ID of a project issue' + requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' end - delete ":id/issues/:issue_id" do - issue = user_project.issues.find_by(id: params[:issue_id]) + delete ":id/issues/:issue_iid" do + issue = user_project.issues.find_by(iid: params[:issue_iid]) not_found!('Issue') unless issue authorize!(:destroy_issue, issue) diff --git a/lib/api/merge_request_diffs.rb b/lib/api/merge_request_diffs.rb index 4901a7cfea6..a59e39cca26 100644 --- a/lib/api/merge_request_diffs.rb +++ b/lib/api/merge_request_diffs.rb @@ -13,11 +13,11 @@ module API params do requires :id, type: String, desc: 'The ID of a project' - requires :merge_request_id, type: Integer, desc: 'The ID of a merge request' + requires :merge_request_iid, type: Integer, desc: 'The IID of a merge request' use :pagination end - get ":id/merge_requests/:merge_request_id/versions" do - merge_request = find_merge_request_with_access(params[:merge_request_id]) + get ":id/merge_requests/:merge_request_iid/versions" do + merge_request = find_merge_request_with_access(params[:merge_request_iid]) present paginate(merge_request.merge_request_diffs), with: Entities::MergeRequestDiff end @@ -29,12 +29,12 @@ module API params do requires :id, type: String, desc: 'The ID of a project' - requires :merge_request_id, type: Integer, desc: 'The ID of a merge request' + requires :merge_request_iid, type: Integer, desc: 'The IID of a merge request' requires :version_id, type: Integer, desc: 'The ID of a merge request diff version' end - get ":id/merge_requests/:merge_request_id/versions/:version_id" do - merge_request = find_merge_request_with_access(params[:merge_request_id]) + get ":id/merge_requests/:merge_request_iid/versions/:version_id" do + merge_request = find_merge_request_with_access(params[:merge_request_iid]) present merge_request.merge_request_diffs.find(params[:version_id]), with: Entities::MergeRequestDiffFull end diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 6fc33a7a54a..7a03955a045 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -101,23 +101,23 @@ module API desc 'Delete a merge request' params do - requires :merge_request_id, type: Integer, desc: 'The ID of a merge request' + requires :merge_request_iid, type: Integer, desc: 'The IID of a merge request' end - delete ":id/merge_requests/:merge_request_id" do - merge_request = find_project_merge_request(params[:merge_request_id]) + delete ":id/merge_requests/:merge_request_iid" do + merge_request = find_project_merge_request(params[:merge_request_iid]) authorize!(:destroy_merge_request, merge_request) merge_request.destroy end params do - requires :merge_request_id, type: Integer, desc: 'The ID of a merge request' + requires :merge_request_iid, type: Integer, desc: 'The IID of a merge request' end desc 'Get a single merge request' do success Entities::MergeRequest end - get ':id/merge_requests/:merge_request_id' do - merge_request = find_merge_request_with_access(params[:merge_request_id]) + get ':id/merge_requests/:merge_request_iid' do + merge_request = find_merge_request_with_access(params[:merge_request_iid]) present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project end @@ -125,8 +125,8 @@ module API desc 'Get the commits of a merge request' do success Entities::RepoCommit end - get ':id/merge_requests/:merge_request_id/commits' do - merge_request = find_merge_request_with_access(params[:merge_request_id]) + get ':id/merge_requests/:merge_request_iid/commits' do + merge_request = find_merge_request_with_access(params[:merge_request_iid]) commits = ::Kaminari.paginate_array(merge_request.commits) present paginate(commits), with: Entities::RepoCommit @@ -135,8 +135,8 @@ module API desc 'Show the merge request changes' do success Entities::MergeRequestChanges end - get ':id/merge_requests/:merge_request_id/changes' do - merge_request = find_merge_request_with_access(params[:merge_request_id]) + get ':id/merge_requests/:merge_request_iid/changes' do + merge_request = find_merge_request_with_access(params[:merge_request_iid]) present merge_request, with: Entities::MergeRequestChanges, current_user: current_user end @@ -154,8 +154,8 @@ module API :milestone_id, :labels, :state_event, :remove_source_branch end - put ':id/merge_requests/:merge_request_id' do - merge_request = find_merge_request_with_access(params.delete(:merge_request_id), :update_merge_request) + put ':id/merge_requests/:merge_request_iid' do + merge_request = find_merge_request_with_access(params.delete(:merge_request_iid), :update_merge_request) mr_params = declared_params(include_missing: false) mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params[:remove_source_branch].present? @@ -180,8 +180,8 @@ module API desc: 'When true, this merge request will be merged when the pipeline succeeds' optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch' end - put ':id/merge_requests/:merge_request_id/merge' do - merge_request = find_project_merge_request(params[:merge_request_id]) + put ':id/merge_requests/:merge_request_iid/merge' do + merge_request = find_project_merge_request(params[:merge_request_iid]) # Merge request can not be merged # because user dont have permissions to push into target branch @@ -216,8 +216,8 @@ module API desc 'Cancel merge if "Merge When Pipeline Succeeds" is enabled' do success Entities::MergeRequest end - post ':id/merge_requests/:merge_request_id/cancel_merge_when_pipeline_succeeds' do - merge_request = find_project_merge_request(params[:merge_request_id]) + post ':id/merge_requests/:merge_request_iid/cancel_merge_when_pipeline_succeeds' do + merge_request = find_project_merge_request(params[:merge_request_iid]) unauthorized! unless merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) @@ -232,8 +232,8 @@ module API params do use :pagination end - get ':id/merge_requests/:merge_request_id/comments' do - merge_request = find_merge_request_with_access(params[:merge_request_id]) + get ':id/merge_requests/:merge_request_iid/comments' do + merge_request = find_merge_request_with_access(params[:merge_request_iid]) present paginate(merge_request.notes.fresh), with: Entities::MRNote end @@ -243,8 +243,8 @@ module API params do requires :note, type: String, desc: 'The text of the comment' end - post ':id/merge_requests/:merge_request_id/comments' do - merge_request = find_merge_request_with_access(params[:merge_request_id], :create_note) + post ':id/merge_requests/:merge_request_iid/comments' do + merge_request = find_merge_request_with_access(params[:merge_request_iid], :create_note) opts = { note: params[:note], @@ -267,8 +267,8 @@ module API params do use :pagination end - get ':id/merge_requests/:merge_request_id/closes_issues' do - merge_request = find_merge_request_with_access(params[:merge_request_id]) + get ':id/merge_requests/:merge_request_iid/closes_issues' do + merge_request = find_merge_request_with_access(params[:merge_request_iid]) issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user)) present paginate(issues), with: issue_entity(user_project), current_user: current_user end diff --git a/lib/api/runner.rb b/lib/api/runner.rb index 47858f1866b..c700d2ef4a1 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -48,5 +48,203 @@ module API Ci::Runner.find_by_token(params[:token]).destroy end end + + resource :jobs do + desc 'Request a job' do + success Entities::JobRequest::Response + end + params do + requires :token, type: String, desc: %q(Runner's authentication token) + optional :last_update, type: String, desc: %q(Runner's queue last_update token) + optional :info, type: Hash, desc: %q(Runner's metadata) + end + post '/request' do + authenticate_runner! + not_found! unless current_runner.active? + update_runner_info + + if current_runner.is_runner_queue_value_latest?(params[:last_update]) + header 'X-GitLab-Last-Update', params[:last_update] + Gitlab::Metrics.add_event(:build_not_found_cached) + return job_not_found! + end + + new_update = current_runner.ensure_runner_queue_value + result = ::Ci::RegisterJobService.new(current_runner).execute + + if result.valid? + if result.build + Gitlab::Metrics.add_event(:build_found, + project: result.build.project.path_with_namespace) + present result.build, with: Entities::JobRequest::Response + else + Gitlab::Metrics.add_event(:build_not_found) + header 'X-GitLab-Last-Update', new_update + job_not_found! + end + else + # We received build that is invalid due to concurrency conflict + Gitlab::Metrics.add_event(:build_invalid) + conflict! + end + end + + desc 'Updates a job' do + http_codes [[200, 'Job was updated'], [403, 'Forbidden']] + end + params do + requires :token, type: String, desc: %q(Runners's authentication token) + requires :id, type: Integer, desc: %q(Job's ID) + optional :trace, type: String, desc: %q(Job's full trace) + optional :state, type: String, desc: %q(Job's status: success, failed) + end + put '/:id' do + job = Ci::Build.find_by_id(params[:id]) + authenticate_job!(job) + + job.update_attributes(trace: params[:trace]) if params[:trace] + + Gitlab::Metrics.add_event(:update_build, + project: job.project.path_with_namespace) + + case params[:state].to_s + when 'success' + job.success + when 'failed' + job.drop + end + end + + desc 'Appends a patch to the job trace' do + http_codes [[202, 'Trace was patched'], + [400, 'Missing Content-Range header'], + [403, 'Forbidden'], + [416, 'Range not satisfiable']] + end + params do + requires :id, type: Integer, desc: %q(Job's ID) + optional :token, type: String, desc: %q(Job's authentication token) + end + patch '/:id/trace' do + job = Ci::Build.find_by_id(params[:id]) + authenticate_job!(job) + + error!('400 Missing header Content-Range', 400) unless request.headers.has_key?('Content-Range') + content_range = request.headers['Content-Range'] + content_range = content_range.split('-') + + current_length = job.trace_length + unless current_length == content_range[0].to_i + return error!('416 Range Not Satisfiable', 416, { 'Range' => "0-#{current_length}" }) + end + + job.append_trace(request.body.read, content_range[0].to_i) + + status 202 + header 'Job-Status', job.status + header 'Range', "0-#{job.trace_length}" + end + + desc 'Authorize artifacts uploading for job' do + http_codes [[200, 'Upload allowed'], + [403, 'Forbidden'], + [405, 'Artifacts support not enabled'], + [413, 'File too large']] + end + params do + requires :id, type: Integer, desc: %q(Job's ID) + optional :token, type: String, desc: %q(Job's authentication token) + optional :filesize, type: Integer, desc: %q(Artifacts filesize) + end + post '/:id/artifacts/authorize' do + not_allowed! unless Gitlab.config.artifacts.enabled + require_gitlab_workhorse! + Gitlab::Workhorse.verify_api_request!(headers) + + job = Ci::Build.find_by_id(params[:id]) + authenticate_job!(job) + forbidden!('Job is not running') unless job.running? + + if params[:filesize] + file_size = params[:filesize].to_i + file_to_large! unless file_size < max_artifacts_size + end + + status 200 + content_type Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE + Gitlab::Workhorse.artifact_upload_ok + end + + desc 'Upload artifacts for job' do + success Entities::JobRequest::Response + http_codes [[201, 'Artifact uploaded'], + [400, 'Bad request'], + [403, 'Forbidden'], + [405, 'Artifacts support not enabled'], + [413, 'File too large']] + end + params do + requires :id, type: Integer, desc: %q(Job's ID) + optional :token, type: String, desc: %q(Job's authentication token) + optional :expire_in, type: String, desc: %q(Specify when artifacts should expire) + optional :file, type: File, desc: %q(Artifact's file) + optional 'file.path', type: String, desc: %q(path to locally stored body (generated by Workhorse)) + optional 'file.name', type: String, desc: %q(real filename as send in Content-Disposition (generated by Workhorse)) + optional 'file.type', type: String, desc: %q(real content type as send in Content-Type (generated by Workhorse)) + optional 'metadata.path', type: String, desc: %q(path to locally stored body (generated by Workhorse)) + optional 'metadata.name', type: String, desc: %q(filename (generated by Workhorse)) + end + post '/:id/artifacts' do + not_allowed! unless Gitlab.config.artifacts.enabled + require_gitlab_workhorse! + + job = Ci::Build.find_by_id(params[:id]) + authenticate_job!(job) + forbidden!('Job is not running!') unless job.running? + + artifacts_upload_path = ArtifactUploader.artifacts_upload_path + artifacts = uploaded_file(:file, artifacts_upload_path) + metadata = uploaded_file(:metadata, artifacts_upload_path) + + bad_request!('Missing artifacts file!') unless artifacts + file_to_large! unless artifacts.size < max_artifacts_size + + job.artifacts_file = artifacts + job.artifacts_metadata = metadata + job.artifacts_expire_in = params['expire_in'] || + Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in + + if job.save + present job, with: Entities::JobRequest::Response + else + render_validation_error!(job) + end + end + + desc 'Download the artifacts file for job' do + http_codes [[200, 'Upload allowed'], + [403, 'Forbidden'], + [404, 'Artifact not found']] + end + params do + requires :id, type: Integer, desc: %q(Job's ID) + optional :token, type: String, desc: %q(Job's authentication token) + end + get '/:id/artifacts' do + job = Ci::Build.find_by_id(params[:id]) + authenticate_job!(job) + + artifacts_file = job.artifacts_file + unless artifacts_file.file_storage? + return redirect_to job.artifacts_file.url + end + + unless artifacts_file.exists? + not_found! + end + + present_file!(artifacts_file.path, artifacts_file.filename) + end + end end end diff --git a/lib/api/services.rb b/lib/api/services.rb index 1cf29d9a1a3..5aa2f5eba7b 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -422,6 +422,14 @@ module API desc: 'Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.' } ], + 'prometheus' => [ + { + required: true, + name: :api_url, + type: String, + desc: 'Prometheus API Base URL, like http://prometheus.example.com/' + } + ], 'pushover' => [ { required: true, @@ -558,6 +566,7 @@ module API SlackSlashCommandsService, PipelinesEmailService, PivotaltrackerService, + PrometheusService, PushoverService, RedmineService, SlackService, diff --git a/lib/api/time_tracking_endpoints.rb b/lib/api/time_tracking_endpoints.rb index 85b5f7d98b8..05b4b490e27 100644 --- a/lib/api/time_tracking_endpoints.rb +++ b/lib/api/time_tracking_endpoints.rb @@ -5,11 +5,11 @@ module API included do helpers do def issuable_name - declared_params.has_key?(:issue_id) ? 'issue' : 'merge_request' + declared_params.has_key?(:issue_iid) ? 'issue' : 'merge_request' end def issuable_key - "#{issuable_name}_id".to_sym + "#{issuable_name}_iid".to_sym end def update_issuable_key @@ -50,7 +50,7 @@ module API issuable_name = name.end_with?('Issues') ? 'issue' : 'merge_request' issuable_collection_name = issuable_name.pluralize - issuable_key = "#{issuable_name}_id".to_sym + issuable_key = "#{issuable_name}_iid".to_sym desc "Set a time estimate for a project #{issuable_name}" params do diff --git a/lib/api/todos.rb b/lib/api/todos.rb index e59030428da..d9b8837a5bb 100644 --- a/lib/api/todos.rb +++ b/lib/api/todos.rb @@ -5,8 +5,8 @@ module API before { authenticate! } ISSUABLE_TYPES = { - 'merge_requests' => ->(id) { find_merge_request_with_access(id) }, - 'issues' => ->(id) { find_project_issue(id) } + 'merge_requests' => ->(iid) { find_merge_request_with_access(iid) }, + 'issues' => ->(iid) { find_project_issue(iid) } }.freeze params do @@ -14,13 +14,13 @@ module API end resource :projects do ISSUABLE_TYPES.each do |type, finder| - type_id_str = "#{type.singularize}_id".to_sym + type_id_str = "#{type.singularize}_iid".to_sym desc 'Create a todo on an issuable' do success Entities::Todo end params do - requires type_id_str, type: Integer, desc: 'The ID of an issuable' + requires type_id_str, type: Integer, desc: 'The IID of an issuable' end post ":id/#{type}/:#{type_id_str}/todo" do issuable = instance_exec(params[type_id_str], &finder) diff --git a/lib/api/users.rb b/lib/api/users.rb index 7bb4b76f830..549003f576a 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -9,6 +9,11 @@ module API resource :users, requirements: { uid: /[0-9]*/, id: /[0-9]*/ } do helpers do + def find_user(params) + id = params[:user_id] || params[:id] + User.find_by(id: id) || not_found!('User') + end + params :optional_attributes do optional :skype, type: String, desc: 'The Skype username' optional :linkedin, type: String, desc: 'The LinkedIn username' @@ -362,6 +367,76 @@ module API present paginate(events), with: Entities::Event end + + params do + requires :user_id, type: Integer, desc: 'The ID of the user' + end + segment ':user_id' do + resource :impersonation_tokens do + helpers do + def finder(options = {}) + user = find_user(params) + PersonalAccessTokensFinder.new({ user: user, impersonation: true }.merge(options)) + end + + def find_impersonation_token + finder.find_by(id: declared_params[:impersonation_token_id]) || not_found!('Impersonation Token') + end + end + + before { authenticated_as_admin! } + + desc 'Retrieve impersonation tokens. Available only for admins.' do + detail 'This feature was introduced in GitLab 9.0' + success Entities::ImpersonationToken + end + params do + use :pagination + optional :state, type: String, default: 'all', values: %w[all active inactive], desc: 'Filters (all|active|inactive) impersonation_tokens' + end + get { present paginate(finder(declared_params(include_missing: false)).execute), with: Entities::ImpersonationToken } + + desc 'Create a impersonation token. Available only for admins.' do + detail 'This feature was introduced in GitLab 9.0' + success Entities::ImpersonationToken + end + params do + requires :name, type: String, desc: 'The name of the impersonation token' + optional :expires_at, type: Date, desc: 'The expiration date in the format YEAR-MONTH-DAY of the impersonation token' + optional :scopes, type: Array, desc: 'The array of scopes of the impersonation token' + end + post do + impersonation_token = finder.build(declared_params(include_missing: false)) + + if impersonation_token.save + present impersonation_token, with: Entities::ImpersonationToken + else + render_validation_error!(impersonation_token) + end + end + + desc 'Retrieve impersonation token. Available only for admins.' do + detail 'This feature was introduced in GitLab 9.0' + success Entities::ImpersonationToken + end + params do + requires :impersonation_token_id, type: Integer, desc: 'The ID of the impersonation token' + end + get ':impersonation_token_id' do + present find_impersonation_token, with: Entities::ImpersonationToken + end + + desc 'Revoke a impersonation token. Available only for admins.' do + detail 'This feature was introduced in GitLab 9.0' + end + params do + requires :impersonation_token_id, type: Integer, desc: 'The ID of the impersonation token' + end + delete ':impersonation_token_id' do + find_impersonation_token.revoke! + end + end + end end resource :user do diff --git a/lib/api/v3/award_emoji.rb b/lib/api/v3/award_emoji.rb index 1e35283631f..cf9e1551f60 100644 --- a/lib/api/v3/award_emoji.rb +++ b/lib/api/v3/award_emoji.rb @@ -16,11 +16,64 @@ module API requires :"#{awardable_id_string}", type: Integer, desc: "The ID of an Issue, Merge Request or Snippet" end - [":id/#{awardable_string}/:#{awardable_id_string}/award_emoji", - ":id/#{awardable_string}/:#{awardable_id_string}/notes/:note_id/award_emoji"].each do |endpoint| + [ + ":id/#{awardable_string}/:#{awardable_id_string}/award_emoji", + ":id/#{awardable_string}/:#{awardable_id_string}/notes/:note_id/award_emoji" + ].each do |endpoint| + + desc 'Get a list of project +awardable+ award emoji' do + detail 'This feature was introduced in 8.9' + success Entities::AwardEmoji + end + params do + use :pagination + end + get endpoint do + if can_read_awardable? + awards = awardable.award_emoji + present paginate(awards), with: Entities::AwardEmoji + else + not_found!("Award Emoji") + end + end + + desc 'Get a specific award emoji' do + detail 'This feature was introduced in 8.9' + success Entities::AwardEmoji + end + params do + requires :award_id, type: Integer, desc: 'The ID of the award' + end + get "#{endpoint}/:award_id" do + if can_read_awardable? + present awardable.award_emoji.find(params[:award_id]), with: Entities::AwardEmoji + else + not_found!("Award Emoji") + end + end + + desc 'Award a new Emoji' do + detail 'This feature was introduced in 8.9' + success Entities::AwardEmoji + end + params do + requires :name, type: String, desc: 'The name of a award_emoji (without colons)' + end + post endpoint do + not_found!('Award Emoji') unless can_read_awardable? && can_award_awardable? + + award = awardable.create_award_emoji(params[:name], current_user) + + if award.persisted? + present award, with: Entities::AwardEmoji + else + not_found!("Award Emoji #{award.errors.messages}") + end + end + desc 'Delete a +awardables+ award emoji' do detail 'This feature was introduced in 8.9' - success ::API::Entities::AwardEmoji + success Entities::AwardEmoji end params do requires :award_id, type: Integer, desc: 'The ID of an award emoji' @@ -30,13 +83,22 @@ module API unauthorized! unless award.user == current_user || current_user.admin? - present award.destroy, with: ::API::Entities::AwardEmoji + award.destroy + present award, with: Entities::AwardEmoji end end end end helpers do + def can_read_awardable? + can?(current_user, read_ability(awardable), awardable) + end + + def can_award_awardable? + awardable.user_can_award?(current_user, params[:name]) + end + def awardable @awardable ||= begin @@ -53,6 +115,15 @@ module API end end end + + def read_ability(awardable) + case awardable + when Note + read_ability(awardable.noteable) + else + :"read_#{awardable.class.to_s.underscore}" + end + end end end end diff --git a/lib/api/v3/helpers.rb b/lib/api/v3/helpers.rb new file mode 100644 index 00000000000..0f234d4cdad --- /dev/null +++ b/lib/api/v3/helpers.rb @@ -0,0 +1,19 @@ +module API + module V3 + module Helpers + def find_project_issue(id) + IssuesFinder.new(current_user, project_id: user_project.id).find(id) + end + + def find_project_merge_request(id) + MergeRequestsFinder.new(current_user, project_id: user_project.id).find(id) + end + + def find_merge_request_with_access(id, access_level = :read_merge_request) + merge_request = user_project.merge_requests.find(id) + authorize! access_level, merge_request + merge_request + end + end + end +end diff --git a/lib/api/v3/time_tracking_endpoints.rb b/lib/api/v3/time_tracking_endpoints.rb new file mode 100644 index 00000000000..81ae4e8137d --- /dev/null +++ b/lib/api/v3/time_tracking_endpoints.rb @@ -0,0 +1,116 @@ +module API + module V3 + module TimeTrackingEndpoints + extend ActiveSupport::Concern + + included do + helpers do + def issuable_name + declared_params.has_key?(:issue_id) ? 'issue' : 'merge_request' + end + + def issuable_key + "#{issuable_name}_id".to_sym + end + + def update_issuable_key + "update_#{issuable_name}".to_sym + end + + def read_issuable_key + "read_#{issuable_name}".to_sym + end + + def load_issuable + @issuable ||= begin + case issuable_name + when 'issue' + find_project_issue(params.delete(issuable_key)) + when 'merge_request' + find_project_merge_request(params.delete(issuable_key)) + end + end + end + + def update_issuable(attrs) + custom_params = declared_params(include_missing: false) + custom_params.merge!(attrs) + + issuable = update_service.new(user_project, current_user, custom_params).execute(load_issuable) + if issuable.valid? + present issuable, with: ::API::Entities::IssuableTimeStats + else + render_validation_error!(issuable) + end + end + + def update_service + issuable_name == 'issue' ? ::Issues::UpdateService : ::MergeRequests::UpdateService + end + end + + issuable_name = name.end_with?('Issues') ? 'issue' : 'merge_request' + issuable_collection_name = issuable_name.pluralize + issuable_key = "#{issuable_name}_id".to_sym + + desc "Set a time estimate for a project #{issuable_name}" + params do + requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}" + requires :duration, type: String, desc: 'The duration to be parsed' + end + post ":id/#{issuable_collection_name}/:#{issuable_key}/time_estimate" do + authorize! update_issuable_key, load_issuable + + status :ok + update_issuable(time_estimate: Gitlab::TimeTrackingFormatter.parse(params.delete(:duration))) + end + + desc "Reset the time estimate for a project #{issuable_name}" + params do + requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}" + end + post ":id/#{issuable_collection_name}/:#{issuable_key}/reset_time_estimate" do + authorize! update_issuable_key, load_issuable + + status :ok + update_issuable(time_estimate: 0) + end + + desc "Add spent time for a project #{issuable_name}" + params do + requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}" + requires :duration, type: String, desc: 'The duration to be parsed' + end + post ":id/#{issuable_collection_name}/:#{issuable_key}/add_spent_time" do + authorize! update_issuable_key, load_issuable + + update_issuable(spend_time: { + duration: Gitlab::TimeTrackingFormatter.parse(params.delete(:duration)), + user: current_user + }) + end + + desc "Reset spent time for a project #{issuable_name}" + params do + requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}" + end + post ":id/#{issuable_collection_name}/:#{issuable_key}/reset_spent_time" do + authorize! update_issuable_key, load_issuable + + status :ok + update_issuable(spend_time: { duration: :reset, user: current_user }) + end + + desc "Show time stats for a project #{issuable_name}" + params do + requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}" + end + get ":id/#{issuable_collection_name}/:#{issuable_key}/time_stats" do + authorize! read_issuable_key, load_issuable + + present load_issuable, with: ::API::Entities::IssuableTimeStats + end + end + end + end +end diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb index 3c4ba5d50e6..cd745d35e7c 100644 --- a/lib/backup/repository.rb +++ b/lib/backup/repository.rb @@ -68,7 +68,8 @@ module Backup end def restore - Gitlab.config.repositories.storages.each do |name, path| + Gitlab.config.repositories.storages.each do |name, repository_storage| + path = repository_storage['path'] next unless File.exist?(path) # Move repos dir to 'repositories.old' dir @@ -199,7 +200,7 @@ module Backup private def repository_storage_paths_args - Gitlab.config.repositories.storages.values + Gitlab.config.repositories.storages.values.map { |rs| rs['path'] } end end end diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb index b51e76d93f2..746e76a1b1f 100644 --- a/lib/ci/api/builds.rb +++ b/lib/ci/api/builds.rb @@ -24,7 +24,7 @@ module Ci new_update = current_runner.ensure_runner_queue_value - result = Ci::RegisterBuildService.new(current_runner).execute + result = Ci::RegisterJobService.new(current_runner).execute if result.valid? if result.build diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 0a0bd0e781c..eee5601b0ed 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -2,9 +2,17 @@ module Gitlab module Auth MissingPersonalTokenError = Class.new(StandardError) - SCOPES = [:api, :read_user].freeze + # Scopes used for GitLab API access + API_SCOPES = [:api, :read_user].freeze + + # Scopes used for OpenID Connect + OPENID_SCOPES = [:openid].freeze + + # Default scopes for OAuth applications that don't define their own DEFAULT_SCOPES = [:api].freeze - OPTIONAL_SCOPES = SCOPES - DEFAULT_SCOPES + + # Other available scopes + OPTIONAL_SCOPES = (API_SCOPES + OPENID_SCOPES - DEFAULT_SCOPES).freeze class << self def find_for_git_client(login, password, project:, ip:) @@ -18,8 +26,8 @@ module Gitlab build_access_token_check(login, password) || lfs_token_check(login, password) || oauth_access_token_check(login, password) || - personal_access_token_check(login, password) || user_with_password_for_git(login, password) || + personal_access_token_check(password) || Gitlab::Auth::Result.new rate_limit!(ip, success: result.success?, login: login) @@ -40,7 +48,7 @@ module Gitlab Gitlab::LDAP::Authentication.login(login, password) else - user if user.valid_password?(password) + user if user.active? && user.valid_password?(password) end end end @@ -105,14 +113,13 @@ module Gitlab end end - def personal_access_token_check(login, password) - if login && password - token = PersonalAccessToken.active.find_by_token(password) - validation = User.by_login(login) + def personal_access_token_check(password) + return unless password.present? - if valid_personal_access_token?(token, validation) - Gitlab::Auth::Result.new(validation, nil, :personal_token, full_authentication_abilities) - end + token = PersonalAccessTokensFinder.new(state: 'active').find_by(token: password) + + if token && valid_api_token?(token) + Gitlab::Auth::Result.new(token.user, nil, :personal_token, full_authentication_abilities) end end @@ -120,10 +127,6 @@ module Gitlab token && token.accessible? && valid_api_token?(token) end - def valid_personal_access_token?(token, user) - token && token.user == user && valid_api_token?(token) - end - def valid_api_token?(token) AccessTokenValidationService.new(token).include_any_scope?(['api']) end diff --git a/lib/gitlab/ci/build/image.rb b/lib/gitlab/ci/build/image.rb new file mode 100644 index 00000000000..c62aeb60fa9 --- /dev/null +++ b/lib/gitlab/ci/build/image.rb @@ -0,0 +1,33 @@ +module Gitlab + module Ci + module Build + class Image + attr_reader :name + + class << self + def from_image(job) + image = Gitlab::Ci::Build::Image.new(job.options[:image]) + return unless image.valid? + image + end + + def from_services(job) + services = job.options[:services].to_a.map do |service| + Gitlab::Ci::Build::Image.new(service) + end + + services.select(&:valid?).compact + end + end + + def initialize(image) + @name = image + end + + def valid? + @name.present? + end + end + end + end +end diff --git a/lib/gitlab/ci/build/step.rb b/lib/gitlab/ci/build/step.rb new file mode 100644 index 00000000000..1877429ac46 --- /dev/null +++ b/lib/gitlab/ci/build/step.rb @@ -0,0 +1,46 @@ +module Gitlab + module Ci + module Build + class Step + WHEN_ON_FAILURE = 'on_failure'.freeze + WHEN_ON_SUCCESS = 'on_success'.freeze + WHEN_ALWAYS = 'always'.freeze + + attr_reader :name + attr_writer :script + attr_accessor :timeout, :when, :allow_failure + + class << self + def from_commands(job) + self.new(:script).tap do |step| + step.script = job.commands + step.timeout = job.timeout + step.when = WHEN_ON_SUCCESS + end + end + + def from_after_script(job) + after_script = job.options[:after_script] + return unless after_script + + self.new(:after_script).tap do |step| + step.script = after_script + step.timeout = job.timeout + step.when = WHEN_ALWAYS + step.allow_failure = true + end + end + end + + def initialize(name) + @name = name + @allow_failure = false + end + + def script + @script.split("\n") + end + end + end + end +end diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus.rb new file mode 100644 index 00000000000..62239779454 --- /dev/null +++ b/lib/gitlab/prometheus.rb @@ -0,0 +1,70 @@ +module Gitlab + PrometheusError = Class.new(StandardError) + + # Helper methods to interact with Prometheus network services & resources + class Prometheus + attr_reader :api_url + + def initialize(api_url:) + @api_url = api_url + end + + def ping + json_api_get('query', query: '1') + end + + def query(query) + get_result('vector') do + json_api_get('query', query: query) + end + end + + def query_range(query, start: 8.hours.ago) + get_result('matrix') do + json_api_get('query_range', + query: query, + start: start.to_f, + end: Time.now.utc.to_f, + step: 1.minute.to_i) + end + end + + private + + def json_api_get(type, args = {}) + get(join_api_url(type, args)) + rescue Errno::ECONNREFUSED + raise PrometheusError, 'Connection refused' + end + + def join_api_url(type, args = {}) + url = URI.parse(api_url) + rescue URI::Error + raise PrometheusError, "Invalid API URL: #{api_url}" + else + url.path = [url.path.sub(%r{/+\z}, ''), 'api', 'v1', type].join('/') + url.query = args.to_query + + url.to_s + end + + def get(url) + handle_response(HTTParty.get(url)) + end + + def handle_response(response) + if response.code == 200 && response['status'] == 'success' + response['data'] || {} + elsif response.code == 400 + raise PrometheusError, response['error'] || 'Bad data received' + else + raise PrometheusError, "#{response.code} - #{response.body}" + end + end + + def get_result(expected_type) + data = yield + data['result'] if data['resultType'] == expected_type + end + end +end diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake index 38edd49b6ed..a6f8c4ced5d 100644 --- a/lib/tasks/gitlab/check.rake +++ b/lib/tasks/gitlab/check.rake @@ -354,7 +354,8 @@ namespace :gitlab do def check_repo_base_exists puts "Repo base directory exists?" - Gitlab.config.repositories.storages.each do |name, repo_base_path| + Gitlab.config.repositories.storages.each do |name, repository_storage| + repo_base_path = repository_storage['path'] print "#{name}... " if File.exist?(repo_base_path) @@ -378,7 +379,8 @@ namespace :gitlab do def check_repo_base_is_not_symlink puts "Repo storage directories are symlinks?" - Gitlab.config.repositories.storages.each do |name, repo_base_path| + Gitlab.config.repositories.storages.each do |name, repository_storage| + repo_base_path = repository_storage['path'] print "#{name}... " unless File.exist?(repo_base_path) @@ -401,7 +403,8 @@ namespace :gitlab do def check_repo_base_permissions puts "Repo paths access is drwxrws---?" - Gitlab.config.repositories.storages.each do |name, repo_base_path| + Gitlab.config.repositories.storages.each do |name, repository_storage| + repo_base_path = repository_storage['path'] print "#{name}... " unless File.exist?(repo_base_path) @@ -431,7 +434,8 @@ namespace :gitlab do gitlab_shell_owner_group = Gitlab.config.gitlab_shell.owner_group puts "Repo paths owned by #{gitlab_shell_ssh_user}:#{gitlab_shell_owner_group}?" - Gitlab.config.repositories.storages.each do |name, repo_base_path| + Gitlab.config.repositories.storages.each do |name, repository_storage| + repo_base_path = repository_storage['path'] print "#{name}... " unless File.exist?(repo_base_path) @@ -810,8 +814,8 @@ namespace :gitlab do namespace :repo do desc "GitLab | Check the integrity of the repositories managed by GitLab" task check: :environment do - Gitlab.config.repositories.storages.each do |name, path| - namespace_dirs = Dir.glob(File.join(path, '*')) + Gitlab.config.repositories.storages.each do |name, repository_storage| + namespace_dirs = Dir.glob(File.join(repository_storage['path'], '*')) namespace_dirs.each do |namespace_dir| repo_dirs = Dir.glob(File.join(namespace_dir, '*')) diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake index daf7382dd02..f76bef5f4bf 100644 --- a/lib/tasks/gitlab/cleanup.rake +++ b/lib/tasks/gitlab/cleanup.rake @@ -6,7 +6,8 @@ namespace :gitlab do remove_flag = ENV['REMOVE'] namespaces = Namespace.pluck(:path) - Gitlab.config.repositories.storages.each do |name, git_base_path| + Gitlab.config.repositories.storages.each do |name, repository_storage| + git_base_path = repository_storage['path'] all_dirs = Dir.glob(git_base_path + '/*') puts git_base_path.color(:yellow) @@ -47,7 +48,8 @@ namespace :gitlab do warn_user_is_not_gitlab move_suffix = "+orphaned+#{Time.now.to_i}" - Gitlab.config.repositories.storages.each do |name, repo_root| + Gitlab.config.repositories.storages.each do |name, repository_storage| + repo_root = repository_storage['path'] # Look for global repos (legacy, depth 1) and normal repos (depth 2) IO.popen(%W(find #{repo_root} -mindepth 1 -maxdepth 2 -name *.git)) do |find| find.each_line do |path| diff --git a/lib/tasks/gitlab/import.rake b/lib/tasks/gitlab/import.rake index 66e7b7685f7..48bd9139ce8 100644 --- a/lib/tasks/gitlab/import.rake +++ b/lib/tasks/gitlab/import.rake @@ -11,7 +11,8 @@ namespace :gitlab do # desc "GitLab | Import bare repositories from repositories -> storages into GitLab project instance" task repos: :environment do - Gitlab.config.repositories.storages.each do |name, git_base_path| + Gitlab.config.repositories.storages.each_value do |repository_storage| + git_base_path = repository_storage['path'] repos_to_import = Dir.glob(git_base_path + '/**/*.git') repos_to_import.each do |repo_path| diff --git a/lib/tasks/gitlab/info.rake b/lib/tasks/gitlab/info.rake index b8dd654b9a9..a2a2db487b7 100644 --- a/lib/tasks/gitlab/info.rake +++ b/lib/tasks/gitlab/info.rake @@ -65,8 +65,8 @@ namespace :gitlab do puts "GitLab Shell".color(:yellow) puts "Version:\t#{gitlab_shell_version || "unknown".color(:red)}" puts "Repository storage paths:" - Gitlab.config.repositories.storages.each do |name, path| - puts "- #{name}: \t#{path}" + Gitlab.config.repositories.storages.each do |name, repository_storage| + puts "- #{name}: \t#{repository_storage['path']}" end puts "Hooks:\t\t#{Gitlab.config.gitlab_shell.hooks_path}" puts "Git:\t\t#{Gitlab.config.git.bin_path}" diff --git a/lib/tasks/gitlab/task_helpers.rb b/lib/tasks/gitlab/task_helpers.rb index 2a999ad6959..bb755ae689b 100644 --- a/lib/tasks/gitlab/task_helpers.rb +++ b/lib/tasks/gitlab/task_helpers.rb @@ -130,8 +130,8 @@ module Gitlab end def all_repos - Gitlab.config.repositories.storages.each do |name, path| - IO.popen(%W(find #{path} -mindepth 2 -maxdepth 2 -type d -name *.git)) do |find| + Gitlab.config.repositories.storages.each_value do |repository_storage| + IO.popen(%W(find #{repository_storage['path']} -mindepth 2 -maxdepth 2 -type d -name *.git)) do |find| find.each_line do |path| yield path.chomp end @@ -140,7 +140,7 @@ module Gitlab end def repository_storage_paths_args - Gitlab.config.repositories.storages.values + Gitlab.config.repositories.storages.values.map { |rs| rs['path'] } end def user_home diff --git a/rubocop/cop/migration/add_concurrent_index.rb b/rubocop/cop/migration/add_concurrent_index.rb new file mode 100644 index 00000000000..332fb7dcbd7 --- /dev/null +++ b/rubocop/cop/migration/add_concurrent_index.rb @@ -0,0 +1,34 @@ +require_relative '../../migration_helpers' + +module RuboCop + module Cop + module Migration + # Cop that checks if `add_concurrent_index` is used with `up`/`down` methods + # and not `change`. + class AddConcurrentIndex < RuboCop::Cop::Cop + include MigrationHelpers + + MSG = '`add_concurrent_index` is not reversible so you must manually define ' \ + 'the `up` and `down` methods in your migration class, using `remove_index` in `down`'.freeze + + def on_send(node) + return unless in_migration?(node) + + name = node.children[1] + + return unless name == :add_concurrent_index + + node.each_ancestor(:def) do |def_node| + next unless method_name(def_node) == :change + + add_offense(def_node, :name) + end + end + + def method_name(node) + node.children.first + end + end + end + end +end diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb index ea8e0f64b0d..a50a522cf9d 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -3,4 +3,5 @@ require_relative 'cop/gem_fetcher' require_relative 'cop/migration/add_column' require_relative 'cop/migration/add_column_with_default' require_relative 'cop/migration/add_concurrent_foreign_key' +require_relative 'cop/migration/add_concurrent_index' require_relative 'cop/migration/add_index' diff --git a/spec/controllers/admin/applications_controller_spec.rb b/spec/controllers/admin/applications_controller_spec.rb new file mode 100644 index 00000000000..e311b8a63b2 --- /dev/null +++ b/spec/controllers/admin/applications_controller_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe Admin::ApplicationsController do + let(:admin) { create(:admin) } + let(:application) { create(:oauth_application, owner_id: nil, owner_type: nil) } + + before do + sign_in(admin) + end + + describe 'GET #new' do + it 'renders the application form' do + get :new + + expect(response).to render_template :new + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end + end + + describe 'GET #edit' do + it 'renders the application form' do + get :edit, id: application.id + + expect(response).to render_template :edit + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end + end + + describe 'POST #create' do + it 'creates the application' do + expect do + post :create, doorkeeper_application: attributes_for(:application) + end.to change { Doorkeeper::Application.count }.by(1) + + application = Doorkeeper::Application.last + + expect(response).to redirect_to(admin_application_path(application)) + end + + it 'renders the application form on errors' do + expect do + post :create, doorkeeper_application: attributes_for(:application).merge(redirect_uri: nil) + end.not_to change { Doorkeeper::Application.count } + + expect(response).to render_template :new + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end + end + + describe 'PATCH #update' do + it 'updates the application' do + patch :update, id: application.id, doorkeeper_application: { redirect_uri: 'http://example.com/' } + + expect(response).to redirect_to(admin_application_path(application)) + expect(application.reload.redirect_uri).to eq 'http://example.com/' + end + + it 'renders the application form on errors' do + patch :update, id: application.id, doorkeeper_application: { redirect_uri: nil } + + expect(response).to render_template :edit + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end + end +end diff --git a/spec/controllers/profiles/personal_access_tokens_spec.rb b/spec/controllers/profiles/personal_access_tokens_spec.rb index 9d5f4c99f6d..dfed1de2046 100644 --- a/spec/controllers/profiles/personal_access_tokens_spec.rb +++ b/spec/controllers/profiles/personal_access_tokens_spec.rb @@ -2,48 +2,55 @@ require 'spec_helper' describe Profiles::PersonalAccessTokensController do let(:user) { create(:user) } + let(:token_attributes) { attributes_for(:personal_access_token) } + + before { sign_in(user) } describe '#create' do def created_token PersonalAccessToken.order(:created_at).last end - before { sign_in(user) } - - it "allows creation of a token" do + it "allows creation of a token with scopes" do name = FFaker::Product.brand + scopes = %w[api read_user] - post :create, personal_access_token: { name: name } + post :create, personal_access_token: token_attributes.merge(scopes: scopes, name: name) expect(created_token).not_to be_nil expect(created_token.name).to eq(name) - expect(created_token.expires_at).to be_nil + expect(created_token.scopes).to eq(scopes) expect(PersonalAccessToken.active).to include(created_token) end it "allows creation of a token with an expiry date" do - expires_at = 5.days.from_now + expires_at = 5.days.from_now.to_date - post :create, personal_access_token: { name: FFaker::Product.brand, expires_at: expires_at } + post :create, personal_access_token: token_attributes.merge(expires_at: expires_at) expect(created_token).not_to be_nil - expect(created_token.expires_at.to_i).to eq(expires_at.to_i) + expect(created_token.expires_at).to eq(expires_at) end + end - context "scopes" do - it "allows creation of a token with scopes" do - post :create, personal_access_token: { name: FFaker::Product.brand, scopes: %w(api read_user) } + describe '#index' do + let!(:active_personal_access_token) { create(:personal_access_token, user: user) } + let!(:inactive_personal_access_token) { create(:personal_access_token, :revoked, user: user) } + let!(:impersonation_personal_access_token) { create(:personal_access_token, :impersonation, user: user) } - expect(created_token).not_to be_nil - expect(created_token.scopes).to eq(%w(api read_user)) - end + before { get :index } - it "allows creation of a token with no scopes" do - post :create, personal_access_token: { name: FFaker::Product.brand, scopes: [] } + it "retrieves active personal access tokens" do + expect(assigns(:active_personal_access_tokens)).to include(active_personal_access_token) + end + + it "retrieves inactive personal access tokens" do + expect(assigns(:inactive_personal_access_tokens)).to include(inactive_personal_access_token) + end - expect(created_token).not_to be_nil - expect(created_token.scopes).to eq([]) - end + it "does not retrieve impersonation personal access tokens" do + expect(assigns(:active_personal_access_tokens)).not_to include(impersonation_personal_access_token) + expect(assigns(:inactive_personal_access_tokens)).not_to include(impersonation_personal_access_token) end end end diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 84d119f1867..83d80b376fb 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -187,6 +187,52 @@ describe Projects::EnvironmentsController do end end + describe 'GET #metrics' do + before do + allow(controller).to receive(:environment).and_return(environment) + end + + context 'when environment has no metrics' do + before do + expect(environment).to receive(:metrics).and_return(nil) + end + + it 'returns a metrics page' do + get :metrics, environment_params + + expect(response).to be_ok + end + + context 'when requesting metrics as JSON' do + it 'returns a metrics JSON document' do + get :metrics, environment_params(format: :json) + + expect(response).to have_http_status(204) + expect(json_response).to eq({}) + end + end + end + + context 'when environment has some metrics' do + before do + expect(environment).to receive(:metrics).and_return({ + success: true, + metrics: {}, + last_update: 42 + }) + end + + it 'returns a metrics JSON document' do + get :metrics, environment_params(format: :json) + + expect(response).to be_ok + expect(json_response['success']).to be(true) + expect(json_response['metrics']).to eq({}) + expect(json_response['last_update']).to eq(42) + end + end + end + def environment_params(opts = {}) opts.reverse_merge(namespace_id: project.namespace, project_id: project, diff --git a/spec/controllers/projects/settings/repository_controller_spec.rb b/spec/controllers/projects/settings/repository_controller_spec.rb new file mode 100644 index 00000000000..f73471f8ca8 --- /dev/null +++ b/spec/controllers/projects/settings/repository_controller_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Projects::Settings::RepositoryController do + let(:project) { create(:project_empty_repo, :public) } + let(:user) { create(:user) } + + before do + project.add_master(user) + sign_in(user) + end + + describe 'GET show' do + it 'renders show with 200 status code' do + get :show, namespace_id: project.namespace, project_id: project + + expect(response).to have_http_status(200) + expect(response).to render_template(:show) + end + end +end diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index c9584ddf18c..f67d26da0ac 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -1,4 +1,9 @@ require 'spec_helper' +shared_examples 'content not cached without revalidation' do + it 'ensures content will not be cached without revalidation' do + expect(subject['Cache-Control']).to eq('max-age=0, private, must-revalidate') + end +end describe UploadsController do let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } @@ -50,6 +55,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'user', mounted_as: 'avatar', id: user.id, filename: 'image.png' + response + end + end end end @@ -59,6 +71,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'user', mounted_as: 'avatar', id: user.id, filename: 'image.png' + response + end + end end end @@ -76,6 +95,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'project', mounted_as: 'avatar', id: project.id, filename: 'image.png' + response + end + end end context "when signed in" do @@ -88,6 +114,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'project', mounted_as: 'avatar', id: project.id, filename: 'image.png' + response + end + end end end @@ -133,6 +166,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'project', mounted_as: 'avatar', id: project.id, filename: 'image.png' + response + end + end end end @@ -157,6 +197,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'group', mounted_as: 'avatar', id: group.id, filename: 'image.png' + response + end + end end context "when signed in" do @@ -169,6 +216,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'group', mounted_as: 'avatar', id: group.id, filename: 'image.png' + response + end + end end end @@ -205,6 +259,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'group', mounted_as: 'avatar', id: group.id, filename: 'image.png' + response + end + end end end @@ -234,6 +295,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'note', mounted_as: 'attachment', id: note.id, filename: 'image.png' + response + end + end end context "when signed in" do @@ -246,6 +314,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'note', mounted_as: 'attachment', id: note.id, filename: 'image.png' + response + end + end end end @@ -291,6 +366,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'note', mounted_as: 'attachment', id: note.id, filename: 'image.png' + response + end + end end end diff --git a/spec/factories/chat_teams.rb b/spec/factories/chat_teams.rb new file mode 100644 index 00000000000..82f44fa3d15 --- /dev/null +++ b/spec/factories/chat_teams.rb @@ -0,0 +1,9 @@ +FactoryGirl.define do + factory :chat_team, class: ChatTeam do + sequence :team_id do |n| + "abcdefghijklm#{n}" + end + + namespace factory: :group + end +end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 279583c2c44..6b0d084614b 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -15,8 +15,8 @@ FactoryGirl.define do options do { - image: "ruby:2.1", - services: ["postgres"] + image: 'ruby:2.1', + services: ['postgres'] } end @@ -166,5 +166,31 @@ FactoryGirl.define do allow(build).to receive(:commit).and_return build(:commit) end end + + trait :extended_options do + options do + { + image: 'ruby:2.1', + services: ['postgres'], + after_script: "ls\ndate", + artifacts: { + name: 'artifacts_file', + untracked: false, + paths: ['out/'], + when: 'always', + expire_in: '7d' + }, + cache: { + key: 'cache_key', + untracked: false, + paths: ['vendor/*'] + } + } + end + end + + trait :no_options do + options { {} } + end end end diff --git a/spec/factories/oauth_access_grants.rb b/spec/factories/oauth_access_grants.rb new file mode 100644 index 00000000000..543b3e99274 --- /dev/null +++ b/spec/factories/oauth_access_grants.rb @@ -0,0 +1,11 @@ +FactoryGirl.define do + factory :oauth_access_grant do + resource_owner_id { create(:user).id } + application + token { Doorkeeper::OAuth::Helpers::UniqueToken.generate } + expires_in 2.hours + + redirect_uri { application.redirect_uri } + scopes { application.scopes } + end +end diff --git a/spec/factories/oauth_access_tokens.rb b/spec/factories/oauth_access_tokens.rb index ccf02d0719b..a46bc1d8ce8 100644 --- a/spec/factories/oauth_access_tokens.rb +++ b/spec/factories/oauth_access_tokens.rb @@ -2,6 +2,7 @@ FactoryGirl.define do factory :oauth_access_token do resource_owner application - token '123456' + token { Doorkeeper::OAuth::Helpers::UniqueToken.generate } + scopes { application.scopes } end end diff --git a/spec/factories/oauth_applications.rb b/spec/factories/oauth_applications.rb index d116a573830..86cdc208268 100644 --- a/spec/factories/oauth_applications.rb +++ b/spec/factories/oauth_applications.rb @@ -1,7 +1,7 @@ FactoryGirl.define do factory :oauth_application, class: 'Doorkeeper::Application', aliases: [:application] do name { FFaker::Name.name } - uid { FFaker::Name.name } + uid { Doorkeeper::OAuth::Helpers::UniqueToken.generate } redirect_uri { FFaker::Internet.uri('http') } owner owner_type 'User' diff --git a/spec/factories/personal_access_tokens.rb b/spec/factories/personal_access_tokens.rb index 811eab7e15b..7b15ba47de1 100644 --- a/spec/factories/personal_access_tokens.rb +++ b/spec/factories/personal_access_tokens.rb @@ -6,5 +6,22 @@ FactoryGirl.define do revoked false expires_at { 5.days.from_now } scopes ['api'] + impersonation false + + trait :impersonation do + impersonation true + end + + trait :revoked do + revoked true + end + + trait :expired do + expires_at { 1.day.ago } + end + + trait :invalid do + token nil + end end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 70c65bc693a..c6f91e05d83 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -195,4 +195,15 @@ FactoryGirl.define do factory :kubernetes_project, parent: :empty_project do kubernetes_service end + + factory :prometheus_project, parent: :empty_project do + after :create do |project| + project.create_prometheus_service( + active: true, + properties: { + api_url: 'https://prometheus.example.com' + } + ) + end + end end diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb new file mode 100644 index 00000000000..9ff5c2f9d40 --- /dev/null +++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe 'Admin > Users > Impersonation Tokens', feature: true, js: true do + let(:admin) { create(:admin) } + let!(:user) { create(:user) } + + def active_impersonation_tokens + find(".table.active-tokens") + end + + def inactive_impersonation_tokens + find(".table.inactive-tokens") + end + + before { login_as(admin) } + + describe "token creation" do + it "allows creation of a token" do + name = FFaker::Product.brand + + visit admin_user_impersonation_tokens_path(user_id: user.username) + fill_in "Name", with: name + + # Set date to 1st of next month + find_field("Expires at").trigger('focus') + find(".pika-next").click + click_on "1" + + # Scopes + check "api" + check "read_user" + + expect { click_on "Create Impersonation Token" }.to change { PersonalAccessTokensFinder.new(impersonation: true).execute.count } + expect(active_impersonation_tokens).to have_text(name) + expect(active_impersonation_tokens).to have_text('In') + expect(active_impersonation_tokens).to have_text('api') + expect(active_impersonation_tokens).to have_text('read_user') + end + end + + describe 'active tokens' do + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + let!(:personal_access_token) { create(:personal_access_token, user: user) } + + it 'only shows impersonation tokens' do + visit admin_user_impersonation_tokens_path(user_id: user.username) + + expect(active_impersonation_tokens).to have_text(impersonation_token.name) + expect(active_impersonation_tokens).not_to have_text(personal_access_token.name) + end + end + + describe "inactive tokens" do + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + + it "allows revocation of an active impersonation token" do + visit admin_user_impersonation_tokens_path(user_id: user.username) + + click_on "Revoke" + + expect(inactive_impersonation_tokens).to have_text(impersonation_token.name) + end + + it "moves expired tokens to the 'inactive' section" do + impersonation_token.update(expires_at: 5.days.ago) + + visit admin_user_impersonation_tokens_path(user_id: user.username) + + expect(inactive_impersonation_tokens).to have_text(impersonation_token.name) + end + end +end diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb index 93763f092fb..ede6aa0c201 100644 --- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -1,25 +1,16 @@ require 'rails_helper' -describe 'Dropdown assignee', js: true, feature: true do - include WaitForAjax - +describe 'Dropdown assignee', :feature, :js do let!(:project) { create(:empty_project) } let!(:user) { create(:user, name: 'administrator', username: 'root') } let!(:user_john) { create(:user, name: 'John', username: 'th0mas') } let!(:user_jacob) { create(:user, name: 'Jacob', username: 'otter32') } let(:filtered_search) { find('.filtered-search') } let(:js_dropdown_assignee) { '#js-dropdown-assignee' } - - def send_keys_to_filtered_search(input) - input.split("").each do |i| - filtered_search.send_keys(i) - sleep 5 - wait_for_ajax - end - end + let(:filter_dropdown) { find("#{js_dropdown_assignee} .filter-dropdown") } def dropdown_assignee_size - page.all('#js-dropdown-assignee .filter-dropdown .filter-dropdown-item').size + filter_dropdown.all('.filter-dropdown-item').size end def click_assignee(text) @@ -56,63 +47,80 @@ describe 'Dropdown assignee', js: true, feature: true do end it 'should hide loading indicator when loaded' do - send_keys_to_filtered_search('assignee:') + filtered_search.set('assignee:') - expect(page).not_to have_css('#js-dropdown-assignee .filter-dropdown-loading') + expect(find(js_dropdown_assignee)).to have_css('.filter-dropdown-loading') + expect(find(js_dropdown_assignee)).not_to have_css('.filter-dropdown-loading') end it 'should load all the assignees when opened' do - send_keys_to_filtered_search('assignee:') + filtered_search.set('assignee:') expect(dropdown_assignee_size).to eq(3) end it 'shows current user at top of dropdown' do - send_keys_to_filtered_search('assignee:') + filtered_search.set('assignee:') - expect(first('#js-dropdown-assignee .filter-dropdown li')).to have_content(user.name) + expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name) end end describe 'filtering' do before do - send_keys_to_filtered_search('assignee:') + filtered_search.set('assignee:') + + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_john.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) end it 'filters by name' do - send_keys_to_filtered_search('j') + filtered_search.send_keys('j') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_john.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user.name) end it 'filters by case insensitive name' do - send_keys_to_filtered_search('J') + filtered_search.send_keys('J') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_john.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user.name) end it 'filters by username with symbol' do - send_keys_to_filtered_search('@ot') + filtered_search.send_keys('@ot') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) end it 'filters by case insensitive username with symbol' do - send_keys_to_filtered_search('@OT') + filtered_search.send_keys('@OT') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) end it 'filters by username without symbol' do - send_keys_to_filtered_search('ot') + filtered_search.send_keys('ot') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) end it 'filters by case insensitive username without symbol' do - send_keys_to_filtered_search('OT') + filtered_search.send_keys('OT') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) end end @@ -129,7 +137,7 @@ describe 'Dropdown assignee', js: true, feature: true do end it 'fills in the assignee username when the assignee has been filtered' do - send_keys_to_filtered_search('roo') + filtered_search.send_keys('roo') click_assignee(user.name) expect(page).to have_css(js_dropdown_assignee, visible: false) @@ -173,7 +181,7 @@ describe 'Dropdown assignee', js: true, feature: true do describe 'caching requests' do it 'caches requests after the first load' do filtered_search.set('assignee') - send_keys_to_filtered_search(':') + filtered_search.send_keys(':') initial_size = dropdown_assignee_size expect(initial_size).to be > 0 @@ -182,7 +190,7 @@ describe 'Dropdown assignee', js: true, feature: true do project.team << [new_user, :master] find('.filtered-search-input-container .clear-search').click filtered_search.set('assignee') - send_keys_to_filtered_search(':') + filtered_search.send_keys(':') expect(dropdown_assignee_size).to eq(initial_size) end diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index b575aeff0d8..c2db7d8da3c 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -37,7 +37,12 @@ describe 'Merge request', :feature, :js do context 'view merge request' do let!(:environment) { create(:environment, project: project) } - let!(:deployment) { create(:deployment, environment: environment, ref: 'feature', sha: merge_request.diff_head_sha) } + + let!(:deployment) do + create(:deployment, environment: environment, + ref: 'feature', + sha: merge_request.diff_head_sha) + end before do visit namespace_project_merge_request_path(project.namespace, project, merge_request) @@ -96,6 +101,26 @@ describe 'Merge request', :feature, :js do end end + context 'when merge request is in the blocked pipeline state' do + before do + create(:ci_pipeline, project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + status: :manual) + + visit namespace_project_merge_request_path(project.namespace, + project, + merge_request) + end + + it 'shows information about blocked pipeline' do + expect(page).to have_content("Pipeline blocked") + expect(page).to have_content( + "The pipeline for this merge request requires a manual action") + expect(page).to have_css('.ci-status-icon-manual') + end + end + context 'view merge request with MWBS button' do before do commit_status = create(:commit_status, project: project, status: 'pending') diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index eb7b8a24669..0917d4dc3ef 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -4,11 +4,11 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do let(:user) { create(:user) } def active_personal_access_tokens - find(".table.active-personal-access-tokens") + find(".table.active-tokens") end def inactive_personal_access_tokens - find(".table.inactive-personal-access-tokens") + find(".table.inactive-tokens") end def created_personal_access_token @@ -26,7 +26,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do end describe "token creation" do - it "allows creation of a token" do + it "allows creation of a personal access token" do name = FFaker::Product.brand visit profile_personal_access_tokens_path @@ -43,7 +43,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do click_on "Create Personal Access Token" expect(active_personal_access_tokens).to have_text(name) - expect(active_personal_access_tokens).to have_text(Date.today.next_month.at_beginning_of_month.to_s(:medium)) + expect(active_personal_access_tokens).to have_text('In') expect(active_personal_access_tokens).to have_text('api') expect(active_personal_access_tokens).to have_text('read_user') end @@ -60,6 +60,18 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do end end + describe 'active tokens' do + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + let!(:personal_access_token) { create(:personal_access_token, user: user) } + + it 'only shows personal access tokens' do + visit profile_personal_access_tokens_path + + expect(active_personal_access_tokens).to have_text(personal_access_token.name) + expect(active_personal_access_tokens).not_to have_text(impersonation_token.name) + end + end + describe "inactive tokens" do let!(:personal_access_token) { create(:personal_access_token, user: user) } diff --git a/spec/features/projects/environments/environment_metrics_spec.rb b/spec/features/projects/environments/environment_metrics_spec.rb new file mode 100644 index 00000000000..ee925e811e1 --- /dev/null +++ b/spec/features/projects/environments/environment_metrics_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +feature 'Environment > Metrics', :feature do + include PrometheusHelpers + + given(:user) { create(:user) } + given(:project) { create(:prometheus_project) } + given(:pipeline) { create(:ci_pipeline, project: project) } + given(:build) { create(:ci_build, pipeline: pipeline) } + given(:environment) { create(:environment, project: project) } + given(:current_time) { Time.now.utc } + + background do + project.add_developer(user) + create(:deployment, environment: environment, deployable: build) + stub_all_prometheus_requests(environment.slug) + + login_as(user) + visit_environment(environment) + end + + around do |example| + Timecop.freeze(current_time) { example.run } + end + + context 'with deployments and related deployable present' do + scenario 'shows metrics' do + click_link('See metrics') + + expect(page).to have_css('svg.prometheus-graph') + end + end + + def visit_environment(environment) + visit namespace_project_environment_path(environment.project.namespace, + environment.project, + environment) + end +end diff --git a/spec/features/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 65373e3f77d..e2d16e0830a 100644 --- a/spec/features/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -37,13 +37,7 @@ feature 'Environment', :feature do scenario 'does show deployment SHA' do expect(page).to have_link(deployment.short_sha) - end - - scenario 'does not show a re-deploy button for deployment without build' do expect(page).not_to have_link('Re-deploy') - end - - scenario 'does not show terminal button' do expect(page).not_to have_terminal_button end end @@ -58,13 +52,7 @@ feature 'Environment', :feature do scenario 'does show build name' do expect(page).to have_link("#{build.name} (##{build.id})") - end - - scenario 'does show re-deploy button' do expect(page).to have_link('Re-deploy') - end - - scenario 'does not show terminal button' do expect(page).not_to have_terminal_button end @@ -117,9 +105,6 @@ feature 'Environment', :feature do it 'displays a web terminal' do expect(page).to have_selector('#terminal') - end - - it 'displays a link to the environment external url' do expect(page).to have_link(nil, href: environment.external_url) end end @@ -147,10 +132,6 @@ feature 'Environment', :feature do on_stop: 'close_app') end - scenario 'does show stop button' do - expect(page).to have_link('Stop') - end - scenario 'does allow to stop environment' do click_link('Stop') diff --git a/spec/features/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 25f31b423b8..25f31b423b8 100644 --- a/spec/features/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb diff --git a/spec/features/projects/labels/issues_sorted_by_priority_spec.rb b/spec/features/projects/labels/issues_sorted_by_priority_spec.rb index 7414ce21f59..de3c6eceb82 100644 --- a/spec/features/projects/labels/issues_sorted_by_priority_spec.rb +++ b/spec/features/projects/labels/issues_sorted_by_priority_spec.rb @@ -32,7 +32,7 @@ feature 'Issue prioritization', feature: true do visit namespace_project_issues_path(project.namespace, project, sort: 'priority') # Ensure we are indicating that issues are sorted by priority - expect(page).to have_selector('.dropdown-toggle', text: 'Priority') + expect(page).to have_selector('.dropdown-toggle', text: 'Label priority') page.within('.issues-holder') do issue_titles = all('.issues-list .issue-title-text').map(&:text) @@ -70,7 +70,7 @@ feature 'Issue prioritization', feature: true do login_as user visit namespace_project_issues_path(project.namespace, project, sort: 'priority') - expect(page).to have_selector('.dropdown-toggle', text: 'Priority') + expect(page).to have_selector('.dropdown-toggle', text: 'Label priority') page.within('.issues-holder') do issue_titles = all('.issues-list .issue-title-text').map(&:text) diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 24af062d763..1a66d1a6a1e 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -110,6 +110,20 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_denied_for(:external) } end + describe "GET /:project_path/settings/repository" do + subject { namespace_project_settings_repository_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_denied_for(:developer).of(project) } + it { is_expected.to be_denied_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:visitor) } + it { is_expected.to be_denied_for(:external) } + end + describe "GET /:project_path/blob" do let(:commit) { project.repository.commit } subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore')) } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index c511dcfa18e..ad3bd60a313 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -110,6 +110,20 @@ describe "Private Project Access", feature: true do it { is_expected.to be_denied_for(:external) } end + describe "GET /:project_path/settings/repository" do + subject { namespace_project_settings_repository_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_denied_for(:developer).of(project) } + it { is_expected.to be_denied_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/blob" do let(:commit) { project.repository.commit } subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore'))} diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index d8cc012c27e..e06aab4e0b2 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -110,6 +110,20 @@ describe "Public Project Access", feature: true do it { is_expected.to be_denied_for(:external) } end + describe "GET /:project_path/settings/repository" do + subject { namespace_project_settings_repository_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_denied_for(:developer).of(project) } + it { is_expected.to be_denied_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:visitor) } + it { is_expected.to be_denied_for(:external) } + end + describe "GET /:project_path/pipelines" do subject { namespace_project_pipelines_path(project.namespace, project) } diff --git a/spec/features/todos/todos_sorting_spec.rb b/spec/features/todos/todos_sorting_spec.rb index fec28c55d30..4d5bd476301 100644 --- a/spec/features/todos/todos_sorting_spec.rb +++ b/spec/features/todos/todos_sorting_spec.rb @@ -56,8 +56,8 @@ describe "Dashboard > User sorts todos", feature: true do expect(results_list.all('p')[4]).to have_content("merge_request_1") end - it "sorts by priority" do - click_link "Priority" + it "sorts by label priority" do + click_link "Label priority" results_list = page.find('.todos-list') expect(results_list.all('p')[0]).to have_content("issue_3") @@ -85,8 +85,8 @@ describe "Dashboard > User sorts todos", feature: true do visit dashboard_todos_path end - it "doesn't mix issues and merge requests priorities" do - click_link "Priority" + it "doesn't mix issues and merge requests label priorities" do + click_link "Label priority" results_list = page.find('.todos-list') expect(results_list.all('p')[0]).to have_content("issue_1") diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb index 4a7511589d6..c1ae6db00c6 100644 --- a/spec/features/triggers_spec.rb +++ b/spec/features/triggers_spec.rb @@ -1,28 +1,175 @@ require 'spec_helper' -describe 'Triggers' do +feature 'Triggers', feature: true, js: true do + let(:trigger_title) { 'trigger desc' } let(:user) { create(:user) } + let(:user2) { create(:user) } + let(:guest_user) { create(:user) } before { login_as(user) } before do - @project = FactoryGirl.create :empty_project + @project = create(:empty_project) @project.team << [user, :master] + @project.team << [user2, :master] + @project.team << [guest_user, :guest] visit namespace_project_settings_ci_cd_path(@project.namespace, @project) end - context 'create a trigger' do - before do - click_on 'Add trigger' - expect(@project.triggers.count).to eq(1) + describe 'create trigger workflow' do + scenario 'prevents adding new trigger with no description' do + fill_in 'trigger_description', with: '' + click_button 'Add trigger' + + # See if input has error due to empty value + expect(page.find('form.gl-show-field-errors .gl-field-error')['style']).to eq 'display: block;' + end + + scenario 'adds new trigger with description' do + fill_in 'trigger_description', with: 'trigger desc' + click_button 'Add trigger' + + # See if "trigger creation successful" message displayed and description and owner are correct + expect(page.find('.flash-notice')).to have_content 'Trigger was created successfully.' + expect(page.find('.triggers-list')).to have_content 'trigger desc' + expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name + end + end + + describe 'edit trigger workflow' do + let(:new_trigger_title) { 'new trigger' } + + scenario 'click on edit trigger opens edit trigger page' do + create(:ci_trigger, owner: user, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if edit page has correct descrption + find('a[title="Edit"]').click + expect(page.find('#trigger_description').value).to have_content 'trigger desc' + end + + scenario 'edit trigger and save' do + create(:ci_trigger, owner: user, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if edit page opens, then fill in new description and save + find('a[title="Edit"]').click + fill_in 'trigger_description', with: new_trigger_title + click_button 'Save trigger' + + # See if "trigger updated successfully" message displayed and description and owner are correct + expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' + expect(page.find('.triggers-list')).to have_content new_trigger_title + expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name + end + + scenario 'edit "legacy" trigger and save' do + # Create new trigger without owner association, i.e. Legacy trigger + create(:ci_trigger, owner: nil, project: @project) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if the trigger can be edited and description is blank + find('a[title="Edit"]').click + expect(page.find('#trigger_description').value).to have_content '' + + # See if trigger can be updated with description and saved successfully + fill_in 'trigger_description', with: new_trigger_title + click_button 'Save trigger' + expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' + expect(page.find('.triggers-list')).to have_content new_trigger_title + end + end + + describe 'trigger "Take ownership" workflow' do + before(:each) do + create(:ci_trigger, owner: user2, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + end + + scenario 'button "Take ownership" has correct alert' do + expected_alert = 'By taking ownership you will bind this trigger to your user account. With this the trigger will have access to all your projects as if it was you. Are you sure?' + expect(page.find('a.btn-trigger-take-ownership')['data-confirm']).to eq expected_alert end - it 'contains trigger token' do - expect(page).to have_content(@project.triggers.first.token) + scenario 'take trigger ownership' do + # See if "Take ownership" on trigger works post trigger creation + find('a.btn-trigger-take-ownership').click + page.accept_confirm do + expect(page.find('.flash-notice')).to have_content 'Trigger was re-assigned.' + expect(page.find('.triggers-list')).to have_content trigger_title + expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name + end end + end + + describe 'trigger "Revoke" workflow' do + before(:each) do + create(:ci_trigger, owner: user2, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + end + + scenario 'button "Revoke" has correct alert' do + expected_alert = 'By revoking a trigger you will break any processes making use of it. Are you sure?' + expect(page.find('a.btn-trigger-revoke')['data-confirm']).to eq expected_alert + end + + scenario 'revoke trigger' do + # See if "Revoke" on trigger works post trigger creation + find('a.btn-trigger-revoke').click + page.accept_confirm do + expect(page.find('.flash-notice')).to have_content 'Trigger removed' + expect(page).to have_selector('p.settings-message.text-center.append-bottom-default') + end + end + end + + describe 'show triggers workflow' do + scenario 'contains trigger description placeholder' do + expect(page.find('#trigger_description')['placeholder']).to eq 'Trigger description' + end + + scenario 'show "legacy" badge for legacy trigger' do + create(:ci_trigger, owner: nil, project: @project) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if trigger without owner (i.e. legacy) shows "legacy" badge and is editable + expect(page.find('.triggers-list')).to have_content 'legacy' + expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]') + end + + scenario 'show "invalid" badge for trigger with owner having insufficient permissions' do + create(:ci_trigger, owner: guest_user, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if trigger without owner (i.e. legacy) shows "legacy" badge and is non-editable + expect(page.find('.triggers-list')).to have_content 'invalid' + expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]') + end + + scenario 'do not show "Edit" or full token for not owned trigger' do + # Create trigger with user different from current_user + create(:ci_trigger, owner: user2, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if trigger not owned by current_user shows only first few token chars and doesn't have copy-to-clipboard button + expect(page.find('.triggers-list')).to have_content(@project.triggers.first.token[0..3]) + expect(page.find('.triggers-list')).not_to have_selector('button.btn-clipboard') + + # See if trigger owner name doesn't match with current_user and trigger is non-editable + expect(page.find('.triggers-list .trigger-owner')).not_to have_content @user.name + expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]') + end + + scenario 'show "Edit" and full token for owned trigger' do + create(:ci_trigger, owner: user, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if trigger shows full token and has copy-to-clipboard button + expect(page.find('.triggers-list')).to have_content @project.triggers.first.token + expect(page.find('.triggers-list')).to have_selector('button.btn-clipboard') - it 'revokes the trigger' do - click_on 'Revoke' - expect(@project.triggers.count).to eq(0) + # See if trigger owner name matches with current_user and is editable + expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name + expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]') end end end diff --git a/spec/finders/personal_access_tokens_finder_spec.rb b/spec/finders/personal_access_tokens_finder_spec.rb new file mode 100644 index 00000000000..fd92664ca24 --- /dev/null +++ b/spec/finders/personal_access_tokens_finder_spec.rb @@ -0,0 +1,196 @@ +require 'spec_helper' + +describe PersonalAccessTokensFinder do + def finder(options = {}) + described_class.new(options) + end + + describe '#execute' do + let(:user) { create(:user) } + let(:params) { {} } + let!(:active_personal_access_token) { create(:personal_access_token, user: user) } + let!(:expired_personal_access_token) { create(:personal_access_token, :expired, user: user) } + let!(:revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user) } + let!(:active_impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + let!(:expired_impersonation_token) { create(:personal_access_token, :expired, :impersonation, user: user) } + let!(:revoked_impersonation_token) { create(:personal_access_token, :revoked, :impersonation, user: user) } + + subject { finder(params).execute } + + describe 'without user' do + it do + is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token, + revoked_personal_access_token, expired_personal_access_token, + revoked_impersonation_token, expired_impersonation_token) + end + + describe 'without impersonation' do + before { params[:impersonation] = false } + + it { is_expected.to contain_exactly(active_personal_access_token, revoked_personal_access_token, expired_personal_access_token) } + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_personal_access_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it { is_expected.to contain_exactly(revoked_personal_access_token, expired_personal_access_token) } + end + end + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to contain_exactly(active_impersonation_token, revoked_impersonation_token, expired_impersonation_token) } + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_impersonation_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it { is_expected.to contain_exactly(revoked_impersonation_token, expired_impersonation_token) } + end + end + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it do + is_expected.to contain_exactly(expired_personal_access_token, revoked_personal_access_token, + expired_impersonation_token, revoked_impersonation_token) + end + end + + describe 'with id' do + subject { finder(params).find_by(id: active_personal_access_token.id) } + + it { is_expected.to eq(active_personal_access_token) } + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to be_nil } + end + end + + describe 'with token' do + subject { finder(params).find_by(token: active_personal_access_token.token) } + + it { is_expected.to eq(active_personal_access_token) } + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to be_nil } + end + end + end + + describe 'with user' do + let(:user2) { create(:user) } + let!(:other_user_active_personal_access_token) { create(:personal_access_token, user: user2) } + let!(:other_user_expired_personal_access_token) { create(:personal_access_token, :expired, user: user2) } + let!(:other_user_revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user2) } + let!(:other_user_active_impersonation_token) { create(:personal_access_token, :impersonation, user: user2) } + let!(:other_user_expired_impersonation_token) { create(:personal_access_token, :expired, :impersonation, user: user2) } + let!(:other_user_revoked_impersonation_token) { create(:personal_access_token, :revoked, :impersonation, user: user2) } + + before { params[:user] = user } + + it do + is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token, + revoked_personal_access_token, expired_personal_access_token, + revoked_impersonation_token, expired_impersonation_token) + end + + describe 'without impersonation' do + before { params[:impersonation] = false } + + it { is_expected.to contain_exactly(active_personal_access_token, revoked_personal_access_token, expired_personal_access_token) } + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_personal_access_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it { is_expected.to contain_exactly(revoked_personal_access_token, expired_personal_access_token) } + end + end + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to contain_exactly(active_impersonation_token, revoked_impersonation_token, expired_impersonation_token) } + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_impersonation_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it { is_expected.to contain_exactly(revoked_impersonation_token, expired_impersonation_token) } + end + end + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it do + is_expected.to contain_exactly(expired_personal_access_token, revoked_personal_access_token, + expired_impersonation_token, revoked_impersonation_token) + end + end + + describe 'with id' do + subject { finder(params).find_by(id: active_personal_access_token.id) } + + it { is_expected.to eq(active_personal_access_token) } + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to be_nil } + end + end + + describe 'with token' do + subject { finder(params).find_by(token: active_personal_access_token.token) } + + it { is_expected.to eq(active_personal_access_token) } + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to be_nil } + end + end + end + end +end diff --git a/spec/helpers/events_helper_spec.rb b/spec/helpers/events_helper_spec.rb index 594b40303bc..81ba693f2f3 100644 --- a/spec/helpers/events_helper_spec.rb +++ b/spec/helpers/events_helper_spec.rb @@ -61,6 +61,13 @@ describe EventsHelper do '</code></pre>' expect(helper.event_note(input)).to eq(expected) end + + it 'preserves style attribute within a tag' do + input = '<span class="" style="background-color: #44ad8e; color: #FFFFFF;"></span>' + expected = '<p><span style="background-color: #44ad8e; color: #FFFFFF;"></span></p>' + + expect(helper.event_note(input)).to eq(expected) + end end describe '#event_commit_title' do diff --git a/spec/initializers/6_validations_spec.rb b/spec/initializers/6_validations_spec.rb index baab30f482f..cf182e6d221 100644 --- a/spec/initializers/6_validations_spec.rb +++ b/spec/initializers/6_validations_spec.rb @@ -14,7 +14,7 @@ describe '6_validations', lib: true do context 'with correct settings' do before do - mock_storages('foo' => 'tmp/tests/paths/a/b/c', 'bar' => 'tmp/tests/paths/a/b/d') + mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/d' }) end it 'passes through' do @@ -24,7 +24,7 @@ describe '6_validations', lib: true do context 'with invalid storage names' do before do - mock_storages('name with spaces' => 'tmp/tests/paths/a/b/c') + mock_storages('name with spaces' => { 'path' => 'tmp/tests/paths/a/b/c' }) end it 'throws an error' do @@ -34,7 +34,7 @@ describe '6_validations', lib: true do context 'with nested storage paths' do before do - mock_storages('foo' => 'tmp/tests/paths/a/b/c', 'bar' => 'tmp/tests/paths/a/b/c/d') + mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/c/d' }) end it 'throws an error' do @@ -44,7 +44,7 @@ describe '6_validations', lib: true do context 'with similar but un-nested storage paths' do before do - mock_storages('foo' => 'tmp/tests/paths/a/b/c', 'bar' => 'tmp/tests/paths/a/b/c2') + mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/c2' }) end it 'passes through' do @@ -52,6 +52,26 @@ describe '6_validations', lib: true do end end + context 'with incomplete settings' do + before do + mock_storages('foo' => {}) + end + + it 'throws an error suggesting the user to update its settings' do + expect { validate_storages }.to raise_error('foo is not a valid storage, because it has no `path` key. Refer to gitlab.yml.example for an updated example. Please fix this in your gitlab.yml before starting GitLab.') + end + end + + context 'with deprecated settings structure' do + before do + mock_storages('foo' => 'tmp/tests/paths/a/b/c') + end + + it 'throws an error suggesting the user to update its settings' do + expect { validate_storages }.to raise_error("foo is not a valid storage, because it has no `path` key. It may be configured as:\n\nfoo:\n path: tmp/tests/paths/a/b/c\n\nRefer to gitlab.yml.example for an updated example. Please fix this in your gitlab.yml before starting GitLab.") + end + end + def mock_storages(storages) allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end diff --git a/spec/initializers/doorkeeper_spec.rb b/spec/initializers/doorkeeper_spec.rb new file mode 100644 index 00000000000..74bdbb01166 --- /dev/null +++ b/spec/initializers/doorkeeper_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' +require_relative '../../config/initializers/doorkeeper' + +describe Doorkeeper.configuration do + describe '#default_scopes' do + it 'matches Gitlab::Auth::DEFAULT_SCOPES' do + expect(subject.default_scopes).to eq Gitlab::Auth::DEFAULT_SCOPES + end + end + + describe '#optional_scopes' do + it 'matches Gitlab::Auth::OPTIONAL_SCOPES' do + expect(subject.optional_scopes).to eq Gitlab::Auth::OPTIONAL_SCOPES + end + end + + describe '#resource_owner_authenticator' do + subject { controller.instance_exec(&Doorkeeper.configuration.authenticate_resource_owner) } + + let(:controller) { double } + + before do + allow(controller).to receive(:current_user).and_return(current_user) + allow(controller).to receive(:session).and_return({}) + allow(controller).to receive(:request).and_return(OpenStruct.new(fullpath: '/return-path')) + allow(controller).to receive(:redirect_to) + allow(controller).to receive(:new_user_session_url).and_return('/login') + end + + context 'with a user present' do + let(:current_user) { create(:user) } + + it 'returns the user' do + expect(subject).to eq current_user + end + + it 'does not redirect' do + expect(controller).not_to receive(:redirect_to) + + subject + end + + it 'does not store the return path' do + subject + + expect(controller.session).not_to include :user_return_to + end + end + + context 'without a user present' do + let(:current_user) { nil } + + # NOTE: this is required for doorkeeper-openid_connect + it 'returns nil' do + expect(subject).to eq nil + end + + it 'redirects to the login form' do + expect(controller).to receive(:redirect_to).with('/login') + + subject + end + + it 'stores the return path' do + subject + + expect(controller.session[:user_return_to]).to eq '/return-path' + end + end + end +end diff --git a/spec/initializers/secret_token_spec.rb b/spec/initializers/secret_token_spec.rb index ad7f032d1e5..65c97da2efd 100644 --- a/spec/initializers/secret_token_spec.rb +++ b/spec/initializers/secret_token_spec.rb @@ -6,6 +6,9 @@ describe 'create_tokens', lib: true do let(:secrets) { ActiveSupport::OrderedOptions.new } + HEX_KEY = /\h{128}/ + RSA_KEY = /\A-----BEGIN RSA PRIVATE KEY-----\n.+\n-----END RSA PRIVATE KEY-----\n\Z/m + before do allow(File).to receive(:write) allow(File).to receive(:delete) @@ -15,7 +18,7 @@ describe 'create_tokens', lib: true do allow(self).to receive(:exit) end - context 'setting secret_key_base and otp_key_base' do + context 'setting secret keys' do context 'when none of the secrets exist' do before do stub_env('SECRET_KEY_BASE', nil) @@ -24,19 +27,29 @@ describe 'create_tokens', lib: true do allow(self).to receive(:warn_missing_secret) end - it 'generates different secrets for secret_key_base, otp_key_base, and db_key_base' do + it 'generates different hashes for secret_key_base, otp_key_base, and db_key_base' do create_tokens keys = secrets.values_at(:secret_key_base, :otp_key_base, :db_key_base) expect(keys.uniq).to eq(keys) - expect(keys.map(&:length)).to all(eq(128)) + expect(keys).to all(match(HEX_KEY)) + end + + it 'generates an RSA key for jws_private_key' do + create_tokens + + keys = secrets.values_at(:jws_private_key) + + expect(keys.uniq).to eq(keys) + expect(keys).to all(match(RSA_KEY)) end it 'warns about the secrets to add to secrets.yml' do expect(self).to receive(:warn_missing_secret).with('secret_key_base') expect(self).to receive(:warn_missing_secret).with('otp_key_base') expect(self).to receive(:warn_missing_secret).with('db_key_base') + expect(self).to receive(:warn_missing_secret).with('jws_private_key') create_tokens end @@ -48,6 +61,7 @@ describe 'create_tokens', lib: true do expect(new_secrets['secret_key_base']).to eq(secrets.secret_key_base) expect(new_secrets['otp_key_base']).to eq(secrets.otp_key_base) expect(new_secrets['db_key_base']).to eq(secrets.db_key_base) + expect(new_secrets['jws_private_key']).to eq(secrets.jws_private_key) end create_tokens @@ -63,6 +77,7 @@ describe 'create_tokens', lib: true do context 'when the other secrets all exist' do before do secrets.db_key_base = 'db_key_base' + secrets.jws_private_key = 'jws_private_key' allow(File).to receive(:exist?).with('.secret').and_return(true) allow(File).to receive(:read).with('.secret').and_return('file_key') @@ -73,6 +88,7 @@ describe 'create_tokens', lib: true do stub_env('SECRET_KEY_BASE', 'env_key') secrets.secret_key_base = 'secret_key_base' secrets.otp_key_base = 'otp_key_base' + secrets.jws_private_key = 'jws_private_key' end it 'does not issue a warning' do @@ -98,6 +114,7 @@ describe 'create_tokens', lib: true do before do secrets.secret_key_base = 'secret_key_base' secrets.otp_key_base = 'otp_key_base' + secrets.jws_private_key = 'jws_private_key' end it 'does not write any files' do @@ -112,6 +129,7 @@ describe 'create_tokens', lib: true do expect(secrets.secret_key_base).to eq('secret_key_base') expect(secrets.otp_key_base).to eq('otp_key_base') expect(secrets.db_key_base).to eq('db_key_base') + expect(secrets.jws_private_key).to eq('jws_private_key') end it 'deletes the .secret file' do @@ -135,6 +153,7 @@ describe 'create_tokens', lib: true do expect(new_secrets['secret_key_base']).to eq('file_key') expect(new_secrets['otp_key_base']).to eq('file_key') expect(new_secrets['db_key_base']).to eq('db_key_base') + expect(new_secrets['jws_private_key']).to eq('jws_private_key') end create_tokens diff --git a/spec/javascripts/fixtures/environments/metrics.html.haml b/spec/javascripts/fixtures/environments/metrics.html.haml new file mode 100644 index 00000000000..483063fb889 --- /dev/null +++ b/spec/javascripts/fixtures/environments/metrics.html.haml @@ -0,0 +1,12 @@ +%div + .top-area + .row + .col-sm-6 + %h3.page-title + Metrics for environment + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'memory_values' }
\ No newline at end of file diff --git a/spec/javascripts/monitoring/prometheus_graph_spec.js b/spec/javascripts/monitoring/prometheus_graph_spec.js new file mode 100644 index 00000000000..823b4bab7fc --- /dev/null +++ b/spec/javascripts/monitoring/prometheus_graph_spec.js @@ -0,0 +1,78 @@ +import 'jquery'; +import es6Promise from 'es6-promise'; +import '~/lib/utils/common_utils'; +import PrometheusGraph from '~/monitoring/prometheus_graph'; +import { prometheusMockData } from './prometheus_mock_data'; + +es6Promise.polyfill(); + +describe('PrometheusGraph', () => { + const fixtureName = 'static/environments/metrics.html.raw'; + const prometheusGraphContainer = '.prometheus-graph'; + const prometheusGraphContents = `${prometheusGraphContainer}[graph-type=cpu_values]`; + + preloadFixtures(fixtureName); + + beforeEach(() => { + loadFixtures(fixtureName); + this.prometheusGraph = new PrometheusGraph(); + const self = this; + const fakeInit = (metricsResponse) => { + self.prometheusGraph.transformData(metricsResponse); + self.prometheusGraph.createGraph(); + }; + spyOn(this.prometheusGraph, 'init').and.callFake(fakeInit); + }); + + it('initializes graph properties', () => { + // Test for the measurements + expect(this.prometheusGraph.margin).toBeDefined(); + expect(this.prometheusGraph.marginLabelContainer).toBeDefined(); + expect(this.prometheusGraph.originalWidth).toBeDefined(); + expect(this.prometheusGraph.originalHeight).toBeDefined(); + expect(this.prometheusGraph.height).toBeDefined(); + expect(this.prometheusGraph.width).toBeDefined(); + expect(this.prometheusGraph.backOffRequestCounter).toBeDefined(); + // Test for the graph properties (colors, radius, etc.) + expect(this.prometheusGraph.graphSpecificProperties).toBeDefined(); + expect(this.prometheusGraph.commonGraphProperties).toBeDefined(); + }); + + it('transforms the data', () => { + this.prometheusGraph.init(prometheusMockData.metrics); + expect(this.prometheusGraph.data).toBeDefined(); + expect(this.prometheusGraph.data.cpu_values.length).toBe(121); + expect(this.prometheusGraph.data.memory_values.length).toBe(121); + }); + + it('creates two graphs', () => { + this.prometheusGraph.init(prometheusMockData.metrics); + expect($(prometheusGraphContainer).length).toBe(2); + }); + + describe('Graph contents', () => { + beforeEach(() => { + this.prometheusGraph.init(prometheusMockData.metrics); + }); + + it('has axis, an area, a line and a overlay', () => { + const $graphContainer = $(prometheusGraphContents).find('.x-axis').parent(); + expect($graphContainer.find('.x-axis')).toBeDefined(); + expect($graphContainer.find('.y-axis')).toBeDefined(); + expect($graphContainer.find('.prometheus-graph-overlay')).toBeDefined(); + expect($graphContainer.find('.metric-line')).toBeDefined(); + expect($graphContainer.find('.metric-area')).toBeDefined(); + }); + + it('has legends, labels and an extra axis that labels the metrics', () => { + const $prometheusGraphContents = $(prometheusGraphContents); + const $axisLabelContainer = $(prometheusGraphContents).find('.label-x-axis-line').parent(); + expect($prometheusGraphContents.find('.label-x-axis-line')).toBeDefined(); + expect($prometheusGraphContents.find('.label-y-axis-line')).toBeDefined(); + expect($prometheusGraphContents.find('.label-axis-text')).toBeDefined(); + expect($prometheusGraphContents.find('.rect-axis-text')).toBeDefined(); + expect($axisLabelContainer.find('rect').length).toBe(2); + expect($axisLabelContainer.find('text').length).toBe(4); + }); + }); +}); diff --git a/spec/javascripts/monitoring/prometheus_mock_data.js b/spec/javascripts/monitoring/prometheus_mock_data.js new file mode 100644 index 00000000000..1cdc14faaa8 --- /dev/null +++ b/spec/javascripts/monitoring/prometheus_mock_data.js @@ -0,0 +1,1014 @@ +/* eslint-disable import/prefer-default-export*/ +export const prometheusMockData = { + status: 200, + metrics: { + success: true, + metrics: { + memory_values: [ + { + metric: { + }, + values: [ + [ + 1488462917.256, + '10.12890625', + ], + [ + 1488462977.256, + '10.140625', + ], + [ + 1488463037.256, + '10.140625', + ], + [ + 1488463097.256, + '10.14453125', + ], + [ + 1488463157.256, + '10.1484375', + ], + [ + 1488463217.256, + '10.15625', + ], + [ + 1488463277.256, + '10.15625', + ], + [ + 1488463337.256, + '10.15625', + ], + [ + 1488463397.256, + '10.1640625', + ], + [ + 1488463457.256, + '10.171875', + ], + [ + 1488463517.256, + '10.171875', + ], + [ + 1488463577.256, + '10.171875', + ], + [ + 1488463637.256, + '10.18359375', + ], + [ + 1488463697.256, + '10.1953125', + ], + [ + 1488463757.256, + '10.203125', + ], + [ + 1488463817.256, + '10.20703125', + ], + [ + 1488463877.256, + '10.20703125', + ], + [ + 1488463937.256, + '10.20703125', + ], + [ + 1488463997.256, + '10.20703125', + ], + [ + 1488464057.256, + '10.2109375', + ], + [ + 1488464117.256, + '10.2109375', + ], + [ + 1488464177.256, + '10.2109375', + ], + [ + 1488464237.256, + '10.2109375', + ], + [ + 1488464297.256, + '10.21484375', + ], + [ + 1488464357.256, + '10.22265625', + ], + [ + 1488464417.256, + '10.22265625', + ], + [ + 1488464477.256, + '10.2265625', + ], + [ + 1488464537.256, + '10.23046875', + ], + [ + 1488464597.256, + '10.23046875', + ], + [ + 1488464657.256, + '10.234375', + ], + [ + 1488464717.256, + '10.234375', + ], + [ + 1488464777.256, + '10.234375', + ], + [ + 1488464837.256, + '10.234375', + ], + [ + 1488464897.256, + '10.234375', + ], + [ + 1488464957.256, + '10.234375', + ], + [ + 1488465017.256, + '10.23828125', + ], + [ + 1488465077.256, + '10.23828125', + ], + [ + 1488465137.256, + '10.2421875', + ], + [ + 1488465197.256, + '10.2421875', + ], + [ + 1488465257.256, + '10.2421875', + ], + [ + 1488465317.256, + '10.2421875', + ], + [ + 1488465377.256, + '10.2421875', + ], + [ + 1488465437.256, + '10.2421875', + ], + [ + 1488465497.256, + '10.2421875', + ], + [ + 1488465557.256, + '10.2421875', + ], + [ + 1488465617.256, + '10.2421875', + ], + [ + 1488465677.256, + '10.2421875', + ], + [ + 1488465737.256, + '10.2421875', + ], + [ + 1488465797.256, + '10.24609375', + ], + [ + 1488465857.256, + '10.25', + ], + [ + 1488465917.256, + '10.25390625', + ], + [ + 1488465977.256, + '9.98828125', + ], + [ + 1488466037.256, + '9.9921875', + ], + [ + 1488466097.256, + '9.9921875', + ], + [ + 1488466157.256, + '9.99609375', + ], + [ + 1488466217.256, + '10', + ], + [ + 1488466277.256, + '10.00390625', + ], + [ + 1488466337.256, + '10.0078125', + ], + [ + 1488466397.256, + '10.01171875', + ], + [ + 1488466457.256, + '10.0234375', + ], + [ + 1488466517.256, + '10.02734375', + ], + [ + 1488466577.256, + '10.02734375', + ], + [ + 1488466637.256, + '10.03125', + ], + [ + 1488466697.256, + '10.03125', + ], + [ + 1488466757.256, + '10.03125', + ], + [ + 1488466817.256, + '10.03125', + ], + [ + 1488466877.256, + '10.03125', + ], + [ + 1488466937.256, + '10.03125', + ], + [ + 1488466997.256, + '10.03125', + ], + [ + 1488467057.256, + '10.0390625', + ], + [ + 1488467117.256, + '10.0390625', + ], + [ + 1488467177.256, + '10.04296875', + ], + [ + 1488467237.256, + '10.05078125', + ], + [ + 1488467297.256, + '10.05859375', + ], + [ + 1488467357.256, + '10.06640625', + ], + [ + 1488467417.256, + '10.06640625', + ], + [ + 1488467477.256, + '10.0703125', + ], + [ + 1488467537.256, + '10.07421875', + ], + [ + 1488467597.256, + '10.0859375', + ], + [ + 1488467657.256, + '10.0859375', + ], + [ + 1488467717.256, + '10.09765625', + ], + [ + 1488467777.256, + '10.1015625', + ], + [ + 1488467837.256, + '10.10546875', + ], + [ + 1488467897.256, + '10.10546875', + ], + [ + 1488467957.256, + '10.125', + ], + [ + 1488468017.256, + '10.13671875', + ], + [ + 1488468077.256, + '10.1484375', + ], + [ + 1488468137.256, + '10.15625', + ], + [ + 1488468197.256, + '10.16796875', + ], + [ + 1488468257.256, + '10.171875', + ], + [ + 1488468317.256, + '10.171875', + ], + [ + 1488468377.256, + '10.171875', + ], + [ + 1488468437.256, + '10.171875', + ], + [ + 1488468497.256, + '10.171875', + ], + [ + 1488468557.256, + '10.171875', + ], + [ + 1488468617.256, + '10.171875', + ], + [ + 1488468677.256, + '10.17578125', + ], + [ + 1488468737.256, + '10.17578125', + ], + [ + 1488468797.256, + '10.265625', + ], + [ + 1488468857.256, + '10.19921875', + ], + [ + 1488468917.256, + '10.19921875', + ], + [ + 1488468977.256, + '10.19921875', + ], + [ + 1488469037.256, + '10.19921875', + ], + [ + 1488469097.256, + '10.19921875', + ], + [ + 1488469157.256, + '10.203125', + ], + [ + 1488469217.256, + '10.43359375', + ], + [ + 1488469277.256, + '10.20703125', + ], + [ + 1488469337.256, + '10.2109375', + ], + [ + 1488469397.256, + '10.22265625', + ], + [ + 1488469457.256, + '10.21484375', + ], + [ + 1488469517.256, + '10.21484375', + ], + [ + 1488469577.256, + '10.21484375', + ], + [ + 1488469637.256, + '10.22265625', + ], + [ + 1488469697.256, + '10.234375', + ], + [ + 1488469757.256, + '10.234375', + ], + [ + 1488469817.256, + '10.234375', + ], + [ + 1488469877.256, + '10.2421875', + ], + [ + 1488469937.256, + '10.25', + ], + [ + 1488469997.256, + '10.25390625', + ], + [ + 1488470057.256, + '10.26171875', + ], + [ + 1488470117.256, + '10.2734375', + ], + ], + }, + ], + memory_current: [ + { + metric: { + }, + value: [ + 1488470117.737, + '10.2734375', + ], + }, + ], + cpu_values: [ + { + metric: { + }, + values: [ + [ + 1488462918.15, + '0.0002996458625058103', + ], + [ + 1488462978.15, + '0.0002652382333333314', + ], + [ + 1488463038.15, + '0.0003485461333333421', + ], + [ + 1488463098.15, + '0.0003420421999999886', + ], + [ + 1488463158.15, + '0.00023107150000001297', + ], + [ + 1488463218.15, + '0.00030463981666664826', + ], + [ + 1488463278.15, + '0.0002477177833333677', + ], + [ + 1488463338.15, + '0.00026936656666665115', + ], + [ + 1488463398.15, + '0.000406264750000022', + ], + [ + 1488463458.15, + '0.00029592802026561453', + ], + [ + 1488463518.15, + '0.00023426999683316343', + ], + [ + 1488463578.15, + '0.0003057080666666915', + ], + [ + 1488463638.15, + '0.0003408470500000149', + ], + [ + 1488463698.15, + '0.00025497336666665166', + ], + [ + 1488463758.15, + '0.0003009282833333534', + ], + [ + 1488463818.15, + '0.0003119383499999924', + ], + [ + 1488463878.15, + '0.00028719019999998705', + ], + [ + 1488463938.15, + '0.000327864749999988', + ], + [ + 1488463998.15, + '0.0002514917333333422', + ], + [ + 1488464058.15, + '0.0003614651166666742', + ], + [ + 1488464118.15, + '0.0003221668000000122', + ], + [ + 1488464178.15, + '0.00023323083333330884', + ], + [ + 1488464238.15, + '0.00028531499475009274', + ], + [ + 1488464298.15, + '0.0002627695294921391', + ], + [ + 1488464358.15, + '0.00027145463333333453', + ], + [ + 1488464418.15, + '0.00025669488333335266', + ], + [ + 1488464478.15, + '0.00022307761666665965', + ], + [ + 1488464538.15, + '0.0003307265833333517', + ], + [ + 1488464598.15, + '0.0002817050666666709', + ], + [ + 1488464658.15, + '0.00022357458333332285', + ], + [ + 1488464718.15, + '0.00032648590000000275', + ], + [ + 1488464778.15, + '0.00028410750000000816', + ], + [ + 1488464838.15, + '0.0003038076999999954', + ], + [ + 1488464898.15, + '0.00037568226666667335', + ], + [ + 1488464958.15, + '0.00020160354999999202', + ], + [ + 1488465018.15, + '0.0003229403333333399', + ], + [ + 1488465078.15, + '0.00033516069999999236', + ], + [ + 1488465138.15, + '0.0003365978333333371', + ], + [ + 1488465198.15, + '0.00020262178333331585', + ], + [ + 1488465258.15, + '0.00040567498333331876', + ], + [ + 1488465318.15, + '0.00029114155000001436', + ], + [ + 1488465378.15, + '0.0002498841000000122', + ], + [ + 1488465438.15, + '0.00027296763333331715', + ], + [ + 1488465498.15, + '0.0002958794000000135', + ], + [ + 1488465558.15, + '0.0002922354666666867', + ], + [ + 1488465618.15, + '0.00034186624999999653', + ], + [ + 1488465678.15, + '0.0003397984166666627', + ], + [ + 1488465738.15, + '0.0002658284166666469', + ], + [ + 1488465798.15, + '0.00026221139999999346', + ], + [ + 1488465858.15, + '0.00029467960000001034', + ], + [ + 1488465918.15, + '0.0002634141333333358', + ], + [ + 1488465978.15, + '0.0003202958333333209', + ], + [ + 1488466038.15, + '0.00037890760000000394', + ], + [ + 1488466098.15, + '0.00023453356666666518', + ], + [ + 1488466158.15, + '0.0002866827333333433', + ], + [ + 1488466218.15, + '0.0003335935499999998', + ], + [ + 1488466278.15, + '0.00022787131666666125', + ], + [ + 1488466338.15, + '0.00033821938333333064', + ], + [ + 1488466398.15, + '0.00029233375000001043', + ], + [ + 1488466458.15, + '0.00026562758333333514', + ], + [ + 1488466518.15, + '0.0003142600999999819', + ], + [ + 1488466578.15, + '0.00027392178333333444', + ], + [ + 1488466638.15, + '0.00028178598333334173', + ], + [ + 1488466698.15, + '0.0002463400666666911', + ], + [ + 1488466758.15, + '0.00040234373333332125', + ], + [ + 1488466818.15, + '0.00023677453333332822', + ], + [ + 1488466878.15, + '0.00030852703333333523', + ], + [ + 1488466938.15, + '0.0003582272833333455', + ], + [ + 1488466998.15, + '0.0002176380833332973', + ], + [ + 1488467058.15, + '0.00026180203333335447', + ], + [ + 1488467118.15, + '0.00027862966666667436', + ], + [ + 1488467178.15, + '0.0002769731166666567', + ], + [ + 1488467238.15, + '0.0002832899166666477', + ], + [ + 1488467298.15, + '0.0003446533500000311', + ], + [ + 1488467358.15, + '0.0002691345999999761', + ], + [ + 1488467418.15, + '0.000284919933333357', + ], + [ + 1488467478.15, + '0.0002396026166666528', + ], + [ + 1488467538.15, + '0.00035625295000002075', + ], + [ + 1488467598.15, + '0.00036759816666664946', + ], + [ + 1488467658.15, + '0.00030326608333333855', + ], + [ + 1488467718.15, + '0.00023584972418043393', + ], + [ + 1488467778.15, + '0.00025744508892115107', + ], + [ + 1488467838.15, + '0.00036737541666663395', + ], + [ + 1488467898.15, + '0.00034325741666666094', + ], + [ + 1488467958.15, + '0.00026390046666667407', + ], + [ + 1488468018.15, + '0.0003302534500000102', + ], + [ + 1488468078.15, + '0.00035243794999999527', + ], + [ + 1488468138.15, + '0.00020149738333333407', + ], + [ + 1488468198.15, + '0.0003183469666666679', + ], + [ + 1488468258.15, + '0.0003835329166666845', + ], + [ + 1488468318.15, + '0.0002485075333333124', + ], + [ + 1488468378.15, + '0.0003011457166666768', + ], + [ + 1488468438.15, + '0.00032242785497684965', + ], + [ + 1488468498.15, + '0.0002659713747457531', + ], + [ + 1488468558.15, + '0.0003476860333333202', + ], + [ + 1488468618.15, + '0.00028336403333334794', + ], + [ + 1488468678.15, + '0.00017132354999998728', + ], + [ + 1488468738.15, + '0.0003001915833333276', + ], + [ + 1488468798.15, + '0.0003025715666666725', + ], + [ + 1488468858.15, + '0.0003012370166666815', + ], + [ + 1488468918.15, + '0.00030203619999997025', + ], + [ + 1488468978.15, + '0.0002804355000000314', + ], + [ + 1488469038.15, + '0.00033194884999998564', + ], + [ + 1488469098.15, + '0.00025201496666665455', + ], + [ + 1488469158.15, + '0.0002777531500000189', + ], + [ + 1488469218.15, + '0.0003314885833333392', + ], + [ + 1488469278.15, + '0.0002234891422095589', + ], + [ + 1488469338.15, + '0.000349117355867791', + ], + [ + 1488469398.15, + '0.0004036731333333303', + ], + [ + 1488469458.15, + '0.00024553911666667835', + ], + [ + 1488469518.15, + '0.0003056456833333184', + ], + [ + 1488469578.15, + '0.0002618737166666681', + ], + [ + 1488469638.15, + '0.00022972643333331414', + ], + [ + 1488469698.15, + '0.0003713522500000307', + ], + [ + 1488469758.15, + '0.00018322576666666515', + ], + [ + 1488469818.15, + '0.00034534762753952466', + ], + [ + 1488469878.15, + '0.00028200510008501677', + ], + [ + 1488469938.15, + '0.0002773708499999768', + ], + [ + 1488469998.15, + '0.00027547160000001013', + ], + [ + 1488470058.15, + '0.00031713610000000023', + ], + [ + 1488470118.15, + '0.00035276853333332525', + ], + ], + }, + ], + cpu_current: [ + { + metric: { + }, + value: [ + 1488470118.566, + '0.00035276853333332525', + ], + }, + ], + last_update: '2017-03-02T15:55:18.981Z', + }, + }, +}; diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index daf8f5c1d6c..03c4879ed6f 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -3,6 +3,24 @@ require 'spec_helper' describe Gitlab::Auth, lib: true do let(:gl_auth) { described_class } + describe 'constants' do + it 'API_SCOPES contains all scopes for API access' do + expect(subject::API_SCOPES).to eq [:api, :read_user] + end + + it 'OPENID_SCOPES contains all scopes for OpenID Connect' do + expect(subject::OPENID_SCOPES).to eq [:openid] + end + + it 'DEFAULT_SCOPES contains all default scopes' do + expect(subject::DEFAULT_SCOPES).to eq [:api] + end + + it 'OPTIONAL_SCOPES contains all non-default scopes' do + expect(subject::OPTIONAL_SCOPES).to eq [:read_user, :openid] + end + end + describe 'find_for_git_client' do context 'build token' do subject { gl_auth.find_for_git_client('gitlab-ci-token', build.token, project: project, ip: 'ip') } @@ -118,25 +136,37 @@ describe Gitlab::Auth, lib: true do end context 'while using personal access tokens as passwords' do - let(:user) { create(:user) } - let(:token_w_api_scope) { create(:personal_access_token, user: user, scopes: ['api']) } - it 'succeeds for personal access tokens with the `api` scope' do - expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: user.email) - expect(gl_auth.find_for_git_client(user.email, token_w_api_scope.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :personal_token, full_authentication_abilities)) + personal_access_token = create(:personal_access_token, scopes: ['api']) + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') + expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, full_authentication_abilities)) + end + + it 'succeeds if it is an impersonation token' do + impersonation_token = create(:personal_access_token, :impersonation, scopes: ['api']) + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') + expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(impersonation_token.user, nil, :personal_token, full_authentication_abilities)) end it 'fails for personal access tokens with other scopes' do - personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) + personal_access_token = create(:personal_access_token, scopes: ['read_user']) - expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: user.email) - expect(gl_auth.find_for_git_client(user.email, personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '') + expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) end - it 'does not try password auth before personal access tokens' do - expect(gl_auth).not_to receive(:find_with_user_password) + it 'fails for impersonation token with other scopes' do + impersonation_token = create(:personal_access_token, scopes: ['read_user']) - gl_auth.find_for_git_client(user.email, token_w_api_scope.token, project: nil, ip: 'ip') + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '') + expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) + end + + it 'fails if password is nil' do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '') + expect(gl_auth.find_for_git_client('', nil, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) end end @@ -210,6 +240,18 @@ describe Gitlab::Auth, lib: true do end end + it "does not find user in blocked state" do + user.block + + expect( gl_auth.find_with_user_password(username, password) ).not_to eql user + end + + it "does not find user in ldap_blocked state" do + user.ldap_block + + expect( gl_auth.find_with_user_password(username, password) ).not_to eql user + end + context "with ldap enabled" do before do allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true) diff --git a/spec/lib/gitlab/ci/build/image_spec.rb b/spec/lib/gitlab/ci/build/image_spec.rb new file mode 100644 index 00000000000..382385dfd6b --- /dev/null +++ b/spec/lib/gitlab/ci/build/image_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe Gitlab::Ci::Build::Image do + let(:job) { create(:ci_build, :no_options) } + + describe '#from_image' do + subject { described_class.from_image(job) } + + context 'when image is defined in job' do + let(:image_name) { 'ruby:2.1' } + let(:job) { create(:ci_build, options: { image: image_name } ) } + + it 'fabricates an object of the proper class' do + is_expected.to be_kind_of(described_class) + end + + it 'populates fabricated object with the proper name attribute' do + expect(subject.name).to eq(image_name) + end + + context 'when image name is empty' do + let(:image_name) { '' } + + it 'does not fabricate an object' do + is_expected.to be_nil + end + end + end + + context 'when image is not defined in job' do + it 'does not fabricate an object' do + is_expected.to be_nil + end + end + end + + describe '#from_services' do + subject { described_class.from_services(job) } + + context 'when services are defined in job' do + let(:service_image_name) { 'postgres' } + let(:job) { create(:ci_build, options: { services: [service_image_name] }) } + + it 'fabricates an non-empty array of objects' do + is_expected.to be_kind_of(Array) + is_expected.not_to be_empty + expect(subject.first.name).to eq(service_image_name) + end + + context 'when service image name is empty' do + let(:service_image_name) { '' } + + it 'fabricates an empty array' do + is_expected.to be_kind_of(Array) + is_expected.to be_empty + end + end + end + + context 'when services are not defined in job' do + it 'fabricates an empty array' do + is_expected.to be_kind_of(Array) + is_expected.to be_empty + end + end + end +end diff --git a/spec/lib/gitlab/ci/build/step_spec.rb b/spec/lib/gitlab/ci/build/step_spec.rb new file mode 100644 index 00000000000..2a314a744ca --- /dev/null +++ b/spec/lib/gitlab/ci/build/step_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe Gitlab::Ci::Build::Step do + let(:job) { create(:ci_build, :no_options, commands: "ls -la\ndate") } + + describe '#from_commands' do + subject { described_class.from_commands(job) } + + it 'fabricates an object' do + expect(subject.name).to eq(:script) + expect(subject.script).to eq(['ls -la', 'date']) + expect(subject.timeout).to eq(job.timeout) + expect(subject.when).to eq('on_success') + expect(subject.allow_failure).to be_falsey + end + end + + describe '#from_after_script' do + subject { described_class.from_after_script(job) } + + context 'when after_script is empty' do + it 'doesn not fabricate an object' do + is_expected.to be_nil + end + end + + context 'when after_script is not empty' do + let(:job) { create(:ci_build, options: { after_script: "ls -la\ndate" }) } + + it 'fabricates an object' do + expect(subject.name).to eq(:after_script) + expect(subject.script).to eq(['ls -la', 'date']) + expect(subject.timeout).to eq(job.timeout) + expect(subject.when).to eq('always') + expect(subject.allow_failure).to be_truthy + end + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 1a1280e5198..e47956a365f 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -136,6 +136,7 @@ project: - slack_slash_commands_service - irker_service - pivotaltracker_service +- prometheus_service - hipchat_service - flowdock_service - assembla_service diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_spec.rb new file mode 100644 index 00000000000..280264188e2 --- /dev/null +++ b/spec/lib/gitlab/prometheus_spec.rb @@ -0,0 +1,143 @@ +require 'spec_helper' + +describe Gitlab::Prometheus, lib: true do + include PrometheusHelpers + + subject { described_class.new(api_url: 'https://prometheus.example.com') } + + describe '#ping' do + it 'issues a "query" request to the API endpoint' do + req_stub = stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector')) + + expect(subject.ping).to eq({ "resultType" => "vector", "result" => [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }] }) + expect(req_stub).to have_been_requested + end + end + + # This shared examples expect: + # - query_url: A query URL + # - execute_query: A query call + shared_examples 'failure response' do + context 'when request returns 400 with an error message' do + it 'raises a Gitlab::PrometheusError error' do + req_stub = stub_prometheus_request(query_url, status: 400, body: { error: 'bar!' }) + + expect { execute_query } + .to raise_error(Gitlab::PrometheusError, 'bar!') + expect(req_stub).to have_been_requested + end + end + + context 'when request returns 400 without an error message' do + it 'raises a Gitlab::PrometheusError error' do + req_stub = stub_prometheus_request(query_url, status: 400) + + expect { execute_query } + .to raise_error(Gitlab::PrometheusError, 'Bad data received') + expect(req_stub).to have_been_requested + end + end + + context 'when request returns 500' do + it 'raises a Gitlab::PrometheusError error' do + req_stub = stub_prometheus_request(query_url, status: 500, body: { message: 'FAIL!' }) + + expect { execute_query } + .to raise_error(Gitlab::PrometheusError, '500 - {"message":"FAIL!"}') + expect(req_stub).to have_been_requested + end + end + end + + describe '#query' do + let(:prometheus_query) { prometheus_cpu_query('env-slug') } + let(:query_url) { prometheus_query_url(prometheus_query) } + + context 'when request returns vector results' do + it 'returns data from the API call' do + req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('vector')) + + expect(subject.query(prometheus_query)).to eq [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }] + expect(req_stub).to have_been_requested + end + end + + context 'when request returns matrix results' do + it 'returns nil' do + req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('matrix')) + + expect(subject.query(prometheus_query)).to be_nil + expect(req_stub).to have_been_requested + end + end + + context 'when request returns no data' do + it 'returns []' do + req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('vector')) + + expect(subject.query(prometheus_query)).to be_empty + expect(req_stub).to have_been_requested + end + end + + it_behaves_like 'failure response' do + let(:execute_query) { subject.query(prometheus_query) } + end + end + + describe '#query_range' do + let(:prometheus_query) { prometheus_memory_query('env-slug') } + let(:query_url) { prometheus_query_range_url(prometheus_query) } + + around do |example| + Timecop.freeze { example.run } + end + + context 'when a start time is passed' do + let(:query_url) { prometheus_query_range_url(prometheus_query, start: 2.hours.ago) } + + it 'passed it in the requested URL' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector')) + + subject.query_range(prometheus_query, start: 2.hours.ago) + expect(req_stub).to have_been_requested + end + end + + context 'when request returns vector results' do + it 'returns nil' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector')) + + expect(subject.query_range(prometheus_query)).to be_nil + expect(req_stub).to have_been_requested + end + end + + context 'when request returns matrix results' do + it 'returns data from the API call' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('matrix')) + + expect(subject.query_range(prometheus_query)).to eq([ + { + "metric" => {}, + "values" => [[1488758662.506, "0.00002996364761904785"], [1488758722.506, "0.00003090239047619091"]] + } + ]) + expect(req_stub).to have_been_requested + end + end + + context 'when request returns no data' do + it 'returns []' do + req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('matrix')) + + expect(subject.query_range(prometheus_query)).to be_empty + expect(req_stub).to have_been_requested + end + end + + it_behaves_like 'failure response' do + let(:execute_query) { subject.query_range(prometheus_query) } + end + end +end diff --git a/spec/models/chat_team_spec.rb b/spec/models/chat_team_spec.rb index 1aab161ec13..5283561a83f 100644 --- a/spec/models/chat_team_spec.rb +++ b/spec/models/chat_team_spec.rb @@ -1,9 +1,14 @@ require 'spec_helper' describe ChatTeam, type: :model do + subject { create(:chat_team) } + # Associations it { is_expected.to belong_to(:namespace) } + # Validations + it { is_expected.to validate_uniqueness_of(:namespace) } + # Fields it { is_expected.to respond_to(:name) } it { is_expected.to respond_to(:team_id) } diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 2db42a94077..fd6ea2d6722 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -345,11 +345,11 @@ describe Ci::Build, :models do describe '#expanded_environment_name' do subject { build.expanded_environment_name } - context 'when environment uses $CI_BUILD_REF_NAME' do + context 'when environment uses $CI_COMMIT_REF_NAME' do let(:build) do create(:ci_build, ref: 'master', - environment: 'review/$CI_BUILD_REF_NAME') + environment: 'review/$CI_COMMIT_REF_NAME') end it { is_expected.to eq('review/master') } @@ -915,7 +915,7 @@ describe Ci::Build, :models do end context 'referenced with a variable' do - let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-$CI_BUILD_REF_NAME") } + let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-$CI_COMMIT_REF_NAME") } it { is_expected.to eq(@environment) } end @@ -1286,23 +1286,25 @@ describe Ci::Build, :models do [ { key: 'CI', value: 'true', public: true }, { key: 'GITLAB_CI', value: 'true', public: true }, - { key: 'CI_BUILD_ID', value: build.id.to_s, public: true }, - { key: 'CI_BUILD_TOKEN', value: build.token, public: false }, - { key: 'CI_BUILD_REF', value: build.sha, public: true }, - { key: 'CI_BUILD_BEFORE_SHA', value: build.before_sha, public: true }, - { key: 'CI_BUILD_REF_NAME', value: 'master', public: true }, - { key: 'CI_BUILD_REF_SLUG', value: 'master', public: true }, - { key: 'CI_BUILD_NAME', value: 'test', public: true }, - { key: 'CI_BUILD_STAGE', value: 'test', public: true }, { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true }, + { key: 'CI_JOB_ID', value: build.id.to_s, public: true }, + { key: 'CI_JOB_NAME', value: 'test', public: true }, + { key: 'CI_JOB_STAGE', value: 'test', public: true }, + { key: 'CI_JOB_TOKEN', value: build.token, public: false }, + { key: 'CI_COMMIT_SHA', value: build.sha, public: true }, + { key: 'CI_COMMIT_REF_NAME', value: build.ref, public: true }, + { key: 'CI_COMMIT_REF_SLUG', value: build.ref_slug, public: true }, { key: 'CI_PROJECT_ID', value: project.id.to_s, public: true }, { key: 'CI_PROJECT_NAME', value: project.path, public: true }, { key: 'CI_PROJECT_PATH', value: project.full_path, public: true }, { key: 'CI_PROJECT_NAMESPACE', value: project.namespace.full_path, public: true }, { key: 'CI_PROJECT_URL', value: project.web_url, public: true }, - { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true } + { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true }, + { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true }, + { key: 'CI_REGISTRY_PASSWORD', value: build.token, public: false }, + { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false }, ] end @@ -1317,7 +1319,7 @@ describe Ci::Build, :models do build.yaml_variables = [] end - it { is_expected.to eq(predefined_variables) } + it { is_expected.to include(*predefined_variables) } end context 'when build has user' do @@ -1355,7 +1357,7 @@ describe Ci::Build, :models do end let(:manual_variable) do - { key: 'CI_BUILD_MANUAL', value: 'true', public: true } + { key: 'CI_JOB_MANUAL', value: 'true', public: true } end it { is_expected.to include(manual_variable) } @@ -1363,7 +1365,7 @@ describe Ci::Build, :models do context 'when build is for tag' do let(:tag_variable) do - { key: 'CI_BUILD_TAG', value: 'master', public: true } + { key: 'CI_COMMIT_TAG', value: 'master', public: true } end before do @@ -1392,7 +1394,7 @@ describe Ci::Build, :models do { key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1', public: false } end let(:predefined_trigger_variable) do - { key: 'CI_BUILD_TRIGGERED', value: 'true', public: true } + { key: 'CI_PIPELINE_TRIGGERED', value: 'true', public: true } end before do @@ -1416,7 +1418,7 @@ describe Ci::Build, :models do context 'when config is not found' do let(:config) { nil } - it { is_expected.to eq(predefined_variables) } + it { is_expected.to include(*predefined_variables) } end context 'when config does not have a questioned job' do @@ -1428,7 +1430,7 @@ describe Ci::Build, :models do }) end - it { is_expected.to eq(predefined_variables) } + it { is_expected.to include(*predefined_variables) } end context 'when config has variables' do @@ -1446,7 +1448,8 @@ describe Ci::Build, :models do [{ key: 'KEY', value: 'value', public: true }] end - it { is_expected.to eq(predefined_variables + variables) } + it { is_expected.to include(*predefined_variables) } + it { is_expected.to include(*variables) } end end end diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb index 074cf1a0bd7..1bcb673cb16 100644 --- a/spec/models/ci/trigger_spec.rb +++ b/spec/models/ci/trigger_spec.rb @@ -22,4 +22,62 @@ describe Ci::Trigger, models: true do expect(trigger.token).to eq('token') end end + + describe '#short_token' do + let(:trigger) { create(:ci_trigger, token: '12345678') } + + subject { trigger.short_token } + + it 'returns shortened token' do + is_expected.to eq('1234') + end + end + + describe '#legacy?' do + let(:trigger) { create(:ci_trigger, owner: owner, project: project) } + + subject { trigger } + + context 'when owner is blank' do + let(:owner) { nil } + + it { is_expected.to be_legacy } + end + + context 'when owner is set' do + let(:owner) { create(:user) } + + it { is_expected.not_to be_legacy } + end + end + + describe '#can_access_project?' do + let(:trigger) { create(:ci_trigger, owner: owner, project: project) } + + context 'when owner is blank' do + let(:owner) { nil } + + subject { trigger.can_access_project? } + + it { is_expected.to eq(true) } + end + + context 'when owner is set' do + let(:owner) { create(:user) } + + subject { trigger.can_access_project? } + + context 'and is member of the project' do + before do + project.team << [owner, :developer] + end + + it { is_expected.to eq(true) } + end + + context 'and is not member of the project' do + it { is_expected.to eq(false) } + end + end + end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index dce18f008f8..b4305e92812 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -271,7 +271,11 @@ describe Environment, models: true do context 'when the environment is unavailable' do let(:project) { create(:kubernetes_project) } - before { environment.stop } + + before do + environment.stop + end + it { is_expected.to be_falsy } end end @@ -281,20 +285,85 @@ describe Environment, models: true do subject { environment.terminals } context 'when the environment has terminals' do - before { allow(environment).to receive(:has_terminals?).and_return(true) } + before do + allow(environment).to receive(:has_terminals?).and_return(true) + end it 'returns the terminals from the deployment service' do - expect(project.deployment_service). - to receive(:terminals).with(environment). - and_return(:fake_terminals) + expect(project.deployment_service) + .to receive(:terminals).with(environment) + .and_return(:fake_terminals) is_expected.to eq(:fake_terminals) end end context 'when the environment does not have terminals' do - before { allow(environment).to receive(:has_terminals?).and_return(false) } - it { is_expected.to eq(nil) } + before do + allow(environment).to receive(:has_terminals?).and_return(false) + end + + it { is_expected.to be_nil } + end + end + + describe '#has_metrics?' do + subject { environment.has_metrics? } + + context 'when the enviroment is available' do + context 'with a deployment service' do + let(:project) { create(:prometheus_project) } + + context 'and a deployment' do + let!(:deployment) { create(:deployment, environment: environment) } + it { is_expected.to be_truthy } + end + + context 'but no deployments' do + it { is_expected.to be_falsy } + end + end + + context 'without a monitoring service' do + it { is_expected.to be_falsy } + end + end + + context 'when the environment is unavailable' do + let(:project) { create(:prometheus_project) } + + before do + environment.stop + end + + it { is_expected.to be_falsy } + end + end + + describe '#metrics' do + let(:project) { create(:prometheus_project) } + subject { environment.metrics } + + context 'when the environment has metrics' do + before do + allow(environment).to receive(:has_metrics?).and_return(true) + end + + it 'returns the metrics from the deployment service' do + expect(project.monitoring_service) + .to receive(:metrics).with(environment) + .and_return(:fake_metrics) + + is_expected.to eq(:fake_metrics) + end + end + + context 'when the environment does not have metrics' do + before do + allow(environment).to receive(:has_metrics?).and_return(false) + end + + it { is_expected.to be_nil } end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 7525a1b79ee..757f3921450 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -165,7 +165,7 @@ describe Namespace, models: true do describe :rm_dir do let!(:project) { create(:empty_project, namespace: namespace) } - let!(:path) { File.join(Gitlab.config.repositories.storages.default, namespace.full_path) } + let!(:path) { File.join(Gitlab.config.repositories.storages.default['path'], namespace.full_path) } it "removes its dirs when deleted" do namespace.destroy diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb index 46eb71cef14..823623d96fa 100644 --- a/spec/models/personal_access_token_spec.rb +++ b/spec/models/personal_access_token_spec.rb @@ -1,15 +1,61 @@ require 'spec_helper' describe PersonalAccessToken, models: true do - describe ".generate" do - it "generates a random token" do - personal_access_token = PersonalAccessToken.generate({}) - expect(personal_access_token.token).to be_present + describe '.build' do + let(:personal_access_token) { build(:personal_access_token) } + let(:invalid_personal_access_token) { build(:personal_access_token, :invalid) } + + it 'is a valid personal access token' do + expect(personal_access_token).to be_valid + end + + it 'ensures that the token is generated' do + invalid_personal_access_token.save! + + expect(invalid_personal_access_token).to be_valid + expect(invalid_personal_access_token.token).not_to be_nil end + end + + describe ".active?" do + let(:active_personal_access_token) { build(:personal_access_token) } + let(:revoked_personal_access_token) { build(:personal_access_token, :revoked) } + let(:expired_personal_access_token) { build(:personal_access_token, :expired) } + + it "returns false if the personal_access_token is revoked" do + expect(revoked_personal_access_token).not_to be_active + end + + it "returns false if the personal_access_token is expired" do + expect(expired_personal_access_token).not_to be_active + end + + it "returns true if the personal_access_token is not revoked and not expired" do + expect(active_personal_access_token).to be_active + end + end + + context "validations" do + let(:personal_access_token) { build(:personal_access_token) } + + it "requires at least one scope" do + personal_access_token.scopes = [] + + expect(personal_access_token).not_to be_valid + expect(personal_access_token.errors[:scopes].first).to eq "can't be blank" + end + + it "allows creating a token with API scopes" do + personal_access_token.scopes = [:api, :read_user] + + expect(personal_access_token).to be_valid + end + + it "rejects creating a token with non-API scopes" do + personal_access_token.scopes = [:openid, :api] - it "doesn't save the record" do - personal_access_token = PersonalAccessToken.generate({}) - expect(personal_access_token).not_to be_persisted + expect(personal_access_token).not_to be_valid + expect(personal_access_token.errors[:scopes].first).to eq "can only contain API scopes" end end end diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb new file mode 100644 index 00000000000..d15079b686b --- /dev/null +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe PrometheusService, models: true, caching: true do + include PrometheusHelpers + include ReactiveCachingHelpers + + let(:project) { create(:prometheus_project) } + let(:service) { project.prometheus_service } + + describe "Associations" do + it { is_expected.to belong_to :project } + end + + describe 'Validations' do + context 'when service is active' do + before { subject.active = true } + + it { is_expected.to validate_presence_of(:api_url) } + end + + context 'when service is inactive' do + before { subject.active = false } + + it { is_expected.not_to validate_presence_of(:api_url) } + end + end + + describe '#test' do + let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector')) } + + context 'success' do + it 'reads the discovery endpoint' do + expect(service.test[:success]).to be_truthy + expect(req_stub).to have_been_requested + end + end + + context 'failure' do + let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), status: 404) } + + it 'fails to read the discovery endpoint' do + expect(service.test[:success]).to be_falsy + expect(req_stub).to have_been_requested + end + end + end + + describe '#metrics' do + let(:environment) { build_stubbed(:environment, slug: 'env-slug') } + subject { service.metrics(environment) } + + around do |example| + Timecop.freeze { example.run } + end + + context 'with valid data' do + before do + stub_reactive_cache(service, prometheus_data, 'env-slug') + end + + it 'returns reactive data' do + is_expected.to eq(prometheus_data) + end + end + end + + describe '#calculate_reactive_cache' do + let(:environment) { build_stubbed(:environment, slug: 'env-slug') } + + around do |example| + Timecop.freeze { example.run } + end + + subject do + service.calculate_reactive_cache(environment.slug) + end + + context 'when service is inactive' do + before do + service.active = false + end + + it { is_expected.to be_nil } + end + + context 'when Prometheus responds with valid data' do + before do + stub_all_prometheus_requests(environment.slug) + end + + it { expect(subject.to_json).to eq(prometheus_data.to_json) } + end + + [404, 500].each do |status| + context "when Prometheus responds with #{status}" do + before do + stub_all_prometheus_requests(environment.slug, status: status, body: 'QUERY FAILED!') + end + + it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) } + end + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 84bdcbe8e59..e120e21af06 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -179,7 +179,7 @@ describe Project, models: true do let(:project2) { build(:empty_project, repository_storage: 'missing') } before do - storages = { 'custom' => 'tmp/tests/custom_repositories' } + storages = { 'custom' => { 'path' => 'tmp/tests/custom_repositories' } } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end @@ -381,7 +381,7 @@ describe Project, models: true do before do FileUtils.mkdir('tmp/tests/custom_repositories') - storages = { 'custom' => 'tmp/tests/custom_repositories' } + storages = { 'custom' => { 'path' => 'tmp/tests/custom_repositories' } } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end @@ -947,8 +947,8 @@ describe Project, models: true do before do storages = { - 'default' => 'tmp/tests/repositories', - 'picked' => 'tmp/tests/repositories', + 'default' => { 'path' => 'tmp/tests/repositories' }, + 'picked' => { 'path' => 'tmp/tests/repositories' }, } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end diff --git a/spec/policies/ci/trigger_policy_spec.rb b/spec/policies/ci/trigger_policy_spec.rb new file mode 100644 index 00000000000..63ad5eb7322 --- /dev/null +++ b/spec/policies/ci/trigger_policy_spec.rb @@ -0,0 +1,103 @@ +require 'spec_helper' + +describe Ci::TriggerPolicy, :models do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:trigger) { create(:ci_trigger, project: project, owner: owner) } + + let(:policies) do + described_class.abilities(user, trigger).to_set + end + + shared_examples 'allows to admin and manage trigger' do + it 'does include ability to admin trigger' do + expect(policies).to include :admin_trigger + end + + it 'does include ability to manage trigger' do + expect(policies).to include :manage_trigger + end + end + + shared_examples 'allows to manage trigger' do + it 'does not include ability to admin trigger' do + expect(policies).not_to include :admin_trigger + end + + it 'does include ability to manage trigger' do + expect(policies).to include :manage_trigger + end + end + + shared_examples 'disallows to admin and manage trigger' do + it 'does not include ability to admin trigger' do + expect(policies).not_to include :admin_trigger + end + + it 'does not include ability to manage trigger' do + expect(policies).not_to include :manage_trigger + end + end + + describe '#rules' do + context 'when owner is undefined' do + let(:owner) { nil } + + context 'when user is master of the project' do + before do + project.team << [user, :master] + end + + it_behaves_like 'allows to admin and manage trigger' + end + + context 'when user is developer of the project' do + before do + project.team << [user, :developer] + end + + it_behaves_like 'disallows to admin and manage trigger' + end + + context 'when user is not member of the project' do + it_behaves_like 'disallows to admin and manage trigger' + end + end + + context 'when owner is an user' do + let(:owner) { user } + + context 'when user is master of the project' do + before do + project.team << [user, :master] + end + + it_behaves_like 'allows to admin and manage trigger' + end + end + + context 'when owner is another user' do + let(:owner) { create(:user) } + + context 'when user is master of the project' do + before do + project.team << [user, :master] + end + + it_behaves_like 'allows to manage trigger' + end + + context 'when user is developer of the project' do + before do + project.team << [user, :developer] + end + + it_behaves_like 'disallows to admin and manage trigger' + end + + context 'when user is not member of the project' do + it_behaves_like 'disallows to admin and manage trigger' + end + end + end +end diff --git a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb new file mode 100644 index 00000000000..6443f86b6a1 --- /dev/null +++ b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe Projects::Settings::DeployKeysPresenter do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:deploy_key) { create(:deploy_key, public: true) } + + let!(:deploy_keys_project) do + create(:deploy_keys_project, project: project, deploy_key: deploy_key) + end + + subject(:presenter) do + described_class.new(project, current_user: user) + end + + it 'inherits from Gitlab::View::Presenter::Simple' do + expect(described_class.superclass).to eq(Gitlab::View::Presenter::Simple) + end + + describe '#enabled_keys' do + it 'returns currently enabled keys' do + expect(presenter.enabled_keys).to eq [deploy_keys_project.deploy_key] + end + + it 'does not contain enabled_keys inside available_keys' do + expect(presenter.available_keys).not_to include deploy_key + end + + it 'returns the enabled_keys size' do + expect(presenter.enabled_keys_size).to eq(1) + end + + it 'returns true if there is any enabled_keys' do + expect(presenter.any_keys_enabled?).to eq(true) + end + end + + describe '#available_keys/#available_project_keys' do + let(:other_deploy_key) { create(:another_deploy_key) } + + before do + project_key = create(:deploy_keys_project, deploy_key: other_deploy_key) + project_key.project.add_developer(user) + end + + it 'returns the current available_keys' do + expect(presenter.available_keys).not_to be_empty + end + + it 'returns the current available_project_keys' do + expect(presenter.available_project_keys).not_to be_empty + end + + it 'returns false if any available_project_keys are enabled' do + expect(presenter.any_available_project_keys_enabled?).to eq(true) + end + + it 'returns the available_project_keys size' do + expect(presenter.available_project_keys_size).to eq(1) + end + + it 'shows if there is an available key' do + expect(presenter.key_available?(deploy_key)).to eq(false) + end + end +end diff --git a/spec/requests/api/api_internal_helpers_spec.rb b/spec/requests/api/api_internal_helpers_spec.rb index be4bc39ada2..f5265ea60ff 100644 --- a/spec/requests/api/api_internal_helpers_spec.rb +++ b/spec/requests/api/api_internal_helpers_spec.rb @@ -21,7 +21,7 @@ describe ::API::Helpers::InternalHelpers do # Relative and absolute storage paths, with and without trailing / ['.', './', Dir.pwd, Dir.pwd + '/'].each do |storage_path| context "storage path is #{storage_path}" do - subject { clean_project_path(project_path, [storage_path]) } + subject { clean_project_path(project_path, [{ 'path' => storage_path }]) } it { is_expected.to eq(expected) } end diff --git a/spec/requests/api/award_emoji_spec.rb b/spec/requests/api/award_emoji_spec.rb index 9756991162e..f4d4a8a2cc7 100644 --- a/spec/requests/api/award_emoji_spec.rb +++ b/spec/requests/api/award_emoji_spec.rb @@ -15,7 +15,7 @@ describe API::AwardEmoji, api: true do describe "GET /projects/:id/awardable/:awardable_id/award_emoji" do context 'on an issue' do it "returns an array of award_emoji" do - get api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user) + get api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -31,7 +31,7 @@ describe API::AwardEmoji, api: true do context 'on a merge request' do it "returns an array of award_emoji" do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/award_emoji", user) expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -57,7 +57,7 @@ describe API::AwardEmoji, api: true do it 'returns a status code 404' do user1 = create(:user) - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji", user1) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/award_emoji", user1) expect(response).to have_http_status(404) end @@ -68,7 +68,7 @@ describe API::AwardEmoji, api: true do let!(:rocket) { create(:award_emoji, awardable: note, name: 'rocket') } it 'returns an array of award emoji' do - get api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user) + get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -79,7 +79,7 @@ describe API::AwardEmoji, api: true do describe "GET /projects/:id/awardable/:awardable_id/award_emoji/:award_id" do context 'on an issue' do it "returns the award emoji" do - get api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/#{award_emoji.id}", user) + get api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji/#{award_emoji.id}", user) expect(response).to have_http_status(200) expect(json_response['name']).to eq(award_emoji.name) @@ -88,7 +88,7 @@ describe API::AwardEmoji, api: true do end it "returns a 404 error if the award is not found" do - get api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/12345", user) + get api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji/12345", user) expect(response).to have_http_status(404) end @@ -96,7 +96,7 @@ describe API::AwardEmoji, api: true do context 'on a merge request' do it 'returns the award emoji' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/award_emoji/#{downvote.id}", user) expect(response).to have_http_status(200) expect(json_response['name']).to eq(downvote.name) @@ -123,7 +123,7 @@ describe API::AwardEmoji, api: true do it 'returns a status code 404' do user1 = create(:user) - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user1) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/award_emoji/#{downvote.id}", user1) expect(response).to have_http_status(404) end @@ -134,7 +134,7 @@ describe API::AwardEmoji, api: true do let!(:rocket) { create(:award_emoji, awardable: note, name: 'rocket') } it 'returns an award emoji' do - get api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji/#{rocket.id}", user) + get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji/#{rocket.id}", user) expect(response).to have_http_status(200) expect(json_response).not_to be_an Array @@ -147,7 +147,7 @@ describe API::AwardEmoji, api: true do context "on an issue" do it "creates a new award emoji" do - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'blowfish' + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user), name: 'blowfish' expect(response).to have_http_status(201) expect(json_response['name']).to eq('blowfish') @@ -155,13 +155,13 @@ describe API::AwardEmoji, api: true do end it "returns a 400 bad request error if the name is not given" do - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user) + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user) expect(response).to have_http_status(400) end it "returns a 401 unauthorized error if the user is not authenticated" do - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji"), name: 'thumbsup' + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji"), name: 'thumbsup' expect(response).to have_http_status(401) end @@ -173,15 +173,15 @@ describe API::AwardEmoji, api: true do end it "normalizes +1 as thumbsup award" do - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: '+1' + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user), name: '+1' expect(issue.award_emoji.last.name).to eq("thumbsup") end context 'when the emoji already has been awarded' do it 'returns a 404 status code' do - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'thumbsup' - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'thumbsup' + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user), name: 'thumbsup' + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user), name: 'thumbsup' expect(response).to have_http_status(404) expect(json_response["message"]).to match("has already been taken") @@ -207,7 +207,7 @@ describe API::AwardEmoji, api: true do it 'creates a new award emoji' do expect do - post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' + post api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji", user), name: 'rocket' end.to change { note.award_emoji.count }.from(0).to(1) expect(response).to have_http_status(201) @@ -215,21 +215,21 @@ describe API::AwardEmoji, api: true do end it "it returns 404 error when user authored note" do - post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note2.id}/award_emoji", user), name: 'thumbsup' + post api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note2.id}/award_emoji", user), name: 'thumbsup' expect(response).to have_http_status(404) end it "normalizes +1 as thumbsup award" do - post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: '+1' + post api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji", user), name: '+1' expect(note.award_emoji.last.name).to eq("thumbsup") end context 'when the emoji already has been awarded' do it 'returns a 404 status code' do - post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' - post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' + post api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji", user), name: 'rocket' + post api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji", user), name: 'rocket' expect(response).to have_http_status(404) expect(json_response["message"]).to match("has already been taken") @@ -241,14 +241,14 @@ describe API::AwardEmoji, api: true do context 'when the awardable is an Issue' do it 'deletes the award' do expect do - delete api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/#{award_emoji.id}", user) + delete api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji/#{award_emoji.id}", user) expect(response).to have_http_status(204) end.to change { issue.award_emoji.count }.from(1).to(0) end it 'returns a 404 error when the award emoji can not be found' do - delete api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/12345", user) + delete api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji/12345", user) expect(response).to have_http_status(404) end @@ -257,14 +257,14 @@ describe API::AwardEmoji, api: true do context 'when the awardable is a Merge Request' do it 'deletes the award' do expect do - delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user) + delete api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/award_emoji/#{downvote.id}", user) expect(response).to have_http_status(204) end.to change { merge_request.award_emoji.count }.from(1).to(0) end it 'returns a 404 error when note id not found' do - delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}/notes/12345", user) + delete api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes/12345", user) expect(response).to have_http_status(404) end @@ -289,7 +289,7 @@ describe API::AwardEmoji, api: true do it 'deletes the award' do expect do - delete api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji/#{rocket.id}", user) + delete api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji/#{rocket.id}", user) expect(response).to have_http_status(204) end.to change { note.award_emoji.count }.from(1).to(0) diff --git a/spec/requests/api/doorkeeper_access_spec.rb b/spec/requests/api/doorkeeper_access_spec.rb index 2974875510a..f6fd567eca5 100644 --- a/spec/requests/api/doorkeeper_access_spec.rb +++ b/spec/requests/api/doorkeeper_access_spec.rb @@ -39,4 +39,22 @@ describe API::API, api: true do end end end + + describe "when user is blocked" do + it "returns authentication error" do + user.block + get api("/user"), access_token: token.token + + expect(response).to have_http_status(401) + end + end + + describe "when user is ldap_blocked" do + it "returns authentication error" do + user.ldap_block + get api("/user"), access_token: token.token + + expect(response).to have_http_status(401) + end + end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index aca7c6a0734..2fc11a3b782 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -757,9 +757,9 @@ describe API::Issues, api: true do end end - describe "GET /projects/:id/issues/:issue_id" do + describe "GET /projects/:id/issues/:issue_iid" do it 'exposes known attributes' do - get api("/projects/#{project.id}/issues/#{issue.id}", user) + get api("/projects/#{project.id}/issues/#{issue.iid}", user) expect(response).to have_http_status(200) expect(json_response['id']).to eq(issue.id) @@ -777,8 +777,8 @@ describe API::Issues, api: true do expect(json_response['confidential']).to be_falsy end - it "returns a project issue by id" do - get api("/projects/#{project.id}/issues/#{issue.id}", user) + it "returns a project issue by internal id" do + get api("/projects/#{project.id}/issues/#{issue.iid}", user) expect(response).to have_http_status(200) expect(json_response['title']).to eq(issue.title) @@ -790,40 +790,52 @@ describe API::Issues, api: true do expect(response).to have_http_status(404) end + it "returns 404 if the issue ID is used" do + get api("/projects/#{project.id}/issues/#{issue.id}", user) + + expect(response).to have_http_status(404) + end + context 'confidential issues' do it "returns 404 for non project members" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", non_member) + expect(response).to have_http_status(404) end it "returns 404 for project members with guest role" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", guest) + expect(response).to have_http_status(404) end it "returns confidential issue for project members" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", user) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user) + expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) end it "returns confidential issue for author" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", author) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", author) + expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) end it "returns confidential issue for assignee" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", assignee) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", assignee) + expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) end it "returns confidential issue for admin" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", admin) + expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) @@ -1004,23 +1016,29 @@ describe API::Issues, api: true do end end - describe "PUT /projects/:id/issues/:issue_id to update only title" do + describe "PUT /projects/:id/issues/:issue_iid to update only title" do it "updates a project issue" do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end - it "returns 404 error if issue id not found" do + it "returns 404 error if issue iid not found" do put api("/projects/#{project.id}/issues/44444", user), title: 'updated title' expect(response).to have_http_status(404) end - it 'allows special label names' do + it "returns 404 error if issue id is used instead of the iid" do put api("/projects/#{project.id}/issues/#{issue.id}", user), + title: 'updated title' + expect(response).to have_http_status(404) + end + + it 'allows special label names' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), title: 'updated title', labels: 'label, label?, label&foo, ?, &' @@ -1034,40 +1052,40 @@ describe API::Issues, api: true do context 'confidential issues' do it "returns 403 for non project members" do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", non_member), title: 'updated title' expect(response).to have_http_status(403) end it "returns 403 for project members with guest role" do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", guest), title: 'updated title' expect(response).to have_http_status(403) end it "updates a confidential issue for project members" do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end it "updates a confidential issue for author" do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", author), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", author), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end it "updates a confidential issue for admin" do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", admin), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end it 'sets an issue to confidential' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), confidential: true expect(response).to have_http_status(200) @@ -1075,7 +1093,7 @@ describe API::Issues, api: true do end it 'makes a confidential issue public' do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), confidential: false expect(response).to have_http_status(200) @@ -1083,7 +1101,7 @@ describe API::Issues, api: true do end it 'does not update a confidential issue with wrong confidential flag' do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), confidential: 'foo' expect(response).to have_http_status(400) @@ -1092,7 +1110,7 @@ describe API::Issues, api: true do end end - describe 'PUT /projects/:id/issues/:issue_id with spam filtering' do + describe 'PUT /projects/:id/issues/:issue_iid with spam filtering' do let(:params) do { title: 'updated title', @@ -1105,7 +1123,7 @@ describe API::Issues, api: true do allow_any_instance_of(SpamService).to receive_messages(check_for_spam?: true) allow_any_instance_of(AkismetService).to receive_messages(is_spam?: true) - put api("/projects/#{project.id}/issues/#{issue.id}", user), params + put api("/projects/#{project.id}/issues/#{issue.iid}", user), params expect(response).to have_http_status(400) expect(json_response['message']).to eq({ "error" => "Spam detected" }) @@ -1119,12 +1137,12 @@ describe API::Issues, api: true do end end - describe 'PUT /projects/:id/issues/:issue_id to update labels' do + describe 'PUT /projects/:id/issues/:issue_iid to update labels' do let!(:label) { create(:label, title: 'dummy', project: project) } let!(:label_link) { create(:label_link, label: label, target: issue) } it 'does not update labels if not present' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['labels']).to eq([label.title]) @@ -1135,7 +1153,7 @@ describe API::Issues, api: true do label.toggle_subscription(user2, project) perform_enqueued_jobs do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), title: 'updated title', labels: label.title end @@ -1143,14 +1161,14 @@ describe API::Issues, api: true do end it 'removes all labels' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), labels: '' + put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: '' expect(response).to have_http_status(200) expect(json_response['labels']).to eq([]) end it 'updates labels' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: 'foo,bar' expect(response).to have_http_status(200) expect(json_response['labels']).to include 'foo' @@ -1158,7 +1176,7 @@ describe API::Issues, api: true do end it 'allows special label names' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: 'label:foo, label-bar,label_bar,label/bar,label?bar,label&bar,?,&' expect(response.status).to eq(200) expect(json_response['labels']).to include 'label:foo' @@ -1172,7 +1190,7 @@ describe API::Issues, api: true do end it 'returns 400 if title is too long' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), title: 'g' * 256 expect(response).to have_http_status(400) expect(json_response['message']['title']).to eq([ @@ -1181,9 +1199,9 @@ describe API::Issues, api: true do end end - describe "PUT /projects/:id/issues/:issue_id to update state and label" do + describe "PUT /projects/:id/issues/:issue_iid to update state and label" do it "updates a project issue" do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: 'label2', state_event: "close" expect(response).to have_http_status(200) @@ -1192,7 +1210,7 @@ describe API::Issues, api: true do end it 'reopens a project isssue' do - put api("/projects/#{project.id}/issues/#{closed_issue.id}", user), state_event: 'reopen' + put api("/projects/#{project.id}/issues/#{closed_issue.iid}", user), state_event: 'reopen' expect(response).to have_http_status(200) expect(json_response['state']).to eq 'reopened' @@ -1201,7 +1219,7 @@ describe API::Issues, api: true do context 'when an admin or owner makes the request' do it 'accepts the update date to be set' do update_time = 2.weeks.ago - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: 'label3', state_event: 'close', updated_at: update_time expect(response).to have_http_status(200) @@ -1211,25 +1229,25 @@ describe API::Issues, api: true do end end - describe 'PUT /projects/:id/issues/:issue_id to update due date' do + describe 'PUT /projects/:id/issues/:issue_iid to update due date' do it 'creates a new project issue' do due_date = 2.weeks.from_now.strftime('%Y-%m-%d') - put api("/projects/#{project.id}/issues/#{issue.id}", user), due_date: due_date + put api("/projects/#{project.id}/issues/#{issue.iid}", user), due_date: due_date expect(response).to have_http_status(200) expect(json_response['due_date']).to eq(due_date) end end - describe "DELETE /projects/:id/issues/:issue_id" do + describe "DELETE /projects/:id/issues/:issue_iid" do it "rejects a non member from deleting an issue" do - delete api("/projects/#{project.id}/issues/#{issue.id}", non_member) + delete api("/projects/#{project.id}/issues/#{issue.iid}", non_member) expect(response).to have_http_status(403) end it "rejects a developer from deleting an issue" do - delete api("/projects/#{project.id}/issues/#{issue.id}", author) + delete api("/projects/#{project.id}/issues/#{issue.iid}", author) expect(response).to have_http_status(403) end @@ -1238,7 +1256,7 @@ describe API::Issues, api: true do let(:project) { create(:empty_project, namespace: owner.namespace) } it "deletes the issue if an admin requests it" do - delete api("/projects/#{project.id}/issues/#{issue.id}", owner) + delete api("/projects/#{project.id}/issues/#{issue.iid}", owner) expect(response).to have_http_status(204) end @@ -1251,14 +1269,20 @@ describe API::Issues, api: true do expect(response).to have_http_status(404) end end + + it 'returns 404 when using the issue ID instead of IID' do + delete api("/projects/#{project.id}/issues/#{issue.id}", user) + + expect(response).to have_http_status(404) + end end - describe '/projects/:id/issues/:issue_id/move' do + describe '/projects/:id/issues/:issue_iid/move' do let!(:target_project) { create(:empty_project, path: 'project2', creator_id: user.id, namespace: user.namespace ) } let!(:target_project2) { create(:empty_project, creator_id: non_member.id, namespace: non_member.namespace ) } it 'moves an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), to_project_id: target_project.id expect(response).to have_http_status(201) @@ -1267,7 +1291,7 @@ describe API::Issues, api: true do context 'when source and target projects are the same' do it 'returns 400 when trying to move an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), to_project_id: project.id expect(response).to have_http_status(400) @@ -1277,7 +1301,7 @@ describe API::Issues, api: true do context 'when the user does not have the permission to move issues' do it 'returns 400 when trying to move an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), to_project_id: target_project2.id expect(response).to have_http_status(400) @@ -1286,13 +1310,23 @@ describe API::Issues, api: true do end it 'moves the issue to another namespace if I am admin' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", admin), + post api("/projects/#{project.id}/issues/#{issue.iid}/move", admin), to_project_id: target_project2.id expect(response).to have_http_status(201) expect(json_response['project_id']).to eq(target_project2.id) end + context 'when using the issue ID instead of iid' do + it 'returns 404 when trying to move an issue' do + post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + to_project_id: target_project.id + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Issue Not Found') + end + end + context 'when issue does not exist' do it 'returns 404 when trying to move an issue' do post api("/projects/#{project.id}/issues/123/move", user), @@ -1305,7 +1339,7 @@ describe API::Issues, api: true do context 'when source project does not exist' do it 'returns 404 when trying to move an issue' do - post api("/projects/123/issues/#{issue.id}/move", user), + post api("/projects/123/issues/#{issue.iid}/move", user), to_project_id: target_project.id expect(response).to have_http_status(404) @@ -1315,7 +1349,7 @@ describe API::Issues, api: true do context 'when target project does not exist' do it 'returns 404 when trying to move an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), to_project_id: 123 expect(response).to have_http_status(404) @@ -1323,16 +1357,16 @@ describe API::Issues, api: true do end end - describe 'POST :id/issues/:issue_id/subscribe' do + describe 'POST :id/issues/:issue_iid/subscribe' do it 'subscribes to an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/subscribe", user2) + post api("/projects/#{project.id}/issues/#{issue.iid}/subscribe", user2) expect(response).to have_http_status(201) expect(json_response['subscribed']).to eq(true) end it 'returns 304 if already subscribed' do - post api("/projects/#{project.id}/issues/#{issue.id}/subscribe", user) + post api("/projects/#{project.id}/issues/#{issue.iid}/subscribe", user) expect(response).to have_http_status(304) end @@ -1343,8 +1377,14 @@ describe API::Issues, api: true do expect(response).to have_http_status(404) end + it 'returns 404 if the issue ID is used instead of the iid' do + post api("/projects/#{project.id}/issues/#{issue.id}/subscribe", user) + + expect(response).to have_http_status(404) + end + it 'returns 404 if the issue is confidential' do - post api("/projects/#{project.id}/issues/#{confidential_issue.id}/subscribe", non_member) + post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/subscribe", non_member) expect(response).to have_http_status(404) end @@ -1352,14 +1392,14 @@ describe API::Issues, api: true do describe 'POST :id/issues/:issue_id/unsubscribe' do it 'unsubscribes from an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/unsubscribe", user) + post api("/projects/#{project.id}/issues/#{issue.iid}/unsubscribe", user) expect(response).to have_http_status(201) expect(json_response['subscribed']).to eq(false) end it 'returns 304 if not subscribed' do - post api("/projects/#{project.id}/issues/#{issue.id}/unsubscribe", user2) + post api("/projects/#{project.id}/issues/#{issue.iid}/unsubscribe", user2) expect(response).to have_http_status(304) end @@ -1370,8 +1410,14 @@ describe API::Issues, api: true do expect(response).to have_http_status(404) end + it 'returns 404 if using the issue ID instead of iid' do + post api("/projects/#{project.id}/issues/#{issue.id}/unsubscribe", user) + + expect(response).to have_http_status(404) + end + it 'returns 404 if the issue is confidential' do - post api("/projects/#{project.id}/issues/#{confidential_issue.id}/unsubscribe", non_member) + post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/unsubscribe", non_member) expect(response).to have_http_status(404) end diff --git a/spec/requests/api/merge_request_diffs_spec.rb b/spec/requests/api/merge_request_diffs_spec.rb index 1d02e827183..79f3151ba52 100644 --- a/spec/requests/api/merge_request_diffs_spec.rb +++ b/spec/requests/api/merge_request_diffs_spec.rb @@ -13,9 +13,9 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do project.team << [user, :master] end - describe 'GET /projects/:id/merge_requests/:merge_request_id/versions' do + describe 'GET /projects/:id/merge_requests/:merge_request_iid/versions' do it 'returns 200 for a valid merge request' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/versions", user) merge_request_diff = merge_request.merge_request_diffs.first expect(response.status).to eq 200 @@ -26,16 +26,22 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do expect(json_response.first['head_commit_sha']).to eq(merge_request_diff.head_commit_sha) end - it 'returns a 404 when merge_request_id not found' do + it 'returns a 404 when merge_request id is used instead of the iid' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions", user) + expect(response).to have_http_status(404) + end + + it 'returns a 404 when merge_request_iid not found' do get api("/projects/#{project.id}/merge_requests/999/versions", user) expect(response).to have_http_status(404) end end - describe 'GET /projects/:id/merge_requests/:merge_request_id/versions/:version_id' do + describe 'GET /projects/:id/merge_requests/:merge_request_iid/versions/:version_id' do + let(:merge_request_diff) { merge_request.merge_request_diffs.first } + it 'returns a 200 for a valid merge request' do - merge_request_diff = merge_request.merge_request_diffs.first - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/#{merge_request_diff.id}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/versions/#{merge_request_diff.id}", user) expect(response.status).to eq 200 expect(json_response['id']).to eq(merge_request_diff.id) @@ -43,8 +49,18 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do expect(json_response['diffs'].size).to eq(merge_request_diff.diffs.size) end - it 'returns a 404 when merge_request_id not found' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/999", user) + it 'returns a 404 when merge_request id is used instead of the iid' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/#{merge_request_diff.id}", user) + expect(response).to have_http_status(404) + end + + it 'returns a 404 when merge_request version_id is not found' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/versions/999", user) + expect(response).to have_http_status(404) + end + + it 'returns a 404 when merge_request_iid is not found' do + get api("/projects/#{project.id}/merge_requests/12345/versions/#{merge_request_diff.id}", user) expect(response).to have_http_status(404) end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 1083abf2ad3..9aba1d75612 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -153,9 +153,9 @@ describe API::MergeRequests, api: true do end end - describe "GET /projects/:id/merge_requests/:merge_request_id" do + describe "GET /projects/:id/merge_requests/:merge_request_iid" do it 'exposes known attributes' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) expect(response).to have_http_status(200) expect(json_response['id']).to eq(merge_request.id) @@ -184,7 +184,7 @@ describe API::MergeRequests, api: true do end it "returns merge_request" do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) expect(response).to have_http_status(200) expect(json_response['title']).to eq(merge_request.title) expect(json_response['iid']).to eq(merge_request.iid) @@ -194,25 +194,31 @@ describe API::MergeRequests, api: true do expect(json_response['force_close_merge_request']).to be_falsy end - it "returns a 404 error if merge_request_id not found" do + it "returns a 404 error if merge_request_iid not found" do get api("/projects/#{project.id}/merge_requests/999", user) expect(response).to have_http_status(404) end + it "returns a 404 error if merge_request `id` is used instead of iid" do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + + expect(response).to have_http_status(404) + end + context 'Work in Progress' do let!(:merge_request_wip) { create(:merge_request, author: user, assignee: user, source_project: project, target_project: project, title: "WIP: Test", created_at: base_time + 1.second) } it "returns merge_request" do - get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.id}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.iid}", user) expect(response).to have_http_status(200) expect(json_response['work_in_progress']).to eq(true) end end end - describe 'GET /projects/:id/merge_requests/:merge_request_id/commits' do + describe 'GET /projects/:id/merge_requests/:merge_request_iid/commits' do it 'returns a 200 when merge request is valid' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/commits", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/commits", user) commit = merge_request.commits.first expect(response.status).to eq 200 @@ -223,24 +229,36 @@ describe API::MergeRequests, api: true do expect(json_response.first['title']).to eq(commit.title) end - it 'returns a 404 when merge_request_id not found' do + it 'returns a 404 when merge_request_iid not found' do get api("/projects/#{project.id}/merge_requests/999/commits", user) expect(response).to have_http_status(404) end + + it 'returns a 404 when merge_request id is used instead of iid' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/commits", user) + + expect(response).to have_http_status(404) + end end - describe 'GET /projects/:id/merge_requests/:merge_request_id/changes' do + describe 'GET /projects/:id/merge_requests/:merge_request_iid/changes' do it 'returns the change information of the merge_request' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/changes", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes", user) expect(response.status).to eq 200 expect(json_response['changes'].size).to eq(merge_request.diffs.size) end - it 'returns a 404 when merge_request_id not found' do + it 'returns a 404 when merge_request_iid not found' do get api("/projects/#{project.id}/merge_requests/999/changes", user) expect(response).to have_http_status(404) end + + it 'returns a 404 when merge_request id is used instead of iid' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/changes", user) + + expect(response).to have_http_status(404) + end end describe "POST /projects/:id/merge_requests" do @@ -400,7 +418,7 @@ describe API::MergeRequests, api: true do end end - describe "DELETE /projects/:id/merge_requests/:merge_request_id" do + describe "DELETE /projects/:id/merge_requests/:merge_request_iid" do context "when the user is developer" do let(:developer) { create(:user) } @@ -409,25 +427,37 @@ describe API::MergeRequests, api: true do end it "denies the deletion of the merge request" do - delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}", developer) + delete api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", developer) expect(response).to have_http_status(403) end end context "when the user is project owner" do it "destroys the merge request owners can destroy" do - delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + delete api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) expect(response).to have_http_status(204) end + + it "returns 404 for an invalid merge request IID" do + delete api("/projects/#{project.id}/merge_requests/12345", user) + + expect(response).to have_http_status(404) + end + + it "returns 404 if the merge request id is used instead of iid" do + delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + + expect(response).to have_http_status(404) + end end end - describe "PUT /projects/:id/merge_requests/:merge_request_id/merge" do + describe "PUT /projects/:id/merge_requests/:merge_request_iid/merge" do let(:pipeline) { create(:ci_pipeline_without_jobs) } it "returns merge_request in case of success" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) expect(response).to have_http_status(200) end @@ -436,7 +466,7 @@ describe API::MergeRequests, api: true do allow_any_instance_of(MergeRequest). to receive(:can_be_merged?).and_return(false) - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) expect(response).to have_http_status(406) expect(json_response['message']).to eq('Branch cannot be merged') @@ -444,14 +474,14 @@ describe API::MergeRequests, api: true do it "returns 405 if merge_request is not open" do merge_request.close - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('405 Method Not Allowed') end it "returns 405 if merge_request is a work in progress" do merge_request.update_attribute(:title, "WIP: #{merge_request.title}") - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('405 Method Not Allowed') end @@ -459,7 +489,7 @@ describe API::MergeRequests, api: true do it 'returns 405 if the build failed for a merge request that requires success' do allow_any_instance_of(MergeRequest).to receive(:mergeable_ci_state?).and_return(false) - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('405 Method Not Allowed') @@ -468,20 +498,20 @@ describe API::MergeRequests, api: true do it "returns 401 if user has no permissions to merge" do user2 = create(:user) project.team << [user2, :reporter] - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user2) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user2) expect(response).to have_http_status(401) expect(json_response['message']).to eq('401 Unauthorized') end it "returns 409 if the SHA parameter doesn't match" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), sha: merge_request.diff_head_sha.reverse + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), sha: merge_request.diff_head_sha.reverse expect(response).to have_http_status(409) expect(json_response['message']).to start_with('SHA does not match HEAD of source branch') end it "succeeds if the SHA parameter matches" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), sha: merge_request.diff_head_sha + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), sha: merge_request.diff_head_sha expect(response).to have_http_status(200) end @@ -490,18 +520,30 @@ describe API::MergeRequests, api: true do allow_any_instance_of(MergeRequest).to receive(:head_pipeline).and_return(pipeline) allow(pipeline).to receive(:active?).and_return(true) - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), merge_when_pipeline_succeeds: true + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), merge_when_pipeline_succeeds: true expect(response).to have_http_status(200) expect(json_response['title']).to eq('Test') expect(json_response['merge_when_pipeline_succeeds']).to eq(true) end + + it "returns 404 for an invalid merge request IID" do + put api("/projects/#{project.id}/merge_requests/12345/merge", user) + + expect(response).to have_http_status(404) + end + + it "returns 404 if the merge request id is used instead of iid" do + put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + + expect(response).to have_http_status(404) + end end - describe "PUT /projects/:id/merge_requests/:merge_request_id" do + describe "PUT /projects/:id/merge_requests/:merge_request_iid" do context "to close a MR" do it "returns merge_request" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: "close" + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), state_event: "close" expect(response).to have_http_status(200) expect(json_response['state']).to eq('closed') @@ -509,38 +551,38 @@ describe API::MergeRequests, api: true do end it "updates title and returns merge_request" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), title: "New title" + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), title: "New title" expect(response).to have_http_status(200) expect(json_response['title']).to eq('New title') end it "updates description and returns merge_request" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), description: "New description" + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), description: "New description" expect(response).to have_http_status(200) expect(json_response['description']).to eq('New description') end it "updates milestone_id and returns merge_request" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), milestone_id: milestone.id + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), milestone_id: milestone.id expect(response).to have_http_status(200) expect(json_response['milestone']['id']).to eq(milestone.id) end it "returns merge_request with renamed target_branch" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), target_branch: "wiki" + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), target_branch: "wiki" expect(response).to have_http_status(200) expect(json_response['target_branch']).to eq('wiki') end it "returns merge_request that removes the source branch" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), remove_source_branch: true + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), remove_source_branch: true expect(response).to have_http_status(200) expect(json_response['force_remove_source_branch']).to be_truthy end it 'allows special label names' do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), title: 'new issue', labels: 'label, label?, label&foo, ?, &' @@ -553,7 +595,7 @@ describe API::MergeRequests, api: true do end it 'does not update state when title is empty' do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: 'close', title: nil + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), state_event: 'close', title: nil merge_request.reload expect(response).to have_http_status(400) @@ -561,19 +603,31 @@ describe API::MergeRequests, api: true do end it 'does not update state when target_branch is empty' do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: 'close', target_branch: nil + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), state_event: 'close', target_branch: nil merge_request.reload expect(response).to have_http_status(400) expect(merge_request.state).to eq('opened') end + + it "returns 404 for an invalid merge request IID" do + put api("/projects/#{project.id}/merge_requests/12345", user), state_event: "close" + + expect(response).to have_http_status(404) + end + + it "returns 404 if the merge request id is used instead of iid" do + put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: "close" + + expect(response).to have_http_status(404) + end end - describe "POST /projects/:id/merge_requests/:merge_request_id/comments" do + describe "POST /projects/:id/merge_requests/:merge_request_iid/comments" do it "returns comment" do original_count = merge_request.notes.size - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user), note: "My comment" + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/comments", user), note: "My comment" expect(response).to have_http_status(201) expect(json_response['note']).to eq('My comment') @@ -583,23 +637,29 @@ describe API::MergeRequests, api: true do end it "returns 400 if note is missing" do - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/comments", user) expect(response).to have_http_status(400) end - it "returns 404 if note is attached to non existent merge request" do + it "returns 404 if merge request iid is invalid" do post api("/projects/#{project.id}/merge_requests/404/comments", user), note: 'My comment' expect(response).to have_http_status(404) end + + it "returns 404 if merge request id is used instead of iid" do + post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user), + note: 'My comment' + expect(response).to have_http_status(404) + end end - describe "GET :id/merge_requests/:merge_request_id/comments" do + describe "GET :id/merge_requests/:merge_request_iid/comments" do let!(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") } let!(:note2) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "another comment on a MR") } it "returns merge_request comments ordered by created_at" do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/comments", user) expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -610,20 +670,25 @@ describe API::MergeRequests, api: true do expect(json_response.last['note']).to eq("another comment on a MR") end - it "returns a 404 error if merge_request_id not found" do + it "returns a 404 error if merge_request_iid is invalid" do get api("/projects/#{project.id}/merge_requests/999/comments", user) expect(response).to have_http_status(404) end + + it "returns a 404 error if merge_request id is used instead of iid" do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user) + expect(response).to have_http_status(404) + end end - describe 'GET :id/merge_requests/:merge_request_id/closes_issues' do + describe 'GET :id/merge_requests/:merge_request_iid/closes_issues' do it 'returns the issue that will be closed on merge' do issue = create(:issue, project: project) mr = merge_request.tap do |mr| mr.update_attribute(:description, "Closes #{issue.to_reference(mr.project)}") end - get api("/projects/#{project.id}/merge_requests/#{mr.id}/closes_issues", user) + get api("/projects/#{project.id}/merge_requests/#{mr.iid}/closes_issues", user) expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -633,7 +698,7 @@ describe API::MergeRequests, api: true do end it 'returns an empty array when there are no issues to be closed' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/closes_issues", user) expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -647,7 +712,7 @@ describe API::MergeRequests, api: true do merge_request = create(:merge_request, :simple, author: user, assignee: user, source_project: jira_project) merge_request.update_attribute(:description, "Closes #{issue.to_reference(jira_project)}") - get api("/projects/#{jira_project.id}/merge_requests/#{merge_request.id}/closes_issues", user) + get api("/projects/#{jira_project.id}/merge_requests/#{merge_request.iid}/closes_issues", user) expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -663,22 +728,34 @@ describe API::MergeRequests, api: true do guest = create(:user) project.team << [guest, :guest] - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", guest) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/closes_issues", guest) expect(response).to have_http_status(403) end + + it "returns 404 for an invalid merge request IID" do + get api("/projects/#{project.id}/merge_requests/12345/closes_issues", user) + + expect(response).to have_http_status(404) + end + + it "returns 404 if the merge request id is used instead of iid" do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", user) + + expect(response).to have_http_status(404) + end end - describe 'POST :id/merge_requests/:merge_request_id/subscribe' do + describe 'POST :id/merge_requests/:merge_request_iid/subscribe' do it 'subscribes to a merge request' do - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscribe", admin) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", admin) expect(response).to have_http_status(201) expect(json_response['subscribed']).to eq(true) end it 'returns 304 if already subscribed' do - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscribe", user) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", user) expect(response).to have_http_status(304) end @@ -689,26 +766,32 @@ describe API::MergeRequests, api: true do expect(response).to have_http_status(404) end + it 'returns 404 if the merge request id is used instead of iid' do + post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscribe", user) + + expect(response).to have_http_status(404) + end + it 'returns 403 if user has no access to read code' do guest = create(:user) project.team << [guest, :guest] - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscribe", guest) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", guest) expect(response).to have_http_status(403) end end - describe 'POST :id/merge_requests/:merge_request_id/unsubscribe' do + describe 'POST :id/merge_requests/:merge_request_iid/unsubscribe' do it 'unsubscribes from a merge request' do - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/unsubscribe", user) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", user) expect(response).to have_http_status(201) expect(json_response['subscribed']).to eq(false) end it 'returns 304 if not subscribed' do - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/unsubscribe", admin) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", admin) expect(response).to have_http_status(304) end @@ -719,11 +802,17 @@ describe API::MergeRequests, api: true do expect(response).to have_http_status(404) end + it 'returns 404 if the merge request id is used instead of iid' do + post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/unsubscribe", user) + + expect(response).to have_http_status(404) + end + it 'returns 403 if user has no access to read code' do guest = create(:user) project.team << [guest, :guest] - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/unsubscribe", guest) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", guest) expect(response).to have_http_status(403) end diff --git a/spec/requests/api/oauth_tokens_spec.rb b/spec/requests/api/oauth_tokens_spec.rb index 7e2cc50e591..367225df717 100644 --- a/spec/requests/api/oauth_tokens_spec.rb +++ b/spec/requests/api/oauth_tokens_spec.rb @@ -29,5 +29,27 @@ describe API::API, api: true do expect(json_response['access_token']).not_to be_nil end end + + context "when user is blocked" do + it "does not create an access token" do + user = create(:user) + user.block + + request_oauth_token(user) + + expect(response).to have_http_status(401) + end + end + + context "when user is ldap_blocked" do + it "does not create an access token" do + user = create(:user) + user.ldap_block + + request_oauth_token(user) + + expect(response).to have_http_status(401) + end + end end end diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index e83202e4196..15d458e0795 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -16,6 +16,7 @@ describe API::Runner do context 'when no token is provided' do it 'returns 400 error' do post api('/runners') + expect(response).to have_http_status 400 end end @@ -23,6 +24,7 @@ describe API::Runner do context 'when invalid token is provided' do it 'returns 403 error' do post api('/runners'), token: 'invalid' + expect(response).to have_http_status 403 end end @@ -108,7 +110,7 @@ describe API::Runner do context "when info parameter '#{param}' info is present" do let(:value) { "#{param}_value" } - it %q(updates provided Runner's parameter) do + it "updates provided Runner's parameter" do post api('/runners'), token: registration_token, info: { param => value } @@ -148,4 +150,874 @@ describe API::Runner do end end end + + describe '/api/v4/jobs' do + let(:project) { create(:empty_project, shared_runners_enabled: false) } + let(:pipeline) { create(:ci_pipeline_without_jobs, project: project, ref: 'master') } + let(:runner) { create(:ci_runner) } + let!(:job) do + create(:ci_build, :artifacts, :extended_options, + pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0, commands: "ls\ndate") + end + + before { project.runners << runner } + + describe 'POST /api/v4/jobs/request' do + let!(:last_update) {} + let!(:new_update) { } + let(:user_agent) { 'gitlab-runner 9.0.0 (9-0-stable; go1.7.4; linux/amd64)' } + + before { stub_container_registry_config(enabled: false) } + + shared_examples 'no jobs available' do + before { request_job } + + context 'when runner sends version in User-Agent' do + context 'for stable version' do + it 'gives 204 and set X-GitLab-Last-Update' do + expect(response).to have_http_status(204) + expect(response.header).to have_key('X-GitLab-Last-Update') + end + end + + context 'when last_update is up-to-date' do + let(:last_update) { runner.ensure_runner_queue_value } + + it 'gives 204 and set the same X-GitLab-Last-Update' do + expect(response).to have_http_status(204) + expect(response.header['X-GitLab-Last-Update']).to eq(last_update) + end + end + + context 'when last_update is outdated' do + let(:last_update) { runner.ensure_runner_queue_value } + let(:new_update) { runner.tick_runner_queue } + + it 'gives 204 and set a new X-GitLab-Last-Update' do + expect(response).to have_http_status(204) + expect(response.header['X-GitLab-Last-Update']).to eq(new_update) + end + end + + context 'when beta version is sent' do + let(:user_agent) { 'gitlab-runner 9.0.0~beta.167.g2b2bacc (master; go1.7.4; linux/amd64)' } + + it { expect(response).to have_http_status(204) } + end + + context 'when pre-9-0 version is sent' do + let(:user_agent) { 'gitlab-ci-multi-runner 1.6.0 (1-6-stable; go1.6.3; linux/amd64)' } + + it { expect(response).to have_http_status(204) } + end + + context 'when pre-9-0 beta version is sent' do + let(:user_agent) { 'gitlab-ci-multi-runner 1.6.0~beta.167.g2b2bacc (master; go1.6.3; linux/amd64)' } + + it { expect(response).to have_http_status(204) } + end + end + + context "when runner doesn't send version in User-Agent" do + let(:user_agent) { 'Go-http-client/1.1' } + + it { expect(response).to have_http_status(404) } + end + + context "when runner doesn't have a User-Agent" do + let(:user_agent) { nil } + + it { expect(response).to have_http_status(404) } + end + end + + context 'when no token is provided' do + it 'returns 400 error' do + post api('/jobs/request') + + expect(response).to have_http_status 400 + end + end + + context 'when invalid token is provided' do + it 'returns 403 error' do + post api('/jobs/request'), token: 'invalid' + + expect(response).to have_http_status 403 + end + end + + context 'when valid token is provided' do + context 'when Runner is not active' do + let(:runner) { create(:ci_runner, :inactive) } + + it 'returns 404 error' do + request_job + + expect(response).to have_http_status 404 + end + end + + context 'when jobs are finished' do + before { job.success } + + it_behaves_like 'no jobs available' + end + + context 'when other projects have pending jobs' do + before do + job.success + create(:ci_build, :pending) + end + + it_behaves_like 'no jobs available' + end + + context 'when shared runner requests job for project without shared_runners_enabled' do + let(:runner) { create(:ci_runner, :shared) } + + it_behaves_like 'no jobs available' + end + + context 'when there is a pending job' do + let(:expected_job_info) do + { 'name' => job.name, + 'stage' => job.stage, + 'project_id' => job.project.id, + 'project_name' => job.project.name } + end + + let(:expected_git_info) do + { 'repo_url' => job.repo_url, + 'ref' => job.ref, + 'sha' => job.sha, + 'before_sha' => job.before_sha, + 'ref_type' => 'branch' } + end + + let(:expected_steps) do + [{ 'name' => 'script', + 'script' => %w(ls date), + 'timeout' => job.timeout, + 'when' => 'on_success', + 'allow_failure' => false }, + { 'name' => 'after_script', + 'script' => %w(ls date), + 'timeout' => job.timeout, + 'when' => 'always', + 'allow_failure' => true }] + end + + let(:expected_variables) do + [{ 'key' => 'CI_BUILD_NAME', 'value' => 'spinach', 'public' => true }, + { 'key' => 'CI_BUILD_STAGE', 'value' => 'test', 'public' => true }, + { 'key' => 'DB_NAME', 'value' => 'postgres', 'public' => true }] + end + + let(:expected_artifacts) do + [{ 'name' => 'artifacts_file', + 'untracked' => false, + 'paths' => %w(out/), + 'when' => 'always', + 'expire_in' => '7d' }] + end + + let(:expected_cache) do + [{ 'key' => 'cache_key', + 'untracked' => false, + 'paths' => ['vendor/*'] }] + end + + it 'picks a job' do + request_job info: { platform: :darwin } + + expect(response).to have_http_status(201) + expect(response.headers).not_to have_key('X-GitLab-Last-Update') + expect(runner.reload.platform).to eq('darwin') + expect(json_response['id']).to eq(job.id) + expect(json_response['token']).to eq(job.token) + expect(json_response['job_info']).to eq(expected_job_info) + expect(json_response['git_info']).to eq(expected_git_info) + expect(json_response['image']).to eq({ 'name' => 'ruby:2.1' }) + expect(json_response['services']).to eq([{ 'name' => 'postgres' }]) + expect(json_response['steps']).to eq(expected_steps) + expect(json_response['artifacts']).to eq(expected_artifacts) + expect(json_response['cache']).to eq(expected_cache) + expect(json_response['variables']).to include(*expected_variables) + end + + context 'when job is made for tag' do + let!(:job) { create(:ci_build_tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } + + it 'sets branch as ref_type' do + request_job + + expect(response).to have_http_status(201) + expect(json_response['git_info']['ref_type']).to eq('tag') + end + end + + context 'when job is made for branch' do + it 'sets tag as ref_type' do + request_job + + expect(response).to have_http_status(201) + expect(json_response['git_info']['ref_type']).to eq('branch') + end + end + + it 'updates runner info' do + expect { request_job }.to change { runner.reload.contacted_at } + end + + %w(name version revision platform architecture).each do |param| + context "when info parameter '#{param}' is present" do + let(:value) { "#{param}_value" } + + it "updates provided Runner's parameter" do + request_job info: { param => value } + + expect(response).to have_http_status(201) + expect(runner.reload.read_attribute(param.to_sym)).to eq(value) + end + end + end + + context 'when concurrently updating a job' do + before do + expect_any_instance_of(Ci::Build).to receive(:run!). + and_raise(ActiveRecord::StaleObjectError.new(nil, nil)) + end + + it 'returns a conflict' do + request_job + + expect(response).to have_http_status(409) + expect(response.headers).not_to have_key('X-GitLab-Last-Update') + end + end + + context 'when project and pipeline have multiple jobs' do + let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) } + + before { job.success } + + it 'returns dependent jobs' do + request_job + + expect(response).to have_http_status(201) + expect(json_response['id']).to eq(test_job.id) + expect(json_response['dependencies'].count).to eq(1) + expect(json_response['dependencies'][0]).to include('id' => job.id, 'name' => 'spinach') + end + end + + context 'when job has no tags' do + before { job.update(tags: []) } + + context 'when runner is allowed to pick untagged jobs' do + before { runner.update_column(:run_untagged, true) } + + it 'picks job' do + request_job + + expect(response).to have_http_status 201 + end + end + + context 'when runner is not allowed to pick untagged jobs' do + before { runner.update_column(:run_untagged, false) } + + it_behaves_like 'no jobs available' + end + end + + context 'when triggered job is available' do + let(:expected_variables) do + [{ 'key' => 'CI_BUILD_NAME', 'value' => 'spinach', 'public' => true }, + { 'key' => 'CI_BUILD_STAGE', 'value' => 'test', 'public' => true }, + { 'key' => 'CI_BUILD_TRIGGERED', 'value' => 'true', 'public' => true }, + { 'key' => 'DB_NAME', 'value' => 'postgres', 'public' => true }, + { 'key' => 'SECRET_KEY', 'value' => 'secret_value', 'public' => false }, + { 'key' => 'TRIGGER_KEY_1', 'value' => 'TRIGGER_VALUE_1', 'public' => false }] + end + + before do + trigger = create(:ci_trigger, project: project) + create(:ci_trigger_request_with_variables, pipeline: pipeline, builds: [job], trigger: trigger) + project.variables << Ci::Variable.new(key: 'SECRET_KEY', value: 'secret_value') + end + + it 'returns variables for triggers' do + request_job + + expect(response).to have_http_status(201) + expect(json_response['variables']).to include(*expected_variables) + end + end + + describe 'registry credentials support' do + let(:registry_url) { 'registry.example.com:5005' } + let(:registry_credentials) do + { 'type' => 'registry', + 'url' => registry_url, + 'username' => 'gitlab-ci-token', + 'password' => job.token } + end + + context 'when registry is enabled' do + before { stub_container_registry_config(enabled: true, host_port: registry_url) } + + it 'sends registry credentials key' do + request_job + + expect(json_response).to have_key('credentials') + expect(json_response['credentials']).to include(registry_credentials) + end + end + + context 'when registry is disabled' do + before { stub_container_registry_config(enabled: false, host_port: registry_url) } + + it 'does not send registry credentials' do + request_job + + expect(json_response).to have_key('credentials') + expect(json_response['credentials']).not_to include(registry_credentials) + end + end + end + end + + def request_job(token = runner.token, **params) + new_params = params.merge(token: token, last_update: last_update) + post api('/jobs/request'), new_params, { 'User-Agent' => user_agent } + end + end + end + + describe 'PUT /api/v4/jobs/:id' do + let(:job) { create(:ci_build, :pending, :trace, pipeline: pipeline, runner_id: runner.id) } + + before { job.run! } + + context 'when status is given' do + it 'mark job as succeeded' do + update_job(state: 'success') + + expect(job.reload.status).to eq 'success' + end + + it 'mark job as failed' do + update_job(state: 'failed') + + expect(job.reload.status).to eq 'failed' + end + end + + context 'when tace is given' do + it 'updates a running build' do + update_job(trace: 'BUILD TRACE UPDATED') + + expect(response).to have_http_status(200) + expect(job.reload.trace).to eq 'BUILD TRACE UPDATED' + end + end + + context 'when no trace is given' do + it 'does not override trace information' do + update_job + + expect(job.reload.trace).to eq 'BUILD TRACE' + end + end + + context 'when job has been erased' do + let(:job) { create(:ci_build, runner_id: runner.id, erased_at: Time.now) } + + it 'responds with forbidden' do + update_job + + expect(response).to have_http_status(403) + end + end + + def update_job(token = job.token, **params) + new_params = params.merge(token: token) + put api("/jobs/#{job.id}"), new_params + end + end + + describe 'PATCH /api/v4/jobs/:id/trace' do + let(:job) { create(:ci_build, :running, :trace, runner_id: runner.id, pipeline: pipeline) } + let(:headers) { { API::Helpers::Runner::JOB_TOKEN_HEADER => job.token, 'Content-Type' => 'text/plain' } } + let(:headers_with_range) { headers.merge({ 'Content-Range' => '11-20' }) } + let(:update_interval) { 10.seconds.to_i } + + before { initial_patch_the_trace } + + context 'when request is valid' do + it 'gets correct response' do + expect(response.status).to eq 202 + expect(job.reload.trace).to eq 'BUILD TRACE appended' + expect(response.header).to have_key 'Range' + expect(response.header).to have_key 'Job-Status' + end + + context 'when job has been updated recently' do + it { expect{ patch_the_trace }.not_to change { job.updated_at }} + + it "changes the job's trace" do + patch_the_trace + + expect(job.reload.trace).to eq 'BUILD TRACE appended appended' + end + + context 'when Runner makes a force-patch' do + it { expect{ force_patch_the_trace }.not_to change { job.updated_at }} + + it "doesn't change the build.trace" do + force_patch_the_trace + + expect(job.reload.trace).to eq 'BUILD TRACE appended' + end + end + end + + context 'when job was not updated recently' do + let(:update_interval) { 15.minutes.to_i } + + it { expect { patch_the_trace }.to change { job.updated_at } } + + it 'changes the job.trace' do + patch_the_trace + + expect(job.reload.trace).to eq 'BUILD TRACE appended appended' + end + + context 'when Runner makes a force-patch' do + it { expect { force_patch_the_trace }.to change { job.updated_at } } + + it "doesn't change the job.trace" do + force_patch_the_trace + + expect(job.reload.trace).to eq 'BUILD TRACE appended' + end + end + end + + context 'when project for the build has been deleted' do + let(:job) do + create(:ci_build, :running, :trace, runner_id: runner.id, pipeline: pipeline) do |job| + job.project.update(pending_delete: true) + end + end + + it 'responds with forbidden' do + expect(response.status).to eq(403) + end + end + end + + context 'when Runner makes a force-patch' do + before do + force_patch_the_trace + end + + it 'gets correct response' do + expect(response.status).to eq 202 + expect(job.reload.trace).to eq 'BUILD TRACE appended' + expect(response.header).to have_key 'Range' + expect(response.header).to have_key 'Job-Status' + end + end + + context 'when content-range start is too big' do + let(:headers_with_range) { headers.merge({ 'Content-Range' => '15-20' }) } + + it 'gets 416 error response with range headers' do + expect(response.status).to eq 416 + expect(response.header).to have_key 'Range' + expect(response.header['Range']).to eq '0-11' + end + end + + context 'when content-range start is too small' do + let(:headers_with_range) { headers.merge({ 'Content-Range' => '8-20' }) } + + it 'gets 416 error response with range headers' do + expect(response.status).to eq 416 + expect(response.header).to have_key 'Range' + expect(response.header['Range']).to eq '0-11' + end + end + + context 'when Content-Range header is missing' do + let(:headers_with_range) { headers } + + it { expect(response.status).to eq 400 } + end + + context 'when job has been errased' do + let(:job) { create(:ci_build, runner_id: runner.id, erased_at: Time.now) } + + it { expect(response.status).to eq 403 } + end + + def patch_the_trace(content = ' appended', request_headers = nil) + unless request_headers + offset = job.trace_length + limit = offset + content.length - 1 + request_headers = headers.merge({ 'Content-Range' => "#{offset}-#{limit}" }) + end + + Timecop.travel(job.updated_at + update_interval) do + patch api("/jobs/#{job.id}/trace"), content, request_headers + job.reload + end + end + + def initial_patch_the_trace + patch_the_trace(' appended', headers_with_range) + end + + def force_patch_the_trace + 2.times { patch_the_trace('') } + end + end + + describe 'artifacts' do + let(:job) { create(:ci_build, :pending, pipeline: pipeline, runner_id: runner.id) } + let(:jwt_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let(:headers) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => jwt_token } } + let(:headers_with_token) { headers.merge(API::Helpers::Runner::JOB_TOKEN_HEADER => job.token) } + let(:file_upload) { fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') } + let(:file_upload2) { fixture_file_upload(Rails.root + 'spec/fixtures/dk.png', 'image/gif') } + + before { job.run! } + + describe 'POST /api/v4/jobs/:id/artifacts/authorize' do + context 'when using token as parameter' do + it 'authorizes posting artifacts to running job' do + authorize_artifacts_with_token_in_params + + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).not_to be_nil + end + + it 'fails to post too large artifact' do + stub_application_setting(max_artifacts_size: 0) + + authorize_artifacts_with_token_in_params(filesize: 100) + + expect(response).to have_http_status(413) + end + end + + context 'when using token as header' do + it 'authorizes posting artifacts to running job' do + authorize_artifacts_with_token_in_headers + + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).not_to be_nil + end + + it 'fails to post too large artifact' do + stub_application_setting(max_artifacts_size: 0) + + authorize_artifacts_with_token_in_headers(filesize: 100) + + expect(response).to have_http_status(413) + end + end + + context 'when using runners token' do + it 'fails to authorize artifacts posting' do + authorize_artifacts(token: job.project.runners_token) + + expect(response).to have_http_status(403) + end + end + + it 'reject requests that did not go through gitlab-workhorse' do + headers.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) + + authorize_artifacts + + expect(response).to have_http_status(500) + end + + context 'authorization token is invalid' do + it 'responds with forbidden' do + authorize_artifacts(token: 'invalid', filesize: 100 ) + + expect(response).to have_http_status(403) + end + end + + def authorize_artifacts(params = {}, request_headers = headers) + post api("/jobs/#{job.id}/artifacts/authorize"), params, request_headers + end + + def authorize_artifacts_with_token_in_params(params = {}, request_headers = headers) + params = params.merge(token: job.token) + authorize_artifacts(params, request_headers) + end + + def authorize_artifacts_with_token_in_headers(params = {}, request_headers = headers_with_token) + authorize_artifacts(params, request_headers) + end + end + + describe 'POST /api/v4/jobs/:id/artifacts' do + context 'when artifacts are being stored inside of tmp path' do + before do + # by configuring this path we allow to pass temp file from any path + allow(ArtifactUploader).to receive(:artifacts_upload_path).and_return('/') + end + + context 'when job has been erased' do + let(:job) { create(:ci_build, erased_at: Time.now) } + + before do + upload_artifacts(file_upload, headers_with_token) + end + + it 'responds with forbidden' do + upload_artifacts(file_upload, headers_with_token) + + expect(response).to have_http_status(403) + end + end + + context 'when job is running' do + shared_examples 'successful artifacts upload' do + it 'updates successfully' do + expect(response).to have_http_status(201) + end + end + + context 'when uses regular file post' do + before { upload_artifacts(file_upload, headers_with_token, false) } + + it_behaves_like 'successful artifacts upload' + end + + context 'when uses accelerated file post' do + before { upload_artifacts(file_upload, headers_with_token, true) } + + it_behaves_like 'successful artifacts upload' + end + + context 'when updates artifact' do + before do + upload_artifacts(file_upload2, headers_with_token) + upload_artifacts(file_upload, headers_with_token) + end + + it_behaves_like 'successful artifacts upload' + end + + context 'when using runners token' do + it 'responds with forbidden' do + upload_artifacts(file_upload, headers.merge(API::Helpers::Runner::JOB_TOKEN_HEADER => job.project.runners_token)) + + expect(response).to have_http_status(403) + end + end + end + + context 'when artifacts file is too large' do + it 'fails to post too large artifact' do + stub_application_setting(max_artifacts_size: 0) + + upload_artifacts(file_upload, headers_with_token) + + expect(response).to have_http_status(413) + end + end + + context 'when artifacts post request does not contain file' do + it 'fails to post artifacts without file' do + post api("/jobs/#{job.id}/artifacts"), {}, headers_with_token + + expect(response).to have_http_status(400) + end + end + + context 'GitLab Workhorse is not configured' do + it 'fails to post artifacts without GitLab-Workhorse' do + post api("/jobs/#{job.id}/artifacts"), { token: job.token }, {} + + expect(response).to have_http_status(403) + end + end + + context 'when setting an expire date' do + let(:default_artifacts_expire_in) {} + let(:post_data) do + { 'file.path' => file_upload.path, + 'file.name' => file_upload.original_filename, + 'expire_in' => expire_in } + end + + before do + stub_application_setting(default_artifacts_expire_in: default_artifacts_expire_in) + + post(api("/jobs/#{job.id}/artifacts"), post_data, headers_with_token) + end + + context 'when an expire_in is given' do + let(:expire_in) { '7 days' } + + it 'updates when specified' do + expect(response).to have_http_status(201) + expect(job.reload.artifacts_expire_at).to be_within(5.minutes).of(7.days.from_now) + end + end + + context 'when no expire_in is given' do + let(:expire_in) { nil } + + it 'ignores if not specified' do + expect(response).to have_http_status(201) + expect(job.reload.artifacts_expire_at).to be_nil + end + + context 'with application default' do + context 'when default is 5 days' do + let(:default_artifacts_expire_in) { '5 days' } + + it 'sets to application default' do + expect(response).to have_http_status(201) + expect(job.reload.artifacts_expire_at).to be_within(5.minutes).of(5.days.from_now) + end + end + + context 'when default is 0' do + let(:default_artifacts_expire_in) { '0' } + + it 'does not set expire_in' do + expect(response).to have_http_status(201) + expect(job.reload.artifacts_expire_at).to be_nil + end + end + end + end + end + + context 'posts artifacts file and metadata file' do + let!(:artifacts) { file_upload } + let!(:metadata) { file_upload2 } + + let(:stored_artifacts_file) { job.reload.artifacts_file.file } + let(:stored_metadata_file) { job.reload.artifacts_metadata.file } + let(:stored_artifacts_size) { job.reload.artifacts_size } + + before do + post(api("/jobs/#{job.id}/artifacts"), post_data, headers_with_token) + end + + context 'when posts data accelerated by workhorse is correct' do + let(:post_data) do + { 'file.path' => artifacts.path, + 'file.name' => artifacts.original_filename, + 'metadata.path' => metadata.path, + 'metadata.name' => metadata.original_filename } + end + + it 'stores artifacts and artifacts metadata' do + expect(response).to have_http_status(201) + expect(stored_artifacts_file.original_filename).to eq(artifacts.original_filename) + expect(stored_metadata_file.original_filename).to eq(metadata.original_filename) + expect(stored_artifacts_size).to eq(71759) + end + end + + context 'when there is no artifacts file in post data' do + let(:post_data) do + { 'metadata' => metadata } + end + + it 'is expected to respond with bad request' do + expect(response).to have_http_status(400) + end + + it 'does not store metadata' do + expect(stored_metadata_file).to be_nil + end + end + end + end + + context 'when artifacts are being stored outside of tmp path' do + before do + # by configuring this path we allow to pass file from @tmpdir only + # but all temporary files are stored in system tmp directory + @tmpdir = Dir.mktmpdir + allow(ArtifactUploader).to receive(:artifacts_upload_path).and_return(@tmpdir) + end + + after { FileUtils.remove_entry @tmpdir } + + it' "fails to post artifacts for outside of tmp path"' do + upload_artifacts(file_upload, headers_with_token) + + expect(response).to have_http_status(400) + end + end + + def upload_artifacts(file, headers = {}, accelerated = true) + params = if accelerated + { 'file.path' => file.path, 'file.name' => file.original_filename } + else + { 'file' => file } + end + post api("/jobs/#{job.id}/artifacts"), params, headers + end + end + + describe 'GET /api/v4/jobs/:id/artifacts' do + let(:token) { job.token } + + before { download_artifact } + + context 'when job has artifacts' do + let(:job) { create(:ci_build, :artifacts) } + let(:download_headers) do + { 'Content-Transfer-Encoding' => 'binary', + 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' } + end + + context 'when using job token' do + it 'download artifacts' do + expect(response).to have_http_status(200) + expect(response.headers).to include download_headers + end + end + + context 'when using runnners token' do + let(:token) { job.project.runners_token } + + it 'responds with forbidden' do + expect(response).to have_http_status(403) + end + end + end + + context 'when job does not has artifacts' do + it 'responds with not found' do + expect(response).to have_http_status(404) + end + end + + def download_artifact(params = {}, request_headers = headers) + params = params.merge(token: token) + get api("/jobs/#{job.id}/artifacts"), params, request_headers + end + end + end + end end diff --git a/spec/requests/api/session_spec.rb b/spec/requests/api/session_spec.rb index 794e2b5c04d..28fab2011a5 100644 --- a/spec/requests/api/session_spec.rb +++ b/spec/requests/api/session_spec.rb @@ -87,5 +87,23 @@ describe API::Session, api: true do expect(response).to have_http_status(400) end end + + context "when user is blocked" do + it "returns authentication error" do + user.block + post api("/session"), email: user.username, password: user.password + + expect(response).to have_http_status(401) + end + end + + context "when user is ldap_blocked" do + it "returns authentication error" do + user.ldap_block + post api("/session"), email: user.username, password: user.password + + expect(response).to have_http_status(401) + end + end end end diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb index 1e401935662..b789284fa8d 100644 --- a/spec/requests/api/todos_spec.rb +++ b/spec/requests/api/todos_spec.rb @@ -163,7 +163,7 @@ describe API::Todos, api: true do shared_examples 'an issuable' do |issuable_type| it 'creates a todo on an issuable' do - post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.id}/todo", john_doe) + post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.iid}/todo", john_doe) expect(response.status).to eq(201) expect(json_response['project']).to be_a Hash @@ -180,7 +180,7 @@ describe API::Todos, api: true do it 'returns 304 there already exist a todo on that issuable' do create(:todo, project: project_1, author: author_1, user: john_doe, target: issuable) - post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.id}/todo", john_doe) + post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.iid}/todo", john_doe) expect(response.status).to eq(304) end @@ -195,7 +195,7 @@ describe API::Todos, api: true do guest = create(:user) project_1.team << [guest, :guest] - post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.id}/todo", guest) + post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.iid}/todo", guest) if issuable_type == 'merge_requests' expect(response).to have_http_status(403) diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 881c48c75e0..04e7837fd7a 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -10,6 +10,8 @@ describe API::Users, api: true do let(:omniauth_user) { create(:omniauth_user) } let(:ldap_user) { create(:omniauth_user, provider: 'ldapmain') } let(:ldap_blocked_user) { create(:omniauth_user, provider: 'ldapmain', state: 'ldap_blocked') } + let(:not_existing_user_id) { (User.maximum('id') || 0 ) + 10 } + let(:not_existing_pat_id) { (PersonalAccessToken.maximum('id') || 0 ) + 10 } describe "GET /users" do context "when unauthenticated" do @@ -1155,4 +1157,187 @@ describe API::Users, api: true do expect(json_response['message']).to eq('404 User Not Found') end end + + describe 'GET /users/:user_id/impersonation_tokens' do + let!(:active_personal_access_token) { create(:personal_access_token, user: user) } + let!(:revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user) } + let!(:expired_personal_access_token) { create(:personal_access_token, :expired, user: user) } + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + let!(:revoked_impersonation_token) { create(:personal_access_token, :impersonation, :revoked, user: user) } + + it 'returns a 404 error if user not found' do + get api("/users/#{not_existing_user_id}/impersonation_tokens", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns a 403 error when authenticated as normal user' do + get api("/users/#{not_existing_user_id}/impersonation_tokens", user) + + expect(response).to have_http_status(403) + expect(json_response['message']).to eq('403 Forbidden') + end + + it 'returns an array of all impersonated tokens' do + get api("/users/#{user.id}/impersonation_tokens", admin) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(2) + end + + it 'returns an array of active impersonation tokens if state active' do + get api("/users/#{user.id}/impersonation_tokens?state=active", admin) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response).to all(include('active' => true)) + end + + it 'returns an array of inactive personal access tokens if active is set to false' do + get api("/users/#{user.id}/impersonation_tokens?state=inactive", admin) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response).to all(include('active' => false)) + end + end + + describe 'POST /users/:user_id/impersonation_tokens' do + let(:name) { 'my new pat' } + let(:expires_at) { '2016-12-28' } + let(:scopes) { %w(api read_user) } + let(:impersonation) { true } + + it 'returns validation error if impersonation token misses some attributes' do + post api("/users/#{user.id}/impersonation_tokens", admin) + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('name is missing') + end + + it 'returns a 404 error if user not found' do + post api("/users/#{not_existing_user_id}/impersonation_tokens", admin), + name: name, + expires_at: expires_at + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns a 403 error when authenticated as normal user' do + post api("/users/#{user.id}/impersonation_tokens", user), + name: name, + expires_at: expires_at + + expect(response).to have_http_status(403) + expect(json_response['message']).to eq('403 Forbidden') + end + + it 'creates a impersonation token' do + post api("/users/#{user.id}/impersonation_tokens", admin), + name: name, + expires_at: expires_at, + scopes: scopes, + impersonation: impersonation + + expect(response).to have_http_status(201) + expect(json_response['name']).to eq(name) + expect(json_response['scopes']).to eq(scopes) + expect(json_response['expires_at']).to eq(expires_at) + expect(json_response['id']).to be_present + expect(json_response['created_at']).to be_present + expect(json_response['active']).to be_falsey + expect(json_response['revoked']).to be_falsey + expect(json_response['token']).to be_present + expect(json_response['impersonation']).to eq(impersonation) + end + end + + describe 'GET /users/:user_id/impersonation_tokens/:impersonation_token_id' do + let!(:personal_access_token) { create(:personal_access_token, user: user) } + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + + it 'returns 404 error if user not found' do + get api("/users/#{not_existing_user_id}/impersonation_tokens/1", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns a 404 error if impersonation token not found' do + get api("/users/#{user.id}/impersonation_tokens/#{not_existing_pat_id}", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Impersonation Token Not Found') + end + + it 'returns a 404 error if token is not impersonation token' do + get api("/users/#{user.id}/impersonation_tokens/#{personal_access_token.id}", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Impersonation Token Not Found') + end + + it 'returns a 403 error when authenticated as normal user' do + get api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", user) + + expect(response).to have_http_status(403) + expect(json_response['message']).to eq('403 Forbidden') + end + + it 'returns a personal access token' do + get api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", admin) + + expect(response).to have_http_status(200) + expect(json_response['token']).to be_present + expect(json_response['impersonation']).to be_truthy + end + end + + describe 'DELETE /users/:user_id/impersonation_tokens/:impersonation_token_id' do + let!(:personal_access_token) { create(:personal_access_token, user: user) } + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + + it 'returns a 404 error if user not found' do + delete api("/users/#{not_existing_user_id}/impersonation_tokens/1", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns a 404 error if impersonation token not found' do + delete api("/users/#{user.id}/impersonation_tokens/#{not_existing_pat_id}", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Impersonation Token Not Found') + end + + it 'returns a 404 error if token is not impersonation token' do + delete api("/users/#{user.id}/impersonation_tokens/#{personal_access_token.id}", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Impersonation Token Not Found') + end + + it 'returns a 403 error when authenticated as normal user' do + delete api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", user) + + expect(response).to have_http_status(403) + expect(json_response['message']).to eq('403 Forbidden') + end + + it 'revokes a impersonation token' do + delete api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", admin) + + expect(response).to have_http_status(204) + expect(impersonation_token.revoked).to be_falsey + expect(impersonation_token.reload.revoked).to be_truthy + end + end end diff --git a/spec/requests/api/v3/award_emoji_spec.rb b/spec/requests/api/v3/award_emoji_spec.rb index 91145c8e72c..eeb4d128c1b 100644 --- a/spec/requests/api/v3/award_emoji_spec.rb +++ b/spec/requests/api/v3/award_emoji_spec.rb @@ -13,6 +13,231 @@ describe API::V3::AwardEmoji, api: true do before { project.team << [user, :master] } + describe "GET /projects/:id/awardable/:awardable_id/award_emoji" do + context 'on an issue' do + it "returns an array of award_emoji" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(award_emoji.name) + end + + it "returns a 404 error when issue id not found" do + get v3_api("/projects/#{project.id}/issues/12345/award_emoji", user) + + expect(response).to have_http_status(404) + end + end + + context 'on a merge request' do + it "returns an array of award_emoji" do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(downvote.name) + end + end + + context 'on a snippet' do + let(:snippet) { create(:project_snippet, :public, project: project) } + let!(:award) { create(:award_emoji, awardable: snippet) } + + it 'returns the awarded emoji' do + get v3_api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(award.name) + end + end + + context 'when the user has no access' do + it 'returns a status code 404' do + user1 = create(:user) + + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji", user1) + + expect(response).to have_http_status(404) + end + end + end + + describe 'GET /projects/:id/awardable/:awardable_id/notes/:note_id/award_emoji' do + let!(:rocket) { create(:award_emoji, awardable: note, name: 'rocket') } + + it 'returns an array of award emoji' do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(rocket.name) + end + end + + describe "GET /projects/:id/awardable/:awardable_id/award_emoji/:award_id" do + context 'on an issue' do + it "returns the award emoji" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/#{award_emoji.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(award_emoji.name) + expect(json_response['awardable_id']).to eq(issue.id) + expect(json_response['awardable_type']).to eq("Issue") + end + + it "returns a 404 error if the award is not found" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/12345", user) + + expect(response).to have_http_status(404) + end + end + + context 'on a merge request' do + it 'returns the award emoji' do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(downvote.name) + expect(json_response['awardable_id']).to eq(merge_request.id) + expect(json_response['awardable_type']).to eq("MergeRequest") + end + end + + context 'on a snippet' do + let(:snippet) { create(:project_snippet, :public, project: project) } + let!(:award) { create(:award_emoji, awardable: snippet) } + + it 'returns the awarded emoji' do + get v3_api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji/#{award.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(award.name) + expect(json_response['awardable_id']).to eq(snippet.id) + expect(json_response['awardable_type']).to eq("Snippet") + end + end + + context 'when the user has no access' do + it 'returns a status code 404' do + user1 = create(:user) + + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user1) + + expect(response).to have_http_status(404) + end + end + end + + describe 'GET /projects/:id/awardable/:awardable_id/notes/:note_id/award_emoji/:award_id' do + let!(:rocket) { create(:award_emoji, awardable: note, name: 'rocket') } + + it 'returns an award emoji' do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji/#{rocket.id}", user) + + expect(response).to have_http_status(200) + expect(json_response).not_to be_an Array + expect(json_response['name']).to eq(rocket.name) + end + end + + describe "POST /projects/:id/awardable/:awardable_id/award_emoji" do + let(:issue2) { create(:issue, project: project, author: user) } + + context "on an issue" do + it "creates a new award emoji" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'blowfish' + + expect(response).to have_http_status(201) + expect(json_response['name']).to eq('blowfish') + expect(json_response['user']['username']).to eq(user.username) + end + + it "returns a 400 bad request error if the name is not given" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user) + + expect(response).to have_http_status(400) + end + + it "returns a 401 unauthorized error if the user is not authenticated" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji"), name: 'thumbsup' + + expect(response).to have_http_status(401) + end + + it "returns a 404 error if the user authored issue" do + post v3_api("/projects/#{project.id}/issues/#{issue2.id}/award_emoji", user), name: 'thumbsup' + + expect(response).to have_http_status(404) + end + + it "normalizes +1 as thumbsup award" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: '+1' + + expect(issue.award_emoji.last.name).to eq("thumbsup") + end + + context 'when the emoji already has been awarded' do + it 'returns a 404 status code' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'thumbsup' + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'thumbsup' + + expect(response).to have_http_status(404) + expect(json_response["message"]).to match("has already been taken") + end + end + end + + context 'on a snippet' do + it 'creates a new award emoji' do + snippet = create(:project_snippet, :public, project: project) + + post v3_api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji", user), name: 'blowfish' + + expect(response).to have_http_status(201) + expect(json_response['name']).to eq('blowfish') + expect(json_response['user']['username']).to eq(user.username) + end + end + end + + describe "POST /projects/:id/awardable/:awardable_id/notes/:note_id/award_emoji" do + let(:note2) { create(:note, project: project, noteable: issue, author: user) } + + it 'creates a new award emoji' do + expect do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' + end.to change { note.award_emoji.count }.from(0).to(1) + + expect(response).to have_http_status(201) + expect(json_response['user']['username']).to eq(user.username) + end + + it "it returns 404 error when user authored note" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note2.id}/award_emoji", user), name: 'thumbsup' + + expect(response).to have_http_status(404) + end + + it "normalizes +1 as thumbsup award" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: '+1' + + expect(note.award_emoji.last.name).to eq("thumbsup") + end + + context 'when the emoji already has been awarded' do + it 'returns a 404 status code' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' + + expect(response).to have_http_status(404) + expect(json_response["message"]).to match("has already been taken") + end + end + end + describe 'DELETE /projects/:id/awardable/:awardable_id/award_emoji/:award_id' do context 'when the awardable is an Issue' do it 'deletes the award' do diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index 2a8105d5a2b..1941ca0d7d8 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -1288,6 +1288,6 @@ describe API::V3::Issues, api: true do describe 'time tracking endpoints' do let(:issuable) { issue } - include_examples 'time tracking endpoints', 'issue' + include_examples 'V3 time tracking endpoints', 'issue' end end diff --git a/spec/requests/api/v3/merge_request_diffs_spec.rb b/spec/requests/api/v3/merge_request_diffs_spec.rb index e1887138aab..c53800eef30 100644 --- a/spec/requests/api/v3/merge_request_diffs_spec.rb +++ b/spec/requests/api/v3/merge_request_diffs_spec.rb @@ -1,6 +1,6 @@ require "spec_helper" -describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do +describe API::V3::MergeRequestDiffs, 'MergeRequestDiffs', api: true do include ApiHelpers let!(:user) { create(:user) } @@ -15,7 +15,7 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do describe 'GET /projects/:id/merge_requests/:merge_request_id/versions' do it 'returns 200 for a valid merge request' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions", user) + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions", user) merge_request_diff = merge_request.merge_request_diffs.first expect(response.status).to eq 200 @@ -25,7 +25,7 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do end it 'returns a 404 when merge_request_id not found' do - get api("/projects/#{project.id}/merge_requests/999/versions", user) + get v3_api("/projects/#{project.id}/merge_requests/999/versions", user) expect(response).to have_http_status(404) end end @@ -33,7 +33,7 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do describe 'GET /projects/:id/merge_requests/:merge_request_id/versions/:version_id' do it 'returns a 200 for a valid merge request' do merge_request_diff = merge_request.merge_request_diffs.first - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/#{merge_request_diff.id}", user) + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/#{merge_request_diff.id}", user) expect(response.status).to eq 200 expect(json_response['id']).to eq(merge_request_diff.id) @@ -42,7 +42,8 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do end it 'returns a 404 when merge_request_id not found' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/999", user) + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/999", user) + expect(response).to have_http_status(404) end end diff --git a/spec/requests/api/v3/merge_requests_spec.rb b/spec/requests/api/v3/merge_requests_spec.rb index b7ed643bc21..d73e9635c9b 100644 --- a/spec/requests/api/v3/merge_requests_spec.rb +++ b/spec/requests/api/v3/merge_requests_spec.rb @@ -712,7 +712,7 @@ describe API::MergeRequests, api: true do describe 'Time tracking' do let(:issuable) { merge_request } - include_examples 'time tracking endpoints', 'merge_request' + include_examples 'V3 time tracking endpoints', 'merge_request' end def mr_with_later_created_and_updated_at_time diff --git a/spec/requests/api/v3/services_spec.rb b/spec/requests/api/v3/services_spec.rb index 7e8c8753d02..3a760a8f25c 100644 --- a/spec/requests/api/v3/services_spec.rb +++ b/spec/requests/api/v3/services_spec.rb @@ -6,7 +6,9 @@ describe API::V3::Services, api: true do let(:user) { create(:user) } let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) } - Service.available_services_names.each do |service| + available_services = Service.available_services_names + available_services.delete('prometheus') + available_services.each do |service| describe "DELETE /projects/:id/services/#{service.dasherize}" do include_context service diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 87786e85621..006d6a6af1c 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -221,12 +221,20 @@ describe 'Git HTTP requests', lib: true do end context "when the user is blocked" do - it "responds with status 404" do + it "responds with status 401" do user.block project.team << [user, :master] download(path, env) do |response| - expect(response).to have_http_status(404) + expect(response).to have_http_status(401) + end + end + + it "responds with status 401 for unknown projects (no project existence information leak)" do + user.block + + download('doesnt/exist.git', env) do |response| + expect(response).to have_http_status(401) end end end diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb new file mode 100644 index 00000000000..5206634bca5 --- /dev/null +++ b/spec/requests/openid_connect_spec.rb @@ -0,0 +1,134 @@ +require 'spec_helper' + +describe 'OpenID Connect requests' do + include ApiHelpers + + let(:user) { create :user } + let(:access_grant) { create :oauth_access_grant, application: application, resource_owner_id: user.id } + let(:access_token) { create :oauth_access_token, application: application, resource_owner_id: user.id } + + def request_access_token + login_as user + + post '/oauth/token', + grant_type: 'authorization_code', + code: access_grant.token, + redirect_uri: application.redirect_uri, + client_id: application.uid, + client_secret: application.secret + end + + def request_user_info + get '/oauth/userinfo', nil, 'Authorization' => "Bearer #{access_token.token}" + end + + def hashed_subject + Digest::SHA256.hexdigest("#{user.id}-#{Rails.application.secrets.secret_key_base}") + end + + context 'Application without OpenID scope' do + let(:application) { create :oauth_application, scopes: 'api' } + + it 'token response does not include an ID token' do + request_access_token + + expect(json_response).to include 'access_token' + expect(json_response).not_to include 'id_token' + end + + it 'userinfo response is unauthorized' do + request_user_info + + expect(response).to have_http_status 403 + expect(response.body).to be_blank + end + end + + context 'Application with OpenID scope' do + let(:application) { create :oauth_application, scopes: 'openid' } + + it 'token response includes an ID token' do + request_access_token + + expect(json_response).to include 'id_token' + end + + context 'UserInfo payload' do + let(:user) do + create( + :user, + name: 'Alice', + username: 'alice', + emails: [private_email, public_email], + email: private_email.email, + public_email: public_email.email, + website_url: 'https://example.com', + avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png"), + ) + end + + let(:public_email) { build :email, email: 'public@example.com' } + let(:private_email) { build :email, email: 'private@example.com' } + + it 'includes all user information' do + request_user_info + + expect(json_response).to eq({ + 'sub' => hashed_subject, + 'name' => 'Alice', + 'nickname' => 'alice', + 'email' => 'public@example.com', + 'email_verified' => true, + 'website' => 'https://example.com', + 'profile' => 'http://localhost/alice', + 'picture' => "http://localhost/uploads/user/avatar/#{user.id}/dk.png", + }) + end + end + + context 'ID token payload' do + before do + request_access_token + @payload = JSON::JWT.decode(json_response['id_token'], :skip_verification) + end + + it 'includes the Gitlab root URL' do + expect(@payload['iss']).to eq Gitlab.config.gitlab.url + end + + it 'includes the hashed user ID' do + expect(@payload['sub']).to eq hashed_subject + end + + it 'includes the time of the last authentication' do + expect(@payload['auth_time']).to eq user.current_sign_in_at.to_i + end + + it 'does not include any unknown properties' do + expect(@payload.keys).to eq %w[iss sub aud exp iat auth_time] + end + end + + context 'when user is blocked' do + it 'returns authentication error' do + access_grant + user.block + + expect do + request_access_token + end.to throw_symbol :warden + end + end + + context 'when user is ldap_blocked' do + it 'returns authentication error' do + access_grant + user.ldap_block + + expect do + request_access_token + end.to throw_symbol :warden + end + end + end +end diff --git a/spec/routing/openid_connect_spec.rb b/spec/routing/openid_connect_spec.rb new file mode 100644 index 00000000000..2c3bc08f1a1 --- /dev/null +++ b/spec/routing/openid_connect_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +# oauth_discovery_keys GET /oauth/discovery/keys(.:format) doorkeeper/openid_connect/discovery#keys +# oauth_discovery_provider GET /.well-known/openid-configuration(.:format) doorkeeper/openid_connect/discovery#provider +# oauth_discovery_webfinger GET /.well-known/webfinger(.:format) doorkeeper/openid_connect/discovery#webfinger +describe Doorkeeper::OpenidConnect::DiscoveryController, 'routing' do + it "to #provider" do + expect(get('/.well-known/openid-configuration')).to route_to('doorkeeper/openid_connect/discovery#provider') + end + + it "to #webfinger" do + expect(get('/.well-known/webfinger')).to route_to('doorkeeper/openid_connect/discovery#webfinger') + end + + it "to #keys" do + expect(get('/oauth/discovery/keys')).to route_to('doorkeeper/openid_connect/discovery#keys') + end +end + +# oauth_userinfo GET /oauth/userinfo(.:format) doorkeeper/openid_connect/userinfo#show +# POST /oauth/userinfo(.:format) doorkeeper/openid_connect/userinfo#show +describe Doorkeeper::OpenidConnect::UserinfoController, 'routing' do + it "to #show" do + expect(get('/oauth/userinfo')).to route_to('doorkeeper/openid_connect/userinfo#show') + end + + it "to #show" do + expect(post('/oauth/userinfo')).to route_to('doorkeeper/openid_connect/userinfo#show') + end +end diff --git a/spec/rubocop/cop/migration/add_concurrent_index_spec.rb b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb new file mode 100644 index 00000000000..19a5718b0b1 --- /dev/null +++ b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../../rubocop/cop/migration/add_concurrent_index' + +describe RuboCop::Cop::Migration::AddConcurrentIndex do + include CopHelper + + subject(:cop) { described_class.new } + + context 'in migration' do + before do + allow(cop).to receive(:in_migration?).and_return(true) + end + + it 'registers an offense when add_concurrent_index is used inside a change method' do + inspect_source(cop, 'def change; add_concurrent_index :table, :column; end') + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([1]) + end + end + + it 'registers no offense when add_concurrent_index is used inside an up method' do + inspect_source(cop, 'def up; add_concurrent_index :table, :column; end') + + expect(cop.offenses.size).to eq(0) + end + end + + context 'outside of migration' do + it 'registers no offense' do + inspect_source(cop, 'def change; add_concurrent_index :table, :column; end') + + expect(cop.offenses.size).to eq(0) + end + end +end diff --git a/spec/services/ci/register_build_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index cd7dd53025c..62ba0b01339 100644 --- a/spec/services/ci/register_build_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' module Ci - describe RegisterBuildService, services: true do + describe RegisterJobService, services: true do let!(:project) { FactoryGirl.create :empty_project, shared_runners_enabled: false } let!(:pipeline) { FactoryGirl.create :ci_pipeline, project: project } let!(:pending_build) { FactoryGirl.create :ci_build, pipeline: pipeline } @@ -181,7 +181,7 @@ module Ci let!(:other_build) { create :ci_build, pipeline: pipeline } before do - allow_any_instance_of(Ci::RegisterBuildService).to receive(:builds_for_specific_runner) + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) .and_return([pending_build, other_build]) end @@ -193,7 +193,7 @@ module Ci context 'when single build is in queue' do before do - allow_any_instance_of(Ci::RegisterBuildService).to receive(:builds_for_specific_runner) + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) .and_return([pending_build]) end @@ -204,7 +204,7 @@ module Ci context 'when there is no build in queue' do before do - allow_any_instance_of(Ci::RegisterBuildService).to receive(:builds_for_specific_runner) + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) .and_return([]) end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 2a0f00ce937..bd71618e6f4 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -150,6 +150,13 @@ describe GitPushService, services: true do execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) end end + + context "Sends System Push data" do + it "when pushing on a branch" do + expect(SystemHookPushWorker).to receive(:perform_async).with(@push_data, :push_hooks) + execute_service(project, user, @oldrev, @newrev, @ref ) + end + end end describe "Updates git attributes" do diff --git a/spec/support/api/time_tracking_shared_examples.rb b/spec/support/api/time_tracking_shared_examples.rb index 210cd5817e0..16a3cf06be7 100644 --- a/spec/support/api/time_tracking_shared_examples.rb +++ b/spec/support/api/time_tracking_shared_examples.rb @@ -7,13 +7,13 @@ shared_examples 'time tracking endpoints' do |issuable_name| describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_estimate" do context 'with an unauthorized user' do - subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", non_member), duration: '1w') } + subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_estimate", non_member), duration: '1w') } it_behaves_like 'an unauthorized API user' end it "sets the time estimate for #{issuable_name}" do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w' + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_estimate", user), duration: '1w' expect(response).to have_http_status(200) expect(json_response['human_time_estimate']).to eq('1w') @@ -21,12 +21,12 @@ shared_examples 'time tracking endpoints' do |issuable_name| describe 'updating the current estimate' do before do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w' + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_estimate", user), duration: '1w' end context 'when duration has a bad format' do it 'does not modify the original estimate' do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: 'foo' + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_estimate", user), duration: 'foo' expect(response).to have_http_status(400) expect(issuable.reload.human_time_estimate).to eq('1w') @@ -35,7 +35,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| context 'with a valid duration' do it 'updates the estimate' do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '3w1h' + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_estimate", user), duration: '3w1h' expect(response).to have_http_status(200) expect(issuable.reload.human_time_estimate).to eq('3w 1h') @@ -46,13 +46,13 @@ shared_examples 'time tracking endpoints' do |issuable_name| describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_time_estimate" do context 'with an unauthorized user' do - subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", non_member)) } + subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/reset_time_estimate", non_member)) } it_behaves_like 'an unauthorized API user' end it "resets the time estimate for #{issuable_name}" do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", user) + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/reset_time_estimate", user) expect(response).to have_http_status(200) expect(json_response['time_estimate']).to eq(0) @@ -62,7 +62,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/add_spent_time" do context 'with an unauthorized user' do subject do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", non_member), + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/add_spent_time", non_member), duration: '2h' end @@ -70,7 +70,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| end it "add spent time for #{issuable_name}" do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/add_spent_time", user), duration: '2h' expect(response).to have_http_status(201) @@ -81,7 +81,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| it 'subtracts time of the total spent time' do issuable.update_attributes!(spend_time: { duration: 7200, user: user }) - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/add_spent_time", user), duration: '-1h' expect(response).to have_http_status(201) @@ -93,7 +93,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| it 'does not modify the total time spent' do issuable.update_attributes!(spend_time: { duration: 7200, user: user }) - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/add_spent_time", user), duration: '-1w' expect(response).to have_http_status(400) @@ -104,13 +104,13 @@ shared_examples 'time tracking endpoints' do |issuable_name| describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_spent_time" do context 'with an unauthorized user' do - subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", non_member)) } + subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/reset_spent_time", non_member)) } it_behaves_like 'an unauthorized API user' end it "resets spent time for #{issuable_name}" do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", user) + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/reset_spent_time", user) expect(response).to have_http_status(200) expect(json_response['total_time_spent']).to eq(0) @@ -122,7 +122,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| issuable.update_attributes!(spend_time: { duration: 1800, user: user }, time_estimate: 3600) - get api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_stats", user) + get api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_stats", user) expect(response).to have_http_status(200) expect(json_response['total_time_spent']).to eq(1800) diff --git a/spec/support/api/v3/time_tracking_shared_examples.rb b/spec/support/api/v3/time_tracking_shared_examples.rb new file mode 100644 index 00000000000..f982b10d999 --- /dev/null +++ b/spec/support/api/v3/time_tracking_shared_examples.rb @@ -0,0 +1,128 @@ +shared_examples 'V3 time tracking endpoints' do |issuable_name| + issuable_collection_name = issuable_name.pluralize + + describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_estimate" do + context 'with an unauthorized user' do + subject { post(v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", non_member), duration: '1w') } + + it_behaves_like 'an unauthorized API user' + end + + it "sets the time estimate for #{issuable_name}" do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w' + + expect(response).to have_http_status(200) + expect(json_response['human_time_estimate']).to eq('1w') + end + + describe 'updating the current estimate' do + before do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w' + end + + context 'when duration has a bad format' do + it 'does not modify the original estimate' do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: 'foo' + + expect(response).to have_http_status(400) + expect(issuable.reload.human_time_estimate).to eq('1w') + end + end + + context 'with a valid duration' do + it 'updates the estimate' do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '3w1h' + + expect(response).to have_http_status(200) + expect(issuable.reload.human_time_estimate).to eq('3w 1h') + end + end + end + end + + describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_time_estimate" do + context 'with an unauthorized user' do + subject { post(v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", non_member)) } + + it_behaves_like 'an unauthorized API user' + end + + it "resets the time estimate for #{issuable_name}" do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", user) + + expect(response).to have_http_status(200) + expect(json_response['time_estimate']).to eq(0) + end + end + + describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/add_spent_time" do + context 'with an unauthorized user' do + subject do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", non_member), + duration: '2h' + end + + it_behaves_like 'an unauthorized API user' + end + + it "add spent time for #{issuable_name}" do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + duration: '2h' + + expect(response).to have_http_status(201) + expect(json_response['human_total_time_spent']).to eq('2h') + end + + context 'when subtracting time' do + it 'subtracts time of the total spent time' do + issuable.update_attributes!(spend_time: { duration: 7200, user: user }) + + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + duration: '-1h' + + expect(response).to have_http_status(201) + expect(json_response['total_time_spent']).to eq(3600) + end + end + + context 'when time to subtract is greater than the total spent time' do + it 'does not modify the total time spent' do + issuable.update_attributes!(spend_time: { duration: 7200, user: user }) + + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + duration: '-1w' + + expect(response).to have_http_status(400) + expect(json_response['message']['time_spent'].first).to match(/exceeds the total time spent/) + end + end + end + + describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_spent_time" do + context 'with an unauthorized user' do + subject { post(v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", non_member)) } + + it_behaves_like 'an unauthorized API user' + end + + it "resets spent time for #{issuable_name}" do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", user) + + expect(response).to have_http_status(200) + expect(json_response['total_time_spent']).to eq(0) + end + end + + describe "GET /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_stats" do + it "returns the time stats for #{issuable_name}" do + issuable.update_attributes!(spend_time: { duration: 1800, user: user }, + time_estimate: 3600) + + get v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_stats", user) + + expect(response).to have_http_status(200) + expect(json_response['total_time_spent']).to eq(1800) + expect(json_response['time_estimate']).to eq(3600) + end + end +end diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb new file mode 100644 index 00000000000..a52d8f37d14 --- /dev/null +++ b/spec/support/prometheus_helpers.rb @@ -0,0 +1,117 @@ +module PrometheusHelpers + def prometheus_memory_query(environment_slug) + %{sum(container_memory_usage_bytes{container_name="app",environment="#{environment_slug}"})/1024/1024} + end + + def prometheus_cpu_query(environment_slug) + %{sum(rate(container_cpu_usage_seconds_total{container_name="app",environment="#{environment_slug}"}[2m]))} + end + + def prometheus_query_url(prometheus_query) + query = { query: prometheus_query }.to_query + + "https://prometheus.example.com/api/v1/query?#{query}" + end + + def prometheus_query_range_url(prometheus_query, start: 8.hours.ago) + query = { + query: prometheus_query, + start: start.to_f, + end: Time.now.utc.to_f, + step: 1.minute.to_i + }.to_query + + "https://prometheus.example.com/api/v1/query_range?#{query}" + end + + def stub_prometheus_request(url, body: {}, status: 200) + WebMock.stub_request(:get, url) + .to_return({ + status: status, + headers: { 'Content-Type' => 'application/json' }, + body: body.to_json + }) + end + + def stub_all_prometheus_requests(environment_slug, body: nil, status: 200) + stub_prometheus_request( + prometheus_query_url(prometheus_memory_query(environment_slug)), + status: status, + body: body || prometheus_value_body + ) + stub_prometheus_request( + prometheus_query_range_url(prometheus_memory_query(environment_slug)), + status: status, + body: body || prometheus_values_body + ) + stub_prometheus_request( + prometheus_query_url(prometheus_cpu_query(environment_slug)), + status: status, + body: body || prometheus_value_body + ) + stub_prometheus_request( + prometheus_query_range_url(prometheus_cpu_query(environment_slug)), + status: status, + body: body || prometheus_values_body + ) + end + + def prometheus_data(last_update: Time.now.utc) + { + success: true, + metrics: { + memory_values: prometheus_values_body('matrix').dig(:data, :result), + memory_current: prometheus_value_body('vector').dig(:data, :result), + cpu_values: prometheus_values_body('matrix').dig(:data, :result), + cpu_current: prometheus_value_body('vector').dig(:data, :result) + }, + last_update: last_update + } + end + + def prometheus_empty_body(type) + { + "status": "success", + "data": { + "resultType": type, + "result": [] + } + } + end + + def prometheus_value_body(type = 'vector') + { + "status": "success", + "data": { + "resultType": type, + "result": [ + { + "metric": {}, + "value": [ + 1488772511.004, + "0.000041021495238095323" + ] + } + ] + } + } + end + + def prometheus_values_body(type = 'matrix') + { + "status": "success", + "data": { + "resultType": type, + "result": [ + { + "metric": {}, + "values": [ + [1488758662.506, "0.00002996364761904785"], + [1488758722.506, "0.00003090239047619091"] + ] + } + ] + } + } + end +end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index c3aa3ef44c2..f1d226b6ae3 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -143,7 +143,7 @@ module TestEnv end def repos_path - Gitlab.config.repositories.storages.default + Gitlab.config.repositories.storages.default['path'] end def backup_path diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index dfbfbd05f43..10458966cb9 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -227,8 +227,8 @@ describe 'gitlab:app namespace rake task' do FileUtils.mkdir('tmp/tests/default_storage') FileUtils.mkdir('tmp/tests/custom_storage') storages = { - 'default' => 'tmp/tests/default_storage', - 'custom' => 'tmp/tests/custom_storage' + 'default' => { 'path' => 'tmp/tests/default_storage' }, + 'custom' => { 'path' => 'tmp/tests/custom_storage' } } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) diff --git a/spec/views/projects/pipelines/_stage.html.haml_spec.rb b/spec/views/projects/pipelines/_stage.html.haml_spec.rb index d25de8af5d2..65f9d0125e6 100644 --- a/spec/views/projects/pipelines/_stage.html.haml_spec.rb +++ b/spec/views/projects/pipelines/_stage.html.haml_spec.rb @@ -50,4 +50,23 @@ describe 'projects/pipelines/_stage', :view do expect(rendered).to have_text 'test:build', count: 1 end end + + context 'when there are multiple builds' do + before do + HasStatus::AVAILABLE_STATUSES.each do |status| + create_build(status) + end + end + + it 'shows them in order' do + render + + expect(rendered).to have_text(HasStatus::ORDERED_STATUSES.join(" ")) + end + + def create_build(status) + create(:ci_build, name: status, status: status, + pipeline: pipeline, stage: stage.name) + end + end end diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 5919b99a6ed..7bcb5521202 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -105,6 +105,6 @@ describe PostReceive do end def pwd(project) - File.join(Gitlab.config.repositories.storages.default, project.path_with_namespace) + File.join(Gitlab.config.repositories.storages.default['path'], project.path_with_namespace) end end diff --git a/spec/workers/system_hook_push_worker_spec.rb b/spec/workers/system_hook_push_worker_spec.rb new file mode 100644 index 00000000000..b1d446ed25f --- /dev/null +++ b/spec/workers/system_hook_push_worker_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe SystemHookPushWorker do + include RepoHelpers + + subject { described_class.new } + + describe '#perform' do + it 'executes SystemHooksService with expected values' do + push_data = double('push_data') + system_hook_service = double('system_hook_service') + + expect(SystemHooksService).to receive(:new).and_return(system_hook_service) + expect(system_hook_service).to receive(:execute_hooks).with(push_data, :push_hooks) + + subject.perform(push_data, :push_hooks) + end + end +end diff --git a/spec/workers/update_merge_requests_worker_spec.rb b/spec/workers/update_merge_requests_worker_spec.rb index c78a69eda67..262d6e5a9ab 100644 --- a/spec/workers/update_merge_requests_worker_spec.rb +++ b/spec/workers/update_merge_requests_worker_spec.rb @@ -23,16 +23,5 @@ describe UpdateMergeRequestsWorker do perform end - - it 'executes SystemHooksService with expected values' do - push_data = double('push_data') - expect(Gitlab::DataBuilder::Push).to receive(:build).with(project, user, oldrev, newrev, ref, []).and_return(push_data) - - system_hook_service = double('system_hook_service') - expect(SystemHooksService).to receive(:new).and_return(system_hook_service) - expect(system_hook_service).to receive(:execute_hooks).with(push_data, :push_hooks) - - perform - end end end |