# frozen_string_literal: true # :markup: markdown 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) assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block) end end class TestCookies < ActiveSupport::HashWithIndifferentAccess # :nodoc: def []=(name, options) value = options.is_a?(Hash) ? options.symbolize_keys[:value] : options super(name, value) 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 < TestCookies def signed @signed ||= TestCookies.new end def encrypted @encrypted ||= TestCookies.new end end class TestSocket # Make session and cookies available to the connection class Request < ActionDispatch::TestRequest attr_accessor :session, :cookie_jar end attr_reader :logger, :request, :transmissions, :closed, :env class << self def build_request(path, params: nil, headers: {}, session: {}, env: {}, cookies: nil) 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 Request.create(request_env).tap do |request| request.session = session.with_indifferent_access request.cookie_jar = cookies end end end def initialize(request) inner_logger = ActiveSupport::Logger.new(StringIO.new) tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger) @logger = ActionCable::Server::TaggedLoggerProxy.new(tagged_logging, tags: []) @request = request @env = request.env @connection = nil @closed = false @transmissions = [] end def transmit(data) @transmissions << data.with_indifferent_access end def close @closed = true end end # TestServer provides test pub/sub and executor implementations class TestServer attr_reader :streams, :config def initialize(server) @streams = Hash.new { |h, k| h[k] = [] } @config = server.config end alias_method :pubsub, :itself alias_method :executor, :itself #== Executor interface == # Inline async calls def post(&work) = work.call # We don't support timers in unit tests yet def timer(_every) = nil #== Pub/sub interface == def subscribe(stream, callback, success_callback = nil) @streams[stream] << callback success_callback&.call end def unsubscribe(stream, callback) @streams[stream].delete(callback) @streams.delete(stream) if @streams[stream].empty? end end # # Action Cable Connection TestCase # # Unit test Action Cable connections. # # Useful to check whether a connection's `identified_by` gets assigned properly # and that any improper connection requests are rejected. # # ## Basic example # # Unit tests are written as follows: # # 1. Simulate a connection attempt by calling `connect`. # 2. Assert state, e.g. identifiers, has been assigned. # # # class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase # def test_connects_with_proper_cookie # # Simulate the connection request with a cookie. # cookies["user_id"] = users(:john).id # # connect # # # Assert the connection identifier matches the fixture. # assert_equal users(:john).id, connection.user.id # end # # def test_rejects_connection_without_proper_cookie # assert_reject_connection { connect } # end # end # # `connect` accepts additional information about the HTTP request with the # `params`, `headers`, `session`, and Rack `env` options. # # def test_connect_with_headers_and_query_string # connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" } # # assert_equal "1", connection.user.id # assert_equal "secret-my", connection.token # end # # def test_connect_with_params # connect params: { user_id: 1 } # # assert_equal "1", connection.user.id # end # # You can also set up the correct cookies before the connection request: # # def test_connect_with_cookies # # Plain cookies: # cookies["user_id"] = 1 # # # Or signed/encrypted: # # cookies.signed["user_id"] = 1 # # cookies.encrypted["user_id"] = 1 # # connect # # assert_equal "1", connection.user_id # 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 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 attr_reader :connection, :socket, :testserver # Performs connection attempt to exert #connect on the connection under test. # # 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 – additional Rack env configuration (Hash) def connect(path = ActionCable.server.config.mount_path, server: ActionCable.server, **request_params) path ||= DEFAULT_PATH @socket = TestSocket.new(TestSocket.build_request(path, **request_params, cookies: cookies)) @testserver = Connection::TestServer.new(server) connection = self.class.connection_class.new(@testserver, socket) connection.connect if connection.respond_to?(:connect) # Only set instance variable if connected successfully @connection = connection end # Exert #disconnect on the connection under test. 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 def transmissions socket&.transmissions || [] end end include Behavior end end end