diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 0000000000000000000000000000000000000000..9aec2305390f808ce1accaed3fccddefb742cb69 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000000000000000000000000000000000000..87c833f3593c2bc034d2a530f7ffda68be8bf7a5 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + self.current_user = find_user_from_session_store + end + + private + + def find_user_from_session_store + session = ActiveSession.sessions_from_ids([session_id]).first + Warden::SessionSerializer.new('rack.session' => session).fetch(:user) + end + + def session_id + Rack::Session::SessionId.new(cookies[Gitlab::Application.config.session_options[:key]]) + end + end +end diff --git a/app/channels/issues_channel.rb b/app/channels/issues_channel.rb new file mode 100644 index 0000000000000000000000000000000000000000..5f3909b77167fc86d8ffe25fc2df5a3e27e2035c --- /dev/null +++ b/app/channels/issues_channel.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class IssuesChannel < ApplicationCable::Channel + def subscribed + project = Project.find_by_full_path(params[:project_path]) + return reject unless project + + issue = project.issues.find_by_iid(params[:iid]) + return reject unless issue && Ability.allowed?(current_user, :read_issue, issue) + + stream_for issue + end +end diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 050155398ab39b37e8001bc77c137b5ff0586a08..065bd5507be80eeacaabe3e069f2e08d8dc7e84a 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -124,7 +124,7 @@ def self.session_ids_for_user(user_id) end end - # Lists the ActiveSession objects for the given session IDs. + # Lists the session Hash objects for the given session IDs. # # session_ids - An array of Rack::Session::SessionId objects # @@ -143,7 +143,7 @@ def self.sessions_from_ids(session_ids) end end - # Deserializes an ActiveSession object from Redis. + # Deserializes a session Hash object from Redis. # # raw_session - Raw bytes from Redis # diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 78ebbd7bff2594f54b71ace4265e4571802bb414..ee1a22634af0bf2e83ce0eb2fee2ff78b7436de4 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -21,6 +21,10 @@ def before_update(issue, skip_spam_check: false) spam_check(issue, current_user) unless skip_spam_check end + def after_update(issue) + IssuesChannel.broadcast_to(issue, event: 'updated') if Feature.enabled?(:broadcast_issue_updates, issue.project) + end + def handle_changes(issue, options) old_associations = options.fetch(:old_associations, {}) old_labels = old_associations.fetch(:labels, []) diff --git a/config/cable.yml.example b/config/cable.yml.example new file mode 100644 index 0000000000000000000000000000000000000000..ee3a8da9be8c9a644d629b090a3c0ae94e8b4b2d --- /dev/null +++ b/config/cable.yml.example @@ -0,0 +1,14 @@ +# This file is used for configuring ActionCable in our CI environment +# When using GDK or Omnibus, cable.yml is generated from a different template +development: + adapter: redis + url: redis://localhost:6379 + channel_prefix: gitlab_development +test: + adapter: redis + url: redis://localhost:6379 + channel_prefix: gitlab_test +production: + adapter: redis + url: unix:/var/run/redis/redis.sock + channel_prefix: gitlab_production diff --git a/lib/quality/test_level.rb b/lib/quality/test_level.rb index bbd8b4dcc3f629b0000666b6f6ca5b6412c7a66d..97b86fa8c2e5164e0efe833004998375c751193b 100644 --- a/lib/quality/test_level.rb +++ b/lib/quality/test_level.rb @@ -14,6 +14,7 @@ class TestLevel ], unit: %w[ bin + channels config db dependencies diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index e80d752f09fd09588e10364062700ef6344fabab..7bf3f887e976bde3f4d449d65dd6abb82f09dc90 100644 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -33,6 +33,9 @@ if [ -f config/database_geo.yml ]; then sed -i 's/username: git/username: postgres/g' config/database_geo.yml fi +cp config/cable.yml.example config/cable.yml +sed -i 's|url:.*$|url: redis://redis:6379|g' config/cable.yml + cp config/resque.yml.example config/resque.yml sed -i 's|url:.*$|url: redis://redis:6379|g' config/resque.yml diff --git a/spec/channels/application_cable/connection_spec.rb b/spec/channels/application_cable/connection_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f3d671335282fefc1fa26d2f2d5022c6c998f209 --- /dev/null +++ b/spec/channels/application_cable/connection_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ApplicationCable::Connection, :clean_gitlab_redis_shared_state do + let(:session_id) { Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d') } + + before do + Gitlab::Redis::SharedState.with do |redis| + redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash)) + end + + cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id + end + + context 'when user is logged in' do + let(:user) { create(:user) } + let(:session_hash) { { 'warden.user.user.key' => [[user.id], user.encrypted_password[0, 29]] } } + + it 'sets current_user' do + connect + + expect(connection.current_user).to eq(user) + end + + context 'with a stale password' do + let(:partial_password_hash) { build(:user, password: 'some_old_password').encrypted_password[0, 29] } + let(:session_hash) { { 'warden.user.user.key' => [[user.id], partial_password_hash] } } + + it 'sets current_user to nil' do + connect + + expect(connection.current_user).to be_nil + end + end + end + + context 'when user is not logged in' do + let(:session_hash) { {} } + + it 'sets current_user to nil' do + connect + + expect(connection.current_user).to be_nil + end + end +end diff --git a/spec/channels/issues_channel_spec.rb b/spec/channels/issues_channel_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1c88cc734564a34471eac70fafa8a6dee40a74d9 --- /dev/null +++ b/spec/channels/issues_channel_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe IssuesChannel do + let_it_be(:issue) { create(:issue) } + + it 'rejects when project path is invalid' do + subscribe(project_path: 'invalid_project_path', iid: issue.iid) + + expect(subscription).to be_rejected + end + + it 'rejects when iid is invalid' do + subscribe(project_path: issue.project.full_path, iid: non_existing_record_iid) + + expect(subscription).to be_rejected + end + + it 'rejects when the user does not have access' do + stub_connection current_user: nil + + subscribe(project_path: issue.project.full_path, iid: issue.iid) + + expect(subscription).to be_rejected + end + + it 'subscribes to a stream when the user has access' do + stub_connection current_user: issue.author + + subscribe(project_path: issue.project.full_path, iid: issue.iid) + + expect(subscription).to be_confirmed + expect(subscription).to have_stream_for(issue) + end +end diff --git a/spec/lib/quality/test_level_spec.rb b/spec/lib/quality/test_level_spec.rb index 6042ab24787399c8a07b68e11fa43c04766482ba..b784a92fa85331edbfed90bfe1618a00e738af36 100644 --- a/spec/lib/quality/test_level_spec.rb +++ b/spec/lib/quality/test_level_spec.rb @@ -21,7 +21,7 @@ context 'when level is unit' do it 'returns a pattern' do expect(subject.pattern(:unit)) - .to eq("spec/{bin,config,db,dependencies,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,support_specs,tasks,uploaders,validators,views,workers,elastic_integration}{,/**/}*_spec.rb") + .to eq("spec/{bin,channels,config,db,dependencies,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,support_specs,tasks,uploaders,validators,views,workers,elastic_integration}{,/**/}*_spec.rb") end end @@ -89,7 +89,7 @@ context 'when level is unit' do it 'returns a regexp' do expect(subject.regexp(:unit)) - .to eq(%r{spec/(bin|config|db|dependencies|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|support_specs|tasks|uploaders|validators|views|workers|elastic_integration)}) + .to eq(%r{spec/(bin|channels|config|db|dependencies|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|support_specs|tasks|uploaders|validators|views|workers|elastic_integration)}) end end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index c32bef5a1a578b7cad8144324f25355d51a14ec1..556a0d605d59eb390ef6cae2d5fee4074e870e12 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -842,5 +842,33 @@ def update_issue(opts) let(:open_issuable) { issue } let(:closed_issuable) { create(:closed_issue, project: project) } end + + context 'real-time updates' do + let(:update_params) { { assignee_ids: [user2.id] } } + + context 'when broadcast_issue_updates is enabled' do + before do + stub_feature_flags(broadcast_issue_updates: true) + end + + it 'broadcasts to the issues channel' do + expect(IssuesChannel).to receive(:broadcast_to).with(issue, event: 'updated') + + update_issue(update_params) + end + end + + context 'when broadcast_issue_updates is disabled' do + before do + stub_feature_flags(broadcast_issue_updates: false) + end + + it 'does not broadcast to the issues channel' do + expect(IssuesChannel).not_to receive(:broadcast_to) + + update_issue(update_params) + end + end + end end end