aboutsummaryrefslogtreecommitdiffstats
path: root/actioncable/lib/action_cable/connection/test_case.rb
blob: ac3bf32df56aa104d547b1d577d77f278909c2bf (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
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