summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG5
-rw-r--r--Gemfile7
-rw-r--r--Gemfile.lock21
-rw-r--r--app/assets/stylesheets/pages/commits.scss4
-rw-r--r--app/assets/stylesheets/pages/notes.scss11
-rw-r--r--app/assets/stylesheets/pages/projects.scss3
-rw-r--r--app/assets/stylesheets/pages/tree.scss8
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/passwords_controller.rb21
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb49
-rw-r--r--app/controllers/sessions_controller.rb55
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/helpers/icons_helper.rb6
-rw-r--r--app/models/concerns/mentionable.rb2
-rw-r--r--app/models/note.rb243
-rw-r--r--app/models/user.rb15
-rw-r--r--app/services/issuable_base_service.rb6
-rw-r--r--app/services/issues/close_service.rb2
-rw-r--r--app/services/issues/reopen_service.rb2
-rw-r--r--app/services/merge_requests/base_service.rb2
-rw-r--r--app/services/merge_requests/refresh_service.rb5
-rw-r--r--app/services/system_note_service.rb303
-rw-r--r--app/views/devise/sessions/two_factor.html.haml10
-rw-r--r--app/views/layouts/nav/_profile.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml31
-rw-r--r--app/views/profiles/two_factor_auths/_codes.html.haml11
-rw-r--r--app/views/profiles/two_factor_auths/codes.html.haml5
-rw-r--r--app/views/profiles/two_factor_auths/create.html.haml6
-rw-r--r--app/views/profiles/two_factor_auths/new.html.haml23
-rw-r--r--app/views/projects/_aside.html.haml143
-rw-r--r--app/views/projects/_section.html.haml2
-rw-r--r--app/views/projects/commits/_head.html.haml7
-rw-r--r--app/views/projects/notes/_note.html.haml26
-rw-r--r--app/views/projects/tree/show.html.haml2
-rw-r--r--config/application.rb2
-rw-r--r--config/initializers/devise.rb5
-rw-r--r--config/routes.rb5
-rw-r--r--db/migrate/20150327223628_add_devise_two_factor_to_users.rb8
-rw-r--r--db/migrate/20150331183602_add_devise_two_factor_backupable_to_users.rb5
-rw-r--r--db/migrate/20150423033240_add_default_project_visibililty_to_application_settings.rb6
-rw-r--r--db/migrate/20150429002313_remove_abandoned_group_members_records.rb5
-rw-r--r--db/migrate/20150509180749_convert_legacy_reference_notes.rb16
-rw-r--r--db/schema.rb7
-rw-r--r--lib/tasks/brakeman.rake2
-rw-r--r--spec/controllers/profiles/two_factor_auths_controller_spec.rb126
-rw-r--r--spec/factories.rb7
-rw-r--r--spec/factories/notes.rb19
-rw-r--r--spec/features/login_spec.rb101
-rw-r--r--spec/features/password_reset_spec.rb53
-rw-r--r--spec/models/note_spec.rb535
-rw-r--r--spec/models/user_spec.rb5
-rw-r--r--spec/services/system_note_service_spec.rb346
52 files changed, 1460 insertions, 835 deletions
diff --git a/CHANGELOG b/CHANGELOG
index 5c4634fb151..7330f23501c 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -2,7 +2,9 @@ Please view this file on the master branch, on stable branches it's out of date.
v 7.11.0 (unreleased)
- Don't show duplicate deploy keys
+ - Fix commit time being displayed in the wrong timezone in some cases (Hannes Rosenögger)
- Make the first branch pushed to an empty repository the default HEAD (Stan Hu)
+ - Fix broken view when using a tag to display a tree that contains git submodules (Stan Hu)
- Make Reply-To config apply to change e-mail confirmation and other Devise notifications (Stan Hu)
- Add application setting to restrict user signups to e-mail domains (Stan Hu)
- Don't allow a merge request to be merged when its title starts with "WIP".
@@ -25,7 +27,7 @@ v 7.11.0 (unreleased)
- When use change branches link at MR form - save source branch selection instead of target one
- Improve handling of large diffs
- Added GitLab Event header for project hooks
- -
+ - Add Two-factor authentication (2FA) for GitLab logins
- Show Atom feed buttons everywhere where applicable.
- Add project activity atom feed.
- Don't crash when an MR from a fork has a cross-reference comment from the target project on one of its commits.
@@ -51,6 +53,7 @@ v 7.11.0 (unreleased)
- Add current_sign_in_at to UserFull REST api.
- Make Sidekiq MemoryKiller shutdown signal configurable
- Add "Create Merge Request" buttons to commits and branches pages and push event.
+ - Show user roles by comments.
v 7.10.2
- Fix CI links on MR page
diff --git a/Gemfile b/Gemfile
index fa89112a31d..c5d7089750e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -34,12 +34,17 @@ gem 'omniauth-bitbucket'
gem 'doorkeeper', '2.1.3'
gem "rack-oauth2", "~> 1.0.5"
+# Two-factor authentication
+gem 'devise-two-factor'
+gem 'rqrcode-rails3'
+gem 'attr_encrypted', '1.3.4'
+
# Browser detection
gem "browser"
# Extracting information from a git repository
# Provide access to Gitlab::Git library
-gem "gitlab_git", '~> 7.1.10'
+gem "gitlab_git", '~> 7.1.11'
# Ruby/Rack Git Smart-HTTP Server Handler
gem 'gitlab-grack', '~> 2.0.2', require: 'grack'
diff --git a/Gemfile.lock b/Gemfile.lock
index 6f58c4f4fda..9940ab15242 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -46,6 +46,8 @@ GEM
ast (2.0.0)
astrolabe (1.3.0)
parser (>= 2.2.0.pre.3, < 3.0)
+ attr_encrypted (1.3.4)
+ encryptor (>= 1.3.0)
attr_required (1.0.0)
autoprefixer-rails (5.1.11)
execjs
@@ -136,6 +138,13 @@ GEM
warden (~> 1.2.3)
devise-async (0.9.0)
devise (~> 3.2)
+ devise-two-factor (1.0.1)
+ activemodel
+ activesupport
+ attr_encrypted (~> 1.3.2)
+ devise (~> 3.2.4)
+ rails
+ rotp (~> 1.6.1)
diff-lcs (1.2.5)
diffy (3.0.3)
docile (1.1.5)
@@ -147,6 +156,7 @@ GEM
email_spec (1.5.0)
launchy (~> 2.1)
mail (~> 2.2)
+ encryptor (1.3.0)
enumerize (0.7.0)
activesupport (>= 3.2)
equalizer (0.0.8)
@@ -215,7 +225,7 @@ GEM
mime-types (~> 1.19)
gitlab_emoji (0.1.0)
gemojione (~> 2.0)
- gitlab_git (7.1.10)
+ gitlab_git (7.1.11)
activesupport (~> 4.0)
charlock_holmes (~> 0.6)
gitlab-linguist (~> 3.0)
@@ -482,7 +492,11 @@ GEM
rest-client (1.6.7)
mime-types (>= 1.16)
rinku (1.7.3)
+ rotp (1.6.1)
rouge (1.7.7)
+ rqrcode (0.4.2)
+ rqrcode-rails3 (0.1.7)
+ rqrcode (>= 0.4.2)
rspec (2.99.0)
rspec-core (~> 2.99.0)
rspec-expectations (~> 2.99.0)
@@ -670,6 +684,7 @@ DEPENDENCIES
annotate (~> 2.6.0.beta2)
asana (~> 0.0.6)
asciidoctor (= 0.1.4)
+ attr_encrypted (= 1.3.4)
awesome_print
better_errors
binding_of_caller
@@ -691,6 +706,7 @@ DEPENDENCIES
default_value_for (~> 3.0.0)
devise (= 3.2.4)
devise-async (= 0.9.0)
+ devise-two-factor
diffy (~> 3.0.3)
doorkeeper (= 2.1.3)
dropzonejs-rails
@@ -707,7 +723,7 @@ DEPENDENCIES
gitlab-grack (~> 2.0.2)
gitlab-linguist (~> 3.0.1)
gitlab_emoji (~> 0.1)
- gitlab_git (~> 7.1.10)
+ gitlab_git (~> 7.1.11)
gitlab_meta (= 7.0)
gitlab_omniauth-ldap (= 1.2.1)
gollum-lib (~> 4.0.2)
@@ -762,6 +778,7 @@ DEPENDENCIES
redis-rails
request_store
rerun (~> 0.10.0)
+ rqrcode-rails3
rspec-rails (= 2.99)
rubocop (= 0.28.0)
rugments
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 84361e15481..359f4073e87 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -29,10 +29,6 @@
.commits-feed-holder {
float: right;
-
- .btn {
- padding: 4px 12px;
- }
}
li.commit {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 589a43c4264..61b907e39be 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -44,6 +44,14 @@ ul.notes {
}
.author-username {
}
+
+ .note-role {
+ float: right;
+ margin-top: 2px;
+ border: 1px solid #bbb;
+ background-color: transparent;
+ color: #999;
+ }
}
.discussion {
@@ -136,6 +144,7 @@ ul.notes {
.note {
&.note:hover {
.note-actions { display: block; }
+ .note-actions + .note-role { display: none; }
}
.discussion-header:hover {
.discussion-actions { display: block; }
@@ -153,6 +162,8 @@ ul.notes {
}
a {
+ margin-left: 5px;
+
@extend .cgray;
&:hover {
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 5a8d4665294..224aea2db59 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -249,7 +249,8 @@ ul.nav.nav-projects-tabs {
}
.breadcrumb.repo-breadcrumb {
- padding: 2px 0;
+ padding: 0;
+ line-height: 34px;
background: white;
border: none;
font-size: 16px;
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index 57f63b52aa1..34ee4d7b31e 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -106,17 +106,9 @@
}
}
-.tree-download-holder .btn {
- padding: 4px 12px;
-}
-
.tree-ref-holder {
float: left;
margin-right: 15px;
-
- .select2-container .select2-choice, .select2-container.select2-drop-above .select2-choice {
- padding: 4px 12px;
- }
}
.readme-holder {
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index eee10d6c22a..8ce881c7414 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -252,7 +252,7 @@ class ApplicationController < ActionController::Base
end
def configure_permitted_parameters
- devise_parameter_sanitizer.sanitize(:sign_in) { |u| u.permit(:username, :email, :password, :login, :remember_me) }
+ devise_parameter_sanitizer.for(:sign_in) { |u| u.permit(:username, :email, :password, :login, :remember_me, :otp_attempt) }
end
def hexdigest(string)
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index dcbbe5baa4b..88459d4080a 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -15,4 +15,25 @@ class PasswordsController < Devise::PasswordsController
respond_with(resource)
end
end
+
+ # After a user resets their password, prompt for 2FA code if enabled instead
+ # of signing in automatically
+ #
+ # See http://git.io/vURrI
+ def update
+ super do |resource|
+ # TODO (rspeicher): In Devise master (> 3.4.1), we can set
+ # `Devise.sign_in_after_reset_password = false` and avoid this mess.
+ if resource.errors.empty? && resource.try(:otp_required_for_login?)
+ resource.unlock_access! if unlockable?(resource)
+
+ # Since we are not signing this user in, we use the :updated_not_active
+ # message which only contains "Your password was changed successfully."
+ set_flash_message(:notice, :updated_not_active) if is_flashing_format?
+
+ # Redirect to sign in so they can enter 2FA code
+ respond_with(resource, location: new_session_path(resource)) and return
+ end
+ end
+ end
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
new file mode 100644
index 00000000000..30ee6891733
--- /dev/null
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -0,0 +1,49 @@
+class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
+ def new
+ unless current_user.otp_secret
+ current_user.otp_secret = User.generate_otp_secret
+ current_user.save!
+ end
+
+ @qr_code = build_qr_code
+ end
+
+ def create
+ if current_user.valid_otp?(params[:pin_code])
+ current_user.otp_required_for_login = true
+ @codes = current_user.generate_otp_backup_codes!
+ current_user.save!
+
+ render 'create'
+ else
+ @error = 'Invalid pin code'
+ @qr_code = build_qr_code
+ render 'new'
+ end
+ end
+
+ def codes
+ @codes = current_user.generate_otp_backup_codes!
+ current_user.save!
+ end
+
+ def destroy
+ current_user.update_attributes({
+ otp_required_for_login: false,
+ encrypted_otp_secret: nil,
+ encrypted_otp_secret_iv: nil,
+ encrypted_otp_secret_salt: nil,
+ otp_backup_codes: nil
+ })
+
+ redirect_to profile_account_path
+ end
+
+ private
+
+ def build_qr_code
+ issuer = "GitLab | #{current_user.email}"
+ uri = current_user.otp_provisioning_uri(current_user.email, issuer: issuer)
+ RQRCode::render_qrcode(uri, :svg, level: :m, unit: 3)
+ end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 3f11d7afe6f..d4ff0d97561 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -1,4 +1,12 @@
class SessionsController < Devise::SessionsController
+ prepend_before_action :authenticate_with_two_factor, only: [:create]
+
+ # This action comes from DeviseController, but because we call `sign_in`
+ # manually inside `authenticate_with_two_factor`, not skipping this action
+ # would cause a "You are already signed in." error message to be shown upon
+ # successful login.
+ skip_before_action :require_no_authentication, only: [:create]
+
def new
redirect_path =
if request.referer.present? && (params['redirect_to_referer'] == 'yes')
@@ -14,7 +22,7 @@ class SessionsController < Devise::SessionsController
# Prevent a 'you are already signed in' message directly after signing:
# we should never redirect to '/users/sign_in' after signing in successfully.
- unless redirect_path == '/users/sign_in'
+ unless redirect_path == new_user_session_path
store_location_for(:redirect, redirect_path)
end
@@ -27,11 +35,54 @@ class SessionsController < Devise::SessionsController
def create
super do |resource|
- # User has successfully signed in, so clear any unused reset tokens
+ # User has successfully signed in, so clear any unused reset token
if resource.reset_password_token.present?
resource.update_attributes(reset_password_token: nil,
reset_password_sent_at: nil)
end
end
end
+
+ private
+
+ def user_params
+ params.require(:user).permit(:login, :password, :remember_me, :otp_attempt)
+ end
+
+ def find_user
+ if user_params[:login]
+ User.by_login(user_params[:login])
+ elsif user_params[:otp_attempt] && session[:otp_user_id]
+ User.find(session[:otp_user_id])
+ end
+ end
+
+ def authenticate_with_two_factor
+ user = self.resource = find_user
+
+ return unless user && user.otp_required_for_login
+
+ if user_params[:otp_attempt].present? && session[:otp_user_id]
+ if valid_otp_attempt?(user)
+ # Remove any lingering user data from login
+ session.delete(:otp_user_id)
+
+ sign_in(user) and return
+ else
+ flash.now[:alert] = 'Invalid two-factor code.'
+ render :two_factor and return
+ end
+ else
+ if user && user.valid_password?(user_params[:password])
+ # Save the user's ID to session so we can ask for a one-time password
+ session[:otp_user_id] = user.id
+ render :two_factor and return
+ end
+ end
+ end
+
+ def valid_otp_attempt?(user)
+ user.valid_otp?(user_params[:otp_attempt]) ||
+ user.invalidate_otp_backup_code!(user_params[:otp_attempt])
+ end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 6e86400a4f6..672be54e66f 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -214,7 +214,7 @@ module ApplicationHelper
def time_ago_with_tooltip(date, placement = 'top', html_class = 'time_ago')
capture_haml do
haml_tag :time, date.to_s,
- class: html_class, datetime: date.getutc.iso8601, title: date.stamp('Aug 21, 2011 9:23pm'),
+ class: html_class, datetime: date.getutc.iso8601, title: date.in_time_zone.stamp('Aug 21, 2011 9:23pm'),
data: { toggle: 'tooltip', placement: placement }
haml_tag :script, "$('." + html_class + "').timeago().tooltip()"
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index a9030729b48..a730684f8f3 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -26,15 +26,15 @@ module IconsHelper
end
def public_icon
- icon('globe')
+ icon('globe fw')
end
def internal_icon
- icon('shield')
+ icon('shield fw')
end
def private_icon
- icon('lock')
+ icon('lock fw')
end
def file_type_icon_class(type, mode, name)
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index 3ef3e8b67d8..a5957391bb7 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -39,7 +39,7 @@ module Mentionable
# Determine whether or not a cross-reference Note has already been created between this Mentionable and
# the specified target.
def has_mentioned?(target)
- Note.cross_reference_exists?(target, local_reference)
+ SystemNoteService.cross_reference_exists?(target, local_reference)
end
def mentioned_users(current_user = nil, p = project)
diff --git a/app/models/note.rb b/app/models/note.rb
index cbce6786683..6939a7e73a0 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -63,143 +63,9 @@ class Note < ActiveRecord::Base
after_update :set_references
class << self
- def create_status_change_note(noteable, project, author, status, source)
- body = "Status changed to #{status}#{' by ' + source.gfm_reference if source}"
-
- create(
- noteable: noteable,
- project: project,
- author: author,
- note: body,
- system: true
- )
- end
-
- # +noteable+ was referenced from +mentioner+, by including GFM in either
- # +mentioner+'s description or an associated Note.
- # Create a system Note associated with +noteable+ with a GFM back-reference
- # to +mentioner+.
- def create_cross_reference_note(noteable, mentioner, author)
- gfm_reference = mentioner_gfm_ref(noteable, mentioner)
-
- note_options = {
- project: noteable.project,
- author: author,
- note: cross_reference_note_content(gfm_reference),
- system: true
- }
-
- if noteable.kind_of?(Commit)
- note_options.merge!(noteable_type: 'Commit', commit_id: noteable.id)
- else
- note_options.merge!(noteable: noteable)
- end
-
- create(note_options) unless cross_reference_disallowed?(noteable, mentioner)
- end
-
- def create_milestone_change_note(noteable, project, author, milestone)
- body = if milestone.nil?
- 'Milestone removed'
- else
- "Milestone changed to #{milestone.title}"
- end
-
- create(
- noteable: noteable,
- project: project,
- author: author,
- note: body,
- system: true
- )
- end
-
- def create_assignee_change_note(noteable, project, author, assignee)
- body = assignee.nil? ? 'Assignee removed' : "Reassigned to @#{assignee.username}"
-
- create({
- noteable: noteable,
- project: project,
- author: author,
- note: body,
- system: true
- })
- end
-
- def create_labels_change_note(noteable, project, author, added_labels, removed_labels)
- labels_count = added_labels.count + removed_labels.count
- added_labels = added_labels.map{ |label| "~#{label.id}" }.join(' ')
- removed_labels = removed_labels.map{ |label| "~#{label.id}" }.join(' ')
- message = ''
-
- if added_labels.present?
- message << "added #{added_labels}"
- end
-
- if added_labels.present? && removed_labels.present?
- message << ' and '
- end
-
- if removed_labels.present?
- message << "removed #{removed_labels}"
- end
-
- message << ' ' << 'label'.pluralize(labels_count)
- body = "#{message.capitalize}"
-
- create(
- noteable: noteable,
- project: project,
- author: author,
- note: body,
- system: true
- )
- end
-
- def create_new_commits_note(merge_request, project, author, new_commits, existing_commits = [], oldrev = nil)
- total_count = new_commits.length + existing_commits.length
- commits_text = ActionController::Base.helpers.pluralize(total_count, 'commit')
- body = "Added #{commits_text}:\n\n"
-
- if existing_commits.length > 0
- commit_ids =
- if existing_commits.length == 1
- existing_commits.first.short_id
- else
- if oldrev
- "#{Commit.truncate_sha(oldrev)}...#{existing_commits.last.short_id}"
- else
- "#{existing_commits.first.short_id}..#{existing_commits.last.short_id}"
- end
- end
-
- commits_text = ActionController::Base.helpers.pluralize(existing_commits.length, 'commit')
-
- branch =
- if merge_request.for_fork?
- "#{merge_request.target_project_namespace}:#{merge_request.target_branch}"
- else
- merge_request.target_branch
- end
-
- message = "* #{commit_ids} - #{commits_text} from branch `#{branch}`"
- body << message
- body << "\n"
- end
-
- new_commits.each do |commit|
- message = "* #{commit.short_id} - #{commit.title}"
- body << message
- body << "\n"
- end
-
- create(
- noteable: merge_request,
- project: project,
- author: author,
- note: body,
- system: true
- )
+ # TODO (rspeicher): Update usages
+ def create_cross_reference_note(*args)
+ SystemNoteService.cross_reference(*args)
end
def discussions_from_notes(notes)
@@ -227,88 +93,19 @@ class Note < ActiveRecord::Base
[:discussion, type.try(:underscore), id, line_code].join("-").to_sym
end
- # Determine if cross reference note should be created.
- # eg. mentioning a commit in MR comments which exists inside a MR
- # should not create "mentioned in" note.
- def cross_reference_disallowed?(noteable, mentioner)
- if mentioner.kind_of?(MergeRequest)
- mentioner.commits.map(&:id).include? noteable.id
- end
- end
-
- # Determine whether or not a cross-reference note already exists.
- def cross_reference_exists?(noteable, mentioner)
- gfm_reference = mentioner_gfm_ref(noteable, mentioner, true)
- notes = if noteable.is_a?(Commit)
- where(commit_id: noteable.id, noteable_type: 'Commit')
- else
- where(noteable_id: noteable.id, noteable_type: noteable.class)
- end
-
- notes.where('note like ?', cross_reference_note_pattern(gfm_reference)).
- system.any?
- end
-
def search(query)
where("note like :query", query: "%#{query}%")
end
+ end
- def cross_reference_note_prefix
- 'mentioned in '
- end
-
- private
-
- def cross_reference_note_content(gfm_reference)
- cross_reference_note_prefix + "#{gfm_reference}"
- end
-
- def cross_reference_note_pattern(gfm_reference)
- # Older cross reference notes contained underscores for emphasis
- "%" + cross_reference_note_content(gfm_reference) + "%"
- end
-
- # Prepend the mentioner's namespaced project path to the GFM reference for
- # cross-project references. For same-project references, return the
- # unmodified GFM reference.
- def mentioner_gfm_ref(noteable, mentioner, cross_reference = false)
- if mentioner.is_a?(Commit) && cross_reference
- return mentioner.gfm_reference.sub('commit ', 'commit %')
- end
-
- full_gfm_reference(mentioner.project, noteable.project, mentioner)
- end
-
- # Return the +mentioner+ GFM reference. If the mentioner and noteable
- # projects are not the same, add the mentioning project's path to the
- # returned value.
- def full_gfm_reference(mentioning_project, noteable_project, mentioner)
- if mentioning_project == noteable_project
- mentioner.gfm_reference
- else
- if mentioner.is_a?(Commit)
- mentioner.gfm_reference.sub(
- /(commit )/,
- "\\1#{mentioning_project.path_with_namespace}@"
- )
- else
- mentioner.gfm_reference.sub(
- /(issue |merge request )/,
- "\\1#{mentioning_project.path_with_namespace}"
- )
- end
- end
- end
+ def cross_reference?
+ system && SystemNoteService.cross_reference?(note)
end
def max_attachment_size
current_application_settings.max_attachment_size.megabytes.to_i
end
- def cross_reference?
- note.start_with?(self.class.cross_reference_note_prefix)
- end
-
def find_diff
return nil unless noteable && noteable.diffs.present?
@@ -449,16 +246,6 @@ class Note < ActiveRecord::Base
@discussion_id ||= Note.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
end
- # Returns true if this is a downvote note,
- # otherwise false is returned
- def downvote?
- votable? && (note.start_with?('-1') ||
- note.start_with?(':-1:') ||
- note.start_with?(':thumbsdown:') ||
- note.start_with?(':thumbs_down_sign:')
- )
- end
-
def for_commit?
noteable_type == "Commit"
end
@@ -500,14 +287,18 @@ class Note < ActiveRecord::Base
nil
end
- # Returns true if this is an upvote note,
- # otherwise false is returned
+ DOWNVOTES = %w(-1 :-1: :thumbsdown: :thumbs_down_sign:)
+
+ # Check if the note is a downvote
+ def downvote?
+ votable? && note.start_with?(*DOWNVOTES)
+ end
+
+ UPVOTES = %w(+1 :+1: :thumbsup: :thumbs_up_sign:)
+
+ # Check if the note is an upvote
def upvote?
- votable? && (note.start_with?('+1') ||
- note.start_with?(':+1:') ||
- note.start_with?(':thumbsup:') ||
- note.start_with?(':thumbs_up_sign:')
- )
+ votable? && note.start_with?(*UPVOTES)
end
def superceded?(notes)
diff --git a/app/models/user.rb b/app/models/user.rb
index a70cbaa518b..d088d2d8630 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -50,6 +50,11 @@
# bitbucket_access_token :string(255)
# bitbucket_access_token_secret :string(255)
# location :string(255)
+# encrypted_otp_secret :string(255)
+# encrypted_otp_secret_iv :string(255)
+# encrypted_otp_secret_salt :string(255)
+# otp_required_for_login :boolean
+# otp_backup_codes :text
# public_email :string(255) default(""), not null
#
@@ -70,8 +75,14 @@ class User < ActiveRecord::Base
default_value_for :hide_no_password, false
default_value_for :theme_id, gitlab_config.default_theme
- devise :database_authenticatable, :lockable, :async,
- :recoverable, :rememberable, :trackable, :validatable, :omniauthable, :confirmable, :registerable
+ devise :two_factor_authenticatable,
+ otp_secret_encryption_key: File.read(Rails.root.join('.secret')).chomp
+
+ devise :two_factor_backupable, otp_number_of_backup_codes: 10
+ serialize :otp_backup_codes, JSON
+
+ devise :lockable, :async, :recoverable, :rememberable, :trackable,
+ :validatable, :omniauthable, :confirmable, :registerable
attr_accessor :force_random_password
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 5e1906ad2ae..8960235b093 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -2,17 +2,17 @@ class IssuableBaseService < BaseService
private
def create_assignee_note(issuable)
- Note.create_assignee_change_note(
+ SystemNoteService.change_assignee(
issuable, issuable.project, current_user, issuable.assignee)
end
def create_milestone_note(issuable)
- Note.create_milestone_change_note(
+ SystemNoteService.change_milestone(
issuable, issuable.project, current_user, issuable.milestone)
end
def create_labels_note(issuable, added_labels, removed_labels)
- Note.create_labels_change_note(
+ SystemNoteService.change_label(
issuable, issuable.project, current_user, added_labels, removed_labels)
end
end
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index f670019cc63..138465859ce 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -14,7 +14,7 @@ module Issues
private
def create_note(issue, current_commit)
- Note.create_status_change_note(issue, issue.project, current_user, issue.state, current_commit)
+ SystemNoteService.change_status(issue, issue.project, current_user, issue.state, current_commit)
end
end
end
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index 1e5c398516d..e48ca359f4f 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -14,7 +14,7 @@ module Issues
private
def create_note(issue)
- Note.create_status_change_note(issue, issue.project, current_user, issue.state, nil)
+ SystemNoteService.change_status(issue, issue.project, current_user, issue.state, nil)
end
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index f6e1ae6f283..e455fe95791 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -2,7 +2,7 @@ module MergeRequests
class BaseService < ::IssuableBaseService
def create_note(merge_request)
- Note.create_status_change_note(merge_request, merge_request.target_project, current_user, merge_request.state, nil)
+ SystemNoteService.change_status(merge_request, merge_request.target_project, current_user, merge_request.state, nil)
end
def hook_data(merge_request, action)
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index e9b526d1fb7..66610a08a44 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -82,8 +82,9 @@ module MergeRequests
mr_commit_ids.include?(commit.id)
end
- Note.create_new_commits_note(merge_request, merge_request.project,
- @current_user, new_commits, existing_commits, @oldrev)
+ SystemNoteService.add_commits(merge_request, merge_request.project,
+ @current_user, new_commits,
+ existing_commits, @oldrev)
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
new file mode 100644
index 00000000000..0614f8689a4
--- /dev/null
+++ b/app/services/system_note_service.rb
@@ -0,0 +1,303 @@
+# SystemNoteService
+#
+# Used for creating system notes (e.g., when a user references a merge request
+# from an issue, an issue's assignee changes, an issue is closed, etc.)
+class SystemNoteService
+ # Called when commits are added to a Merge Request
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # new_commits - Array of Commits added since last push
+ # existing_commits - Array of Commits added in a previous push
+ # oldrev - TODO (rspeicher): I have no idea what this actually does
+ #
+ # See new_commit_summary and existing_commit_summary.
+ #
+ # Returns the created Note object
+ def self.add_commits(noteable, project, author, new_commits, existing_commits = [], oldrev = nil)
+ total_count = new_commits.length + existing_commits.length
+ commits_text = "#{total_count} commit".pluralize(total_count)
+
+ body = "Added #{commits_text}:\n\n"
+ body << existing_commit_summary(noteable, existing_commits, oldrev)
+ body << new_commit_summary(new_commits).join("\n")
+
+ create_note(noteable: noteable, project: project, author: author, note: body)
+ end
+
+ # Called when the assignee of a Noteable is changed or removed
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # assignee - User being assigned, or nil
+ #
+ # Example Note text:
+ #
+ # "Assignee removed"
+ #
+ # "Reassigned to @rspeicher"
+ #
+ # Returns the created Note object
+ def self.change_assignee(noteable, project, author, assignee)
+ body = assignee.nil? ? 'Assignee removed' : "Reassigned to @#{assignee.username}"
+
+ create_note(noteable: noteable, project: project, author: author, note: body)
+ end
+
+ # Called when one or more labels on a Noteable are added and/or removed
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # added_labels - Array of Labels added
+ # removed_labels - Array of Labels removed
+ #
+ # Example Note text:
+ #
+ # "Added ~1 and removed ~2 ~3 labels"
+ #
+ # "Added ~4 label"
+ #
+ # "Removed ~5 label"
+ #
+ # Returns the created Note object
+ def self.change_label(noteable, project, author, added_labels, removed_labels)
+ labels_count = added_labels.count + removed_labels.count
+
+ references = ->(label) { "~#{label.id}" }
+ added_labels = added_labels.map(&references).join(' ')
+ removed_labels = removed_labels.map(&references).join(' ')
+
+ body = ''
+
+ if added_labels.present?
+ body << "added #{added_labels}"
+ body << ' and ' if removed_labels.present?
+ end
+
+ if removed_labels.present?
+ body << "removed #{removed_labels}"
+ end
+
+ body << ' ' << 'label'.pluralize(labels_count)
+ body = "#{body.capitalize}"
+
+ create_note(noteable: noteable, project: project, author: author, note: body)
+ end
+
+ # Called when the milestone of a Noteable is changed
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # milestone - Milestone being assigned, or nil
+ #
+ # Example Note text:
+ #
+ # "Milestone removed"
+ #
+ # "Miletone changed to 7.11"
+ #
+ # Returns the created Note object
+ def self.change_milestone(noteable, project, author, milestone)
+ body = 'Milestone '
+ body += milestone.nil? ? 'removed' : "changed to #{milestone.title}"
+
+ create_note(noteable: noteable, project: project, author: author, note: body)
+ end
+
+ # Called when the status of a Noteable is changed
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # status - String status
+ # source - Mentionable performing the change, or nil
+ #
+ # Example Note text:
+ #
+ # "Status changed to merged"
+ #
+ # "Status changed to closed by bc17db76"
+ #
+ # Returns the created Note object
+ def self.change_status(noteable, project, author, status, source)
+ body = "Status changed to #{status}"
+ body += " by #{source.gfm_reference}" if source
+
+ create_note(noteable: noteable, project: project, author: author, note: body)
+ end
+
+ # Called when a Mentionable references a Noteable
+ #
+ # noteable - Noteable object being referenced
+ # mentioner - Mentionable object
+ # author - User performing the reference
+ #
+ # Example Note text:
+ #
+ # "Mentioned in #1"
+ #
+ # "Mentioned in !2"
+ #
+ # "Mentioned in 54f7727c"
+ #
+ # See cross_reference_note_content.
+ #
+ # Returns the created Note object
+ def self.cross_reference(noteable, mentioner, author)
+ return if cross_reference_disallowed?(noteable, mentioner)
+
+ gfm_reference = mentioner_gfm_ref(noteable, mentioner)
+
+ note_options = {
+ project: noteable.project,
+ author: author,
+ note: cross_reference_note_content(gfm_reference)
+ }
+
+ if noteable.kind_of?(Commit)
+ note_options.merge!(noteable_type: 'Commit', commit_id: noteable.id)
+ else
+ note_options.merge!(noteable: noteable)
+ end
+
+ create_note(note_options)
+ end
+
+ def self.cross_reference?(note_text)
+ note_text.start_with?(cross_reference_note_prefix)
+ end
+
+ # Check if a cross-reference is disallowed
+ #
+ # This method prevents adding a "mentioned in !1" note on every single commit
+ # in a merge request.
+ #
+ # noteable - Noteable object being referenced
+ # mentioner - Mentionable object
+ #
+ # Returns Boolean
+ def self.cross_reference_disallowed?(noteable, mentioner)
+ return false unless MergeRequest === mentioner
+ return false unless Commit === noteable
+
+ mentioner.commits.include?(noteable)
+ end
+
+ def self.cross_reference_exists?(noteable, mentioner)
+ # Initial scope should be system notes of this noteable type
+ notes = Note.system.where(noteable_type: noteable.class)
+
+ if noteable.is_a?(Commit)
+ # Commits have non-integer IDs, so they're stored in `commit_id`
+ notes = notes.where(commit_id: noteable.id)
+ else
+ notes = notes.where(noteable_id: noteable.id)
+ end
+
+ gfm_reference = mentioner_gfm_ref(noteable, mentioner, true)
+ notes = notes.where(note: cross_reference_note_content(gfm_reference))
+
+ notes.count > 0
+ end
+
+ private
+
+ def self.create_note(args = {})
+ Note.create(args.merge(system: true))
+ end
+
+ # Prepend the mentioner's namespaced project path to the GFM reference for
+ # cross-project references. For same-project references, return the
+ # unmodified GFM reference.
+ def self.mentioner_gfm_ref(noteable, mentioner, cross_reference = false)
+ # FIXME (rspeicher): This was breaking things.
+ # if mentioner.is_a?(Commit) && cross_reference
+ # return mentioner.gfm_reference.sub('commit ', 'commit %')
+ # end
+
+ full_gfm_reference(mentioner.project, noteable.project, mentioner)
+ end
+
+ # Return the +mentioner+ GFM reference. If the mentioner and noteable
+ # projects are not the same, add the mentioning project's path to the
+ # returned value.
+ def self.full_gfm_reference(mentioning_project, noteable_project, mentioner)
+ if mentioning_project == noteable_project
+ mentioner.gfm_reference
+ else
+ if mentioner.is_a?(Commit)
+ mentioner.gfm_reference.sub(
+ /(commit )/,
+ "\\1#{mentioning_project.path_with_namespace}@"
+ )
+ else
+ mentioner.gfm_reference.sub(
+ /(issue |merge request )/,
+ "\\1#{mentioning_project.path_with_namespace}"
+ )
+ end
+ end
+ end
+
+ def self.cross_reference_note_prefix
+ 'mentioned in '
+ end
+
+ def self.cross_reference_note_content(gfm_reference)
+ "#{cross_reference_note_prefix}#{gfm_reference}"
+ end
+
+ # Build an Array of lines detailing each commit added in a merge request
+ #
+ # new_commits - Array of new Commit objects
+ #
+ # Returns an Array of Strings
+ def self.new_commit_summary(new_commits)
+ new_commits.collect do |commit|
+ "* #{commit.short_id} - #{commit.title}"
+ end
+ end
+
+ # Build a single line summarizing existing commits being added in a merge
+ # request
+ #
+ # noteable - MergeRequest object
+ # existing_commits - Array of existing Commit objects
+ # oldrev - Optional String SHA of ... TODO (rspeicher): I have no idea what this actually does.
+ #
+ # Examples:
+ #
+ # "* ea0f8418...2f4426b7 - 24 commits from branch `master`"
+ #
+ # "* ea0f8418..4188f0ea - 15 commits from branch `fork:master`"
+ #
+ # "* ea0f8418 - 1 commit from branch `feature`"
+ #
+ # Returns a newline-terminated String
+ def self.existing_commit_summary(noteable, existing_commits, oldrev = nil)
+ return '' if existing_commits.empty?
+
+ count = existing_commits.size
+
+ commit_ids = if count == 1
+ existing_commits.first.short_id
+ else
+ if oldrev
+ "#{Commit.truncate_sha(oldrev)}...#{existing_commits.last.short_id}"
+ else
+ "#{existing_commits.first.short_id}..#{existing_commits.last.short_id}"
+ end
+ end
+
+ commits_text = "#{count} commit".pluralize(count)
+
+ branch = noteable.target_branch
+ branch = "#{noteable.target_project_namespace}:#{branch}" if noteable.for_fork?
+
+ "* #{commit_ids} - #{commits_text} from branch `#{branch}`\n"
+ end
+end
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
new file mode 100644
index 00000000000..22b2c1a186b
--- /dev/null
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -0,0 +1,10 @@
+%div
+ .login-box
+ .login-heading
+ %h3 Two-factor Authentication
+ .login-body
+ = form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
+ = f.text_field :otp_attempt, class: 'form-control', placeholder: 'Two-factor authentication code', required: true, autofocus: true
+ %p.help-block.hint If you've lost your phone, you may enter one of your recovery codes.
+ .prepend-top-20
+ = f.submit "Verify code", class: "btn btn-save"
diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml
index 31d8ed3ed86..ac37fd4c1c1 100644
--- a/app/views/layouts/nav/_profile.html.haml
+++ b/app/views/layouts/nav/_profile.html.haml
@@ -4,7 +4,7 @@
= icon('user fw')
%span
Profile
- = nav_link(controller: :accounts) do
+ = nav_link(controller: [:accounts, :two_factor_auths]) do
= link_to profile_account_path, title: 'Account', data: {placement: 'right'} do
= icon('gear fw')
%span
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index 1c3a3d68aca..6ac60b01f85 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -26,6 +26,33 @@
%span You don`t have one yet. Click generate to fix it.
= f.submit 'Generate', class: "btn success btn-build-token"
+ - unless current_user.ldap_user?
+ %fieldset
+ - if current_user.otp_required_for_login
+ %legend.text-success
+ = icon('check')
+ Two-factor Authentication enabled
+ %div
+ .pull-right
+ = link_to 'Disable Two-factor Authentication', profile_two_factor_auth_path, method: :delete, class: 'btn btn-close btn-sm',
+ data: { confirm: 'Are you sure?' }
+ %p
+ If you lose your recovery codes you can
+ %strong
+ = succeed ',' do
+ = link_to 'generate new ones', codes_profile_two_factor_auth_path, method: :post, data: { confirm: 'Are you sure?' }
+ invalidating all previous codes.
+
+ - else
+ %legend Two-factor Authentication
+ %div
+ %p
+ Increase your account's security by enabling two-factor authentication (2FA).
+ %p
+ Each time you log in you’ll be required to provide your username and
+ password as usual, plus a randomly-generated code from your phone.
+ %div
+ = link_to 'Enable Two-factor Authentication', new_profile_two_factor_auth_path, class: 'btn btn-success'
- if show_profile_social_tab?
%fieldset
@@ -38,7 +65,7 @@
class: "btn btn-lg #{'active' if oauth_active?(provider)}"
- if oauth_active?(provider)
= link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'btn btn-lg' do
- %i.fa.fa-close
+ = icon('close')
- if show_profile_username_tab?
%fieldset.update-username
@@ -52,7 +79,7 @@
&nbsp;
.loading-gif.hide
%p
- %i.fa.fa-spinner.fa-spin
+ = icon('spinner spin')
Saving new username
%p.light
= user_url(@user)
diff --git a/app/views/profiles/two_factor_auths/_codes.html.haml b/app/views/profiles/two_factor_auths/_codes.html.haml
new file mode 100644
index 00000000000..1b1395eaa17
--- /dev/null
+++ b/app/views/profiles/two_factor_auths/_codes.html.haml
@@ -0,0 +1,11 @@
+%p.slead
+ Should you ever lose your phone, each of these recovery codes can be used one
+ time each to regain access to your account. Please save them in a safe place.
+
+.codes.well
+ %ul
+ - @codes.each do |code|
+ %li
+ %span.monospace= code
+
+= link_to 'Proceed', profile_account_path, class: 'btn btn-success'
diff --git a/app/views/profiles/two_factor_auths/codes.html.haml b/app/views/profiles/two_factor_auths/codes.html.haml
new file mode 100644
index 00000000000..addf356697a
--- /dev/null
+++ b/app/views/profiles/two_factor_auths/codes.html.haml
@@ -0,0 +1,5 @@
+- page_title 'Recovery Codes', 'Two-factor Authentication'
+
+%h3.page-title Two-factor Authentication Recovery codes
+%hr
+= render 'codes'
diff --git a/app/views/profiles/two_factor_auths/create.html.haml b/app/views/profiles/two_factor_auths/create.html.haml
new file mode 100644
index 00000000000..e330aadac13
--- /dev/null
+++ b/app/views/profiles/two_factor_auths/create.html.haml
@@ -0,0 +1,6 @@
+- page_title 'Two-factor Authentication', 'Account'
+
+.alert.alert-success
+ Congratulations! You have enabled Two-factor Authentication!
+
+= render 'codes'
diff --git a/app/views/profiles/two_factor_auths/new.html.haml b/app/views/profiles/two_factor_auths/new.html.haml
new file mode 100644
index 00000000000..fe03a259a12
--- /dev/null
+++ b/app/views/profiles/two_factor_auths/new.html.haml
@@ -0,0 +1,23 @@
+- page_title 'Two-factor Authentication', 'Account'
+
+%h2.page-title Two-Factor Authentication (2FA)
+%p
+ Download the Google Authenticator application from App Store for iOS or
+ Google Play for Android and scan this code.
+
+%hr
+
+= form_tag profile_two_factor_auth_path, method: :post, class: 'form-horizontal' do |f|
+ - if @error
+ .alert.alert-danger
+ = @error
+ .form-group
+ .col-sm-2
+ .col-sm-10
+ = raw @qr_code
+ .form-group
+ = label_tag :pin_code, nil, class: "control-label"
+ .col-sm-10
+ = text_field_tag :pin_code, nil, class: "form-control", required: true, autofocus: true
+ .form-actions
+ = submit_tag 'Submit', class: 'btn btn-success'
diff --git a/app/views/projects/_aside.html.haml b/app/views/projects/_aside.html.haml
index 1241b51f9ac..c2f56996ba8 100644
--- a/app/views/projects/_aside.html.haml
+++ b/app/views/projects/_aside.html.haml
@@ -1,85 +1,102 @@
.clearfix
- .append-bottom-20
- = render "shared/clone_panel"
-
- unless @project.empty_repo?
.well
%h4.visibility-level-label
= visibility_level_icon(@project.visibility_level)
= "#{visibility_level_label(@project.visibility_level).capitalize} project"
- %ul.nav.nav-pills
- %li= link_to pluralize(number_with_delimiter(@repository.commit_count), 'commit'), namespace_project_commits_path(@project.namespace, @project, @ref || @repository.root_ref)
- %li= link_to pluralize(number_with_delimiter(@repository.branch_names.count), 'branch'), namespace_project_branches_path(@project.namespace, @project)
- %li= link_to pluralize(number_with_delimiter(@repository.tag_names.count), 'tag'), namespace_project_tags_path(@project.namespace, @project)
- .actions
- = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: @ref || @repository.root_ref), class: 'btn btn-sm' do
- %i.fa.fa-exchange
- Compare code
+ - if @repository.changelog || @repository.license || @repository.contribution_guide
+ %ul.nav.nav-pills
+ - if @repository.changelog
+ %li.hidden-xs
+ = link_to changelog_url(@project) do
+ = icon("list-alt fw")
+ Changelog
+ - if @repository.license
+ %li
+ = link_to license_url(@project) do
+ = icon("check-circle-o fw")
+ License
+ - if @repository.contribution_guide
+ %li
+ = link_to contribution_guide_url(@project) do
+ = icon("info-circle fw")
+ Contribution guide
- - if can?(current_user, :download_code, @project)
- &nbsp;
- = render 'projects/repositories/download_archive', split_button: true, btn_class: 'btn-group-sm'
-
- - unless @project.empty_repo?
- .well
- %h4 Contribute
- %ul.nav.nav-pills
- - if @repository.changelog
- %li.hidden-xs
- = link_to changelog_url(@project) do
- Changelog
- - if @repository.contribution_guide
- %li.hidden-xs
- = link_to contribution_guide_url(@project) do
- Contribution guide
- - if @repository.license
- %li
- = link_to license_url(@project) do
- License
.actions
- = link_to url_for_new_issue(@project, only_path: true), title: "New Issue", class: 'btn btn-sm' do
- %i.fa.fa-fw.fa-exclamation-circle
- New issue
+ - if can? current_user, :write_issue, @project
+ = link_to url_for_new_issue(@project, only_path: true), title: "New Issue", class: 'btn btn-sm append-right-10' do
+ = icon("exclamation-circle fw")
+ New Issue
+
- if can? current_user, :write_merge_request, @project
- &nbsp;
= link_to new_namespace_project_merge_request_path(@project.namespace, @project), class: "btn btn-sm", title: "New Merge Request" do
- %i.fa.fa-plus
+ = icon("plus fw")
New Merge Request
-
-
- - if @project.archived?
- .alert.alert-warning
- %h4
- %i.fa.fa-exclamation-triangle
- Archived project!
- %p Repository is read-only
-
- - if @project.forked_from_project
+ - if forked_from_project = @project.forked_from_project
.well
%h4
+ = icon("code-fork fw")
Forked from
.pull-right
- = link_to @project.forked_from_project.namespace.try(:name), project_path(@project.forked_from_project)
+ = link_to forked_from_project.namespace.try(:name), project_path(forked_from_project)
+ - if version = @repository.version
+ .well
+ %h4
+ = icon("clock-o fw")
+ Version
+ .pull-right
+ = link_to version_url(@project) do
+ = @repository.blob_by_oid(version.id).data
-- if version = @repository.version
- .well
- %h4
- Version
- .pull-right
- = link_to version_url(@project) do
- = @repository.blob_by_oid(version.id).data
+ - @project.ci_services.each do |ci_service|
+ - if ci_service.active? && ci_service.respond_to?(:builds_path)
+ .well
+ %h4
+ = icon("check fw")
+ = ci_service.title
+ .pull-right
+ - if ci_service.respond_to?(:status_img_path)
+ = link_to ci_service.builds_path, :'data-no-turbolink' => 'data-no-turbolink' do
+ = image_tag ci_service.status_img_path, alt: "build status"
+ - else
+ = link_to 'view builds', ci_service.builds_path, :'data-no-turbolink' => 'data-no-turbolink'
-- @project.ci_services.each do |ci_service|
- - if ci_service.active? && ci_service.respond_to?(:builds_path)
+ - unless @project.empty_repo?
.well
%h4
- = ci_service.title
- .pull-right
- - if ci_service.respond_to?(:status_img_path)
- = link_to ci_service.builds_path, :'data-no-turbolink' => 'data-no-turbolink' do
- = image_tag ci_service.status_img_path, alt: "build status"
- - else
- = link_to 'view builds', ci_service.builds_path, :'data-no-turbolink' => 'data-no-turbolink'
+ = icon("archive fw")
+ Repository
+
+ %ul.nav.nav-pills
+ %li
+ = link_to namespace_project_commits_path(@project.namespace, @project, @ref || @repository.root_ref) do
+ = icon("history fw")
+ = pluralize(number_with_delimiter(@repository.commit_count), 'commit')
+ %li
+ = link_to namespace_project_branches_path(@project.namespace, @project) do
+ = icon("code-fork fw")
+ = pluralize(number_with_delimiter(@repository.branch_names.count), 'branch')
+ %li
+ = link_to namespace_project_tags_path(@project.namespace, @project) do
+ = icon("tags fw")
+ = pluralize(number_with_delimiter(@repository.tag_names.count), 'tag')
+
+ .actions
+ = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: @ref || @repository.root_ref), class: 'btn btn-sm append-right-10' do
+ %i.fa.fa-exchange
+ Compare code
+
+ - if can?(current_user, :download_code, @project)
+ = render 'projects/repositories/download_archive', split_button: true, btn_class: 'btn-group-sm'
+
+ = render "shared/clone_panel"
+
+ - if @project.archived?
+ .alert.alert-warning
+ %h4
+ = icon("exclamation-triangle fw")
+ Archived project!
+ %p Repository is read-only
diff --git a/app/views/projects/_section.html.haml b/app/views/projects/_section.html.haml
index 0b7f4cb780a..f4f876f3809 100644
--- a/app/views/projects/_section.html.haml
+++ b/app/views/projects/_section.html.haml
@@ -1,10 +1,12 @@
%ul.nav.nav-tabs
%li.active
= link_to '#tab-activity', 'data-toggle' => 'tab' do
+ = icon("tachometer")
Activity
- if @repository.readme
%li
= link_to '#tab-readme', 'data-toggle' => 'tab' do
+ = icon("file-text-o")
Readme
.tab-content
.tab-pane.active#tab-activity
diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml
index 66101f3f0da..66261c7336d 100644
--- a/app/views/projects/commits/_head.html.haml
+++ b/app/views/projects/commits/_head.html.haml
@@ -1,17 +1,22 @@
%ul.nav.nav-tabs
= nav_link(controller: [:commit, :commits]) do
= link_to namespace_project_commits_path(@project.namespace, @project, @ref || @repository.root_ref) do
+ = icon("history")
Commits
%span.badge= number_with_precision(@repository.commit_count, precision: 0, delimiter: ',')
= nav_link(controller: :compare) do
- = link_to 'Compare', namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: @ref || @repository.root_ref)
+ = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: @ref || @repository.root_ref) do
+ = icon("exchange")
+ Compare
= nav_link(html_options: {class: branches_tab_class}) do
= link_to namespace_project_branches_path(@project.namespace, @project) do
+ = icon("code-fork")
Branches
%span.badge.js-totalbranch-count= @repository.branches.size
= nav_link(controller: :tags) do
= link_to namespace_project_tags_path(@project.namespace, @project) do
+ = icon("tags")
Tags
%span.badge.js-totaltags-count= @repository.tags.length
diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml
index 4d26b52df01..2f3c407d6e9 100644
--- a/app/views/projects/notes/_note.html.haml
+++ b/app/views/projects/notes/_note.html.haml
@@ -9,22 +9,30 @@
.timeline-content
.note-header
.note-actions
- = link_to "##{dom_id(note)}", name: dom_id(note) do
- = icon('link')
+ = link_to "##{dom_id(note)}", name: dom_id(note), title: "Link here" do
+ = icon('link fw')
Link here
- &nbsp;
+
- if note_editable?(note)
= link_to '#', title: 'Edit comment', class: 'js-note-edit' do
- = icon('pencil-square-o')
+ = icon('pencil-square-o fw')
Edit
- &nbsp;
- = link_to namespace_project_note_path(@project.namespace, @project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'danger js-note-delete' do
- = icon('trash-o', class: 'cred')
+
+ = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'danger js-note-delete' do
+ = icon('trash-o fw', class: 'cred')
Remove
+
+ - unless note.system
+ - member = note.project.team.find_member(note.author.id)
+ - if member
+ %span.note-role.label
+ = member.human_access
+
- if note.system
= link_to user_path(note.author) do
= image_tag avatar_icon(note.author_email), class: 'avatar s16', alt: ''
- = link_to_member(@project, note.author, avatar: false)
+
+ = link_to_member(note.project, note.author, avatar: false)
%span.author-username
= '@' + note.author.username
%span.note-last-update
@@ -65,7 +73,7 @@
= link_to note.attachment.url, target: '_blank' do
= icon('paperclip')
= note.attachment_identifier
- = link_to delete_attachment_namespace_project_note_path(@project.namespace, @project, note),
+ = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note),
title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do
= icon('trash-o', class: 'cred')
.clear
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 72916cad182..04590f65b27 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -8,7 +8,7 @@
- if can? current_user, :download_code, @project
.tree-download-holder
- = render 'projects/repositories/download_archive', ref: @ref, btn_class: 'btn-group-sm pull-right hidden-xs hidden-sm', split_button: true
+ = render 'projects/repositories/download_archive', ref: @ref, btn_class: 'btn-group pull-right hidden-xs hidden-sm', split_button: true
#tree-holder.tree-holder.clearfix
= render "tree", tree: @tree
diff --git a/config/application.rb b/config/application.rb
index fa399533e52..7e899cc3b5b 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -31,7 +31,7 @@ module Gitlab
config.encoding = "utf-8"
# Configure sensitive parameters which will be filtered from the log file.
- config.filter_parameters.push(:password, :password_confirmation, :private_token)
+ config.filter_parameters.push(:password, :password_confirmation, :private_token, :otp_attempt)
# Enable escaping HTML in JSON.
config.active_support.escape_html_entities_in_json = true
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index 8f8c4169740..091548348b1 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -1,6 +1,11 @@
# Use this hook to configure devise mailer, warden hooks and so forth. The first
# four configuration values can also be set straight in your models.
Devise.setup do |config|
+ config.warden do |manager|
+ manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
+ manager.default_strategies(scope: :user).unshift :two_factor_backupable
+ end
+
# ==> Mailer Configuration
# Configure the class responsible to send e-mails.
config.mailer = "DeviseMailer"
diff --git a/config/routes.rb b/config/routes.rb
index 4b38dede7b4..bf2cb6421c5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -226,6 +226,11 @@ Gitlab::Application.routes.draw do
resources :keys
resources :emails, only: [:index, :create, :destroy]
resource :avatar, only: [:destroy]
+ resource :two_factor_auth, only: [:new, :create, :destroy] do
+ member do
+ post :codes
+ end
+ end
end
end
diff --git a/db/migrate/20150327223628_add_devise_two_factor_to_users.rb b/db/migrate/20150327223628_add_devise_two_factor_to_users.rb
new file mode 100644
index 00000000000..11b026ee8f3
--- /dev/null
+++ b/db/migrate/20150327223628_add_devise_two_factor_to_users.rb
@@ -0,0 +1,8 @@
+class AddDeviseTwoFactorToUsers < ActiveRecord::Migration
+ def change
+ add_column :users, :encrypted_otp_secret, :string
+ add_column :users, :encrypted_otp_secret_iv, :string
+ add_column :users, :encrypted_otp_secret_salt, :string
+ add_column :users, :otp_required_for_login, :boolean
+ end
+end
diff --git a/db/migrate/20150331183602_add_devise_two_factor_backupable_to_users.rb b/db/migrate/20150331183602_add_devise_two_factor_backupable_to_users.rb
new file mode 100644
index 00000000000..913958db7c5
--- /dev/null
+++ b/db/migrate/20150331183602_add_devise_two_factor_backupable_to_users.rb
@@ -0,0 +1,5 @@
+class AddDeviseTwoFactorBackupableToUsers < ActiveRecord::Migration
+ def change
+ add_column :users, :otp_backup_codes, :text
+ end
+end
diff --git a/db/migrate/20150423033240_add_default_project_visibililty_to_application_settings.rb b/db/migrate/20150423033240_add_default_project_visibililty_to_application_settings.rb
index 9b0f13f3fa7..50a9b2439e0 100644
--- a/db/migrate/20150423033240_add_default_project_visibililty_to_application_settings.rb
+++ b/db/migrate/20150423033240_add_default_project_visibililty_to_application_settings.rb
@@ -1,7 +1,11 @@
class AddDefaultProjectVisibililtyToApplicationSettings < ActiveRecord::Migration
- def change
+ def up
add_column :application_settings, :default_project_visibility, :integer
visibility = Settings.gitlab.default_projects_features['visibility_level']
execute("update application_settings set default_project_visibility = #{visibility}")
end
+
+ def down
+ remove_column :application_settings, :default_project_visibility
+ end
end
diff --git a/db/migrate/20150429002313_remove_abandoned_group_members_records.rb b/db/migrate/20150429002313_remove_abandoned_group_members_records.rb
index 6013605bb35..244637e1c4a 100644
--- a/db/migrate/20150429002313_remove_abandoned_group_members_records.rb
+++ b/db/migrate/20150429002313_remove_abandoned_group_members_records.rb
@@ -1,6 +1,9 @@
class RemoveAbandonedGroupMembersRecords < ActiveRecord::Migration
- def change
+ def up
execute("DELETE FROM members WHERE type = 'GroupMember' AND source_id NOT IN(\
SELECT id FROM namespaces WHERE type='Group')")
end
+
+ def down
+ end
end
diff --git a/db/migrate/20150509180749_convert_legacy_reference_notes.rb b/db/migrate/20150509180749_convert_legacy_reference_notes.rb
new file mode 100644
index 00000000000..b02605489be
--- /dev/null
+++ b/db/migrate/20150509180749_convert_legacy_reference_notes.rb
@@ -0,0 +1,16 @@
+# Convert legacy Markdown-emphasized notes to the current, non-emphasized format
+#
+# _mentioned in 54f7727c850972f0401c1312a7c4a6a380de5666_
+#
+# becomes
+#
+# mentioned in 54f7727c850972f0401c1312a7c4a6a380de5666
+class ConvertLegacyReferenceNotes < ActiveRecord::Migration
+ def up
+ execute %q{UPDATE notes SET note = trim(both '_' from note) WHERE system = true AND note LIKE '\_%\_'}
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 04abf9bb9a6..c89d22d241c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20150502064022) do
+ActiveRecord::Schema.define(version: 20150509180749) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -493,6 +493,11 @@ ActiveRecord::Schema.define(version: 20150502064022) do
t.string "bitbucket_access_token_secret"
t.string "location"
t.string "public_email", default: "", null: false
+ t.string "encrypted_otp_secret"
+ t.string "encrypted_otp_secret_iv"
+ t.string "encrypted_otp_secret_salt"
+ t.boolean "otp_required_for_login"
+ t.text "otp_backup_codes"
end
add_index "users", ["admin"], name: "index_users_on_admin", using: :btree
diff --git a/lib/tasks/brakeman.rake b/lib/tasks/brakeman.rake
index 52a9b017e79..5d4e0740373 100644
--- a/lib/tasks/brakeman.rake
+++ b/lib/tasks/brakeman.rake
@@ -1,5 +1,7 @@
desc 'Security check via brakeman'
task :brakeman do
+ # We get 0 warnings at level 'w3' but we would like to reach 'w2'. Merge
+ # requests are welcome!
if system(*%W(brakeman --skip-files lib/backup/repository.rb -w3 -z))
puts 'Security check succeed'
else
diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
new file mode 100644
index 00000000000..f05d1f5fbe1
--- /dev/null
+++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
@@ -0,0 +1,126 @@
+require 'spec_helper'
+
+describe Profiles::TwoFactorAuthsController do
+ before do
+ # `user` should be defined within the action-specific describe blocks
+ sign_in(user)
+
+ allow(subject).to receive(:current_user).and_return(user)
+ end
+
+ describe 'GET new' do
+ let(:user) { create(:user) }
+
+ it 'generates otp_secret' do
+ expect { get :new }.to change { user.otp_secret }
+ end
+
+ it 'assigns qr_code' do
+ code = double('qr code')
+ expect(subject).to receive(:build_qr_code).and_return(code)
+
+ get :new
+ expect(assigns[:qr_code]).to eq code
+ end
+ end
+
+ describe 'POST create' do
+ let(:user) { create(:user) }
+ let(:pin) { 'pin-code' }
+
+ def go
+ post :create, pin_code: pin
+ end
+
+ context 'with valid pin' do
+ before do
+ expect(user).to receive(:valid_otp?).with(pin).and_return(true)
+ end
+
+ it 'sets otp_required_for_login' do
+ go
+
+ user.reload
+ expect(user.otp_required_for_login).to eq true
+ end
+
+ it 'presents plaintext codes for the user to save' do
+ expect(user).to receive(:generate_otp_backup_codes!).and_return(%w(a b c))
+
+ go
+
+ expect(assigns[:codes]).to match_array %w(a b c)
+ end
+
+ it 'renders create' do
+ go
+ expect(response).to render_template(:create)
+ end
+ end
+
+ context 'with invalid pin' do
+ before do
+ expect(user).to receive(:valid_otp?).with(pin).and_return(false)
+ end
+
+ it 'assigns error' do
+ go
+ expect(assigns[:error]).to eq 'Invalid pin code'
+ end
+
+ it 'assigns qr_code' do
+ code = double('qr code')
+ expect(subject).to receive(:build_qr_code).and_return(code)
+
+ go
+ expect(assigns[:qr_code]).to eq code
+ end
+
+ it 'renders new' do
+ go
+ expect(response).to render_template(:new)
+ end
+ end
+ end
+
+ describe 'POST codes' do
+ let(:user) { create(:user, :two_factor) }
+
+ it 'presents plaintext codes for the user to save' do
+ expect(user).to receive(:generate_otp_backup_codes!).and_return(%w(a b c))
+
+ post :codes
+ expect(assigns[:codes]).to match_array %w(a b c)
+ end
+
+ it 'persists the generated codes' do
+ post :codes
+
+ user.reload
+ expect(user.otp_backup_codes).not_to be_empty
+ end
+ end
+
+ describe 'DELETE destroy' do
+ let(:user) { create(:user, :two_factor) }
+ let!(:codes) { user.generate_otp_backup_codes! }
+
+ it 'clears all 2FA-related fields' do
+ expect(user.otp_required_for_login).to eq true
+ expect(user.otp_backup_codes).not_to be_nil
+ expect(user.encrypted_otp_secret).not_to be_nil
+
+ delete :destroy
+
+ expect(user.otp_required_for_login).to eq false
+ expect(user.otp_backup_codes).to be_nil
+ expect(user.encrypted_otp_secret).to be_nil
+ end
+
+ it 'redirects to profile_account_path' do
+ delete :destroy
+
+ expect(response).to redirect_to(profile_account_path)
+ end
+ end
+end
diff --git a/spec/factories.rb b/spec/factories.rb
index 19f2935f30e..26e8a795fa4 100644
--- a/spec/factories.rb
+++ b/spec/factories.rb
@@ -28,6 +28,13 @@ FactoryGirl.define do
admin true
end
+ trait :two_factor do
+ before(:create) do |user|
+ user.otp_required_for_login = true
+ user.otp_secret = User.generate_otp_secret
+ end
+ end
+
factory :omniauth_user do
ignore do
extern_uid '123456'
diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb
index f1c33461b55..e1009d5916e 100644
--- a/spec/factories/notes.rb
+++ b/spec/factories/notes.rb
@@ -25,15 +25,16 @@ FactoryGirl.define do
note "Note"
author
- factory :note_on_commit, traits: [:on_commit]
- factory :note_on_commit_diff, traits: [:on_commit, :on_diff]
- factory :note_on_issue, traits: [:on_issue], aliases: [:votable_note]
- factory :note_on_merge_request, traits: [:on_merge_request]
+ factory :note_on_commit, traits: [:on_commit]
+ factory :note_on_commit_diff, traits: [:on_commit, :on_diff]
+ factory :note_on_issue, traits: [:on_issue], aliases: [:votable_note]
+ factory :note_on_merge_request, traits: [:on_merge_request]
factory :note_on_merge_request_diff, traits: [:on_merge_request, :on_diff]
- factory :note_on_project_snippet, traits: [:on_project_snippet]
+ factory :note_on_project_snippet, traits: [:on_project_snippet]
+ factory :system_note, traits: [:system]
trait :on_commit do
- project factory: :project
+ project
commit_id RepoHelpers.sample_commit.id
noteable_type "Commit"
end
@@ -43,7 +44,7 @@ FactoryGirl.define do
end
trait :on_merge_request do
- project factory: :project
+ project
noteable_id 1
noteable_type "MergeRequest"
end
@@ -58,6 +59,10 @@ FactoryGirl.define do
noteable_type "Snippet"
end
+ trait :system do
+ system true
+ end
+
trait :with_attachment do
attachment { fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "`/png") }
end
diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb
new file mode 100644
index 00000000000..046a9f6191d
--- /dev/null
+++ b/spec/features/login_spec.rb
@@ -0,0 +1,101 @@
+require 'spec_helper'
+
+feature 'Login' do
+ describe 'with two-factor authentication' do
+ context 'with valid username/password' do
+ let(:user) { create(:user, :two_factor) }
+
+ before do
+ login_with(user)
+ expect(page).to have_content('Two-factor Authentication')
+ end
+
+ def enter_code(code)
+ fill_in 'Two-factor authentication code', with: code
+ click_button 'Verify code'
+ end
+
+ it 'does not show a "You are already signed in." error message' do
+ enter_code(user.current_otp)
+ expect(page).not_to have_content('You are already signed in.')
+ end
+
+ context 'using one-time code' do
+ it 'allows login with valid code' do
+ enter_code(user.current_otp)
+ expect(current_path).to eq root_path
+ end
+
+ it 'blocks login with invalid code' do
+ enter_code('foo')
+ expect(page).to have_content('Invalid two-factor code')
+ end
+
+ it 'allows login with invalid code, then valid code' do
+ enter_code('foo')
+ expect(page).to have_content('Invalid two-factor code')
+
+ enter_code(user.current_otp)
+ expect(current_path).to eq root_path
+ end
+ end
+
+ context 'using backup code' do
+ let(:codes) { user.generate_otp_backup_codes! }
+
+ before do
+ expect(codes.size).to eq 10
+
+ # Ensure the generated codes get saved
+ user.save
+ end
+
+ context 'with valid code' do
+ it 'allows login' do
+ enter_code(codes.sample)
+ expect(current_path).to eq root_path
+ end
+
+ it 'invalidates the used code' do
+ expect { enter_code(codes.sample) }.
+ to change { user.reload.otp_backup_codes.size }.by(-1)
+ end
+ end
+
+ context 'with invalid code' do
+ it 'blocks login' do
+ code = codes.sample
+ expect(user.invalidate_otp_backup_code!(code)).to eq true
+
+ user.save!
+ expect(user.reload.otp_backup_codes.size).to eq 9
+
+ enter_code(code)
+ expect(page).to have_content('Invalid two-factor code.')
+ end
+ end
+ end
+ end
+ end
+
+ describe 'without two-factor authentication' do
+ let(:user) { create(:user) }
+
+ it 'allows basic login' do
+ login_with(user)
+ expect(current_path).to eq root_path
+ end
+
+ it 'does not show a "You are already signed in." error message' do
+ login_with(user)
+ expect(page).not_to have_content('You are already signed in.')
+ end
+
+ it 'blocks invalid login' do
+ user = create(:user, password: 'not-the-default')
+
+ login_with(user)
+ expect(page).to have_content('Invalid email or password.')
+ end
+ end
+end
diff --git a/spec/features/password_reset_spec.rb b/spec/features/password_reset_spec.rb
new file mode 100644
index 00000000000..a34efce09ef
--- /dev/null
+++ b/spec/features/password_reset_spec.rb
@@ -0,0 +1,53 @@
+require 'spec_helper'
+
+feature 'Password reset' do
+ def forgot_password
+ click_on 'Forgot your password?'
+ fill_in 'Email', with: user.email
+ click_button 'Reset password'
+ user.reload
+ end
+
+ def get_reset_token
+ mail = ActionMailer::Base.deliveries.last
+ body = mail.body.encoded
+ body.scan(/reset_password_token=(.+)\"/).flatten.first
+ end
+
+ def reset_password(password = 'password')
+ visit edit_user_password_path(reset_password_token: get_reset_token)
+
+ fill_in 'New password', with: password
+ fill_in 'Confirm new password', with: password
+ click_button 'Change your password'
+ end
+
+ describe 'with two-factor authentication' do
+ let(:user) { create(:user, :two_factor) }
+
+ it 'requires login after password reset' do
+ visit root_path
+
+ forgot_password
+ reset_password
+
+ expect(page).to have_content("Your password was changed successfully.")
+ expect(page).not_to have_content("You are now signed in.")
+ expect(current_path).to eq new_user_session_path
+ end
+ end
+
+ describe 'without two-factor authentication' do
+ let(:user) { create(:user) }
+
+ it 'automatically logs in after password reset' do
+ visit root_path
+
+ forgot_password
+ reset_password
+
+ expect(current_path).to eq root_path
+ expect(page).to have_content("Your password was changed successfully. You are now signed in.")
+ end
+ end
+end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 4a6bfdb2910..ddacba58261 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -20,68 +20,88 @@
require 'spec_helper'
describe Note do
- describe "Associations" do
+ describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:noteable) }
it { is_expected.to belong_to(:author).class_name('User') }
end
- describe "Mass assignment" do
- end
-
- describe "Validation" do
+ describe 'validation' do
it { is_expected.to validate_presence_of(:note) }
it { is_expected.to validate_presence_of(:project) }
end
- describe "Voting score" do
- let(:project) { create(:project) }
+ describe '#votable?' do
+ it 'is true for issue notes' do
+ note = build(:note_on_issue)
+ expect(note).to be_votable
+ end
+
+ it 'is true for merge request notes' do
+ note = build(:note_on_merge_request)
+ expect(note).to be_votable
+ end
+
+ it 'is false for merge request diff notes' do
+ note = build(:note_on_merge_request_diff)
+ expect(note).not_to be_votable
+ end
+
+ it 'is false for commit notes' do
+ note = build(:note_on_commit)
+ expect(note).not_to be_votable
+ end
- it "recognizes a neutral note" do
- note = create(:votable_note, note: "This is not a +1 note")
+ it 'is false for commit diff notes' do
+ note = build(:note_on_commit_diff)
+ expect(note).not_to be_votable
+ end
+ end
+
+ describe 'voting score' do
+ it 'recognizes a neutral note' do
+ note = build(:votable_note, note: 'This is not a +1 note')
expect(note).not_to be_upvote
expect(note).not_to be_downvote
end
- it "recognizes a neutral emoji note" do
+ it 'recognizes a neutral emoji note' do
note = build(:votable_note, note: "I would :+1: this, but I don't want to")
expect(note).not_to be_upvote
expect(note).not_to be_downvote
end
- it "recognizes a +1 note" do
- note = create(:votable_note, note: "+1 for this")
+ it 'recognizes a +1 note' do
+ note = build(:votable_note, note: '+1 for this')
expect(note).to be_upvote
end
- it "recognizes a +1 emoji as a vote" do
- note = build(:votable_note, note: ":+1: for this")
+ it 'recognizes a +1 emoji as a vote' do
+ note = build(:votable_note, note: ':+1: for this')
expect(note).to be_upvote
end
- it "recognizes a thumbsup emoji as a vote" do
- note = build(:votable_note, note: ":thumbsup: for this")
+ it 'recognizes a thumbsup emoji as a vote' do
+ note = build(:votable_note, note: ':thumbsup: for this')
expect(note).to be_upvote
end
- it "recognizes a -1 note" do
- note = create(:votable_note, note: "-1 for this")
+ it 'recognizes a -1 note' do
+ note = build(:votable_note, note: '-1 for this')
expect(note).to be_downvote
end
- it "recognizes a -1 emoji as a vote" do
- note = build(:votable_note, note: ":-1: for this")
+ it 'recognizes a -1 emoji as a vote' do
+ note = build(:votable_note, note: ':-1: for this')
expect(note).to be_downvote
end
- it "recognizes a thumbsdown emoji as a vote" do
- note = build(:votable_note, note: ":thumbsdown: for this")
+ it 'recognizes a thumbsdown emoji as a vote' do
+ note = build(:votable_note, note: ':thumbsdown: for this')
expect(note).to be_downvote
end
end
- let(:project) { create(:project) }
-
describe "Commit notes" do
let!(:note) { create(:note_on_commit, note: "+1 from me") }
let!(:commit) { note.noteable }
@@ -100,10 +120,6 @@ describe Note do
it "should be recognized by #for_commit?" do
expect(note).to be_for_commit
end
-
- it "should not be votable" do
- expect(note).not_to be_votable
- end
end
describe "Commit diff line notes" do
@@ -128,461 +144,7 @@ describe Note do
end
end
- describe "Issue notes" do
- let!(:note) { create(:note_on_issue, note: "+1 from me") }
-
- it "should not be votable" do
- expect(note).to be_votable
- end
- end
-
- describe "Merge request notes" do
- let!(:note) { create(:note_on_merge_request, note: "+1 from me") }
-
- it "should be votable" do
- expect(note).to be_votable
- end
- end
-
- describe "Merge request diff line notes" do
- let!(:note) { create(:note_on_merge_request_diff, note: "+1 from me") }
-
- it "should not be votable" do
- expect(note).not_to be_votable
- end
- end
-
- describe '#create_status_change_note' do
- let(:project) { create(:project) }
- let(:thing) { create(:issue, project: project) }
- let(:author) { create(:user) }
- let(:status) { 'new_status' }
-
- subject { Note.create_status_change_note(thing, project, author, status, nil) }
-
- it 'creates and saves a Note' do
- is_expected.to be_a Note
- expect(subject.id).not_to be_nil
- end
-
- describe '#noteable' do
- subject { super().noteable }
- it { is_expected.to eq(thing) }
- end
-
- describe '#project' do
- subject { super().project }
- it { is_expected.to eq(thing.project) }
- end
-
- describe '#author' do
- subject { super().author }
- it { is_expected.to eq(author) }
- end
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq("Status changed to #{status}") }
- end
-
- it 'appends a back-reference if a closing mentionable is supplied' do
- commit = double('commit', gfm_reference: 'commit 123456')
- n = Note.create_status_change_note(thing, project, author, status, commit)
-
- expect(n.note).to eq("Status changed to #{status} by commit 123456")
- end
- end
-
- describe '#create_assignee_change_note' do
- let(:project) { create(:project) }
- let(:thing) { create(:issue, project: project) }
- let(:author) { create(:user) }
- let(:assignee) { create(:user, username: "assigned_user") }
-
- subject { Note.create_assignee_change_note(thing, project, author, assignee) }
-
- context 'creates and saves a Note' do
- it { is_expected.to be_a Note }
-
- describe '#id' do
- subject { super().id }
- it { is_expected.not_to be_nil }
- end
- end
-
- describe '#noteable' do
- subject { super().noteable }
- it { is_expected.to eq(thing) }
- end
-
- describe '#project' do
- subject { super().project }
- it { is_expected.to eq(thing.project) }
- end
-
- describe '#author' do
- subject { super().author }
- it { is_expected.to eq(author) }
- end
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq('Reassigned to @assigned_user') }
- end
-
- context 'assignee is removed' do
- let(:assignee) { nil }
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq('Assignee removed') }
- end
- end
- end
-
- describe '#create_labels_change_note' do
- let(:project) { create(:project) }
- let(:thing) { create(:issue, project: project) }
- let(:author) { create(:user) }
- let(:label1) { create(:label) }
- let(:label2) { create(:label) }
- let(:added_labels) { [label1, label2] }
- let(:removed_labels) { [] }
-
- subject { Note.create_labels_change_note(thing, project, author, added_labels, removed_labels) }
-
- context 'creates and saves a Note' do
- it { is_expected.to be_a Note }
-
- describe '#id' do
- subject { super().id }
- it { is_expected.not_to be_nil }
- end
- end
-
- describe '#noteable' do
- subject { super().noteable }
- it { is_expected.to eq(thing) }
- end
-
- describe '#project' do
- subject { super().project }
- it { is_expected.to eq(thing.project) }
- end
-
- describe '#author' do
- subject { super().author }
- it { is_expected.to eq(author) }
- end
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq("Added ~#{label1.id} ~#{label2.id} labels") }
- end
-
- context 'label is removed' do
- let(:added_labels) { [label1] }
- let(:removed_labels) { [label2] }
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq("Added ~#{label1.id} and removed ~#{label2.id} labels") }
- end
- end
- end
-
- describe '#create_milestone_change_note' do
- let(:project) { create(:project) }
- let(:thing) { create(:issue, project: project) }
- let(:milestone) { create(:milestone, project: project, title: "first_milestone") }
- let(:author) { create(:user) }
-
- subject { Note.create_milestone_change_note(thing, project, author, milestone) }
-
- context 'creates and saves a Note' do
- it { is_expected.to be_a Note }
-
- describe '#id' do
- subject { super().id }
- it { is_expected.not_to be_nil }
- end
- end
-
- describe '#project' do
- subject { super().project }
- it { is_expected.to eq(thing.project) }
- end
-
- describe '#author' do
- subject { super().author }
- it { is_expected.to eq(author) }
- end
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq("Milestone changed to first_milestone") }
- end
- end
-
- describe '#create_cross_reference_note' do
- let(:project) { create(:project) }
- let(:author) { create(:user) }
- let(:issue) { create(:issue, project: project) }
- let(:mergereq) { create(:merge_request, :simple, target_project: project, source_project: project) }
- let(:commit) { project.commit }
-
- # Test all of {issue, merge request, commit} in both the referenced and referencing
- # roles, to ensure that the correct information can be inferred from any argument.
-
- context 'issue from a merge request' do
- subject { Note.create_cross_reference_note(issue, mergereq, author) }
-
- it { is_expected.to be_valid }
-
- describe '#noteable' do
- subject { super().noteable }
- it { is_expected.to eq(issue) }
- end
-
- describe '#project' do
- subject { super().project }
- it { is_expected.to eq(issue.project) }
- end
-
- describe '#author' do
- subject { super().author }
- it { is_expected.to eq(author) }
- end
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq("mentioned in merge request !#{mergereq.iid}") }
- end
- end
-
- context 'issue from a commit' do
- subject { Note.create_cross_reference_note(issue, commit, author) }
-
- it { is_expected.to be_valid }
-
- describe '#noteable' do
- subject { super().noteable }
- it { is_expected.to eq(issue) }
- end
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq("mentioned in commit #{commit.sha}") }
- end
- end
-
- context 'merge request from an issue' do
- subject { Note.create_cross_reference_note(mergereq, issue, author) }
-
- it { is_expected.to be_valid }
-
- describe '#noteable' do
- subject { super().noteable }
- it { is_expected.to eq(mergereq) }
- end
-
- describe '#project' do
- subject { super().project }
- it { is_expected.to eq(mergereq.project) }
- end
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq("mentioned in issue ##{issue.iid}") }
- end
- end
-
- context 'commit from a merge request' do
- subject { Note.create_cross_reference_note(commit, mergereq, author) }
-
- it { is_expected.to be_valid }
-
- describe '#noteable' do
- subject { super().noteable }
- it { is_expected.to eq(commit) }
- end
-
- describe '#project' do
- subject { super().project }
- it { is_expected.to eq(project) }
- end
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq("mentioned in merge request !#{mergereq.iid}") }
- end
- end
-
- context 'commit contained in a merge request' do
- subject { Note.create_cross_reference_note(mergereq.commits.first, mergereq, author) }
-
- it { is_expected.to be_nil }
- end
-
- context 'commit from issue' do
- subject { Note.create_cross_reference_note(commit, issue, author) }
-
- it { is_expected.to be_valid }
-
- describe '#noteable_type' do
- subject { super().noteable_type }
- it { is_expected.to eq("Commit") }
- end
-
- describe '#noteable_id' do
- subject { super().noteable_id }
- it { is_expected.to be_nil }
- end
-
- describe '#commit_id' do
- subject { super().commit_id }
- it { is_expected.to eq(commit.id) }
- end
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq("mentioned in issue ##{issue.iid}") }
- end
- end
-
- context 'commit from commit' do
- let(:parent_commit) { commit.parents.first }
- subject { Note.create_cross_reference_note(commit, parent_commit, author) }
-
- it { is_expected.to be_valid }
-
- describe '#noteable_type' do
- subject { super().noteable_type }
- it { is_expected.to eq("Commit") }
- end
-
- describe '#noteable_id' do
- subject { super().noteable_id }
- it { is_expected.to be_nil }
- end
-
- describe '#commit_id' do
- subject { super().commit_id }
- it { is_expected.to eq(commit.id) }
- end
-
- describe '#note' do
- subject { super().note }
- it { is_expected.to eq("mentioned in commit #{parent_commit.id}") }
- end
- end
- end
-
- describe '#cross_reference_exists?' do
- let(:project) { create :project }
- let(:author) { create :user }
- let(:issue) { create :issue }
- let(:commit0) { project.commit }
- let(:commit1) { project.commit('HEAD~2') }
-
- before do
- Note.create_cross_reference_note(issue, commit0, author)
- end
-
- it 'detects if a mentionable has already been mentioned' do
- expect(Note.cross_reference_exists?(issue, commit0)).to be_truthy
- end
-
- it 'detects if a mentionable has not already been mentioned' do
- expect(Note.cross_reference_exists?(issue, commit1)).to be_falsey
- end
-
- context 'commit on commit' do
- before do
- Note.create_cross_reference_note(commit0, commit1, author)
- end
-
- it { expect(Note.cross_reference_exists?(commit0, commit1)).to be_truthy }
- it { expect(Note.cross_reference_exists?(commit1, commit0)).to be_falsey }
- end
-
- context 'legacy note with Markdown emphasis' do
- let(:issue2) { create :issue, project: project }
- let!(:note) do
- create :note, system: true, noteable_id: issue2.id,
- noteable_type: "Issue", note: "_mentioned in issue " \
- "#{issue.project.path_with_namespace}##{issue.iid}_"
- end
-
- it 'detects if a mentionable with emphasis has been mentioned' do
- expect(Note.cross_reference_exists?(issue2, issue)).to be_truthy
- end
- end
- end
-
- describe '#cross_references_with_underscores?' do
- let(:project) { create :project, path: "first_project" }
- let(:second_project) { create :project, path: "second_project" }
-
- let(:author) { create :user }
- let(:issue0) { create :issue, project: project }
- let(:issue1) { create :issue, project: second_project }
- let!(:note) { Note.create_cross_reference_note(issue0, issue1, author) }
-
- it 'detects if a mentionable has already been mentioned' do
- expect(Note.cross_reference_exists?(issue0, issue1)).to be_truthy
- end
-
- it 'detects if a mentionable has not already been mentioned' do
- expect(Note.cross_reference_exists?(issue1, issue0)).to be_falsey
- end
-
- it 'detects that text has underscores' do
- expect(note.note).to eq("mentioned in issue #{second_project.path_with_namespace}##{issue1.iid}")
- end
- end
-
- describe '#system?' do
- let(:project) { create(:project) }
- let(:issue) { create(:issue, project: project) }
- let(:other) { create(:issue, project: project) }
- let(:author) { create(:user) }
- let(:assignee) { create(:user) }
- let(:label) { create(:label) }
- let(:milestone) { create(:milestone) }
-
- it 'should recognize user-supplied notes as non-system' do
- @note = create(:note_on_issue)
- expect(@note).not_to be_system
- end
-
- it 'should identify status-change notes as system notes' do
- @note = Note.create_status_change_note(issue, project, author, 'closed', nil)
- expect(@note).to be_system
- end
-
- it 'should identify cross-reference notes as system notes' do
- @note = Note.create_cross_reference_note(issue, other, author)
- expect(@note).to be_system
- end
-
- it 'should identify assignee-change notes as system notes' do
- @note = Note.create_assignee_change_note(issue, project, author, assignee)
- expect(@note).to be_system
- end
-
- it 'should identify label-change notes as system notes' do
- @note = Note.create_labels_change_note(issue, project, author, [label], [])
- expect(@note).to be_system
- end
-
- it 'should identify milestone-change notes as system notes' do
- @note = Note.create_milestone_change_note(issue, project, author, milestone)
- expect(@note).to be_system
- end
- end
-
- describe :authorization do
+ describe 'authorization' do
before do
@p1 = create(:project)
@p2 = create(:project)
@@ -593,7 +155,7 @@ describe Note do
@abilities << Ability
end
- describe :read do
+ describe 'read' do
before do
@p1.project_members.create(user: @u2, access_level: ProjectMember::GUEST)
@p2.project_members.create(user: @u3, access_level: ProjectMember::GUEST)
@@ -604,7 +166,7 @@ describe Note do
it { expect(@abilities.allowed?(@u3, :read_note, @p1)).to be_falsey }
end
- describe :write do
+ describe 'write' do
before do
@p1.project_members.create(user: @u2, access_level: ProjectMember::DEVELOPER)
@p2.project_members.create(user: @u3, access_level: ProjectMember::DEVELOPER)
@@ -615,7 +177,7 @@ describe Note do
it { expect(@abilities.allowed?(@u3, :write_note, @p1)).to be_falsey }
end
- describe :admin do
+ describe 'admin' do
before do
@p1.project_members.create(user: @u1, access_level: ProjectMember::REPORTER)
@p1.project_members.create(user: @u2, access_level: ProjectMember::MASTER)
@@ -631,6 +193,7 @@ describe Note do
it_behaves_like 'an editable mentionable' do
subject { create :note, noteable: issue, project: project }
+ let(:project) { create(:project) }
let(:issue) { create :issue, project: project }
let(:backref_text) { issue.gfm_reference }
let(:set_mentionable_text) { ->(txt) { subject.note = txt } }
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 771709c127a..0dddcd5bda2 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -50,6 +50,11 @@
# bitbucket_access_token :string(255)
# bitbucket_access_token_secret :string(255)
# location :string(255)
+# encrypted_otp_secret :string(255)
+# encrypted_otp_secret_iv :string(255)
+# encrypted_otp_secret_salt :string(255)
+# otp_required_for_login :boolean
+# otp_backup_codes :text
# public_email :string(255) default(""), not null
#
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
new file mode 100644
index 00000000000..4e4cb6d19ed
--- /dev/null
+++ b/spec/services/system_note_service_spec.rb
@@ -0,0 +1,346 @@
+require 'spec_helper'
+
+describe SystemNoteService do
+ let(:project) { create(:project) }
+ let(:author) { create(:user) }
+ let(:noteable) { create(:issue, project: project) }
+
+ shared_examples_for 'a system note' do
+ it 'is valid' do
+ expect(subject).to be_valid
+ end
+
+ it 'sets the noteable model' do
+ expect(subject.noteable).to eq noteable
+ end
+
+ it 'sets the project' do
+ expect(subject.project).to eq project
+ end
+
+ it 'sets the author' do
+ expect(subject.author).to eq author
+ end
+
+ it 'is a system note' do
+ expect(subject).to be_system
+ end
+ end
+
+ describe '.add_commits' do
+ subject { described_class.add_commits(noteable, project, author, new_commits, old_commits, oldrev) }
+
+ let(:noteable) { create(:merge_request, source_project: project) }
+ let(:new_commits) { noteable.commits }
+ let(:old_commits) { [] }
+ let(:oldrev) { nil }
+
+ it_behaves_like 'a system note'
+
+ describe 'note body' do
+ let(:note_lines) { subject.note.split("\n").reject(&:blank?) }
+
+ context 'without existing commits' do
+ it 'adds a message header' do
+ expect(note_lines[0]).to eq "Added #{new_commits.size} commits:"
+ end
+
+ it 'adds a message line for each commit' do
+ new_commits.each_with_index do |commit, i|
+ # Skip the header
+ expect(note_lines[i + 1]).to eq "* #{commit.short_id} - #{commit.title}"
+ end
+ end
+ end
+
+ describe 'summary line for existing commits' do
+ let(:summary_line) { note_lines[1] }
+
+ context 'with one existing commit' do
+ let(:old_commits) { [noteable.commits.last] }
+
+ it 'includes the existing commit' do
+ expect(summary_line).to eq "* #{old_commits.first.short_id} - 1 commit from branch `feature`"
+ end
+ end
+
+ context 'with multiple existing commits' do
+ let(:old_commits) { noteable.commits[3..-1] }
+
+ context 'with oldrev' do
+ let(:oldrev) { noteable.commits[2].id }
+
+ it 'includes a commit range' do
+ expect(summary_line).to start_with "* #{Commit.truncate_sha(oldrev)}...#{old_commits.last.short_id}"
+ end
+
+ it 'includes a commit count' do
+ expect(summary_line).to end_with " - 2 commits from branch `feature`"
+ end
+ end
+
+ context 'without oldrev' do
+ it 'includes a commit range' do
+ expect(summary_line).to start_with "* #{old_commits[0].short_id}..#{old_commits[-1].short_id}"
+ end
+
+ it 'includes a commit count' do
+ expect(summary_line).to end_with " - 2 commits from branch `feature`"
+ end
+ end
+
+ context 'on a fork' do
+ before do
+ expect(noteable).to receive(:for_fork?).and_return(true)
+ end
+
+ it 'includes the project namespace' do
+ expect(summary_line).to end_with "`#{noteable.target_project_namespace}:feature`"
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe '.change_assignee' do
+ subject { described_class.change_assignee(noteable, project, author, assignee) }
+
+ let(:assignee) { create(:user) }
+
+ it_behaves_like 'a system note'
+
+ context 'when assignee added' do
+ it 'sets the note text' do
+ expect(subject.note).to eq "Reassigned to @#{assignee.username}"
+ end
+ end
+
+ context 'when assignee removed' do
+ let(:assignee) { nil }
+
+ it 'sets the note text' do
+ expect(subject.note).to eq 'Assignee removed'
+ end
+ end
+ end
+
+ describe '.change_label' do
+ subject { described_class.change_label(noteable, project, author, added, removed) }
+
+ let(:labels) { create_list(:label, 2) }
+ let(:added) { [] }
+ let(:removed) { [] }
+
+ it_behaves_like 'a system note'
+
+ context 'with added labels' do
+ let(:added) { labels }
+ let(:removed) { [] }
+
+ it 'sets the note text' do
+ expect(subject.note).to eq "Added ~#{labels[0].id} ~#{labels[1].id} labels"
+ end
+ end
+
+ context 'with removed labels' do
+ let(:added) { [] }
+ let(:removed) { labels }
+
+ it 'sets the note text' do
+ expect(subject.note).to eq "Removed ~#{labels[0].id} ~#{labels[1].id} labels"
+ end
+ end
+
+ context 'with added and removed labels' do
+ let(:added) { [labels[0]] }
+ let(:removed) { [labels[1]] }
+
+ it 'sets the note text' do
+ expect(subject.note).to eq "Added ~#{labels[0].id} and removed ~#{labels[1].id} labels"
+ end
+ end
+ end
+
+ describe '.change_milestone' do
+ subject { described_class.change_milestone(noteable, project, author, milestone) }
+
+ let(:milestone) { create(:milestone, project: project) }
+
+ it_behaves_like 'a system note'
+
+ context 'when milestone added' do
+ it 'sets the note text' do
+ expect(subject.note).to eq "Milestone changed to #{milestone.title}"
+ end
+ end
+
+ context 'when milestone removed' do
+ let(:milestone) { nil }
+
+ it 'sets the note text' do
+ expect(subject.note).to eq 'Milestone removed'
+ end
+ end
+ end
+
+ describe '.change_status' do
+ subject { described_class.change_status(noteable, project, author, status, source) }
+
+ let(:status) { 'new_status' }
+ let(:source) { nil }
+
+ it_behaves_like 'a system note'
+
+ context 'with a source' do
+ let(:source) { double('commit', gfm_reference: 'commit 123456') }
+
+ it 'sets the note text' do
+ expect(subject.note).to eq "Status changed to #{status} by commit 123456"
+ end
+ end
+
+ context 'without a source' do
+ it 'sets the note text' do
+ expect(subject.note).to eq "Status changed to #{status}"
+ end
+ end
+ end
+
+ describe '.cross_reference' do
+ subject { described_class.cross_reference(noteable, mentioner, author) }
+
+ let(:mentioner) { create(:issue, project: project) }
+
+ it_behaves_like 'a system note'
+
+ context 'when cross-reference disallowed' do
+ before do
+ expect(described_class).to receive(:cross_reference_disallowed?).and_return(true)
+ end
+
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when cross-reference allowed' do
+ before do
+ expect(described_class).to receive(:cross_reference_disallowed?).and_return(false)
+ end
+
+ describe 'note_body' do
+ context 'cross-project' do
+ let(:project2) { create(:project) }
+ let(:mentioner) { create(:issue, project: project2) }
+
+ context 'from Commit' do
+ let(:mentioner) { project2.repository.commit }
+
+ it 'references the mentioning commit' do
+ expect(subject.note).to eq "mentioned in commit #{project2.path_with_namespace}@#{mentioner.id}"
+ end
+ end
+
+ context 'from non-Commit' do
+ it 'references the mentioning object' do
+ expect(subject.note).to eq "mentioned in issue #{project2.path_with_namespace}##{mentioner.iid}"
+ end
+ end
+ end
+
+ context 'within the same project' do
+ context 'from Commit' do
+ let(:mentioner) { project.repository.commit }
+
+ it 'references the mentioning commit' do
+ expect(subject.note).to eq "mentioned in commit #{mentioner.id}"
+ end
+ end
+
+ context 'from non-Commit' do
+ it 'references the mentioning object' do
+ expect(subject.note).to eq "mentioned in issue ##{mentioner.iid}"
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe '.cross_reference?' do
+ it 'is truthy when text begins with expected text' do
+ expect(described_class.cross_reference?('mentioned in issue #1')).to be_truthy
+ end
+
+ it 'is falsey when text does not begin with expected text' do
+ expect(described_class.cross_reference?('this is a note')).to be_falsey
+ end
+ end
+
+ describe '.cross_reference_disallowed?' do
+ context 'when mentioner is not a MergeRequest' do
+ it 'is falsey' do
+ mentioner = noteable.dup
+ expect(described_class.cross_reference_disallowed?(noteable, mentioner)).
+ to be_falsey
+ end
+ end
+
+ context 'when mentioner is a MergeRequest' do
+ let(:mentioner) { create(:merge_request, :simple, source_project: project) }
+ let(:noteable) { project.commit }
+
+ it 'is truthy when noteable is in commits' do
+ expect(mentioner).to receive(:commits).and_return([noteable])
+ expect(described_class.cross_reference_disallowed?(noteable, mentioner)).
+ to be_truthy
+ end
+
+ it 'is falsey when noteable is not in commits' do
+ expect(mentioner).to receive(:commits).and_return([])
+ expect(described_class.cross_reference_disallowed?(noteable, mentioner)).
+ to be_falsey
+ end
+ end
+ end
+
+ describe '.cross_reference_exists?' do
+ let(:commit0) { project.commit }
+ let(:commit1) { project.commit('HEAD~2') }
+
+ context 'issue from commit' do
+ before do
+ # Mention issue (noteable) from commit0
+ described_class.cross_reference(noteable, commit0, author)
+ end
+
+ it 'is truthy when already mentioned' do
+ expect(described_class.cross_reference_exists?(noteable, commit0)).
+ to be_truthy
+ end
+
+ it 'is falsey when not already mentioned' do
+ expect(described_class.cross_reference_exists?(noteable, commit1)).
+ to be_falsey
+ end
+ end
+
+ context 'commit from commit' do
+ before do
+ # Mention commit1 from commit0
+ described_class.cross_reference(commit0, commit1, author)
+ end
+
+ it 'is truthy when already mentioned' do
+ expect(described_class.cross_reference_exists?(commit0, commit1)).
+ to be_truthy
+ end
+
+ it 'is falsey when not already mentioned' do
+ expect(described_class.cross_reference_exists?(commit1, commit0)).
+ to be_falsey
+ end
+ end
+ end
+end