diff options
author | Lin Jen-Shin <godfat@godfat.org> | 2017-06-06 17:40:08 +0800 |
---|---|---|
committer | Lin Jen-Shin <godfat@godfat.org> | 2017-06-06 17:40:08 +0800 |
commit | d44305eba7fb739447a698e8df123f03dadca9d2 (patch) | |
tree | bda563908cd23f28fcfb67d15be53a0d8d09fb27 | |
parent | 47b93fd76138ce24ec78926647497e52c5101dd8 (diff) | |
parent | 4a811fbc11db2d1ab7459f7264848a3b06ff2299 (diff) | |
download | gitlab-ce-d44305eba7fb739447a698e8df123f03dadca9d2.tar.gz |
Merge remote-tracking branch 'upstream/master' into 30634-protected-pipeline
* upstream/master: (56 commits)
Resolve "When changing project visibility setting, change other dropdowns automatically"
change headings to improve SEO
backports changed import logic from pull mirroring feature into CE
Fix header component spec
Add a Rake task to aid in rotating otp_key_base
Remove unnecessary variable
Add changelog entry
Allow users to be hard-deleted from the admin user show page
Allow users to be hard-removed from the admin users list page
Support hard deletion in Admin::UsersController#destroy
Add changelog entry
Extract and memoize `user_access`
Remove GitAccessStatus (no longer needed)
Refactor construction of response
Refactor to remove a special case
Fix would-be regression
Clarify error messages
Refactor to let GitAccess errors bubble up
Refactor to let `GitAccess` check protocol config
Specify new Git-LFS-over-HTTP behavior
...
110 files changed, 2323 insertions, 723 deletions
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 34e4a257ff9..5b4ca94ed30 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -56,6 +56,8 @@ if (job.import_status === 'finished') { job_item.removeClass("active").addClass("success"); return status_field.html('<span><i class="fa fa-check"></i> done</span>'); + } else if (job.import_status === 'scheduled') { + return status_field.html("<i class='fa fa-spinner fa-spin'></i> scheduled"); } else if (job.import_status === 'started') { return status_field.html("<i class='fa fa-spinner fa-spin'></i> started"); } else { diff --git a/app/assets/javascripts/locale/zh_CN/app.js b/app/assets/javascripts/locale/zh_CN/app.js new file mode 100644 index 00000000000..9525bc88190 --- /dev/null +++ b/app/assets/javascripts/locale/zh_CN/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['zh_CN'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (China) (https://www.transifex.com/gitlab-zh/teams/75177/zh_CN/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_CN","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_CN","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["提交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["周期分析概述了项目从想法到产品实现的各阶段所需的时间。"],"CycleAnalyticsStage|Code":["编码"],"CycleAnalyticsStage|Issue":["议题"],"CycleAnalyticsStage|Plan":["计划"],"CycleAnalyticsStage|Production":["生产"],"CycleAnalyticsStage|Review":["评审"],"CycleAnalyticsStage|Staging":["预发布"],"CycleAnalyticsStage|Test":["测试"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["从创建议题到部署至生产环境"],"From merge request merge until deploy to production":["从合并请求被合并后到部署至生产环境"],"Introducing Cycle Analytics":["周期分析简介"],"Last %d day":["最后 %d 天"],"Limited to showing %d event at most":["最多显示 %d 个事件"],"Median":["中位数"],"New Issue":["新议题"],"Not available":["数据不足"],"Not enough data":["数据不足"],"OpenedNDaysAgo|Opened":["开始于"],"Pipeline Health":["流水线健康指标"],"ProjectLifecycle|Stage":["项目生命周期"],"Read more":["了解更多"],"Related Commits":["相关的提交"],"Related Deployed Jobs":["相关的部署作业"],"Related Issues":["相关的议题"],"Related Jobs":["相关的作业"],"Related Merge Requests":["相关的合并请求"],"Related Merged Requests":["相关已合并的合并请求"],"Showing %d event":["显示 %d 个事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["编码阶段概述了从第一次提交到创建合并请求的时间。创建第一个合并请求后,数据将自动添加到此处。"],"The collection of events added to the data gathered for that stage.":["与该阶段相关的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["议题阶段概述了从创建议题到将议题设置里程碑或将议题添加到议题看板的时间。开始创建议题以查看此阶段的数据。"],"The phase of the development lifecycle.":["项目生命周期中的各个阶段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["计划阶段概述了从议题添加到日程后到推送首次提交的时间。当首次推送提交后,数据将自动添加到此处。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["生产阶段概述了从创建一个议题到将代码部署到生产环境的总时间。当完成想法到部署生产的循环,数据将自动添加到此处。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["评审阶段概述了从创建合并请求到被合并的时间。当创建第一个合并请求后,数据将自动添加到此处。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["预发布阶段概述了从合并请求被合并到部署至生产环境的总时间。首次部署到生产环境后,数据将自动添加到此处。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。"],"The time taken by each data entry gathered by that stage.":["该阶段每条数据所花的时间"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["议题被列入日程表的时间"],"Time before an issue starts implementation":["开始进行编码前的时间"],"Time between merge request creation and merge/close":["从创建合并请求到被合并或关闭的时间"],"Time until first merge request":["创建第一个合并请求之前的时间"],"Time|hr":["小时"],"Time|min":["分钟"],"Time|s":["秒"],"Total Time":["总时间"],"Total test time for all commits/merges":["所有提交和合并的总测试时间"],"Want to see the data? Please ask an administrator for access.":["权限不足。如需查看相关数据,请向管理员申请权限。"],"We don't have enough data to show this stage.":["该阶段的数据不足,无法显示。"],"You need permission.":["您需要相关的权限。"],"day":["天"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/zh_HK/app.js b/app/assets/javascripts/locale/zh_HK/app.js new file mode 100644 index 00000000000..fd0bcd988c5 --- /dev/null +++ b/app/assets/javascripts/locale/zh_HK/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['zh_HK'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/75177/zh_HK/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_HK","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_HK","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["提交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了項目從想法到產品實現的各階段所需的時間。"],"CycleAnalyticsStage|Code":["編碼"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["生產"],"CycleAnalyticsStage|Review":["評審"],"CycleAnalyticsStage|Staging":["預發布"],"CycleAnalyticsStage|Test":["測試"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從創建議題到部署到生產環境"],"From merge request merge until deploy to production":["從合併請求的合併到部署至生產環境"],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"Not available":["不可用"],"Not enough data":["數據不足"],"OpenedNDaysAgo|Opened":["開始於"],"Pipeline Health":["流水線健康指標"],"ProjectLifecycle|Stage":["項目生命週期"],"Read more":["了解更多"],"Related Commits":["相關的提交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的合並請求"],"Showing %d event":["顯示 %d 個事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["編碼階段概述了從第一次提交到創建合併請求的時間。創建第壹個合並請求後,數據將自動添加到此處。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["議題階段概述了從創建議題到將議題設置裏程碑或將議題添加到議題看板的時間。創建一個議題後,數據將自動添加到此處。"],"The phase of the development lifecycle.":["項目生命週期中的各個階段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["計劃階段概述了從議題添加到日程後到推送首次提交的時間。當首次推送提交後,數據將自動添加到此處。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完成完整的想法到部署生產,數據將自動添加到此處。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["評審階段概述了從創建合並請求到合併的時間。當創建第壹個合並請求後,數據將自動添加到此處。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["預發布階段概述了合並請求的合併到部署代碼到生產環境的總時間。當首次部署到生產環境後,數據將自動添加到此處。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。"],"The time taken by each data entry gathered by that stage.":["該階段每條數據所花的時間"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["開始進行編碼前的時間"],"Time between merge request creation and merge/close":["從創建合併請求到被合並或關閉的時間"],"Time until first merge request":["創建第壹個合併請求之前的時間"],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有提交和合併的總測試時間"],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關數據,請向管理員申請權限。"],"We don't have enough data to show this stage.":["該階段的數據不足,無法顯示。"],"You need permission.":["您需要相關的權限。"],"day":["天"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/zh_TW/app.js b/app/assets/javascripts/locale/zh_TW/app.js new file mode 100644 index 00000000000..79904d17bf6 --- /dev/null +++ b/app/assets/javascripts/locale/zh_TW/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['zh_TW'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/75177/zh_TW/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_TW","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_TW","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["送交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了你的專案從想法到產品實現,各階段所需的時間。"],"CycleAnalyticsStage|Code":["程式開發"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["上線"],"CycleAnalyticsStage|Review":["複閱"],"CycleAnalyticsStage|Staging":["預備"],"CycleAnalyticsStage|Test":["測試"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從議題建立至線上部署"],"From merge request merge until deploy to production":["從請求被合併後至線上部署"],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"Not available":["無法使用"],"Not enough data":["資料不足"],"OpenedNDaysAgo|Opened":["開始於"],"Pipeline Health":["流水線健康指標"],"ProjectLifecycle|Stage":["專案生命週期"],"Read more":["了解更多"],"Related Commits":["相關的送交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的請求"],"Showing %d event":["顯示 %d 個事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。"],"The phase of the development lifecycle.":["專案開發生命週期的各個階段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["計劃階段顯示從議題添加到日程後至推送第一個送交的時間。當第一次推送送交後,資料將自動填入。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。"],"The time taken by each data entry gathered by that stage.":["每筆該階段相關資料所花的時間。"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["議題等待開始實作的時間"],"Time between merge request creation and merge/close":["合併請求被合併或是關閉的時間"],"Time until first merge request":["第一個合併請求被建立前的時間"],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有送交和合併的總測試時間"],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關資料,請向管理員申請權限。"],"We don't have enough data to show this stage.":["因該階段的資料不足而無法顯示相關資訊"],"You need permission.":["您需要相關的權限。"],"day":["天"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js index 04b381fe0e0..c0f757269cb 100644 --- a/app/assets/javascripts/project_new.js +++ b/app/assets/javascripts/project_new.js @@ -1,11 +1,17 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */ +function highlightChanges($elm) { + $elm.addClass('highlight-changes'); + setTimeout(() => $elm.removeClass('highlight-changes'), 10); +} + (function() { this.ProjectNew = (function() { function ProjectNew() { this.toggleSettings = this.toggleSettings.bind(this); this.$selects = $('.features select'); this.$repoSelects = this.$selects.filter('.js-repo-select'); + this.$projectSelects = this.$selects.not('.js-repo-select'); $('.project-edit-container').on('ajax:before', (function(_this) { return function() { @@ -26,6 +32,42 @@ if (!visibilityContainer) return; const visibilitySelect = new gl.VisibilitySelect(visibilityContainer); visibilitySelect.init(); + + const $visibilitySelect = $(visibilityContainer).find('select'); + let projectVisibility = $visibilitySelect.val(); + const PROJECT_VISIBILITY_PRIVATE = '0'; + + $visibilitySelect.on('change', () => { + const newProjectVisibility = $visibilitySelect.val(); + + if (projectVisibility !== newProjectVisibility) { + this.$projectSelects.each((idx, select) => { + const $select = $(select); + const $options = $select.find('option'); + const values = $.map($options, e => e.value); + + // if switched to "private", limit visibility options + if (newProjectVisibility === PROJECT_VISIBILITY_PRIVATE) { + if ($select.val() !== values[0] && $select.val() !== values[1]) { + $select.val(values[1]).trigger('change'); + highlightChanges($select); + } + $options.slice(2).disable(); + } + + // if switched from "private", increase visibility for non-disabled options + if (projectVisibility === PROJECT_VISIBILITY_PRIVATE) { + $options.enable(); + if ($select.val() !== values[0] && $select.val() !== values[values.length - 1]) { + $select.val(values[values.length - 1]).trigger('change'); + highlightChanges($select); + } + } + }); + + projectVisibility = newProjectVisibility; + } + }); }; ProjectNew.prototype.toggleSettings = function() { @@ -56,8 +98,10 @@ ProjectNew.prototype.toggleRepoVisibility = function () { var $repoAccessLevel = $('.js-repo-access-level select'); + var $lfsEnabledOption = $('.js-lfs-enabled select'); var containerRegistry = document.querySelectorAll('.js-container-registry')[0]; var containerRegistryCheckbox = document.getElementById('project_container_registry_enabled'); + var prevSelectedVal = parseInt($repoAccessLevel.val(), 10); this.$repoSelects.find("option[value='" + $repoAccessLevel.val() + "']") .nextAll() @@ -71,29 +115,40 @@ var $this = $(this); var repoSelectVal = parseInt($this.val(), 10); - $this.find('option').show(); + $this.find('option').enable(); - if (selectedVal < repoSelectVal) { - $this.val(selectedVal); + if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) { + $this.val(selectedVal).trigger('change'); + highlightChanges($this); } - $this.find("option[value='" + selectedVal + "']").nextAll().hide(); + $this.find("option[value='" + selectedVal + "']").nextAll().disable(); }); if (selectedVal) { this.$repoSelects.removeClass('disabled'); + if ($lfsEnabledOption.length) { + $lfsEnabledOption.removeClass('disabled'); + highlightChanges($lfsEnabledOption); + } if (containerRegistry) { containerRegistry.style.display = ''; } } else { this.$repoSelects.addClass('disabled'); + if ($lfsEnabledOption.length) { + $lfsEnabledOption.val('false').addClass('disabled'); + highlightChanges($lfsEnabledOption); + } if (containerRegistry) { containerRegistry.style.display = 'none'; containerRegistryCheckbox.checked = false; } } + + prevSelectedVal = selectedVal; }.bind(this)); }; diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js index 068e9698e1d..9d045886262 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js @@ -10,7 +10,7 @@ export default class ProtectedTagDropdown { this.$dropdown = options.$dropdown; this.$dropdownContainer = this.$dropdown.parent(); this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer'); - this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag'); + this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag'); this.buildDropdown(); this.bindEvents(); @@ -73,7 +73,7 @@ export default class ProtectedTagDropdown { }; this.$dropdownContainer - .find('.create-new-protected-tag code') + .find('.js-create-new-protected-tag code') .text(tagName); } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index b3a86b92d93..4114a050d9a 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -187,6 +187,7 @@ $divergence-graph-bar-bg: #ccc; $divergence-graph-separator-bg: #ccc; $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; +$highlight-changes-color: rgb(235, 255, 232); /* diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 24ab2bedea2..a2f781a6a6e 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -29,6 +29,20 @@ & > .form-group { padding-left: 0; } + + select option[disabled] { + display: none; + } + } + + select { + background: transparent; + transition: background 2s ease-out; + + &.highlight-changes { + background: $highlight-changes-color; + transition: none; + } } .help-block { @@ -675,14 +689,16 @@ pre.light-well { } } -.new_protected_branch { +.new_protected_branch, +.new-protected-tag { label { margin-top: 6px; font-weight: normal; } } -.create-new-protected-branch-button { +.create-new-protected-branch-button, +.create-new-protected-tag-button { @include dropdown-link; width: 100%; diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 563bcc65bd6..bace99dad58 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -138,7 +138,7 @@ class Admin::UsersController < Admin::ApplicationController end def destroy - DeleteUserWorker.perform_async(current_user.id, user.id) + user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete)) respond_to do |format| format.html { redirect_to admin_users_path, notice: "The user is being deleted." } diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index ae91e02488a..2b6afaa6233 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -106,4 +106,8 @@ module LfsRequest def objects @objects ||= (params[:objects] || []).to_a end + + def has_authentication_ability?(capability) + (authentication_abilities || []).include?(capability) + end end diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index 9a1bf037a95..7f3205a8001 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -128,32 +128,10 @@ class Projects::GitHttpClientController < Projects::ApplicationController @authentication_result = Gitlab::Auth.find_for_git_client( login, password, project: project, ip: request.ip) - return false unless @authentication_result.success? - - if download_request? - authentication_has_download_access? - else - authentication_has_upload_access? - end + @authentication_result.success? end def ci? authentication_result.ci?(project) end - - def authentication_has_download_access? - has_authentication_ability?(:download_code) || has_authentication_ability?(:build_download_code) - end - - def authentication_has_upload_access? - has_authentication_ability?(:push_code) - end - - def has_authentication_ability?(capability) - (authentication_abilities || []).include?(capability) - end - - def authentication_project - authentication_result.project - end end diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 9e4edcae101..b6b62da7b60 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -1,38 +1,27 @@ class Projects::GitHttpController < Projects::GitHttpClientController include WorkhorseRequest + before_action :access_check + + rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403 + rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404 + # GET /foo/bar.git/info/refs?service=git-upload-pack (git pull) # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) def info_refs - if upload_pack? && upload_pack_allowed? - log_user_activity - - render_ok - elsif receive_pack? && receive_pack_allowed? - render_ok - elsif http_blocked? - render_http_not_allowed - else - render_denied - end + log_user_activity if upload_pack? + + render_ok end # POST /foo/bar.git/git-upload-pack (git pull) def git_upload_pack - if upload_pack? && upload_pack_allowed? - render_ok - else - render_denied - end + render_ok end # POST /foo/bar.git/git-receive-pack" (git push) def git_receive_pack - if receive_pack? && receive_pack_allowed? - render_ok - else - render_denied - end + render_ok end private @@ -45,10 +34,6 @@ class Projects::GitHttpController < Projects::GitHttpClientController git_command == 'git-upload-pack' end - def receive_pack? - git_command == 'git-receive-pack' - end - def git_command if action_name == 'info_refs' params[:service] @@ -62,47 +47,27 @@ class Projects::GitHttpController < Projects::GitHttpClientController render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name) end - def render_http_not_allowed - render plain: access_check.message, status: :forbidden + def render_403(exception) + render plain: exception.message, status: :forbidden end - def render_denied - if user && can?(user, :read_project, project) - render plain: access_denied_message, status: :forbidden - else - # Do not leak information about project existence - render_not_found - end - end - - def access_denied_message - 'Access denied' + def render_404(exception) + render plain: exception.message, status: :not_found end - def upload_pack_allowed? - return false unless Gitlab.config.gitlab_shell.upload_pack - - access_check.allowed? || ci? + def access + @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities) end - def access - @access ||= access_klass.new(user, project, 'http', authentication_abilities: authentication_abilities) + def access_actor + return user if user + return :ci if ci? end def access_check # Use the magic string '_any' to indicate we do not know what the # changes are. This is also what gitlab-shell does. - @access_check ||= access.check(git_command, '_any') - end - - def http_blocked? - !access.protocol_allowed? - end - - def receive_pack_allowed? - return false unless Gitlab.config.gitlab_shell.receive_pack - - access_check.allowed? + access.check(git_command, '_any') end def access_klass diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index a1b84afcd91..4b143434ea5 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -14,14 +14,7 @@ class Projects::ImportsController < Projects::ApplicationController @project.import_url = params[:project][:import_url] if @project.save - @project.reload - - if @project.import_failed? - @project.import_retry - else - @project.import_start - @project.add_import_job - end + @project.reload.import_schedule end redirect_to namespace_project_import_path(@project.namespace, @project) diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index ba24fa9acfe..d1719f12072 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -19,7 +19,7 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController def protected_ref_params params.require(:protected_branch).permit(:name, - merge_access_levels_attributes: [:access_level, :id], - push_access_levels_attributes: [:access_level, :id]) + merge_access_levels_attributes: access_level_attributes, + push_access_levels_attributes: access_level_attributes) end end diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb index 083a70968e5..b51bdf7aa78 100644 --- a/app/controllers/projects/protected_refs_controller.rb +++ b/app/controllers/projects/protected_refs_controller.rb @@ -44,4 +44,10 @@ class Projects::ProtectedRefsController < Projects::ApplicationController format.js { head :ok } end end + + protected + + def access_level_attributes + %i(access_level id) + end end diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb index c61ddf145e6..a5dbd7e46ae 100644 --- a/app/controllers/projects/protected_tags_controller.rb +++ b/app/controllers/projects/protected_tags_controller.rb @@ -18,6 +18,6 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController end def protected_ref_params - params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id]) + params.require(:protected_tag).permit(:name, create_access_levels_attributes: access_level_attributes) end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 3ca14dee33c..cd2003586be 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -25,7 +25,7 @@ class RegistrationsController < Devise::RegistrationsController end def destroy - DeleteUserWorker.perform_async(current_user.id, current_user.id) + current_user.delete_async(deleted_by: current_user) respond_to do |format| format.html do diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 7b0584c42a2..f74e61c9481 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -138,11 +138,15 @@ module ProjectsHelper if @project.private? level = @project.project_feature.send(field) - options.delete('Everyone with access') - highest_available_option = options.values.max if level == ProjectFeature::ENABLED + disabled_option = ProjectFeature::ENABLED + highest_available_option = ProjectFeature::PRIVATE if level == disabled_option end - options = options_for_select(options, selected: highest_available_option || @project.project_feature.public_send(field)) + options = options_for_select( + options, + selected: highest_available_option || @project.project_feature.public_send(field), + disabled: disabled_option + ) content_tag( :select, diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index c0763a8a9c4..8e0a1e2ecdf 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -13,6 +13,17 @@ module SubmoduleHelper if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/ namespace, project = $1, $2 + gitlab_hosts = [Gitlab.config.gitlab.url, + Gitlab.config.gitlab_shell.ssh_path_prefix] + + gitlab_hosts.each do |host| + if url.start_with?(host) + namespace, _, project = url.sub(host, '').rpartition('/') + break + end + end + + namespace.sub!(/\A\//, '') project.rstrip! project.sub!(/\.git\z/, '') diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index b4aaf498068..50757b01538 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -31,9 +31,9 @@ module VisibilityLevelHelper when Gitlab::VisibilityLevel::PRIVATE "Project access must be granted explicitly to each user." when Gitlab::VisibilityLevel::INTERNAL - "The project can be cloned by any logged in user." + "The project can be accessed by any logged in user." when Gitlab::VisibilityLevel::PUBLIC - "The project can be cloned without any authentication." + "The project can be accessed without any authentication." end end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 0d7c2d20029..4cbd90c5817 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -15,8 +15,7 @@ class AbuseReport < ActiveRecord::Base alias_method :author, :reporter def remove_user(deleted_by:) - user.block - DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true) + user.delete_async(deleted_by: deleted_by, params: { hard_delete: true }) end def notify diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 9e04976e8fd..3b49cb4e3bf 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -199,7 +199,7 @@ class ApplicationSetting < ActiveRecord::Base ApplicationSetting.define_attribute_methods end - def self.defaults_ce + def self.defaults { after_sign_up_text: nil, akismet_enabled: false, @@ -250,10 +250,6 @@ class ApplicationSetting < ActiveRecord::Base } end - def self.defaults - defaults_ce - end - def self.create_from_defaults create(defaults) end diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index 62eaec2407f..47e71c58557 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -8,32 +8,44 @@ module ProtectedRef validates :project, presence: true delegate :matching, :matches?, :wildcard?, to: :ref_matcher + end + + def commit + project.commit(self.name) + end + + class_methods do + def protected_ref_access_levels(*types) + types.each do |type| + has_many :"#{type}_access_levels", dependent: :destroy + + validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." } - def self.protected_ref_accessible_to?(ref, user, action:) + accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true + end + end + + def protected_ref_accessible_to?(ref, user, action:) access_levels_for_ref(ref, action: action).any? do |access_level| access_level.check_access(user) end end - def self.developers_can?(action, ref) + def developers_can?(action, ref) access_levels_for_ref(ref, action: action).any? do |access_level| access_level.access_level == Gitlab::Access::DEVELOPER end end - def self.access_levels_for_ref(ref, action:) + def access_levels_for_ref(ref, action:) self.matching(ref).map(&:"#{action}_access_levels").flatten end - def self.matching(ref_name, protected_refs: nil) + def matching(ref_name, protected_refs: nil) ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs) end end - def commit - project.commit(self.name) - end - private def ref_matcher diff --git a/app/models/group.rb b/app/models/group.rb index be944da5a67..5bb2cdc5eff 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -222,6 +222,16 @@ class Group < Namespace User.where(id: members_with_parents.select(:user_id)) end + def max_member_access_for_user(user) + return GroupMember::OWNER if user.admin? + + members_with_parents. + where(user_id: user). + reorder(access_level: :desc). + first&. + access_level || GroupMember::NO_ACCESS + end + def mattermost_team_params max_length = 59 diff --git a/app/models/member.rb b/app/models/member.rb index 7228e82e978..29f9d61e870 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -200,6 +200,10 @@ class Member < ActiveRecord::Base source_type end + def access_field + access_level + end + def invite? self.invite_token.present? end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 28e10bc6172..47040f95533 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -25,10 +25,6 @@ class GroupMember < Member source end - def access_field - access_level - end - # Because source_type is `Namespace`... def real_source_type 'Group' diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index b3a91feb091..c0e17f4bfc8 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -79,10 +79,6 @@ class ProjectMember < Member end end - def access_field - access_level - end - def project source end diff --git a/app/models/project.rb b/app/models/project.rb index d21ff274b6e..0caf7387450 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -165,7 +165,7 @@ class Project < ActiveRecord::Base has_many :todos, dependent: :destroy has_many :notification_settings, dependent: :destroy, as: :source - has_one :import_data, dependent: :destroy, class_name: "ProjectImportData" + has_one :import_data, dependent: :delete, class_name: "ProjectImportData" has_one :project_feature, dependent: :destroy has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete has_many :container_repositories, dependent: :destroy @@ -298,8 +298,16 @@ class Project < ActiveRecord::Base scope :excluding_project, ->(project) { where.not(id: project) } state_machine :import_status, initial: :none do + event :import_schedule do + transition [:none, :finished, :failed] => :scheduled + end + + event :force_import_start do + transition [:none, :finished, :failed] => :started + end + event :import_start do - transition [:none, :finished] => :started + transition scheduled: :started end event :import_finish do @@ -307,18 +315,23 @@ class Project < ActiveRecord::Base end event :import_fail do - transition started: :failed + transition [:scheduled, :started] => :failed end event :import_retry do transition failed: :started end + state :scheduled state :started state :finished state :failed - after_transition any => :finished, do: :reset_cache_and_import_attrs + after_transition [:none, :finished, :failed] => :scheduled do |project, _| + project.run_after_commit { add_import_job } + end + + after_transition started: :finished, do: :reset_cache_and_import_attrs end class << self @@ -532,9 +545,17 @@ class Project < ActiveRecord::Base end def import_in_progress? + import_started? || import_scheduled? + end + + def import_started? import? && import_status == 'started' end + def import_scheduled? + import_status == 'scheduled' + end + def import_failed? import_status == 'failed' end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 28b7d5ad072..5f0d0802ac9 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -2,14 +2,7 @@ class ProtectedBranch < ActiveRecord::Base include Gitlab::ShellAdapter include ProtectedRef - has_many :merge_access_levels, dependent: :destroy - has_many :push_access_levels, dependent: :destroy - - validates :merge_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." } - validates :push_access_levels, length: { is: 1, message: "are restricted to a single instance per protected branch." } - - accepts_nested_attributes_for :push_access_levels - accepts_nested_attributes_for :merge_access_levels + protected_ref_access_levels :merge, :push # Check if branch name is marked as protected in the system def self.protected?(project, ref_name) diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb index 83964095516..f38109c0e52 100644 --- a/app/models/protected_tag.rb +++ b/app/models/protected_tag.rb @@ -2,11 +2,7 @@ class ProtectedTag < ActiveRecord::Base include Gitlab::ShellAdapter include ProtectedRef - has_many :create_access_levels, dependent: :destroy - - validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." } - - accepts_nested_attributes_for :create_access_levels + protected_ref_access_levels :create def self.protected?(project, ref_name) self.matching(ref_name, protected_refs: project.protected_tags).present? diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index dd21ee15c6c..56a115d1db4 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -4,8 +4,7 @@ class SpamLog < ActiveRecord::Base validates :user, presence: true def remove_user(deleted_by:) - user.block - DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true) + user.delete_async(deleted_by: deleted_by, params: { hard_delete: true }) end def text diff --git a/app/models/user.rb b/app/models/user.rb index e6eb9d09656..9ed42d6b6f5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -809,6 +809,11 @@ class User < ActiveRecord::Base system_hook_service.execute_hooks_for(self, :destroy) end + def delete_async(deleted_by:, params: {}) + block if params[:hard_delete] + DeleteUserWorker.perform_async(deleted_by.id, id, params) + end + def notification_service NotificationService.new end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 87398303c68..fb07298c6c2 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -4,22 +4,25 @@ class GroupPolicy < BasePolicy return unless @user globally_viewable = @subject.public? || (@subject.internal? && !@user.external?) - member = @subject.users_with_parents.include?(@user) - owner = @user.admin? || @subject.has_owner?(@user) - master = owner || @subject.has_master?(@user) + access_level = @subject.max_member_access_for_user(@user) + owner = access_level >= GroupMember::OWNER + master = access_level >= GroupMember::MASTER + reporter = access_level >= GroupMember::REPORTER can_read = false can_read ||= globally_viewable - can_read ||= member - can_read ||= @user.admin? + can_read ||= access_level >= GroupMember::GUEST can_read ||= GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any? can! :read_group if can_read + if reporter + can! :admin_label + end + # Only group masters and group owners can create new projects if master can! :create_projects can! :admin_milestones - can! :admin_label end # Only group owner and administrators can admin group @@ -31,7 +34,7 @@ class GroupPolicy < BasePolicy can! :create_subgroup if @user.can_create_group end - if globally_viewable && @subject.request_access_enabled && !member + if globally_viewable && @subject.request_access_enabled && access_level == GroupMember::NO_ACCESS can! :request_access end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 535d93385e6..e874a2d8789 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -48,15 +48,14 @@ module Projects save_project_and_import_data(import_data) - @project.import_start if @project.import? - after_create_actions if @project.persisted? if @project.errors.empty? - @project.add_import_job if @project.import? + @project.import_schedule if @project.import? else fail(error: @project.errors.full_messages.join(', ')) end + @project rescue ActiveRecord::RecordInvalid => e message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} " diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index facf21a7f5c..ab532a1fdcf 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -16,7 +16,7 @@ module Users def record_activity Gitlab::UserActivities.record(@author.id) - Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username}") + Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username})") end end end diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index 9eb6a600f6b..673afb8b5b9 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -6,12 +6,27 @@ module Users @current_user = current_user end + # Synchronously destroys +user+ + # + # The operation will fail if the user is the sole owner of any groups. To + # force the groups to be destroyed, pass `delete_solo_owned_groups: true` in + # +options+. + # + # The user's contributions will be migrated to a global ghost user. To + # force the contributions to be destroyed, pass `hard_delete: true` in + # +options+. + # + # `hard_delete: true` implies `delete_solo_owned_groups: true`. To perform + # a hard deletion without destroying solo-owned groups, pass + # `delete_solo_owned_groups: false, hard_delete: true` in +options+. def execute(user, options = {}) + delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete]) + unless Ability.allowed?(current_user, :destroy_user, user) raise Gitlab::Access::AccessDeniedError, "#{current_user} tried to destroy user #{user}!" end - if !options[:delete_solo_owned_groups] && user.solo_owned_groups.present? + if !delete_solo_owned_groups && user.solo_owned_groups.present? user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user' return user end diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index 46d2e3b3de1..4cf4a57ba18 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -34,9 +34,15 @@ - if user.access_locked? %li = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' } - - if user.can_be_removed? && can?(current_user, :destroy_user, user) + - if can?(current_user, :destroy_user, user) %li.divider + - if user.can_be_removed? + %li + = link_to 'Remove user', admin_user_path(user), + data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, + method: :delete %li - = link_to 'Delete user', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" }, - class: 'btn btn-remove btn-block', - method: :delete + = link_to 'Remove user and contributions', admin_user_path(user, hard_delete: true), + data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and comments authored by this user, and groups owned solely by them, will also be removed! Are you sure?" }, + class: 'btn btn-remove btn-block', + method: :delete diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 89d0bbb7126..b556ff056c0 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -177,7 +177,7 @@ %p Deleting a user has the following effects: = render 'users/deletion_guidance', user: @user %br - = link_to 'Remove user', [:admin, @user], data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" + = link_to 'Remove user', admin_user_path(@user), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" - else - if @user.solo_owned_groups.present? %p @@ -188,3 +188,22 @@ - else %p You don't have access to delete this user. + + .panel.panel-danger + .panel-heading + Remove user and contributions + .panel-body + - if can?(current_user, :destroy_user, @user) + %p + This option deletes the user and any contributions that + would usually be moved to the + = succeed "." do + = link_to "system ghost user", help_page_path("user/profile/account/delete_account") + As well as the user's personal projects, groups owned solely by + the user, and projects in them, will also be removed. Commits + to other projects are unaffected. + %br + = link_to 'Remove user and contributions', admin_user_path(@user, hard_delete: true), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" + - else + %p + You don't have access to delete this user. diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index f5549d7f4cd..c3dab68cea5 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -42,7 +42,7 @@ .col-md-9 .label-light = label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level - = link_to "(?)", help_page_path("public_access/public_access") + = link_to icon('question-circle'), help_page_path("public_access/public_access") %span.help-block .col-md-3.visibility-select-container = render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level) @@ -92,14 +92,14 @@ .form-group = render 'shared/allow_request_access', form: f - if Gitlab.config.lfs.enabled && current_user.admin? - .row + .row.js-lfs-enabled .col-md-9 = f.label :lfs_enabled, 'LFS', class: 'label-light' %span.help-block Git Large File Storage = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') .col-md-3 - = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control', data: { field: 'lfs_enabled' } + = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control project-repo-select', data: { field: 'lfs_enabled' } - if Gitlab.config.registry.enabled diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml index af9a080f0a2..dd5b346d922 100644 --- a/app/views/projects/protected_tags/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'js-new-protected-tag' } do |f| += form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f| .panel.panel-default .panel-heading %h3.panel-title diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml index c8531f96f97..9b6923210f7 100644 --- a/app/views/projects/protected_tags/_dropdown.html.haml +++ b/app/views/projects/protected_tags/_dropdown.html.haml @@ -2,7 +2,7 @@ = dropdown_tag('Select tag or create wildcard', options: { toggle_class: 'js-protected-tag-select js-filter-submit wide git-revision-dropdown-toggle', - filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tag", + filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tags", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_tag_name], @@ -10,6 +10,6 @@ %ul.dropdown-footer-list %li - = link_to '#', title: "New Protected Tag", class: "create-new-protected-tag" do + %button{ class: "create-new-protected-tag-button js-create-new-protected-tag", title: "New Protected Tag" } Create wildcard %code diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml index 0bfb1ad191d..663cbd7cd64 100644 --- a/app/views/projects/protected_tags/_index.html.haml +++ b/app/views/projects/protected_tags/_index.html.haml @@ -4,13 +4,14 @@ .row.prepend-top-default.append-bottom-default .col-lg-3 %h4.prepend-top-0 - Protected tags + Protected Tags %p.prepend-top-20 - By default, Protected tags are designed to: + By default, protected tags are designed to: %ul %li Prevent tag creation by everybody except Masters %li Prevent <strong>anyone</strong> from updating the tag %li Prevent <strong>anyone</strong> from deleting the tag + %p.append-bottom-0 Read more about #{link_to "protected tags", help_page_path("user/project/protected_tags"), class: "underlined-link"}. .col-lg-9 - if can? current_user, :admin_project, @project = render 'projects/protected_tags/create_protected_tag' diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml index 54249ec0db1..f11ce0483a9 100644 --- a/app/views/projects/protected_tags/_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_protected_tag.html.haml @@ -19,4 +19,4 @@ - if can_admin_project %td - = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' + = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/protected_tags/_tags_list.html.haml b/app/views/projects/protected_tags/_tags_list.html.haml index 728afd75b50..d432a5c9113 100644 --- a/app/views/projects/protected_tags/_tags_list.html.haml +++ b/app/views/projects/protected_tags/_tags_list.html.haml @@ -1,4 +1,4 @@ -.panel.panel-default.protected-tags-list.js-protected-tags-list +.panel.panel-default.protected-tags-list - if @protected_tags.empty? .panel-heading %h3.panel-title @@ -13,6 +13,8 @@ %col{ width: "25%" } %col{ width: "25%" } %col{ width: "50%" } + - if can_admin_project + %col %thead %tr %th Protected tag (#{@protected_tags.size}) diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml index 94c3612a449..16fc02fe9f4 100644 --- a/app/views/projects/protected_tags/show.html.haml +++ b/app/views/projects/protected_tags/show.html.haml @@ -5,7 +5,7 @@ %h4.prepend-top-0.ref-name = @protected_ref.name - .col-lg-9 + .col-lg-9.edit_protected_tag %h5 Matching Tags - if @matching_refs.present? .table-responsive diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index 9ce6a1aeef5..de52fd00157 100644 --- a/app/views/sent_notifications/unsubscribe.html.haml +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -1,16 +1,14 @@ - noteable = @sent_notification.noteable -- noteable_type = @sent_notification.noteable_type.humanize(capitalize: false) +- noteable_type = @sent_notification.noteable_type.titleize.downcase - noteable_text = %(#{noteable.title} (#{noteable.to_reference})) - -- page_title "Unsubscribe", noteable_text, @sent_notification.noteable_type.humanize.pluralize, @sent_notification.project.name_with_namespace - +- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.name_with_namespace %h3.page-title - Unsubscribe from #{noteable_type} #{noteable_text} + Unsubscribe from #{noteable_type} %p = succeed '?' do - Are you sure you want to unsubscribe from #{noteable_type} + Are you sure you want to unsubscribe from the #{noteable_type}: = link_to noteable_text, url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable]) %p diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index cf0540afb38..fbc335f6176 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -7,7 +7,7 @@ - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - cache_key = project_list_cache_key(project) -- updated_tooltip = time_ago_with_tooltip(project.updated_at) +- updated_tooltip = time_ago_with_tooltip(project.last_activity_at) %li.project-row{ class: css_class } = cache(cache_key) do diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index efc99ec962a..a338523dc6b 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -1,4 +1,6 @@ class RepositoryForkWorker + ForkError = Class.new(StandardError) + include Sidekiq::Worker include Gitlab::ShellAdapter include DedicatedSidekiqQueue @@ -8,29 +10,31 @@ class RepositoryForkWorker source_path: source_path, target_path: target_path) - project = Project.find_by_id(project_id) - - unless project.present? - logger.error("Project #{project_id} no longer exists!") - return - end + project = Project.find(project_id) + project.import_start result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_path, project.repository_storage_path, target_path) - unless result - logger.error("Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}") - project.mark_import_as_failed('The project could not be forked.') - return - end + raise ForkError, "Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}" unless result project.repository.after_import - - unless project.valid_repo? - logger.error("Project #{project_id} had an invalid repository after fork") - project.mark_import_as_failed('The forked repository is invalid.') - return - end + raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo? project.import_finish + rescue ForkError => ex + fail_fork(project, ex.message) + raise + rescue => ex + return unless project + + fail_fork(project, ex.message) + raise ForkError, "#{ex.class} #{ex.message}" + end + + private + + def fail_fork(project, message) + Rails.logger.error(message) + project.mark_import_as_failed(message) end end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index b33ba2ed7c1..625476b7e01 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -1,4 +1,6 @@ class RepositoryImportWorker + ImportError = Class.new(StandardError) + include Sidekiq::Worker include DedicatedSidekiqQueue @@ -10,6 +12,8 @@ class RepositoryImportWorker @project = Project.find(project_id) @current_user = @project.creator + project.import_start + Gitlab::Metrics.add_event(:import_repository, import_url: @project.import_url, path: @project.path_with_namespace) @@ -17,13 +21,23 @@ class RepositoryImportWorker project.update_columns(import_jid: self.jid, import_error: nil) result = Projects::ImportService.new(project, current_user).execute - - if result[:status] == :error - project.mark_import_as_failed(result[:message]) - return - end + raise ImportError, result[:message] if result[:status] == :error project.repository.after_import project.import_finish + rescue ImportError => ex + fail_import(project, ex.message) + raise + rescue => ex + return unless project + + fail_import(project, ex.message) + raise ImportError, "#{ex.class} #{ex.message}" + end + + private + + def fail_import(project, message) + project.mark_import_as_failed(message) end end diff --git a/changelogs/unreleased/24032-when-changing-project-visibility-setting-change-other-dropdowns-automatically.yml b/changelogs/unreleased/24032-when-changing-project-visibility-setting-change-other-dropdowns-automatically.yml new file mode 100644 index 00000000000..dbd8a538d51 --- /dev/null +++ b/changelogs/unreleased/24032-when-changing-project-visibility-setting-change-other-dropdowns-automatically.yml @@ -0,0 +1,4 @@ +--- +title: Automatically adjust project settings to match changes in project visibility +merge_request: 11831 +author: diff --git a/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml b/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml new file mode 100644 index 00000000000..2308a528580 --- /dev/null +++ b/changelogs/unreleased/28694-hard-delete-user-from-admin-panel.yml @@ -0,0 +1,4 @@ +--- +title: Allow users to be hard-deleted from the admin panel +merge_request: 11874 +author: diff --git a/changelogs/unreleased/29690-rotate-otp-key-base.yml b/changelogs/unreleased/29690-rotate-otp-key-base.yml new file mode 100644 index 00000000000..94d73a24758 --- /dev/null +++ b/changelogs/unreleased/29690-rotate-otp-key-base.yml @@ -0,0 +1,4 @@ +--- +title: Add a Rake task to aid in rotating otp_key_base +merge_request: 11881 +author: diff --git a/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml b/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml new file mode 100644 index 00000000000..3b98525167d --- /dev/null +++ b/changelogs/unreleased/33154-permissions-for-project-labels-and-group-labels.yml @@ -0,0 +1,4 @@ +--- +title: Allow group reporters to manage group labels +merge_request: +author: diff --git a/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml b/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml new file mode 100644 index 00000000000..07dd0872d3b --- /dev/null +++ b/changelogs/unreleased/33245-chinese_translation_of_cycle_analytics_page.yml @@ -0,0 +1,4 @@ +--- +title: Add Chinese translation of Cycle Analytics Page to I18N +merge_request: 11644 +author:Huang Tao diff --git a/changelogs/unreleased/dz-fix-submodule-subgroup.yml b/changelogs/unreleased/dz-fix-submodule-subgroup.yml new file mode 100644 index 00000000000..20c7c9ce657 --- /dev/null +++ b/changelogs/unreleased/dz-fix-submodule-subgroup.yml @@ -0,0 +1,4 @@ +--- +title: Fix submodule link to then project under subgroup +merge_request: 11906 +author: diff --git a/changelogs/unreleased/issue-23254.yml b/changelogs/unreleased/issue-23254.yml new file mode 100644 index 00000000000..568a7a41c30 --- /dev/null +++ b/changelogs/unreleased/issue-23254.yml @@ -0,0 +1,4 @@ +--- +title: Fixed style on unsubscribe page +merge_request: +author: Gustav Ernberg diff --git a/changelogs/unreleased/mk-fix-git-over-http-rejections.yml b/changelogs/unreleased/mk-fix-git-over-http-rejections.yml new file mode 100644 index 00000000000..e75740e913f --- /dev/null +++ b/changelogs/unreleased/mk-fix-git-over-http-rejections.yml @@ -0,0 +1,4 @@ +--- +title: Fix Git-over-HTTP error statuses and improve error messages +merge_request: 11398 +author: diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md index bd53f80ce14..a047e809788 100644 --- a/doc/ci/examples/code_climate.md +++ b/doc/ci/examples/code_climate.md @@ -1,11 +1,11 @@ # Analyze project code quality with Code Climate CLI -This example shows how to run [Code Climate CLI][cli] on your code by using\ +This example shows how to run [Code Climate CLI][cli] on your code by using GitLab CI and Docker. -First, you need GitLab Runner with [docker-in-docker executor](../docker/using_docker_build.md#use-docker-in-docker-executor). +First, you need GitLab Runner with [docker-in-docker executor][dind]. -Once you setup the Runner add new job to `.gitlab-ci.yml`: +Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codeclimate`: ```yaml codeclimate: @@ -25,4 +25,10 @@ codeclimate: This will create a `codeclimate` job in your CI pipeline and will allow you to download and analyze the report artifact in JSON format. +For GitLab [Enterprise Edition Starter][ee] users, this information can be automatically +extracted and shown right in the merge request widget. [Learn more on code quality +diffs in merge requests](http://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.md). + [cli]: https://github.com/codeclimate/codeclimate +[dind]: ../docker/using_docker_build.md#use-docker-in-docker-executor +[ee]: https://about.gitlab.com/gitlab-ee/ diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md index b4ffd57afbb..d2442a4fbde 100644 --- a/doc/install/kubernetes/gitlab_chart.md +++ b/doc/install/kubernetes/gitlab_chart.md @@ -207,7 +207,9 @@ its class in an annotation. >**Note:** The Ingress alone doesn't expose GitLab externally. You need to have a Ingress controller setup to do that. Setting up an Ingress controller can be done by installing the `nginx-ingress` helm chart. But be sure -to read the [documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md) +to read the [documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md). +>**Note:** +If you would like to use the Registry, you will also need to ensure your Ingress supports a [sufficiently large request size](http://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size). #### Preserving Source IPs diff --git a/doc/install/kubernetes/gitlab_runner_chart.md b/doc/install/kubernetes/gitlab_runner_chart.md index 305b4593c73..b8bc0795f2e 100644 --- a/doc/install/kubernetes/gitlab_runner_chart.md +++ b/doc/install/kubernetes/gitlab_runner_chart.md @@ -141,7 +141,7 @@ Once you [have configured](#configuration) GitLab Runner in your `values.yml` fi run the following: ```bash -helm install --namepace <NAMEPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> gitlab/gitlab-runner +helm install --namespace <NAMESPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> gitlab/gitlab-runner ``` - `<NAMESPACE>` is the Kubernetes namespace where you want to install the GitLab Runner. @@ -153,7 +153,7 @@ helm install --namepace <NAMEPACE> --name gitlab-runner -f <CONFIG_VALUES_FILE> Once your GitLab Runner Chart is installed, configuration changes and chart updates should we done using `helm upgrade` ```bash -helm upgrade --namepace <NAMEPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab-runner +helm upgrade --namespace <NAMESPACE> -f <CONFIG_VALUES_FILE> <RELEASE-NAME> gitlab/gitlab-runner ``` Where: diff --git a/doc/raketasks/user_management.md b/doc/raketasks/user_management.md index 044b104f5c2..3ae46019daf 100644 --- a/doc/raketasks/user_management.md +++ b/doc/raketasks/user_management.md @@ -71,6 +71,85 @@ sudo gitlab-rake gitlab:two_factor:disable_for_all_users bundle exec rake gitlab:two_factor:disable_for_all_users RAILS_ENV=production ``` +## Rotate Two-factor Authentication (2FA) encryption key + +GitLab stores the secret data enabling 2FA to work in an encrypted database +column. The encryption key for this data is known as `otp_key_base`, and is +stored in `config/secrets.yml`. + + +If that file is leaked, but the individual 2FA secrets have not, it's possible +to re-encrypt those secrets with a new encryption key. This allows you to change +the leaked key without forcing all users to change their 2FA details. + +First, look up the old key. This is in the `config/secrets.yml` file, but +**make sure you're working with the production section**. The line you're +interested in will look like this: + +```yaml +production: + otp_key_base: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +``` + +Next, generate a new secret: + +``` +# omnibus-gitlab +sudo gitlab-rake secret + +# installation from source +bundle exec rake secret RAILS_ENV=production +``` + +Now you need to stop the GitLab server, back up the existing secrets file and +update the database: + +``` +# omnibus-gitlab +sudo gitlab-ctl stop +sudo cp config/secrets.yml config/secrets.yml.bak +sudo gitlab-rake gitlab:two_factor:rotate_key:apply filename=backup.csv old_key=<old key> new_key=<new key> + +# installation from source +sudo /etc/init.d/gitlab stop +cp config/secrets.yml config/secrets.yml.bak +bundle exec rake gitlab:two_factor:rotate_key:apply filename=backup.csv old_key=<old key> new_key=<new key> RAILS_ENV=production +``` + +The `<old key>` value can be read from `config/secrets.yml`; `<new key>` was +generated earlier. The **encrypted** values for the user 2FA secrets will be +written to the specified `filename` - you can use this to rollback in case of +error. + +Finally, change `config/secrets.yml` to set `otp_key_base` to `<new key>` and +restart. Again, make sure you're operating in the **production** section. + +``` +# omnibus-gitlab +sudo gitlab-ctl start + +# installation from source +sudo /etc/init.d/gitlab start +``` + +If there are any problems (perhaps using the wrong value for `old_key`), you can +restore your backup of `config/secrets.yml` and rollback the changes: + +``` +# omnibus-gitlab +sudo gitlab-ctl stop +sudo gitlab-rake gitlab:two_factor:rotate_key:rollback filename=backup.csv +sudo cp config/secrets.yml.bak config/secrets.yml +sudo gitlab-ctl start + +# installation from source +sudo /etc/init.d/gitlab start +bundle exec rake gitlab:two_factor:rotate_key:rollback filename=backup.csv RAILS_ENV=production +cp config/secrets.yml.bak config/secrets.yml +sudo /etc/init.d/gitlab start + +``` + ## Clear authentication tokens for all users. Important! Data loss! Clear authentication tokens for all users in the GitLab database. This diff --git a/doc/user/permissions.md b/doc/user/permissions.md index b0145b0a759..3fda47b9e34 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -126,7 +126,7 @@ which visibility level you select on project settings. ## GitLab CI GitLab CI permissions rely on the role the user has in GitLab. There are four -permission levels it total: +permission levels in total: - admin - master diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md index 6e274a152e5..e7596f5c577 100644 --- a/doc/user/profile/account/delete_account.md +++ b/doc/user/profile/account/delete_account.md @@ -25,7 +25,8 @@ Instead of being deleted, these records will be moved to a system-wide When a user is deleted from an abuse report or spam log, these associated records are not ghosted and will be removed, along with any groups the user is a sole owner of. Administrators can also request this behaviour when -deleting users from the [API](../../../api/users.md#user-deletion) +deleting users from the [API](../../../api/users.md#user-deletion) or the +admin area. [ce-7393]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7393 [ce-10273]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10273 diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md index 9598cb801be..fe87e6f9495 100644 --- a/doc/user/project/issues/index.md +++ b/doc/user/project/issues/index.md @@ -1,4 +1,4 @@ -# GitLab Issues Documentation +# Issues documentation The GitLab Issue Tracker is an advanced and complete tool for tracking the evolution of a new idea or the process @@ -41,13 +41,13 @@ The image bellow illustrates how an issue looks like: Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md). -## New Issue +## New issue Read through the [documentation on creating issues](create_new_issue.md). ## Closing issues -Read through the distinct ways to [close issues](closing_issues.md) on GitLab. +Learn distinct ways to [close issues](closing_issues.md) in GitLab. ## Create a merge request from an issue @@ -84,7 +84,7 @@ Learn more about them on the [issue templates documentation](../../project/descr Learn more about [crosslinking](crosslinking_issues.md) issues and merge requests. -### GitLab Issue Board +### Issue Board The [GitLab Issue Board](https://about.gitlab.com/features/issueboard/) is a way to enhance your workflow by organizing and prioritizing issues in GitLab. diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index 264df7271a3..d3732d67622 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -42,6 +42,22 @@ module API @project, @wiki = Gitlab::RepoPath.parse(params[:project]) end end + + # Project id to pass between components that don't share/don't have + # access to the same filesystem mounts + def gl_repository + Gitlab::GlRepository.gl_repository(project, wiki?) + end + + # Return the repository full path so that gitlab-shell has it when + # handling ssh commands + def repository_path + if wiki? + project.wiki.repository.path_to_repo + else + project.repository.path_to_repo + end + end end end end diff --git a/lib/api/internal.rb b/lib/api/internal.rb index 9ebd4841296..38631953014 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -32,31 +32,23 @@ module API actor.update_last_used_at if actor.is_a?(Key) - access_checker = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess - access_status = access_checker + access_checker_klass = wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess + access_checker = access_checker_klass .new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities) - .check(params[:action], params[:changes]) - response = { status: access_status.status, message: access_status.message } - - if access_status.status - log_user_activity(actor) - - # Project id to pass between components that don't share/don't have - # access to the same filesystem mounts - response[:gl_repository] = Gitlab::GlRepository.gl_repository(project, wiki?) - - # Return the repository full path so that gitlab-shell has it when - # handling ssh commands - response[:repository_path] = - if wiki? - project.wiki.repository.path_to_repo - else - project.repository.path_to_repo - end + begin + access_checker.check(params[:action], params[:changes]) + rescue Gitlab::GitAccess::UnauthorizedError, Gitlab::GitAccess::NotFoundError => e + return { status: false, message: e.message } end - response + log_user_activity(actor) + + { + status: true, + gl_repository: gl_repository, + repository_path: repository_path + } end post "/lfs_authenticate" do diff --git a/lib/api/users.rb b/lib/api/users.rb index 2070dbd8bc7..e8694e90cf2 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -293,7 +293,7 @@ module API user = User.find_by(id: params[:id]) not_found!('User') unless user - DeleteUserWorker.perform_async(current_user.id, user.id, hard_delete: params[:hard_delete]) + user.delete_async(deleted_by: current_user, params: params) end desc 'Block a user. Available only for admins.' diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index c984eb20606..b6805230348 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -1,6 +1,20 @@ module Gitlab module Checks class ChangeAccess + ERROR_MESSAGES = { + push_code: 'You are not allowed to push code to this project.', + delete_default_branch: 'The default branch of a project cannot be deleted.', + force_push_protected_branch: 'You are not allowed to force push code to a protected branch on this project.', + non_master_delete_protected_branch: 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.', + non_web_delete_protected_branch: 'You can only delete protected branches using the web interface.', + merge_protected_branch: 'You are not allowed to merge code into protected branches on this project.', + push_protected_branch: 'You are not allowed to push code to protected branches on this project.', + change_existing_tags: 'You are not allowed to change existing tags on this project.', + update_protected_tag: 'Protected tags cannot be updated.', + delete_protected_tag: 'Protected tags cannot be deleted.', + create_protected_tag: 'You are not allowed to create this tag as it is protected.' + }.freeze + attr_reader :user_access, :project, :skip_authorization, :protocol def initialize( @@ -17,22 +31,20 @@ module Gitlab end def exec - return GitAccessStatus.new(true) if skip_authorization + return true if skip_authorization - error = push_checks || branch_checks || tag_checks + push_checks + branch_checks + tag_checks - if error - GitAccessStatus.new(false, error) - else - GitAccessStatus.new(true) - end + true end protected def push_checks if user_access.cannot_do_action?(:push_code) - "You are not allowed to push code to this project." + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_code] end end @@ -40,7 +52,7 @@ module Gitlab return unless @branch_name if deletion? && @branch_name == project.default_branch - return "The default branch of a project cannot be deleted." + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_default_branch] end protected_branch_checks @@ -50,7 +62,7 @@ module Gitlab return unless ProtectedBranch.protected?(project, @branch_name) if forced_push? - return "You are not allowed to force push code to a protected branch on this project." + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:force_push_protected_branch] end if deletion? @@ -62,22 +74,22 @@ module Gitlab def protected_branch_deletion_checks unless user_access.can_delete_branch?(@branch_name) - return 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.' + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_master_delete_protected_branch] end unless protocol == 'web' - 'You can only delete protected branches using the web interface.' + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_web_delete_protected_branch] end end def protected_branch_push_checks if matching_merge_request? unless user_access.can_merge_to_branch?(@branch_name) || user_access.can_push_to_branch?(@branch_name) - "You are not allowed to merge code into protected branches on this project." + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:merge_protected_branch] end else unless user_access.can_push_to_branch?(@branch_name) - "You are not allowed to push code to protected branches on this project." + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_protected_branch] end end end @@ -86,7 +98,7 @@ module Gitlab return unless @tag_name if tag_exists? && user_access.cannot_do_action?(:admin_project) - return "You are not allowed to change existing tags on this project." + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:change_existing_tags] end protected_tag_checks @@ -95,11 +107,11 @@ module Gitlab def protected_tag_checks return unless ProtectedTag.protected?(project, @tag_name) - return "Protected tags cannot be updated." if update? - return "Protected tags cannot be deleted." if deletion? + raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:update_protected_tag]) if update? + raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_protected_tag]) if deletion? unless user_access.can_create_tag?(@tag_name) - return "You are not allowed to create this tag as it is protected." + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:create_protected_tag] end end diff --git a/lib/gitlab/ci_access.rb b/lib/gitlab/ci_access.rb new file mode 100644 index 00000000000..def1373d8cf --- /dev/null +++ b/lib/gitlab/ci_access.rb @@ -0,0 +1,9 @@ +module Gitlab + # For backwards compatibility, generic CI (which is a build without a user) is + # allowed to :build_download_code without any other checks. + class CiAccess + def can_do_action?(action) + action == :build_download_code + end + end +end diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 99724db8da2..0a19d24eb20 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -3,33 +3,39 @@ module Gitlab class GitAccess UnauthorizedError = Class.new(StandardError) + NotFoundError = Class.new(StandardError) ERROR_MESSAGES = { upload: 'You are not allowed to upload code for this project.', download: 'You are not allowed to download code from this project.', deploy_key_upload: 'This deploy key does not have write access to this project.', - no_repo: 'A repository for this project does not exist yet.' + no_repo: 'A repository for this project does not exist yet.', + project_not_found: 'The project you were looking for could not be found.', + account_blocked: 'Your account has been blocked.', + command_not_allowed: "The command you're trying to execute is not allowed.", + upload_pack_disabled_over_http: 'Pulling over HTTP is not allowed.', + receive_pack_disabled_over_http: 'Pushing over HTTP is not allowed.' }.freeze DOWNLOAD_COMMANDS = %w{ git-upload-pack git-upload-archive }.freeze PUSH_COMMANDS = %w{ git-receive-pack }.freeze ALL_COMMANDS = DOWNLOAD_COMMANDS + PUSH_COMMANDS - attr_reader :actor, :project, :protocol, :user_access, :authentication_abilities + attr_reader :actor, :project, :protocol, :authentication_abilities def initialize(actor, project, protocol, authentication_abilities:) @actor = actor @project = project @protocol = protocol @authentication_abilities = authentication_abilities - @user_access = UserAccess.new(user, project: project) end def check(cmd, changes) check_protocol! check_active_user! check_project_accessibility! + check_command_disabled!(cmd) check_command_existence!(cmd) check_repository_existence! @@ -40,9 +46,7 @@ module Gitlab check_push_access!(changes) end - build_status_object(true) - rescue UnauthorizedError => ex - build_status_object(false, ex.message) + true end def guest_can_download_code? @@ -73,19 +77,39 @@ module Gitlab return if deploy_key? if user && !user_access.allowed? - raise UnauthorizedError, "Your account has been blocked." + raise UnauthorizedError, ERROR_MESSAGES[:account_blocked] end end def check_project_accessibility! if project.blank? || !can_read_project? - raise UnauthorizedError, 'The project you were looking for could not be found.' + raise NotFoundError, ERROR_MESSAGES[:project_not_found] + end + end + + def check_command_disabled!(cmd) + if upload_pack?(cmd) + check_upload_pack_disabled! + elsif receive_pack?(cmd) + check_receive_pack_disabled! + end + end + + def check_upload_pack_disabled! + if http? && upload_pack_disabled_over_http? + raise UnauthorizedError, ERROR_MESSAGES[:upload_pack_disabled_over_http] + end + end + + def check_receive_pack_disabled! + if http? && receive_pack_disabled_over_http? + raise UnauthorizedError, ERROR_MESSAGES[:receive_pack_disabled_over_http] end end def check_command_existence!(cmd) unless ALL_COMMANDS.include?(cmd) - raise UnauthorizedError, "The command you're trying to execute is not allowed." + raise UnauthorizedError, ERROR_MESSAGES[:command_not_allowed] end end @@ -138,11 +162,9 @@ module Gitlab # Iterate over all changes to find if user allowed all of them to be applied changes_list.each do |change| - status = check_single_change_access(change) - unless status.allowed? - # If user does not have access to make at least one change - cancel all push - raise UnauthorizedError, status.message - end + # If user does not have access to make at least one change, cancel all + # push by allowing the exception to bubble up + check_single_change_access(change) end end @@ -168,14 +190,40 @@ module Gitlab actor.is_a?(DeployKey) end + def ci? + actor == :ci + end + def can_read_project? - if deploy_key + if deploy_key? deploy_key.has_access_to?(project) elsif user user.can?(:read_project, project) + elsif ci? + true # allow CI (build without a user) for backwards compatibility end || Guest.can?(:read_project, project) end + def http? + protocol == 'http' + end + + def upload_pack?(command) + command == 'git-upload-pack' + end + + def receive_pack?(command) + command == 'git-receive-pack' + end + + def upload_pack_disabled_over_http? + !Gitlab.config.gitlab_shell.upload_pack + end + + def receive_pack_disabled_over_http? + !Gitlab.config.gitlab_shell.receive_pack + end + protected def user @@ -185,15 +233,19 @@ module Gitlab case actor when User actor - when DeployKey - nil when Key - actor.user + actor.user unless actor.is_a?(DeployKey) + when :ci + nil end end - def build_status_object(status, message = '') - Gitlab::GitAccessStatus.new(status, message) + def user_access + @user_access ||= if ci? + CiAccess.new + else + UserAccess.new(user, project: project) + end end end end diff --git a/lib/gitlab/git_access_status.rb b/lib/gitlab/git_access_status.rb deleted file mode 100644 index 09bb01be694..00000000000 --- a/lib/gitlab/git_access_status.rb +++ /dev/null @@ -1,15 +0,0 @@ -module Gitlab - class GitAccessStatus - attr_accessor :status, :message - alias_method :allowed?, :status - - def initialize(status, message = '') - @status = status - @message = message - end - - def to_json(opts = nil) - { status: @status, message: @message }.to_json(opts) - end - end -end diff --git a/lib/gitlab/git_access_wiki.rb b/lib/gitlab/git_access_wiki.rb index 67eaa5e088d..1fe5155c093 100644 --- a/lib/gitlab/git_access_wiki.rb +++ b/lib/gitlab/git_access_wiki.rb @@ -1,5 +1,9 @@ module Gitlab class GitAccessWiki < GitAccess + ERROR_MESSAGES = { + write_to_wiki: "You are not allowed to write to this project's wiki." + }.freeze + def guest_can_download_code? Guest.can?(:download_wiki_code, project) end @@ -9,11 +13,11 @@ module Gitlab end def check_single_change_access(change) - if user_access.can_do_action?(:create_wiki) - build_status_object(true) - else - build_status_object(false, "You are not allowed to write to this project's wiki.") + unless user_access.can_do_action?(:create_wiki) + raise UnauthorizedError, ERROR_MESSAGES[:write_to_wiki] end + + true end end end diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb index 5ab3eeb3aff..f7ac48f7dbd 100644 --- a/lib/gitlab/i18n.rb +++ b/lib/gitlab/i18n.rb @@ -5,7 +5,10 @@ module Gitlab AVAILABLE_LANGUAGES = { 'en' => 'English', 'es' => 'Español', - 'de' => 'Deutsch' + 'de' => 'Deutsch', + 'zh_CN' => '简体中文', + 'zh_HK' => '繁體中文(香港)', + 'zh_TW' => '繁體中文(臺灣)' }.freeze def available_locales diff --git a/lib/gitlab/otp_key_rotator.rb b/lib/gitlab/otp_key_rotator.rb new file mode 100644 index 00000000000..0d541935bc6 --- /dev/null +++ b/lib/gitlab/otp_key_rotator.rb @@ -0,0 +1,87 @@ +module Gitlab + # The +otp_key_base+ param is used to encrypt the User#otp_secret attribute. + # + # When +otp_key_base+ is changed, it invalidates the current encrypted values + # of User#otp_secret. This class can be used to decrypt all the values with + # the old key, encrypt them with the new key, and and update the database + # with the new values. + # + # For persistence between runs, a CSV file is used with the following columns: + # + # user_id, old_value, new_value + # + # Only the encrypted values are stored in this file. + # + # As users may have their 2FA settings changed at any time, this is only + # guaranteed to be safe if run offline. + class OtpKeyRotator + HEADERS = %w[user_id old_value new_value].freeze + + attr_reader :filename + + # Create a new rotator. +filename+ is used to store values by +calculate!+, + # and to update the database with new and old values in +apply!+ and + # +rollback!+, respectively. + def initialize(filename) + @filename = filename + end + + def rotate!(old_key:, new_key:) + old_key ||= Gitlab::Application.secrets.otp_key_base + + raise ArgumentError.new("Old key is the same as the new key") if old_key == new_key + raise ArgumentError.new("New key is too short! Must be 256 bits") if new_key.size < 64 + + write_csv do |csv| + ActiveRecord::Base.transaction do + User.with_two_factor.in_batches do |relation| + rows = relation.pluck(:id, :encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt) + rows.each do |row| + user = %i[id ciphertext iv salt].zip(row).to_h + new_value = reencrypt(user, old_key, new_key) + + User.where(id: user[:id]).update_all(encrypted_otp_secret: new_value) + csv << [user[:id], user[:ciphertext], new_value] + end + end + end + end + end + + def rollback! + ActiveRecord::Base.transaction do + CSV.foreach(filename, headers: HEADERS, return_headers: false) do |row| + User.where(id: row['user_id']).update_all(encrypted_otp_secret: row['old_value']) + end + end + end + + private + + attr_reader :old_key, :new_key + + def otp_secret_settings + @otp_secret_settings ||= User.encrypted_attributes[:otp_secret] + end + + def reencrypt(user, old_key, new_key) + original = user[:ciphertext].unpack("m").join + opts = { + iv: user[:iv].unpack("m").join, + salt: user[:salt].unpack("m").join, + algorithm: otp_secret_settings[:algorithm], + insecure_mode: otp_secret_settings[:insecure_mode] + } + + decrypted = Encryptor.decrypt(original, opts.merge(key: old_key)) + encrypted = Encryptor.encrypt(decrypted, opts.merge(key: new_key)) + [encrypted].pack("m") + end + + def write_csv(&blk) + File.open(filename, "w") do |file| + yield CSV.new(file, headers: HEADERS, write_headers: false) + end + end + end +end diff --git a/lib/tasks/gitlab/two_factor.rake b/lib/tasks/gitlab/two_factor.rake index fc0ccc726ed..7728c485e8d 100644 --- a/lib/tasks/gitlab/two_factor.rake +++ b/lib/tasks/gitlab/two_factor.rake @@ -19,5 +19,21 @@ namespace :gitlab do puts "There are currently no users with 2FA enabled.".color(:yellow) end end + + namespace :rotate_key do + def rotator + @rotator ||= Gitlab::OtpKeyRotator.new(ENV['filename']) + end + + desc "Encrypt user OTP secrets with a new encryption key" + task apply: :environment do |t, args| + rotator.rotate!(old_key: ENV['old_key'], new_key: ENV['new_key']) + end + + desc "Rollback to secrets encrypted with the old encryption key" + task rollback: :environment do + rotator.rollback! + end + end end end diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake index bc76d7edc55..50b8e331469 100644 --- a/lib/tasks/import.rake +++ b/lib/tasks/import.rake @@ -37,7 +37,7 @@ class GithubImport end def import! - @project.import_start + @project.force_import_start timings = Benchmark.measure do Github::Import.new(@project, @options).execute diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po new file mode 100644 index 00000000000..c2d69b122e2 --- /dev/null +++ b/locale/zh_CN/gitlab.po @@ -0,0 +1,225 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-04 19:24-0500\n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"Last-Translator: HuangTao <htve@outlook.com>, 2017\n" +"Language-Team: Chinese (China) (https://www.transifex.com/gitlab-zh/teams/75177/zh_CN/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: zh_CN\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +msgid "ByAuthor|by" +msgstr "作者:" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "提交" + +msgid "" +"Cycle Analytics gives an overview of how much time it takes to go from idea " +"to production in your project." +msgstr "周期分析概述了项目从想法到产品实现的各阶段所需的时间。" + +msgid "CycleAnalyticsStage|Code" +msgstr "编码" + +msgid "CycleAnalyticsStage|Issue" +msgstr "议题" + +msgid "CycleAnalyticsStage|Plan" +msgstr "计划" + +msgid "CycleAnalyticsStage|Production" +msgstr "生产" + +msgid "CycleAnalyticsStage|Review" +msgstr "评审" + +msgid "CycleAnalyticsStage|Staging" +msgstr "预发布" + +msgid "CycleAnalyticsStage|Test" +msgstr "测试" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "部署" + +msgid "FirstPushedBy|First" +msgstr "首次推送" + +msgid "FirstPushedBy|pushed by" +msgstr "推送者:" + +msgid "From issue creation until deploy to production" +msgstr "从创建议题到部署至生产环境" + +msgid "From merge request merge until deploy to production" +msgstr "从合并请求被合并后到部署至生产环境" + +msgid "Introducing Cycle Analytics" +msgstr "周期分析简介" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "最后 %d 天" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "最多显示 %d 个事件" + +msgid "Median" +msgstr "中位数" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "新议题" + +msgid "Not available" +msgstr "数据不足" + +msgid "Not enough data" +msgstr "数据不足" + +msgid "OpenedNDaysAgo|Opened" +msgstr "开始于" + +msgid "Pipeline Health" +msgstr "流水线健康指标" + +msgid "ProjectLifecycle|Stage" +msgstr "项目生命周期" + +msgid "Read more" +msgstr "了解更多" + +msgid "Related Commits" +msgstr "相关的提交" + +msgid "Related Deployed Jobs" +msgstr "相关的部署作业" + +msgid "Related Issues" +msgstr "相关的议题" + +msgid "Related Jobs" +msgstr "相关的作业" + +msgid "Related Merge Requests" +msgstr "相关的合并请求" + +msgid "Related Merged Requests" +msgstr "相关已合并的合并请求" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "显示 %d 个事件" + +msgid "" +"The coding stage shows the time from the first commit to creating the merge " +"request. The data will automatically be added here once you create your " +"first merge request." +msgstr "编码阶段概述了从第一次提交到创建合并请求的时间。创建第一个合并请求后,数据将自动添加到此处。" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "与该阶段相关的事件。" + +msgid "" +"The issue stage shows the time it takes from creating an issue to assigning " +"the issue to a milestone, or add the issue to a list on your Issue Board. " +"Begin creating issues to see data for this stage." +msgstr "议题阶段概述了从创建议题到将议题设置里程碑或将议题添加到议题看板的时间。开始创建议题以查看此阶段的数据。" + +msgid "The phase of the development lifecycle." +msgstr "项目生命周期中的各个阶段。" + +msgid "" +"The planning stage shows the time from the previous step to pushing your " +"first commit. This time will be added automatically once you push your first" +" commit." +msgstr "计划阶段概述了从议题添加到日程后到推送首次提交的时间。当首次推送提交后,数据将自动添加到此处。" + +msgid "" +"The production stage shows the total time it takes between creating an issue" +" and deploying the code to production. The data will be automatically added " +"once you have completed the full idea to production cycle." +msgstr "生产阶段概述了从创建一个议题到将代码部署到生产环境的总时间。当完成想法到部署生产的循环,数据将自动添加到此处。" + +msgid "" +"The review stage shows the time from creating the merge request to merging " +"it. The data will automatically be added after you merge your first merge " +"request." +msgstr "评审阶段概述了从创建合并请求到被合并的时间。当创建第一个合并请求后,数据将自动添加到此处。" + +msgid "" +"The staging stage shows the time between merging the MR and deploying code " +"to the production environment. The data will be automatically added once you" +" deploy to production for the first time." +msgstr "预发布阶段概述了从合并请求被合并到部署至生产环境的总时间。首次部署到生产环境后,数据将自动添加到此处。" + +msgid "" +"The testing stage shows the time GitLab CI takes to run every pipeline for " +"the related merge request. The data will automatically be added after your " +"first pipeline finishes running." +msgstr "测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "该阶段每条数据所花的时间" + +msgid "" +"The value lying at the midpoint of a series of observed values. E.g., " +"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 " +"= 6." +msgstr "中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (5 + 7)/ 2 = 6。" + +msgid "Time before an issue gets scheduled" +msgstr "议题被列入日程表的时间" + +msgid "Time before an issue starts implementation" +msgstr "开始进行编码前的时间" + +msgid "Time between merge request creation and merge/close" +msgstr "从创建合并请求到被合并或关闭的时间" + +msgid "Time until first merge request" +msgstr "创建第一个合并请求之前的时间" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "小时" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "分钟" + +msgid "Time|s" +msgstr "秒" + +msgid "Total Time" +msgstr "总时间" + +msgid "Total test time for all commits/merges" +msgstr "所有提交和合并的总测试时间" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "权限不足。如需查看相关数据,请向管理员申请权限。" + +msgid "We don't have enough data to show this stage." +msgstr "该阶段的数据不足,无法显示。" + +msgid "You need permission." +msgstr "您需要相关的权限。" + +msgid "day" +msgid_plural "days" +msgstr[0] "天" diff --git a/locale/zh_CN/gitlab.po.time_stamp b/locale/zh_CN/gitlab.po.time_stamp new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/locale/zh_CN/gitlab.po.time_stamp diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po new file mode 100644 index 00000000000..6d56b2897fa --- /dev/null +++ b/locale/zh_HK/gitlab.po @@ -0,0 +1,225 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-04 19:24-0500\n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"Last-Translator: HuangTao <htve@outlook.com>, 2017\n" +"Language-Team: Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/75177/zh_HK/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: zh_HK\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +msgid "ByAuthor|by" +msgstr "作者:" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "提交" + +msgid "" +"Cycle Analytics gives an overview of how much time it takes to go from idea " +"to production in your project." +msgstr "週期分析概述了項目從想法到產品實現的各階段所需的時間。" + +msgid "CycleAnalyticsStage|Code" +msgstr "編碼" + +msgid "CycleAnalyticsStage|Issue" +msgstr "議題" + +msgid "CycleAnalyticsStage|Plan" +msgstr "計劃" + +msgid "CycleAnalyticsStage|Production" +msgstr "生產" + +msgid "CycleAnalyticsStage|Review" +msgstr "評審" + +msgid "CycleAnalyticsStage|Staging" +msgstr "預發布" + +msgid "CycleAnalyticsStage|Test" +msgstr "測試" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "部署" + +msgid "FirstPushedBy|First" +msgstr "首次推送" + +msgid "FirstPushedBy|pushed by" +msgstr "推送者:" + +msgid "From issue creation until deploy to production" +msgstr "從創建議題到部署到生產環境" + +msgid "From merge request merge until deploy to production" +msgstr "從合併請求的合併到部署至生產環境" + +msgid "Introducing Cycle Analytics" +msgstr "週期分析簡介" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "最後 %d 天" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "最多顯示 %d 個事件" + +msgid "Median" +msgstr "中位數" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "新議題" + +msgid "Not available" +msgstr "不可用" + +msgid "Not enough data" +msgstr "數據不足" + +msgid "OpenedNDaysAgo|Opened" +msgstr "開始於" + +msgid "Pipeline Health" +msgstr "流水線健康指標" + +msgid "ProjectLifecycle|Stage" +msgstr "項目生命週期" + +msgid "Read more" +msgstr "了解更多" + +msgid "Related Commits" +msgstr "相關的提交" + +msgid "Related Deployed Jobs" +msgstr "相關的部署作業" + +msgid "Related Issues" +msgstr "相關的議題" + +msgid "Related Jobs" +msgstr "相關的作業" + +msgid "Related Merge Requests" +msgstr "相關的合併請求" + +msgid "Related Merged Requests" +msgstr "相關已合併的合並請求" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "顯示 %d 個事件" + +msgid "" +"The coding stage shows the time from the first commit to creating the merge " +"request. The data will automatically be added here once you create your " +"first merge request." +msgstr "編碼階段概述了從第一次提交到創建合併請求的時間。創建第壹個合並請求後,數據將自動添加到此處。" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "與該階段相關的事件。" + +msgid "" +"The issue stage shows the time it takes from creating an issue to assigning " +"the issue to a milestone, or add the issue to a list on your Issue Board. " +"Begin creating issues to see data for this stage." +msgstr "議題階段概述了從創建議題到將議題設置裏程碑或將議題添加到議題看板的時間。創建一個議題後,數據將自動添加到此處。" + +msgid "The phase of the development lifecycle." +msgstr "項目生命週期中的各個階段。" + +msgid "" +"The planning stage shows the time from the previous step to pushing your " +"first commit. This time will be added automatically once you push your first" +" commit." +msgstr "計劃階段概述了從議題添加到日程後到推送首次提交的時間。當首次推送提交後,數據將自動添加到此處。" + +msgid "" +"The production stage shows the total time it takes between creating an issue" +" and deploying the code to production. The data will be automatically added " +"once you have completed the full idea to production cycle." +msgstr "生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完成完整的想法到部署生產,數據將自動添加到此處。" + +msgid "" +"The review stage shows the time from creating the merge request to merging " +"it. The data will automatically be added after you merge your first merge " +"request." +msgstr "評審階段概述了從創建合並請求到合併的時間。當創建第壹個合並請求後,數據將自動添加到此處。" + +msgid "" +"The staging stage shows the time between merging the MR and deploying code " +"to the production environment. The data will be automatically added once you" +" deploy to production for the first time." +msgstr "預發布階段概述了合並請求的合併到部署代碼到生產環境的總時間。當首次部署到生產環境後,數據將自動添加到此處。" + +msgid "" +"The testing stage shows the time GitLab CI takes to run every pipeline for " +"the related merge request. The data will automatically be added after your " +"first pipeline finishes running." +msgstr "測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "該階段每條數據所花的時間" + +msgid "" +"The value lying at the midpoint of a series of observed values. E.g., " +"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 " +"= 6." +msgstr "中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。" + +msgid "Time before an issue gets scheduled" +msgstr "議題被列入日程表的時間" + +msgid "Time before an issue starts implementation" +msgstr "開始進行編碼前的時間" + +msgid "Time between merge request creation and merge/close" +msgstr "從創建合併請求到被合並或關閉的時間" + +msgid "Time until first merge request" +msgstr "創建第壹個合併請求之前的時間" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "小時" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "分鐘" + +msgid "Time|s" +msgstr "秒" + +msgid "Total Time" +msgstr "總時間" + +msgid "Total test time for all commits/merges" +msgstr "所有提交和合併的總測試時間" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "權限不足。如需查看相關數據,請向管理員申請權限。" + +msgid "We don't have enough data to show this stage." +msgstr "該階段的數據不足,無法顯示。" + +msgid "You need permission." +msgstr "您需要相關的權限。" + +msgid "day" +msgid_plural "days" +msgstr[0] "天" diff --git a/locale/zh_HK/gitlab.po.time_stamp b/locale/zh_HK/gitlab.po.time_stamp new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/locale/zh_HK/gitlab.po.time_stamp diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po new file mode 100644 index 00000000000..0caf35a915b --- /dev/null +++ b/locale/zh_TW/gitlab.po @@ -0,0 +1,225 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-04 19:24-0500\n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"Last-Translator: HuangTao <htve@outlook.com>, 2017\n" +"Language-Team: Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/75177/zh_TW/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: zh_TW\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +msgid "ByAuthor|by" +msgstr "作者:" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "送交" + +msgid "" +"Cycle Analytics gives an overview of how much time it takes to go from idea " +"to production in your project." +msgstr "週期分析概述了你的專案從想法到產品實現,各階段所需的時間。" + +msgid "CycleAnalyticsStage|Code" +msgstr "程式開發" + +msgid "CycleAnalyticsStage|Issue" +msgstr "議題" + +msgid "CycleAnalyticsStage|Plan" +msgstr "計劃" + +msgid "CycleAnalyticsStage|Production" +msgstr "上線" + +msgid "CycleAnalyticsStage|Review" +msgstr "複閱" + +msgid "CycleAnalyticsStage|Staging" +msgstr "預備" + +msgid "CycleAnalyticsStage|Test" +msgstr "測試" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "部署" + +msgid "FirstPushedBy|First" +msgstr "首次推送" + +msgid "FirstPushedBy|pushed by" +msgstr "推送者:" + +msgid "From issue creation until deploy to production" +msgstr "從議題建立至線上部署" + +msgid "From merge request merge until deploy to production" +msgstr "從請求被合併後至線上部署" + +msgid "Introducing Cycle Analytics" +msgstr "週期分析簡介" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "最後 %d 天" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "最多顯示 %d 個事件" + +msgid "Median" +msgstr "中位數" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "新議題" + +msgid "Not available" +msgstr "無法使用" + +msgid "Not enough data" +msgstr "資料不足" + +msgid "OpenedNDaysAgo|Opened" +msgstr "開始於" + +msgid "Pipeline Health" +msgstr "流水線健康指標" + +msgid "ProjectLifecycle|Stage" +msgstr "專案生命週期" + +msgid "Read more" +msgstr "了解更多" + +msgid "Related Commits" +msgstr "相關的送交" + +msgid "Related Deployed Jobs" +msgstr "相關的部署作業" + +msgid "Related Issues" +msgstr "相關的議題" + +msgid "Related Jobs" +msgstr "相關的作業" + +msgid "Related Merge Requests" +msgstr "相關的合併請求" + +msgid "Related Merged Requests" +msgstr "相關已合併的請求" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "顯示 %d 個事件" + +msgid "" +"The coding stage shows the time from the first commit to creating the merge " +"request. The data will automatically be added here once you create your " +"first merge request." +msgstr "程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "與該階段相關的事件。" + +msgid "" +"The issue stage shows the time it takes from creating an issue to assigning " +"the issue to a milestone, or add the issue to a list on your Issue Board. " +"Begin creating issues to see data for this stage." +msgstr "議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。" + +msgid "The phase of the development lifecycle." +msgstr "專案開發生命週期的各個階段。" + +msgid "" +"The planning stage shows the time from the previous step to pushing your " +"first commit. This time will be added automatically once you push your first" +" commit." +msgstr "計劃階段顯示從議題添加到日程後至推送第一個送交的時間。當第一次推送送交後,資料將自動填入。" + +msgid "" +"The production stage shows the total time it takes between creating an issue" +" and deploying the code to production. The data will be automatically added " +"once you have completed the full idea to production cycle." +msgstr "上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。" + +msgid "" +"The review stage shows the time from creating the merge request to merging " +"it. The data will automatically be added after you merge your first merge " +"request." +msgstr "複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。" + +msgid "" +"The staging stage shows the time between merging the MR and deploying code " +"to the production environment. The data will be automatically added once you" +" deploy to production for the first time." +msgstr "預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。" + +msgid "" +"The testing stage shows the time GitLab CI takes to run every pipeline for " +"the related merge request. The data will automatically be added after your " +"first pipeline finishes running." +msgstr "測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "每筆該階段相關資料所花的時間。" + +msgid "" +"The value lying at the midpoint of a series of observed values. E.g., " +"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 " +"= 6." +msgstr "中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。" + +msgid "Time before an issue gets scheduled" +msgstr "議題被列入日程表的時間" + +msgid "Time before an issue starts implementation" +msgstr "議題等待開始實作的時間" + +msgid "Time between merge request creation and merge/close" +msgstr "合併請求被合併或是關閉的時間" + +msgid "Time until first merge request" +msgstr "第一個合併請求被建立前的時間" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "小時" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "分鐘" + +msgid "Time|s" +msgstr "秒" + +msgid "Total Time" +msgstr "總時間" + +msgid "Total test time for all commits/merges" +msgstr "所有送交和合併的總測試時間" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "權限不足。如需查看相關資料,請向管理員申請權限。" + +msgid "We don't have enough data to show this stage." +msgstr "因該階段的資料不足而無法顯示相關資訊" + +msgid "You need permission." +msgstr "您需要相關的權限。" + +msgid "day" +msgid_plural "days" +msgstr[0] "天" diff --git a/locale/zh_TW/gitlab.po.time_stamp b/locale/zh_TW/gitlab.po.time_stamp new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/locale/zh_TW/gitlab.po.time_stamp diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 2ab2ca1b667..7d6c317482f 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -10,15 +10,26 @@ describe Admin::UsersController do describe 'DELETE #user with projects' do let(:project) { create(:empty_project, namespace: user.namespace) } + let!(:issue) { create(:issue, author: user) } before do project.team << [user, :developer] end - it 'deletes user' do + it 'deletes user and ghosts their contributions' do delete :destroy, id: user.username, format: :json + + expect(response).to have_http_status(200) + expect(User.exists?(user.id)).to be_falsy + expect(issue.reload.author).to be_ghost + end + + it 'deletes the user and their contributions when hard delete is specified' do + delete :destroy, id: user.username, hard_delete: true, format: :json + expect(response).to have_http_status(200) - expect { User.find(user.id) }.to raise_exception(ActiveRecord::RecordNotFound) + expect(User.exists?(user.id)).to be_falsy + expect(Issue.exists?(issue.id)).to be_falsy end end diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index 71dd9ef3eb4..634563fc290 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -77,7 +77,7 @@ describe RegistrationsController do end it 'schedules the user for destruction' do - expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id) + expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id, {}) post(:destroy) diff --git a/spec/factories/forked_project_links.rb b/spec/factories/forked_project_links.rb index b16c1272e68..66b0f248959 100644 --- a/spec/factories/forked_project_links.rb +++ b/spec/factories/forked_project_links.rb @@ -7,5 +7,9 @@ FactoryGirl.define do link.forked_from_project.reload link.forked_to_project.reload end + + trait :forked_to_empty_project do + association :forked_to_project, factory: :empty_project + end end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 19a85e5a38f..e17e50db143 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -26,6 +26,22 @@ FactoryGirl.define do visibility_level Gitlab::VisibilityLevel::PRIVATE end + trait :import_scheduled do + import_status :scheduled + end + + trait :import_started do + import_status :started + end + + trait :import_finished do + import_status :finished + end + + trait :import_failed do + import_status :failed + end + trait :archived do archived true end diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 376e80571d0..301a47169a4 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -22,7 +22,8 @@ describe "Admin::Users", feature: true do expect(page).to have_content(user.email) expect(page).to have_content(user.name) expect(page).to have_link('Block', href: block_admin_user_path(user)) - expect(page).to have_link('Delete', href: admin_user_path(user)) + expect(page).to have_link('Remove user', href: admin_user_path(user)) + expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true)) end describe 'Two-factor Authentication filters' do @@ -116,6 +117,9 @@ describe "Admin::Users", feature: true do expect(page).to have_content(user.email) expect(page).to have_content(user.name) + expect(page).to have_link('Block user', href: block_admin_user_path(user)) + expect(page).to have_link('Remove user', href: admin_user_path(user)) + expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true)) end describe 'Impersonation' do diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index fa3435ab719..3568954a548 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -15,6 +15,15 @@ RSpec.describe 'Dashboard Projects', feature: true do expect(page).to have_content('awesome stuff') end + it 'shows the last_activity_at attribute as the update date' do + now = Time.now + project.update_column(:last_activity_at, now) + + visit dashboard_projects_path + + expect(page).to have_xpath("//time[@datetime='#{now.getutc.iso8601}']") + end + context 'when on Starred projects tab' do it 'shows only starred projects' do user.toggle_star(project2) diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index 4860a2a7498..44013df3ea0 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -68,9 +68,14 @@ feature 'Diffs URL', js: true, feature: true do let(:merge_request) { create(:merge_request_with_diffs, source_project: forked_project, target_project: project, author: author_user) } let(:changelog_id) { Digest::SHA1.hexdigest("CHANGELOG") } + before do + forked_project.repository.after_import + end + context 'as author' do it 'shows direct edit link' do login_as(author_user) + visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request) # Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax @@ -81,6 +86,7 @@ feature 'Diffs URL', js: true, feature: true do context 'as user who needs to fork' do it 'shows fork/cancel confirmation' do login_as(user) + visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request) # Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax diff --git a/spec/features/projects/settings/visibility_settings_spec.rb b/spec/features/projects/settings/visibility_settings_spec.rb index cef315ac9cd..fac4506bdf6 100644 --- a/spec/features/projects/settings/visibility_settings_spec.rb +++ b/spec/features/projects/settings/visibility_settings_spec.rb @@ -14,7 +14,7 @@ feature 'Visibility settings', feature: true, js: true do visibility_select_container = find('.js-visibility-select') expect(visibility_select_container.find('.visibility-select').value).to eq project.visibility_level.to_s - expect(visibility_select_container).to have_content 'The project can be cloned without any authentication.' + expect(visibility_select_container).to have_content 'The project can be accessed without any authentication.' end scenario 'project visibility description updates on change' do @@ -41,7 +41,7 @@ feature 'Visibility settings', feature: true, js: true do expect(visibility_select_container).not_to have_select '.visibility-select' expect(visibility_select_container).to have_content 'Public' - expect(visibility_select_container).to have_content 'The project can be cloned without any authentication.' + expect(visibility_select_container).to have_content 'The project can be accessed without any authentication.' end end end diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb index a23c4ca2b92..8509551ce4a 100644 --- a/spec/features/unsubscribe_links_spec.rb +++ b/spec/features/unsubscribe_links_spec.rb @@ -24,8 +24,8 @@ describe 'Unsubscribe links', feature: true do visit body_link expect(current_path).to eq unsubscribe_sent_notification_path(SentNotification.last) - expect(page).to have_text(%(Unsubscribe from issue #{issue.title} (#{issue.to_reference}))) - expect(page).to have_text(%(Are you sure you want to unsubscribe from issue #{issue.title} (#{issue.to_reference})?)) + expect(page).to have_text(%(Unsubscribe from issue)) + expect(page).to have_text(%(Are you sure you want to unsubscribe from the issue: #{issue.title} (#{issue.to_reference})?)) expect(issue.subscribed?(recipient, project)).to be_truthy click_link 'Unsubscribe' diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 54c5ba57bdf..a695621b87a 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -257,7 +257,7 @@ describe ProjectsHelper do result = helper.project_feature_access_select(:issues_access_level) expect(result).to include("Disabled") expect(result).to include("Only team members") - expect(result).not_to include("Everyone with access") + expect(result).to have_selector('option[disabled]', text: "Everyone with access") end end @@ -272,7 +272,7 @@ describe ProjectsHelper do expect(result).to include("Disabled") expect(result).to include("Only team members") - expect(result).not_to include("Everyone with access") + expect(result).to have_selector('option[disabled]', text: "Everyone with access") expect(result).to have_selector('option[selected]', text: "Only team members") end end diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb index b05ae5c2232..cb727430117 100644 --- a/spec/helpers/submodule_helper_spec.rb +++ b/spec/helpers/submodule_helper_spec.rb @@ -52,6 +52,14 @@ describe SubmoduleHelper do stub_url(['http://', config.host, '/gitlab/root/gitlab-org/gitlab-ce.git'].join('')) expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash')]) end + + it 'works with subgroups' do + allow(Gitlab.config.gitlab).to receive(:port).and_return(80) # set this just to be sure + allow(Gitlab.config.gitlab).to receive(:relative_url_root).and_return('/gitlab/root') + allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url)) + stub_url(['http://', config.host, '/gitlab/root/gitlab-org/sub/gitlab-ce.git'].join('')) + expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org/sub', 'gitlab-ce'), namespace_project_tree_path('gitlab-org/sub', 'gitlab-ce', 'hash')]) + end end context 'submodule on github.com' do diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb index 8942b00b128..ad19cf9263d 100644 --- a/spec/helpers/visibility_level_helper_spec.rb +++ b/spec/helpers/visibility_level_helper_spec.rb @@ -37,7 +37,7 @@ describe VisibilityLevelHelper do it "describes public projects" do expect(project_visibility_level_description(Gitlab::VisibilityLevel::PUBLIC)) - .to eq "The project can be cloned without any authentication." + .to eq "The project can be accessed without any authentication." end end diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js index b8531350e43..cecc7ceb53d 100644 --- a/spec/javascripts/pipelines/header_component_spec.js +++ b/spec/javascripts/pipelines/header_component_spec.js @@ -10,6 +10,9 @@ describe('Pipeline details header', () => { beforeEach(() => { HeaderComponent = Vue.extend(headerComponent); + const threeWeeksAgo = new Date(); + threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); + props = { pipeline: { details: { @@ -22,7 +25,7 @@ describe('Pipeline details header', () => { }, }, id: 123, - created_at: '2017-05-08T14:57:39.781Z', + created_at: threeWeeksAgo.toISOString(), user: { web_url: 'path', name: 'Foo', diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb index 8d81ed5856e..c0c309d8179 100644 --- a/spec/lib/gitlab/checks/change_access_spec.rb +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -23,29 +23,27 @@ describe Gitlab::Checks::ChangeAccess, lib: true do before { project.add_developer(user) } context 'without failed checks' do - it "doesn't return any error" do - expect(subject.status).to be(true) + it "doesn't raise an error" do + expect { subject }.not_to raise_error end end context 'when the user is not allowed to push code' do - it 'returns an error' do + it 'raises an error' do expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false) - expect(subject.status).to be(false) - expect(subject.message).to eq('You are not allowed to push code to this project.') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.') end end context 'tags check' do let(:ref) { 'refs/tags/v1.0.0' } - it 'returns an error if the user is not allowed to update tags' do + it 'raises an error if the user is not allowed to update tags' do allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false) - expect(subject.status).to be(false) - expect(subject.message).to eq('You are not allowed to change existing tags on this project.') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to change existing tags on this project.') end context 'with protected tag' do @@ -59,8 +57,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do let(:newrev) { '0000000000000000000000000000000000000000' } it 'is prevented' do - expect(subject.status).to be(false) - expect(subject.message).to include('cannot be deleted') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be deleted/) end end @@ -69,8 +66,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } it 'is prevented' do - expect(subject.status).to be(false) - expect(subject.message).to include('cannot be updated') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be updated/) end end end @@ -81,15 +77,14 @@ describe Gitlab::Checks::ChangeAccess, lib: true do let(:ref) { 'refs/tags/v9.1.0' } it 'prevents creation below access level' do - expect(subject.status).to be(false) - expect(subject.message).to include('allowed to create this tag as it is protected') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /allowed to create this tag as it is protected/) end context 'when user has access' do let!(:protected_tag) { create(:protected_tag, :developers_can_create, project: project, name: 'v*') } it 'allows tag creation' do - expect(subject.status).to be(true) + expect { subject }.not_to raise_error end end end @@ -101,9 +96,8 @@ describe Gitlab::Checks::ChangeAccess, lib: true do let(:newrev) { '0000000000000000000000000000000000000000' } let(:ref) { 'refs/heads/master' } - it 'returns an error' do - expect(subject.status).to be(false) - expect(subject.message).to eq('The default branch of a project cannot be deleted.') + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'The default branch of a project cannot be deleted.') end end @@ -113,27 +107,24 @@ describe Gitlab::Checks::ChangeAccess, lib: true do allow(ProtectedBranch).to receive(:protected?).with(project, 'feature').and_return(true) end - it 'returns an error if the user is not allowed to do forced pushes to protected branches' do + it 'raises an error if the user is not allowed to do forced pushes to protected branches' do expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true) - expect(subject.status).to be(false) - expect(subject.message).to eq('You are not allowed to force push code to a protected branch on this project.') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to force push code to a protected branch on this project.') end - it 'returns an error if the user is not allowed to merge to protected branches' do + it 'raises an error if the user is not allowed to merge to protected branches' do expect_any_instance_of(Gitlab::Checks::MatchingMergeRequest).to receive(:match?).and_return(true) expect(user_access).to receive(:can_merge_to_branch?).and_return(false) expect(user_access).to receive(:can_push_to_branch?).and_return(false) - expect(subject.status).to be(false) - expect(subject.message).to eq('You are not allowed to merge code into protected branches on this project.') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to merge code into protected branches on this project.') end - it 'returns an error if the user is not allowed to push to protected branches' do + it 'raises an error if the user is not allowed to push to protected branches' do expect(user_access).to receive(:can_push_to_branch?).and_return(false) - expect(subject.status).to be(false) - expect(subject.message).to eq('You are not allowed to push code to protected branches on this project.') + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to protected branches on this project.') end context 'branch deletion' do @@ -141,9 +132,8 @@ describe Gitlab::Checks::ChangeAccess, lib: true do let(:ref) { 'refs/heads/feature' } context 'if the user is not allowed to delete protected branches' do - it 'returns an error' do - expect(subject.status).to be(false) - expect(subject.message).to eq('You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.') + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.') end end @@ -156,14 +146,13 @@ describe Gitlab::Checks::ChangeAccess, lib: true do let(:protocol) { 'web' } it 'allows branch deletion' do - expect(subject.status).to be(true) + expect { subject }.not_to raise_error end end context 'over SSH or HTTP' do - it 'returns an error' do - expect(subject.status).to be(false) - expect(subject.message).to eq('You can only delete protected branches using the web interface.') + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You can only delete protected branches using the web interface.') end end end diff --git a/spec/lib/gitlab/ci_access_spec.rb b/spec/lib/gitlab/ci_access_spec.rb new file mode 100644 index 00000000000..eaf8f1d0f1c --- /dev/null +++ b/spec/lib/gitlab/ci_access_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe Gitlab::CiAccess, lib: true do + let(:access) { Gitlab::CiAccess.new } + + describe '#can_do_action?' do + context 'when action is :build_download_code' do + it { expect(access.can_do_action?(:build_download_code)).to be_truthy } + end + + context 'when action is not :build_download_code' do + it { expect(access.can_do_action?(:download_code)).to be_falsey } + end + end +end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 25769977f24..36d1d777583 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -1,10 +1,13 @@ require 'spec_helper' describe Gitlab::GitAccess, lib: true do - let(:access) { Gitlab::GitAccess.new(actor, project, 'ssh', authentication_abilities: authentication_abilities) } + let(:pull_access_check) { access.check('git-upload-pack', '_any') } + let(:push_access_check) { access.check('git-receive-pack', '_any') } + let(:access) { Gitlab::GitAccess.new(actor, project, protocol, authentication_abilities: authentication_abilities) } let(:project) { create(:project, :repository) } let(:user) { create(:user) } let(:actor) { user } + let(:protocol) { 'ssh' } let(:authentication_abilities) do [ :read_project, @@ -15,49 +18,188 @@ describe Gitlab::GitAccess, lib: true do describe '#check with single protocols allowed' do def disable_protocol(protocol) - settings = ::ApplicationSetting.create_from_defaults - settings.update_attribute(:enabled_git_access_protocol, protocol) + allow(Gitlab::ProtocolAccess).to receive(:allowed?).with(protocol).and_return(false) end context 'ssh disabled' do before do disable_protocol('ssh') - @acc = Gitlab::GitAccess.new(actor, project, 'ssh', authentication_abilities: authentication_abilities) end it 'blocks ssh git push' do - expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey + expect { push_access_check }.to raise_unauthorized('Git access over SSH is not allowed') end it 'blocks ssh git pull' do - expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey + expect { pull_access_check }.to raise_unauthorized('Git access over SSH is not allowed') end end context 'http disabled' do + let(:protocol) { 'http' } + before do disable_protocol('http') - @acc = Gitlab::GitAccess.new(actor, project, 'http', authentication_abilities: authentication_abilities) end it 'blocks http push' do - expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey + expect { push_access_check }.to raise_unauthorized('Git access over HTTP is not allowed') end it 'blocks http git pull' do - expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey + expect { pull_access_check }.to raise_unauthorized('Git access over HTTP is not allowed') end end end - describe '#check_download_access!' do - subject { access.check('git-upload-pack', '_any') } + describe '#check_project_accessibility!' do + context 'when the project exists' do + context 'when actor exists' do + context 'when actor is a DeployKey' do + let(:deploy_key) { create(:deploy_key, user: user, can_push: true) } + let(:actor) { deploy_key } + + context 'when the DeployKey has access to the project' do + before { deploy_key.projects << project } + + it 'allows pull access' do + expect { pull_access_check }.not_to raise_error + end + + it 'allows push access' do + expect { push_access_check }.not_to raise_error + end + end + + context 'when the Deploykey does not have access to the project' do + it 'blocks pulls with "not found"' do + expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + + it 'blocks pushes with "not found"' do + expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + end + end + context 'when actor is a User' do + context 'when the User can read the project' do + before { project.team << [user, :master] } + + it 'allows pull access' do + expect { pull_access_check }.not_to raise_error + end + + it 'allows push access' do + expect { push_access_check }.not_to raise_error + end + end + + context 'when the User cannot read the project' do + it 'blocks pulls with "not found"' do + expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + + it 'blocks pushes with "not found"' do + expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + end + end + + # For backwards compatibility + context 'when actor is :ci' do + let(:actor) { :ci } + let(:authentication_abilities) { build_authentication_abilities } + + it 'allows pull access' do + expect { pull_access_check }.not_to raise_error + end + + it 'does not block pushes with "not found"' do + expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') + end + end + end + + context 'when actor is nil' do + let(:actor) { nil } + + context 'when guests can read the project' do + let(:project) { create(:project, :repository, :public) } + + it 'allows pull access' do + expect { pull_access_check }.not_to raise_error + end + + it 'does not block pushes with "not found"' do + expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') + end + end + + context 'when guests cannot read the project' do + it 'blocks pulls with "not found"' do + expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + + it 'blocks pushes with "not found"' do + expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + end + end + end + + context 'when the project is nil' do + let(:project) { nil } + + it 'blocks any command with "not found"' do + expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') + expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') + end + end + end + + describe '#check_command_disabled!' do + before { project.team << [user, :master] } + + context 'over http' do + let(:protocol) { 'http' } + + context 'when the git-upload-pack command is disabled in config' do + before do + allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false) + end + + context 'when calling git-upload-pack' do + it { expect { pull_access_check }.to raise_unauthorized('Pulling over HTTP is not allowed.') } + end + + context 'when calling git-receive-pack' do + it { expect { push_access_check }.not_to raise_error } + end + end + + context 'when the git-receive-pack command is disabled in config' do + before do + allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false) + end + + context 'when calling git-receive-pack' do + it { expect { push_access_check }.to raise_unauthorized('Pushing over HTTP is not allowed.') } + end + + context 'when calling git-upload-pack' do + it { expect { pull_access_check }.not_to raise_error } + end + end + end + end + + describe '#check_download_access!' do describe 'master permissions' do before { project.team << [user, :master] } context 'pull code' do - it { expect(subject.allowed?).to be_truthy } + it { expect { pull_access_check }.not_to raise_error } end end @@ -65,8 +207,7 @@ describe Gitlab::GitAccess, lib: true do before { project.team << [user, :guest] } context 'pull code' do - it { expect(subject.allowed?).to be_falsey } - it { expect(subject.message).to match(/You are not allowed to download code/) } + it { expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') } end end @@ -77,24 +218,22 @@ describe Gitlab::GitAccess, lib: true do end context 'pull code' do - it { expect(subject.allowed?).to be_falsey } - it { expect(subject.message).to match(/Your account has been blocked/) } + it { expect { pull_access_check }.to raise_unauthorized('Your account has been blocked.') } end end describe 'without access to project' do context 'pull code' do - it { expect(subject.allowed?).to be_falsey } + it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') } end context 'when project is public' do let(:public_project) { create(:project, :public, :repository) } - let(:guest_access) { Gitlab::GitAccess.new(nil, public_project, 'web', authentication_abilities: []) } - subject { guest_access.check('git-upload-pack', '_any') } + let(:access) { Gitlab::GitAccess.new(nil, public_project, 'web', authentication_abilities: []) } context 'when repository is enabled' do it 'give access to download code' do - expect(subject.allowed?).to be_truthy + expect { pull_access_check }.not_to raise_error end end @@ -102,8 +241,7 @@ describe Gitlab::GitAccess, lib: true do it 'does not give access to download code' do public_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED) - expect(subject.allowed?).to be_falsey - expect(subject.message).to match(/You are not allowed to download code/) + expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') end end end @@ -117,26 +255,26 @@ describe Gitlab::GitAccess, lib: true do context 'when project is authorized' do before { key.projects << project } - it { expect(subject).to be_allowed } + it { expect { pull_access_check }.not_to raise_error } end context 'when unauthorized' do context 'from public project' do let(:project) { create(:project, :public, :repository) } - it { expect(subject).to be_allowed } + it { expect { pull_access_check }.not_to raise_error } end context 'from internal project' do let(:project) { create(:project, :internal, :repository) } - it { expect(subject).not_to be_allowed } + it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') } end context 'from private project' do let(:project) { create(:project, :private, :repository) } - it { expect(subject).not_to be_allowed } + it { expect { pull_access_check }.to raise_not_found('The project you were looking for could not be found.') } end end end @@ -149,7 +287,7 @@ describe Gitlab::GitAccess, lib: true do let(:project) { create(:project, :repository, namespace: user.namespace) } context 'pull code' do - it { expect(subject).to be_allowed } + it { expect { pull_access_check }.not_to raise_error } end end @@ -157,7 +295,7 @@ describe Gitlab::GitAccess, lib: true do before { project.team << [user, :reporter] } context 'pull code' do - it { expect(subject).to be_allowed } + it { expect { pull_access_check }.not_to raise_error } end end @@ -168,16 +306,24 @@ describe Gitlab::GitAccess, lib: true do before { project.team << [user, :reporter] } context 'pull code' do - it { expect(subject).to be_allowed } + it { expect { pull_access_check }.not_to raise_error } end end context 'when is not member of the project' do context 'pull code' do - it { expect(subject).not_to be_allowed } + it { expect { pull_access_check }.to raise_unauthorized('You are not allowed to download code from this project.') } end end end + + describe 'generic CI (build without a user)' do + let(:actor) { :ci } + + context 'pull code' do + it { expect { pull_access_check }.not_to raise_error } + end + end end end @@ -365,42 +511,32 @@ describe Gitlab::GitAccess, lib: true do end end - shared_examples 'pushing code' do |can| - subject { access.check('git-receive-pack', '_any') } + describe 'build authentication abilities' do + let(:authentication_abilities) { build_authentication_abilities } context 'when project is authorized' do - before { authorize } + before { project.team << [user, :reporter] } - it { expect(subject).public_send(can, be_allowed) } + it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') } end context 'when unauthorized' do context 'to public project' do let(:project) { create(:project, :public, :repository) } - it { expect(subject).not_to be_allowed } + it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') } end context 'to internal project' do let(:project) { create(:project, :internal, :repository) } - it { expect(subject).not_to be_allowed } + it { expect { push_access_check }.to raise_unauthorized('You are not allowed to upload code for this project.') } end context 'to private project' do let(:project) { create(:project, :private, :repository) } - it { expect(subject).not_to be_allowed } - end - end - end - - describe 'build authentication abilities' do - let(:authentication_abilities) { build_authentication_abilities } - - it_behaves_like 'pushing code', :not_to do - def authorize - project.team << [user, :reporter] + it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') } end end end @@ -412,9 +548,29 @@ describe Gitlab::GitAccess, lib: true do context 'when deploy_key can push' do let(:can_push) { true } - it_behaves_like 'pushing code', :to do - def authorize - key.projects << project + context 'when project is authorized' do + before { key.projects << project } + + it { expect { push_access_check }.not_to raise_error } + end + + context 'when unauthorized' do + context 'to public project' do + let(:project) { create(:project, :public, :repository) } + + it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') } + end + + context 'to internal project' do + let(:project) { create(:project, :internal, :repository) } + + it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') } + end + + context 'to private project' do + let(:project) { create(:project, :private, :repository) } + + it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') } end end end @@ -422,9 +578,29 @@ describe Gitlab::GitAccess, lib: true do context 'when deploy_key cannot push' do let(:can_push) { false } - it_behaves_like 'pushing code', :not_to do - def authorize - key.projects << project + context 'when project is authorized' do + before { key.projects << project } + + it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') } + end + + context 'when unauthorized' do + context 'to public project' do + let(:project) { create(:project, :public, :repository) } + + it { expect { push_access_check }.to raise_unauthorized('This deploy key does not have write access to this project.') } + end + + context 'to internal project' do + let(:project) { create(:project, :internal, :repository) } + + it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') } + end + + context 'to private project' do + let(:project) { create(:project, :private, :repository) } + + it { expect { push_access_check }.to raise_not_found('The project you were looking for could not be found.') } end end end @@ -432,6 +608,14 @@ describe Gitlab::GitAccess, lib: true do private + def raise_unauthorized(message) + raise_error(Gitlab::GitAccess::UnauthorizedError, message) + end + + def raise_not_found(message) + raise_error(Gitlab::GitAccess::NotFoundError, message) + end + def build_authentication_abilities [ :read_project, diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb index 1ae293416e4..a1eb95750ba 100644 --- a/spec/lib/gitlab/git_access_wiki_spec.rb +++ b/spec/lib/gitlab/git_access_wiki_spec.rb @@ -20,7 +20,7 @@ describe Gitlab::GitAccessWiki, lib: true do subject { access.check('git-receive-pack', changes) } - it { expect(subject.allowed?).to be_truthy } + it { expect { subject }.not_to raise_error } end def changes @@ -36,7 +36,7 @@ describe Gitlab::GitAccessWiki, lib: true do context 'when wiki feature is enabled' do it 'give access to download wiki code' do - expect(subject.allowed?).to be_truthy + expect { subject }.not_to raise_error end end @@ -44,8 +44,7 @@ describe Gitlab::GitAccessWiki, lib: true do it 'does not give access to download wiki code' do project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED) - expect(subject.allowed?).to be_falsey - expect(subject.message).to match(/You are not allowed to download code/) + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to download code from this project.') end end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 2e9646286df..21296a36729 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -144,7 +144,9 @@ merge_access_levels: push_access_levels: - protected_branch create_access_levels: +- user - protected_tag +- group container_repositories: - project - name diff --git a/spec/lib/gitlab/otp_key_rotator_spec.rb b/spec/lib/gitlab/otp_key_rotator_spec.rb new file mode 100644 index 00000000000..6e6e9ce29ac --- /dev/null +++ b/spec/lib/gitlab/otp_key_rotator_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe Gitlab::OtpKeyRotator do + let(:file) { Tempfile.new("otp-key-rotator-test") } + let(:filename) { file.path } + let(:old_key) { Gitlab::Application.secrets.otp_key_base } + let(:new_key) { "00" * 32 } + let!(:users) { create_list(:user, 5, :two_factor) } + + after do + file.close + file.unlink + end + + def data + CSV.read(filename) + end + + def build_row(user, applied = false) + [user.id.to_s, encrypt_otp(user, old_key), encrypt_otp(user, new_key)] + end + + def encrypt_otp(user, key) + opts = { + value: user.otp_secret, + iv: user.encrypted_otp_secret_iv.unpack("m").join, + salt: user.encrypted_otp_secret_salt.unpack("m").join, + algorithm: 'aes-256-cbc', + insecure_mode: true, + key: key + } + [Encryptor.encrypt(opts)].pack("m") + end + + subject(:rotator) { described_class.new(filename) } + + describe '#rotate!' do + subject(:rotation) { rotator.rotate!(old_key: old_key, new_key: new_key) } + + it 'stores the calculated values in a spreadsheet' do + rotation + + expect(data).to match_array(users.map {|u| build_row(u) }) + end + + context 'new key is too short' do + let(:new_key) { "00" * 31 } + + it { expect { rotation }.to raise_error(ArgumentError) } + end + + context 'new key is the same as the old key' do + let(:new_key) { old_key } + + it { expect { rotation }.to raise_error(ArgumentError) } + end + end + + describe '#rollback!' do + it 'updates rows to the old value' do + file.puts("#{users[0].id},old,new") + file.close + + rotator.rollback! + + expect(users[0].reload.encrypted_otp_secret).to eq('old') + expect(users[1].reload.encrypted_otp_secret).not_to eq('old') + end + end +end diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index ced93c8f762..90aec2b45e6 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -28,9 +28,7 @@ RSpec.describe AbuseReport, type: :model do end it 'lets a worker delete the user' do - expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, - delete_solo_owned_groups: true, - hard_delete: true) + expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, hard_delete: true) subject.remove_user(deleted_by: user) end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 86ab2550bfb..3ed52d42f86 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -50,7 +50,7 @@ describe Project, models: true do it { is_expected.to have_one(:external_wiki_service).dependent(:destroy) } it { is_expected.to have_one(:project_feature).dependent(:destroy) } it { is_expected.to have_one(:statistics).class_name('ProjectStatistics').dependent(:delete) } - it { is_expected.to have_one(:import_data).class_name('ProjectImportData').dependent(:destroy) } + it { is_expected.to have_one(:import_data).class_name('ProjectImportData').dependent(:delete) } it { is_expected.to have_one(:last_event).class_name('Event') } it { is_expected.to have_one(:forked_from_project).through(:forked_project_link) } it { is_expected.to have_many(:commit_statuses) } @@ -1446,16 +1446,13 @@ describe Project, models: true do end describe 'Project import job' do - let(:project) { create(:empty_project) } - let(:mirror) { false } + let(:project) { create(:empty_project, import_url: generate(:url)) } before do allow_any_instance_of(Gitlab::Shell).to receive(:import_repository) .with(project.repository_storage_path, project.path_with_namespace, project.import_url) .and_return(true) - allow(project).to receive(:repository_exists?).and_return(true) - expect_any_instance_of(Repository).to receive(:after_import) .and_call_original end @@ -1463,8 +1460,7 @@ describe Project, models: true do it 'imports a project' do expect_any_instance_of(RepositoryImportWorker).to receive(:perform).and_call_original - project.import_start - project.add_import_job + project.import_schedule expect(project.reload.import_status).to eq('finished') end @@ -1551,7 +1547,7 @@ describe Project, models: true do describe '#add_import_job' do context 'forked' do - let(:forked_project_link) { create(:forked_project_link) } + let(:forked_project_link) { create(:forked_project_link, :forked_to_empty_project) } let(:forked_from_project) { forked_project_link.forked_from_project } let(:project) { forked_project_link.forked_to_project } @@ -1565,9 +1561,9 @@ describe Project, models: true do end context 'not forked' do - let(:project) { create(:empty_project) } - it 'schedules a RepositoryImportWorker job' do + project = create(:empty_project, import_url: generate(:url)) + expect(RepositoryImportWorker).to receive(:perform_async).with(project.id) project.add_import_job diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 4c37a553227..a8331ceb5ff 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -9,11 +9,12 @@ describe GroupPolicy, models: true do let(:admin) { create(:admin) } let(:group) { create(:group) } + let(:reporter_permissions) { [:admin_label] } + let(:master_permissions) do [ :create_projects, - :admin_milestones, - :admin_label + :admin_milestones ] end @@ -42,6 +43,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.not_to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -52,6 +54,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.not_to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -62,6 +65,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -72,6 +76,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -82,6 +87,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -92,6 +98,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.to include(*master_permissions) is_expected.to include(*owner_permissions) end @@ -102,14 +109,27 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.to include(*master_permissions) is_expected.to include(*owner_permissions) end end - describe 'private nested group inherit permissions', :nested_groups do + describe 'private nested group use the highest access level from the group and inherited permissions', :nested_groups do let(:nested_group) { create(:group, :private, parent: group) } + before do + nested_group.add_guest(guest) + nested_group.add_guest(reporter) + nested_group.add_guest(developer) + nested_group.add_guest(master) + + group.owners.destroy_all + + group.add_guest(owner) + nested_group.add_owner(owner) + end + subject { described_class.abilities(current_user, nested_group).to_set } context 'with no user' do @@ -117,6 +137,7 @@ describe GroupPolicy, models: true do it do is_expected.not_to include(:read_group) + is_expected.not_to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -127,6 +148,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.not_to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -137,6 +159,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -147,6 +170,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.not_to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -157,6 +181,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.to include(*master_permissions) is_expected.not_to include(*owner_permissions) end @@ -167,6 +192,7 @@ describe GroupPolicy, models: true do it do is_expected.to include(:read_group) + is_expected.to include(*reporter_permissions) is_expected.to include(*master_permissions) is_expected.to include(*owner_permissions) end diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 6ca3ef18fe6..f018b48ceb2 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -5,76 +5,217 @@ describe 'Git HTTP requests', lib: true do include WorkhorseHelpers include UserActivitiesHelpers - it "gives WWW-Authenticate hints" do - clone_get('doesnt/exist.git') + shared_examples 'pulls require Basic HTTP Authentication' do + context "when no credentials are provided" do + it "responds to downloads with status 401 Unauthorized (no project existence information leak)" do + download(path) do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') + end + end + end - expect(response.header['WWW-Authenticate']).to start_with('Basic ') - end + context "when only username is provided" do + it "responds to downloads with status 401 Unauthorized" do + download(path, user: user.username) do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') + end + end + end - describe "User with no identities" do - let(:user) { create(:user) } - let(:project) { create(:project, :repository, path: 'project.git-project') } + context "when username and password are provided" do + context "when authentication fails" do + it "responds to downloads with status 401 Unauthorized" do + download(path, user: user.username, password: "wrong-password") do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') + end + end + end - context "when the project doesn't exist" do - context "when no authentication is provided" do - it "responds with status 401 (no project existence information leak)" do - download('doesnt/exist.git') do |response| - expect(response).to have_http_status(401) + context "when authentication succeeds" do + it "does not respond to downloads with status 401 Unauthorized" do + download(path, user: user.username, password: user.password) do |response| + expect(response).not_to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to be_nil end end end + end + end - context "when username and password are provided" do - context "when authentication fails" do - it "responds with status 401" do - download('doesnt/exist.git', user: user.username, password: "nope") do |response| - expect(response).to have_http_status(401) - end + shared_examples 'pushes require Basic HTTP Authentication' do + context "when no credentials are provided" do + it "responds to uploads with status 401 Unauthorized (no project existence information leak)" do + upload(path) do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') + end + end + end + + context "when only username is provided" do + it "responds to uploads with status 401 Unauthorized" do + upload(path, user: user.username) do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') + end + end + end + + context "when username and password are provided" do + context "when authentication fails" do + it "responds to uploads with status 401 Unauthorized" do + upload(path, user: user.username, password: "wrong-password") do |response| + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to start_with('Basic ') end end + end - context "when authentication succeeds" do - it "responds with status 404" do - download('/doesnt/exist.git', user: user.username, password: user.password) do |response| - expect(response).to have_http_status(404) - end + context "when authentication succeeds" do + it "does not respond to uploads with status 401 Unauthorized" do + upload(path, user: user.username, password: user.password) do |response| + expect(response).not_to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).to be_nil end end end end + end - context "when the Wiki for a project exists" do - it "responds with the right project" do - wiki = ProjectWiki.new(project) - project.update_attribute(:visibility_level, Project::PUBLIC) + shared_examples_for 'pulls are allowed' do + it do + download(path, env) do |response| + expect(response).to have_http_status(:ok) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + end + end - download("/#{wiki.repository.path_with_namespace}.git") do |response| - json_body = ActiveSupport::JSON.decode(response.body) + shared_examples_for 'pushes are allowed' do + it do + upload(path, env) do |response| + expect(response).to have_http_status(:ok) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + end + end - expect(response).to have_http_status(200) - expect(json_body['RepoPath']).to include(wiki.repository.path_with_namespace) - expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + describe "User with no identities" do + let(:user) { create(:user) } + + context "when the project doesn't exist" do + let(:path) { 'doesnt/exist.git' } + + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' + + context 'when authenticated' do + it 'rejects downloads and uploads with 404 Not Found' do + download_or_upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:not_found) + end end end + end + + context "when requesting the Wiki" do + let(:wiki) { ProjectWiki.new(project) } + let(:path) { "/#{wiki.repository.path_with_namespace}.git" } + + context "when the project is public" do + let(:project) { create(:project, :repository, :public, :wiki_enabled) } + + it_behaves_like 'pushes require Basic HTTP Authentication' + + context 'when unauthenticated' do + let(:env) { {} } - context 'but the repo is disabled' do - let(:project) { create(:project, :repository_disabled, :wiki_enabled) } - let(:wiki) { ProjectWiki.new(project) } - let(:path) { "/#{wiki.repository.path_with_namespace}.git" } + it_behaves_like 'pulls are allowed' - before do - project.team << [user, :developer] + it "responds to pulls with the wiki's repo" do + download(path) do |response| + json_body = ActiveSupport::JSON.decode(response.body) + + expect(json_body['RepoPath']).to include(wiki.repository.path_with_namespace) + end + end end - it 'allows clones' do - download(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(200) + context 'when authenticated' do + let(:env) { { user: user.username, password: user.password } } + + context 'and as a developer on the team' do + before do + project.team << [user, :developer] + end + + context 'but the repo is disabled' do + let(:project) { create(:project, :repository, :public, :repository_disabled, :wiki_enabled) } + + it_behaves_like 'pulls are allowed' + it_behaves_like 'pushes are allowed' + end + end + + context 'and not on the team' do + it_behaves_like 'pulls are allowed' + + it 'rejects pushes with 403 Forbidden' do + upload(path, env) do |response| + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_wiki_error(:write_to_wiki)) + end + end end end + end - it 'allows pushes' do - upload(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(200) + context "when the project is private" do + let(:project) { create(:project, :repository, :private, :wiki_enabled) } + + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' + + context 'when authenticated' do + context 'and as a developer on the team' do + before do + project.team << [user, :developer] + end + + context 'but the repo is disabled' do + let(:project) { create(:project, :repository, :private, :repository_disabled, :wiki_enabled) } + + it 'allows clones' do + download(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:ok) + end + end + + it 'pushes are allowed' do + upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:ok) + end + end + end + end + + context 'and not on the team' do + it 'rejects clones with 404 Not Found' do + download(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:not_found) + expect(response.body).to eq(git_access_error(:project_not_found)) + end + end + + it 'rejects pushes with 404 Not Found' do + upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:not_found) + expect(response.body).to eq(git_access_error(:project_not_found)) + end + end end end end @@ -84,49 +225,60 @@ describe 'Git HTTP requests', lib: true do let(:path) { "#{project.path_with_namespace}.git" } context "when the project is public" do - before do - project.update_attribute(:visibility_level, Project::PUBLIC) - end + let(:project) { create(:project, :repository, :public) } - it "downloads get status 200" do - download(path, {}) do |response| - expect(response).to have_http_status(200) - expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) - end - end + it_behaves_like 'pushes require Basic HTTP Authentication' - it "uploads get status 401" do - upload(path, {}) do |response| - expect(response).to have_http_status(401) - end + context 'when not authenticated' do + let(:env) { {} } + + it_behaves_like 'pulls are allowed' end - context "with correct credentials" do + context "when authenticated" do let(:env) { { user: user.username, password: user.password } } - it "uploads get status 403" do - upload(path, env) do |response| - expect(response).to have_http_status(403) + context 'as a developer on the team' do + before do + project.team << [user, :developer] end - end - context 'but git-receive-pack is disabled' do - it "responds with status 404" do - allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false) + it_behaves_like 'pulls are allowed' + it_behaves_like 'pushes are allowed' - upload(path, env) do |response| - expect(response).to have_http_status(403) + context 'but git-receive-pack over HTTP is disabled in config' do + before do + allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false) + end + + it 'rejects pushes with 403 Forbidden' do + upload(path, env) do |response| + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_error(:receive_pack_disabled_over_http)) + end + end + end + + context 'but git-upload-pack over HTTP is disabled in config' do + it "rejects pushes with 403 Forbidden" do + allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false) + + download(path, env) do |response| + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_error(:upload_pack_disabled_over_http)) + end end end end - end - context 'but git-upload-pack is disabled' do - it "responds with status 404" do - allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false) + context 'and not a member of the team' do + it_behaves_like 'pulls are allowed' - download(path, {}) do |response| - expect(response).to have_http_status(404) + it 'rejects pushes with 403 Forbidden' do + upload(path, env) do |response| + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(change_access_error(:push_code)) + end end end end @@ -141,66 +293,41 @@ describe 'Git HTTP requests', lib: true do context 'when the repo is public' do context 'but the repo is disabled' do - it 'does not allow to clone the repo' do - project = create(:project, :public, :repository_disabled) + let(:project) { create(:project, :public, :repository, :repository_disabled) } + let(:path) { "#{project.path_with_namespace}.git" } + let(:env) { {} } - download("#{project.path_with_namespace}.git", {}) do |response| - expect(response).to have_http_status(:unauthorized) - end - end + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' end context 'but the repo is enabled' do - it 'allows to clone the repo' do - project = create(:project, :public, :repository_enabled) + let(:project) { create(:project, :public, :repository, :repository_enabled) } + let(:path) { "#{project.path_with_namespace}.git" } + let(:env) { {} } - download("#{project.path_with_namespace}.git", {}) do |response| - expect(response).to have_http_status(:ok) - end - end + it_behaves_like 'pulls are allowed' end context 'but only project members are allowed' do - it 'does not allow to clone the repo' do - project = create(:project, :public, :repository_private) + let(:project) { create(:project, :public, :repository, :repository_private) } - download("#{project.path_with_namespace}.git", {}) do |response| - expect(response).to have_http_status(:unauthorized) - end - end + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' end end end context "when the project is private" do - before do - project.update_attribute(:visibility_level, Project::PRIVATE) - end - - context "when no authentication is provided" do - it "responds with status 401 to downloads" do - download(path, {}) do |response| - expect(response).to have_http_status(401) - end - end + let(:project) { create(:project, :repository, :private) } - it "responds with status 401 to uploads" do - upload(path, {}) do |response| - expect(response).to have_http_status(401) - end - end - end + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' context "when username and password are provided" do let(:env) { { user: user.username, password: 'nope' } } context "when authentication fails" do - it "responds with status 401" do - download(path, env) do |response| - expect(response).to have_http_status(401) - end - end - context "when the user is IP banned" do it "responds with status 401" do expect(Rack::Attack::Allow2Ban).to receive(:filter).and_return(true) @@ -208,7 +335,7 @@ describe 'Git HTTP requests', lib: true do clone_get(path, env) - expect(response).to have_http_status(401) + expect(response).to have_http_status(:unauthorized) end end end @@ -222,37 +349,39 @@ describe 'Git HTTP requests', lib: true do end context "when the user is blocked" do - it "responds with status 401" do + it "rejects pulls with 401 Unauthorized" do user.block project.team << [user, :master] download(path, env) do |response| - expect(response).to have_http_status(401) + expect(response).to have_http_status(:unauthorized) end end - it "responds with status 401 for unknown projects (no project existence information leak)" do + it "rejects pulls with 401 Unauthorized 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) + expect(response).to have_http_status(:unauthorized) end end end context "when the user isn't blocked" do - it "downloads get status 200" do - expect(Rack::Attack::Allow2Ban).to receive(:reset) - - clone_get(path, env) + it "resets the IP in Rack Attack on download" do + expect(Rack::Attack::Allow2Ban).to receive(:reset).twice - expect(response).to have_http_status(200) - expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + download(path, env) do + expect(response).to have_http_status(:ok) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end end - it "uploads get status 200" do - upload(path, env) do |response| - expect(response).to have_http_status(200) + it "resets the IP in Rack Attack on upload" do + expect(Rack::Attack::Allow2Ban).to receive(:reset).twice + + upload(path, env) do + expect(response).to have_http_status(:ok) expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) end end @@ -272,56 +401,43 @@ describe 'Git HTTP requests', lib: true do @token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api") end - it "downloads get status 200" do - clone_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token - - expect(response).to have_http_status(200) - expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) - end - - it "uploads get status 200" do - push_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token + let(:path) { "#{project.path_with_namespace}.git" } + let(:env) { { user: 'oauth2', password: @token.token } } - expect(response).to have_http_status(200) - end + it_behaves_like 'pulls are allowed' + it_behaves_like 'pushes are allowed' end context 'when user has 2FA enabled' do let(:user) { create(:user, :two_factor) } let(:access_token) { create(:personal_access_token, user: user) } + let(:path) { "#{project.path_with_namespace}.git" } before do project.team << [user, :master] end context 'when username and password are provided' do - it 'rejects the clone attempt' do - download("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response| - expect(response).to have_http_status(401) + it 'rejects pulls with 2FA error message' do + download(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:unauthorized) expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP') end end it 'rejects the push attempt' do - upload("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response| - expect(response).to have_http_status(401) + upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:unauthorized) expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP') end end end context 'when username and personal access token are provided' do - it 'allows clones' do - download("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response| - expect(response).to have_http_status(200) - end - end + let(:env) { { user: user.username, password: access_token.token } } - it 'allows pushes' do - upload("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response| - expect(response).to have_http_status(200) - end - end + it_behaves_like 'pulls are allowed' + it_behaves_like 'pushes are allowed' end end @@ -357,15 +473,15 @@ describe 'Git HTTP requests', lib: true do end context "when the user doesn't have access to the project" do - it "downloads get status 404" do + it "pulls get status 404" do download(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(404) + expect(response).to have_http_status(:not_found) end end it "uploads get status 404" do upload(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(404) + expect(response).to have_http_status(:not_found) end end end @@ -373,28 +489,41 @@ describe 'Git HTTP requests', lib: true do end context "when a gitlab ci token is provided" do + let(:project) { create(:project, :repository) } let(:build) { create(:ci_build, :running) } - let(:project) { build.project } let(:other_project) { create(:empty_project) } - context 'when build created by system is authenticated' do - it "downloads get status 200" do - clone_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token - - expect(response).to have_http_status(200) - expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) - end - - it "uploads get status 401 (no project existence information leak)" do - push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token + before do + build.update!(project: project) # can't associate it on factory create + end - expect(response).to have_http_status(401) + context 'when build created by system is authenticated' do + let(:path) { "#{project.path_with_namespace}.git" } + let(:env) { { user: 'gitlab-ci-token', password: build.token } } + + it_behaves_like 'pulls are allowed' + + # A non-401 here is not an information leak since the system is + # "authenticated" as CI using the correct token. It does not have + # push access, so pushes should be rejected as forbidden, and giving + # a reason is fine. + # + # We know for sure it is not an information leak since pulls using + # the build token must be allowed. + it "rejects pushes with 403 Forbidden" do + push_get(path, env) + + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_error(:upload)) end - it "downloads from other project get status 404" do - clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token + # We are "authenticated" as CI using a valid token here. But we are + # not authorized to see any other project, so return "not found". + it "rejects pulls for other project with 404 Not Found" do + clone_get("#{other_project.path_with_namespace}.git", env) - expect(response).to have_http_status(404) + expect(response).to have_http_status(:not_found) + expect(response.body).to eq(git_access_error(:project_not_found)) end end @@ -405,31 +534,27 @@ describe 'Git HTTP requests', lib: true do end shared_examples 'can download code only' do - it 'downloads get status 200' do - allow_any_instance_of(Repository). - to receive(:exists?).and_return(true) - - clone_get "#{project.path_with_namespace}.git", - user: 'gitlab-ci-token', password: build.token + let(:path) { "#{project.path_with_namespace}.git" } + let(:env) { { user: 'gitlab-ci-token', password: build.token } } - expect(response).to have_http_status(200) - expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) - end + it_behaves_like 'pulls are allowed' - it 'downloads from non-existing repository and gets 403' do - allow_any_instance_of(Repository). - to receive(:exists?).and_return(false) + context 'when the repo does not exist' do + let(:project) { create(:empty_project) } - clone_get "#{project.path_with_namespace}.git", - user: 'gitlab-ci-token', password: build.token + it 'rejects pulls with 403 Forbidden' do + clone_get path, env - expect(response).to have_http_status(403) + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_error(:no_repo)) + end end - it 'uploads get status 403' do - push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token + it 'rejects pushes with 403 Forbidden' do + push_get path, env - expect(response).to have_http_status(401) + expect(response).to have_http_status(:forbidden) + expect(response.body).to eq(git_access_error(:upload)) end end @@ -441,7 +566,7 @@ describe 'Git HTTP requests', lib: true do it 'downloads from other project get status 403' do clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token - expect(response).to have_http_status(403) + expect(response).to have_http_status(:forbidden) end end @@ -453,91 +578,93 @@ describe 'Git HTTP requests', lib: true do it 'downloads from other project get status 404' do clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token - expect(response).to have_http_status(404) + expect(response).to have_http_status(:not_found) end end end end end - end - context "when the project path doesn't end in .git" do - context "GET info/refs" do - let(:path) { "/#{project.path_with_namespace}/info/refs" } + context "when the project path doesn't end in .git" do + let(:project) { create(:project, :repository, :public, path: 'project.git-project') } + + context "GET info/refs" do + let(:path) { "/#{project.path_with_namespace}/info/refs" } - context "when no params are added" do - before { get path } + context "when no params are added" do + before { get path } - it "redirects to the .git suffix version" do - expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs") + it "redirects to the .git suffix version" do + expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs") + end end - end - context "when the upload-pack service is requested" do - let(:params) { { service: 'git-upload-pack' } } - before { get path, params } + context "when the upload-pack service is requested" do + let(:params) { { service: 'git-upload-pack' } } + before { get path, params } - it "redirects to the .git suffix version" do - expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}") + it "redirects to the .git suffix version" do + expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}") + end end - end - context "when the receive-pack service is requested" do - let(:params) { { service: 'git-receive-pack' } } - before { get path, params } + context "when the receive-pack service is requested" do + let(:params) { { service: 'git-receive-pack' } } + before { get path, params } - it "redirects to the .git suffix version" do - expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}") + it "redirects to the .git suffix version" do + expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}") + end end - end - context "when the params are anything else" do - let(:params) { { service: 'git-implode-pack' } } - before { get path, params } + context "when the params are anything else" do + let(:params) { { service: 'git-implode-pack' } } + before { get path, params } - it "redirects to the sign-in page" do - expect(response).to redirect_to(new_user_session_path) + it "redirects to the sign-in page" do + expect(response).to redirect_to(new_user_session_path) + end end end - end - context "POST git-upload-pack" do - it "fails to find a route" do - expect { clone_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError) + context "POST git-upload-pack" do + it "fails to find a route" do + expect { clone_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError) + end end - end - context "POST git-receive-pack" do - it "failes to find a route" do - expect { push_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError) + context "POST git-receive-pack" do + it "failes to find a route" do + expect { push_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError) + end end end - end - context "retrieving an info/refs file" do - before { project.update_attribute(:visibility_level, Project::PUBLIC) } + context "retrieving an info/refs file" do + let(:project) { create(:project, :repository, :public) } + + context "when the file exists" do + before do + # Provide a dummy file in its place + allow_any_instance_of(Repository).to receive(:blob_at).and_call_original + allow_any_instance_of(Repository).to receive(:blob_at).with('b83d6e391c22777fca1ed3012fce84f633d7fed0', 'info/refs') do + Gitlab::Git::Blob.find(project.repository, 'master', 'bar/branch-test.txt') + end - context "when the file exists" do - before do - # Provide a dummy file in its place - allow_any_instance_of(Repository).to receive(:blob_at).and_call_original - allow_any_instance_of(Repository).to receive(:blob_at).with('b83d6e391c22777fca1ed3012fce84f633d7fed0', 'info/refs') do - Gitlab::Git::Blob.find(project.repository, 'master', 'bar/branch-test.txt') + get "/#{project.path_with_namespace}/blob/master/info/refs" end - get "/#{project.path_with_namespace}/blob/master/info/refs" + it "returns the file" do + expect(response).to have_http_status(:ok) + end end - it "returns the file" do - expect(response).to have_http_status(200) - end - end + context "when the file does not exist" do + before { get "/#{project.path_with_namespace}/blob/master/info/refs" } - context "when the file does not exist" do - before { get "/#{project.path_with_namespace}/blob/master/info/refs" } - - it "returns not found" do - expect(response).to have_http_status(404) + it "returns not found" do + expect(response).to have_http_status(:not_found) + end end end end @@ -546,6 +673,7 @@ describe 'Git HTTP requests', lib: true do describe "User with LDAP identity" do let(:user) { create(:omniauth_user, extern_uid: dn) } let(:dn) { 'uid=john,ou=people,dc=example,dc=com' } + let(:path) { 'doesnt/exist.git' } before do allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true) @@ -553,44 +681,36 @@ describe 'Git HTTP requests', lib: true do allow(Gitlab::LDAP::Authentication).to receive(:login).with(user.username, user.password).and_return(user) end - context "when authentication fails" do - context "when no authentication is provided" do - it "responds with status 401" do - download('doesnt/exist.git') do |response| - expect(response).to have_http_status(401) - end - end - end - - context "when username and invalid password are provided" do - it "responds with status 401" do - download('doesnt/exist.git', user: user.username, password: "nope") do |response| - expect(response).to have_http_status(401) - end - end - end - end + it_behaves_like 'pulls require Basic HTTP Authentication' + it_behaves_like 'pushes require Basic HTTP Authentication' context "when authentication succeeds" do context "when the project doesn't exist" do - it "responds with status 404" do - download('/doesnt/exist.git', user: user.username, password: user.password) do |response| - expect(response).to have_http_status(404) + it "responds with status 404 Not Found" do + download(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(:not_found) end end end context "when the project exists" do - let(:project) { create(:project, path: 'project.git-project') } + let(:project) { create(:project, :repository) } + let(:path) { "#{project.full_path}.git" } + let(:env) { { user: user.username, password: user.password } } - before do - project.team << [user, :master] - end + context 'and the user is on the team' do + before do + project.team << [user, :master] + end - it "responds with status 200" do - clone_get(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(200) + it "responds with status 200" do + clone_get(path, env) do |response| + expect(response).to have_http_status(200) + end end + + it_behaves_like 'pulls are allowed' + it_behaves_like 'pushes are allowed' end end end diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb index 0c9b4121adf..697b150ab34 100644 --- a/spec/requests/lfs_http_spec.rb +++ b/spec/requests/lfs_http_spec.rb @@ -759,8 +759,8 @@ describe 'Git LFS API and storage' do context 'tries to push to own project' do let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } - it 'responds with 401' do - expect(response).to have_http_status(401) + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end @@ -769,8 +769,9 @@ describe 'Git LFS API and storage' do let(:pipeline) { create(:ci_empty_pipeline, project: other_project) } let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } - it 'responds with 401' do - expect(response).to have_http_status(401) + # I'm not sure what this tests that is different from the previous test + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end end @@ -778,8 +779,8 @@ describe 'Git LFS API and storage' do context 'does not have user' do let(:build) { create(:ci_build, :running, pipeline: pipeline) } - it 'responds with 401' do - expect(response).to have_http_status(401) + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end end @@ -979,8 +980,8 @@ describe 'Git LFS API and storage' do put_authorize end - it 'responds with 401' do - expect(response).to have_http_status(401) + it 'responds with 403 (not 404 because the build user can read the project)' do + expect(response).to have_http_status(403) end end @@ -993,8 +994,8 @@ describe 'Git LFS API and storage' do put_authorize end - it 'responds with 401' do - expect(response).to have_http_status(401) + it 'responds with 404 (do not leak non-public project existence)' do + expect(response).to have_http_status(404) end end end @@ -1006,8 +1007,8 @@ describe 'Git LFS API and storage' do put_authorize end - it 'responds with 401' do - expect(response).to have_http_status(401) + it 'responds with 404 (do not leak non-public project existence)' do + expect(response).to have_http_status(404) end end end @@ -1079,8 +1080,8 @@ describe 'Git LFS API and storage' do context 'tries to push to own project' do let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } - it 'responds with 401' do - expect(response).to have_http_status(401) + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end @@ -1089,8 +1090,9 @@ describe 'Git LFS API and storage' do let(:pipeline) { create(:ci_empty_pipeline, project: other_project) } let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } - it 'responds with 401' do - expect(response).to have_http_status(401) + # I'm not sure what this tests that is different from the previous test + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end end @@ -1098,8 +1100,8 @@ describe 'Git LFS API and storage' do context 'does not have user' do let(:build) { create(:ci_build, :running, pipeline: pipeline) } - it 'responds with 401' do - expect(response).to have_http_status(401) + it 'responds with 403 (not 404 because project is public)' do + expect(response).to have_http_status(403) end end end diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 033e6ecd18c..3c566c04d6b 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -161,15 +161,13 @@ describe Projects::CreateService, '#execute', services: true do end context 'when a bad service template is created' do - before do - create(:service, type: 'DroneCiService', project: nil, template: true, active: true) - end - it 'reports an error in the imported project' do opts[:import_url] = 'http://www.gitlab.com/gitlab-org/gitlab-ce' + create(:service, type: 'DroneCiService', project: nil, template: true, active: true) + project = create_project(user, opts) - expect(project.errors.full_messages_for(:base).first).to match /Unable to save project. Error: Unable to save DroneCiService/ + expect(project.errors.full_messages_for(:base).first).to match(/Unable to save project. Error: Unable to save DroneCiService/) expect(project.services.count).to eq 0 end end diff --git a/spec/support/git_http_helpers.rb b/spec/support/git_http_helpers.rb index 46b686fce94..b8289e6c5f1 100644 --- a/spec/support/git_http_helpers.rb +++ b/spec/support/git_http_helpers.rb @@ -35,9 +35,14 @@ module GitHttpHelpers yield response end + def download_or_upload(*args, &block) + download(*args, &block) + upload(*args, &block) + end + def auth_env(user, password, spnego_request_token) env = workhorse_internal_api_request_header - if user && password + if user env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, password) elsif spnego_request_token env['HTTP_AUTHORIZATION'] = "Negotiate #{::Base64.strict_encode64('opaque_request_token')}" @@ -45,4 +50,19 @@ module GitHttpHelpers env end + + def git_access_error(error_key) + message = Gitlab::GitAccess::ERROR_MESSAGES[error_key] + message || raise("GitAccess error message key '#{error_key}' not found") + end + + def git_access_wiki_error(error_key) + message = Gitlab::GitAccessWiki::ERROR_MESSAGES[error_key] + message || raise("GitAccessWiki error message key '#{error_key}' not found") + end + + def change_access_error(error_key) + message = Gitlab::Checks::ChangeAccess::ERROR_MESSAGES[error_key] + message || raise("ChangeAccess error message key '#{error_key}' not found") + end end diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb index 5e1cb74c7fc..6ea5569b438 100644 --- a/spec/workers/repository_fork_worker_spec.rb +++ b/spec/workers/repository_fork_worker_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe RepositoryForkWorker do - let(:project) { create(:project, :repository) } + let(:project) { create(:project, :repository, :import_scheduled) } let(:fork_project) { create(:project, :repository, forked_from_project: project) } let(:shell) { Gitlab::Shell.new } @@ -46,15 +46,27 @@ describe RepositoryForkWorker do end it "handles bad fork" do + source_path = project.full_path + target_path = fork_project.namespace.full_path + error_message = "Unable to fork project #{project.id} for repository #{source_path} -> #{target_path}" + expect(shell).to receive(:fork_repository).and_return(false) - expect(subject.logger).to receive(:error) + expect do + subject.perform(project.id, '/test/path', source_path, target_path) + end.to raise_error(RepositoryForkWorker::ForkError, error_message) + end - subject.perform( - project.id, - '/test/path', - project.full_path, - fork_project.namespace.full_path) + it 'handles unexpected error' do + source_path = project.full_path + target_path = fork_project.namespace.full_path + + allow_any_instance_of(Gitlab::Shell).to receive(:fork_repository).and_raise(RuntimeError) + + expect do + subject.perform(project.id, '/test/path', source_path, target_path) + end.to raise_error(RepositoryForkWorker::ForkError) + expect(project.reload.import_status).to eq('failed') end end end diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb index 5a2c0671dac..9c277c501f1 100644 --- a/spec/workers/repository_import_worker_spec.rb +++ b/spec/workers/repository_import_worker_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe RepositoryImportWorker do - let(:project) { create(:empty_project) } + let(:project) { create(:empty_project, :import_scheduled) } subject { described_class.new } @@ -21,15 +21,26 @@ describe RepositoryImportWorker do context 'when the import has failed' do it 'hide the credentials that were used in the import URL' do error = %q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found } - expect_any_instance_of(Projects::ImportService).to receive(:execute). - and_return({ status: :error, message: error }) - allow(subject).to receive(:jid).and_return('123') - subject.perform(project.id) + expect_any_instance_of(Projects::ImportService).to receive(:execute).and_return({ status: :error, message: error }) + allow(subject).to receive(:jid).and_return('123') - expect(project.reload.import_error).to include("https://*****:*****@test.com/root/repoC.git/") + expect do + subject.perform(project.id) + end.to raise_error(RepositoryImportWorker::ImportError, error) expect(project.reload.import_jid).not_to be_nil end end + + context 'with unexpected error' do + it 'marks import as failed' do + allow_any_instance_of(Projects::ImportService).to receive(:execute).and_raise(RuntimeError) + + expect do + subject.perform(project.id) + end.to raise_error(RepositoryImportWorker::ImportError) + expect(project.reload.import_status).to eq('failed') + end + end end end |