mirror of https://github.com/rails/rails
Merge pull request #34845 from palkan/feature/action-cable-connection-testing
Add ActionCable::Connection::TestCase
This commit is contained in:
commit
907b528854
|
@ -15,6 +15,7 @@ module ActionCable
|
|||
autoload :StreamEventLoop
|
||||
autoload :Subscriptions
|
||||
autoload :TaggedLoggerProxy
|
||||
autoload :TestCase
|
||||
autoload :WebSocket
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue