aboutsummaryrefslogtreecommitdiffstats
path: root/actioncable/lib/action_cable/connection/test_case.rb
blob: 233fd837e0ae2b0ab4a2a7e7cbffaa7dcbecd464 (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
# 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)
        assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block)
      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

    # 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 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 setup 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

          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 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, **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

        # 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

        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