aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--actioncable/lib/action_cable/connection.rb1
-rw-r--r--actioncable/lib/action_cable/connection/test_case.rb241
-rw-r--r--actioncable/test/connection/test_case_test.rb192
3 files changed, 434 insertions, 0 deletions
diff --git a/actioncable/lib/action_cable/connection.rb b/actioncable/lib/action_cable/connection.rb
index 804b89a707..20b5dbe78d 100644
--- a/actioncable/lib/action_cable/connection.rb
+++ b/actioncable/lib/action_cable/connection.rb
@@ -15,6 +15,7 @@ module ActionCable
autoload :StreamEventLoop
autoload :Subscriptions
autoload :TaggedLoggerProxy
+ autoload :TestCase
autoload :WebSocket
end
end
diff --git a/actioncable/lib/action_cable/connection/test_case.rb b/actioncable/lib/action_cable/connection/test_case.rb
new file mode 100644
index 0000000000..ac3bf32df5
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/test_case.rb
@@ -0,0 +1,241 @@
+# frozen_string_literal: true
+
+require "active_support"
+require "active_support/test_case"
+require "active_support/core_ext/hash/indifferent_access"
+require "action_dispatch"
+require "action_dispatch/http/headers"
+require "action_dispatch/testing/test_request"
+
+module ActionCable
+ module Connection
+ class NonInferrableConnectionError < ::StandardError
+ def initialize(name)
+ super "Unable to determine the connection to test from #{name}. " +
+ "You'll need to specify it using `tests YourConnection` in your " +
+ "test case definition."
+ end
+ end
+
+ module Assertions
+ # Asserts that the connection is rejected (via +reject_unauthorized_connection+).
+ #
+ # # Asserts that connection without user_id fails
+ # assert_reject_connection { connect params: { user_id: '' } }
+ def assert_reject_connection(&block)
+ res =
+ begin
+ block.call
+ false
+ rescue ActionCable::Connection::Authorization::UnauthorizedError
+ true
+ end
+
+ assert res, "Expected to reject connection but no rejection were made"
+ end
+ end
+
+ # We don't want to use the whole "encryption stack" for connection
+ # unit-tests, but we want to make sure that users test against the correct types
+ # of cookies (i.e. signed or encrypted or plain)
+ class TestCookieJar < ActiveSupport::HashWithIndifferentAccess
+ def signed
+ self[:signed] ||= {}.with_indifferent_access
+ end
+
+ def encrypted
+ self[:encrypted] ||= {}.with_indifferent_access
+ end
+ end
+
+ class TestRequest < ActionDispatch::TestRequest
+ attr_accessor :session, :cookie_jar
+
+ attr_writer :cookie_jar
+ end
+
+ module TestConnection
+ attr_reader :logger, :request
+
+ def initialize(request)
+ inner_logger = ActiveSupport::Logger.new(StringIO.new)
+ tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger)
+ @logger = ActionCable::Connection::TaggedLoggerProxy.new(tagged_logging, tags: [])
+ @request = request
+ @env = request.env
+ end
+ end
+
+ # Superclass for Action Cable connection unit tests.
+ #
+ # == Basic example
+ #
+ # Unit tests are written as follows:
+ # 1. First, one uses the +connect+ method to simulate connection.
+ # 2. Then, one asserts whether the current state is as expected (e.g. identifiers).
+ #
+ # For example:
+ #
+ # module ApplicationCable
+ # class ConnectionTest < ActionCable::Connection::TestCase
+ # def test_connects_with_cookies
+ # cookies["user_id"] = users[:john].id
+ # # Simulate a connection
+ # connect
+ #
+ # # Asserts that the connection identifier is correct
+ # assert_equal "John", connection.user.name
+ # end
+ #
+ # def test_does_not_connect_without_user
+ # assert_reject_connection do
+ # connect
+ # end
+ # end
+ # end
+ # end
+ #
+ # You can also provide additional information about underlying HTTP request
+ # (params, headers, session and Rack env):
+ #
+ # def test_connect_with_headers_and_query_string
+ # connect "/cable?user_id=1", headers: { "X-API-TOKEN" => 'secret-my' }
+ #
+ # assert_equal connection.user_id, "1"
+ # end
+ #
+ # def test_connect_with_params
+ # connect params: { user_id: 1 }
+ #
+ # assert_equal connection.user_id, "1"
+ # end
+ #
+ # You can also manage request cookies:
+ #
+ # def test_connect_with_cookies
+ # # plain cookies
+ # cookies["user_id"] = 1
+ # # or signed/encrypted
+ # # cookies.signed["user_id"] = 1
+ #
+ # connect
+ #
+ # assert_equal connection.user_id, "1"
+ # end
+ #
+ # == Connection is automatically inferred
+ #
+ # ActionCable::Connection::TestCase will automatically infer the connection under test
+ # from the test class name. If the channel cannot be inferred from the test
+ # class name, you can explicitly set it with +tests+.
+ #
+ # class ConnectionTest < ActionCable::Connection::TestCase
+ # tests ApplicationCable::Connection
+ # end
+ #
+ class TestCase < ActiveSupport::TestCase
+ module Behavior
+ extend ActiveSupport::Concern
+
+ DEFAULT_PATH = "/cable"
+
+ include ActiveSupport::Testing::ConstantLookup
+ include Assertions
+
+ included do
+ class_attribute :_connection_class
+
+ attr_reader :connection
+
+ ActiveSupport.run_load_hooks(:action_cable_connection_test_case, self)
+ end
+
+ module ClassMethods
+ def tests(connection)
+ case connection
+ when String, Symbol
+ self._connection_class = connection.to_s.camelize.constantize
+ when Module
+ self._connection_class = connection
+ else
+ raise NonInferrableConnectionError.new(connection)
+ end
+ end
+
+ def connection_class
+ if connection = self._connection_class
+ connection
+ else
+ tests determine_default_connection(name)
+ end
+ end
+
+ def determine_default_connection(name)
+ connection = determine_constant_from_test_name(name) do |constant|
+ Class === constant && constant < ActionCable::Connection::Base
+ end
+ raise NonInferrableConnectionError.new(name) if connection.nil?
+ connection
+ end
+ end
+
+ # Performs connection attempt (i.e. calls #connect method).
+ #
+ # Accepts request path as the first argument and the following request options:
+ # - params – url parameters (Hash)
+ # - headers – request headers (Hash)
+ # - session – session data (Hash)
+ # - env – addittional Rack env configuration (Hash)
+ def connect(path = ActionCable.server.config.mount_path, **request_params)
+ path ||= DEFAULT_PATH
+
+ connection = self.class.connection_class.allocate
+ connection.singleton_class.include(TestConnection)
+ connection.send(:initialize, build_test_request(path, request_params))
+ connection.connect if connection.respond_to?(:connect)
+
+ # Only set instance variable if connected successfully
+ @connection = connection
+ end
+
+ # Disconnect the connection under test (i.e. calls #disconnect)
+ def disconnect
+ raise "Must be connected!" if connection.nil?
+
+ connection.disconnect if connection.respond_to?(:disconnect)
+ @connection = nil
+ end
+
+ def cookies
+ @cookie_jar ||= TestCookieJar.new
+ end
+
+ private
+
+ def build_test_request(path, params: nil, headers: {}, session: {}, env: {})
+ wrapped_headers = ActionDispatch::Http::Headers.from_hash(headers)
+
+ uri = URI.parse(path)
+
+ query_string = params.nil? ? uri.query : params.to_query
+
+ request_env = {
+ "QUERY_STRING" => query_string,
+ "PATH_INFO" => uri.path
+ }.merge(env)
+
+ if wrapped_headers.present?
+ ActionDispatch::Http::Headers.from_hash(request_env).merge!(wrapped_headers)
+ end
+
+ TestRequest.create(request_env).tap do |request|
+ request.session = session.with_indifferent_access
+ request.cookie_jar = cookies
+ end
+ end
+ end
+
+ include Behavior
+ end
+ end
+end
diff --git a/actioncable/test/connection/test_case_test.rb b/actioncable/test/connection/test_case_test.rb
new file mode 100644
index 0000000000..76cfb2c07c
--- /dev/null
+++ b/actioncable/test/connection/test_case_test.rb
@@ -0,0 +1,192 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class SimpleConnection < ActionCable::Connection::Base
+ identified_by :user_id
+
+ class << self
+ attr_accessor :disconnected_user_id
+ end
+
+ def connect
+ self.user_id = request.params[:user_id] || cookies[:user_id]
+ end
+
+ def disconnect
+ self.class.disconnected_user_id = user_id
+ end
+end
+
+class ConnectionSimpleTest < ActionCable::Connection::TestCase
+ tests SimpleConnection
+
+ def test_connected
+ connect
+
+ assert_nil connection.user_id
+ end
+
+ def test_url_params
+ connect "/cable?user_id=323"
+
+ assert_equal "323", connection.user_id
+ end
+
+ def test_params
+ connect params: { user_id: 323 }
+
+ assert_equal "323", connection.user_id
+ end
+
+ def test_plain_cookie
+ cookies["user_id"] = "456"
+
+ connect
+
+ assert_equal "456", connection.user_id
+ end
+
+ def test_disconnect
+ cookies["user_id"] = "456"
+
+ connect
+
+ assert_equal "456", connection.user_id
+
+ disconnect
+
+ assert_equal "456", SimpleConnection.disconnected_user_id
+ end
+end
+
+class Connection < ActionCable::Connection::Base
+ identified_by :current_user_id
+ identified_by :token
+
+ class << self
+ attr_accessor :disconnected_user_id
+ end
+
+ def connect
+ self.current_user_id = verify_user
+ self.token = request.headers["X-API-TOKEN"]
+ logger.add_tags("ActionCable")
+ end
+
+ private
+
+ def verify_user
+ reject_unauthorized_connection unless cookies.signed[:user_id].present?
+ cookies.signed[:user_id]
+ end
+end
+
+class ConnectionTest < ActionCable::Connection::TestCase
+ def test_connected_with_signed_cookies_and_headers
+ cookies.signed["user_id"] = "456"
+
+ connect headers: { "X-API-TOKEN" => "abc" }
+
+ assert_equal "abc", connection.token
+ assert_equal "456", connection.current_user_id
+ end
+
+ def test_connected_when_no_signed_cookies_set
+ cookies["user_id"] = "456"
+
+ assert_reject_connection { connect }
+ end
+
+ def test_connection_rejected
+ assert_reject_connection { connect }
+ end
+end
+
+class EncryptedCookiesConnection < ActionCable::Connection::Base
+ identified_by :user_id
+
+ def connect
+ self.user_id = verify_user
+ end
+
+ private
+
+ def verify_user
+ reject_unauthorized_connection unless cookies.encrypted[:user_id].present?
+ cookies.encrypted[:user_id]
+ end
+end
+
+class EncryptedCookiesConnectionTest < ActionCable::Connection::TestCase
+ tests EncryptedCookiesConnection
+
+ def test_connected_with_encrypted_cookies
+ cookies.encrypted["user_id"] = "456"
+
+ connect
+
+ assert_equal "456", connection.user_id
+ end
+
+ def test_connection_rejected
+ assert_reject_connection { connect }
+ end
+end
+
+class SessionConnection < ActionCable::Connection::Base
+ identified_by :user_id
+
+ def connect
+ self.user_id = verify_user
+ end
+
+ private
+
+ def verify_user
+ reject_unauthorized_connection unless request.session[:user_id].present?
+ request.session[:user_id]
+ end
+end
+
+class SessionConnectionTest < ActionCable::Connection::TestCase
+ tests SessionConnection
+
+ def test_connected_with_encrypted_cookies
+ connect session: { user_id: "789" }
+ assert_equal "789", connection.user_id
+ end
+
+ def test_connection_rejected
+ assert_reject_connection { connect }
+ end
+end
+
+class EnvConnection < ActionCable::Connection::Base
+ identified_by :user
+
+ def connect
+ self.user = verify_user
+ end
+
+ private
+
+ def verify_user
+ # Warden-like authentication
+ reject_unauthorized_connection unless env["authenticator"]&.user.present?
+ env["authenticator"].user
+ end
+end
+
+class EnvConnectionTest < ActionCable::Connection::TestCase
+ tests EnvConnection
+
+ def test_connected_with_env
+ connect env: { "authenticator" => OpenStruct.new(user: "David") }
+ assert_equal "David", connection.user
+ end
+
+ def test_connection_rejected
+ assert_reject_connection { connect }
+ end
+end