castle-rb 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +107 -33
  3. data/lib/castle.rb +46 -22
  4. data/lib/castle/api.rb +22 -13
  5. data/lib/castle/api/approve_device.rb +25 -0
  6. data/lib/castle/api/authenticate.rb +34 -0
  7. data/lib/castle/api/end_impersonation.rb +29 -0
  8. data/lib/castle/api/get_device.rb +25 -0
  9. data/lib/castle/api/get_devices_for_user.rb +25 -0
  10. data/lib/castle/api/identify.rb +26 -0
  11. data/lib/castle/api/report_device.rb +25 -0
  12. data/lib/castle/api/review.rb +24 -0
  13. data/lib/castle/api/start_impersonation.rb +29 -0
  14. data/lib/castle/api/track.rb +26 -0
  15. data/lib/castle/client.rb +48 -62
  16. data/lib/castle/{extractors/client_id.rb → client_id/extract.rb} +2 -2
  17. data/lib/castle/commands/approve_device.rb +21 -0
  18. data/lib/castle/commands/authenticate.rb +13 -13
  19. data/lib/castle/commands/end_impersonation.rb +25 -0
  20. data/lib/castle/commands/get_device.rb +21 -0
  21. data/lib/castle/commands/get_devices_for_user.rb +21 -0
  22. data/lib/castle/commands/identify.rb +12 -13
  23. data/lib/castle/commands/report_device.rb +21 -0
  24. data/lib/castle/commands/review.rb +6 -3
  25. data/lib/castle/commands/start_impersonation.rb +25 -0
  26. data/lib/castle/commands/track.rb +12 -13
  27. data/lib/castle/configuration.rb +17 -19
  28. data/lib/castle/context/{default.rb → get_default.rb} +5 -6
  29. data/lib/castle/context/{merger.rb → merge.rb} +3 -3
  30. data/lib/castle/context/prepare.rb +18 -0
  31. data/lib/castle/context/{sanitizer.rb → sanitize.rb} +1 -1
  32. data/lib/castle/core/get_connection.rb +25 -0
  33. data/lib/castle/{api/response.rb → core/process_response.rb} +4 -2
  34. data/lib/castle/core/process_webhook.rb +20 -0
  35. data/lib/castle/core/send_request.rb +50 -0
  36. data/lib/castle/errors.rb +2 -0
  37. data/lib/castle/events.rb +1 -1
  38. data/lib/castle/failover/prepare_response.rb +23 -0
  39. data/lib/castle/failover/strategy.rb +20 -0
  40. data/lib/castle/{extractors/headers.rb → headers/extract.rb} +8 -6
  41. data/lib/castle/headers/filter.rb +37 -0
  42. data/lib/castle/headers/format.rb +24 -0
  43. data/lib/castle/{extractors/ip.rb → ip/extract.rb} +8 -7
  44. data/lib/castle/logger.rb +19 -0
  45. data/lib/castle/payload/prepare.rb +27 -0
  46. data/lib/castle/secure_mode.rb +6 -2
  47. data/lib/castle/session.rb +18 -0
  48. data/lib/castle/singleton_configuration.rb +9 -0
  49. data/lib/castle/utils/clean_invalid_chars.rb +24 -0
  50. data/lib/castle/utils/clone.rb +15 -0
  51. data/lib/castle/utils/deep_symbolize_keys.rb +45 -0
  52. data/lib/castle/utils/get_timestamp.rb +15 -0
  53. data/lib/castle/utils/{merger.rb → merge.rb} +3 -3
  54. data/lib/castle/utils/secure_compare.rb +22 -0
  55. data/lib/castle/validators/not_supported.rb +1 -0
  56. data/lib/castle/validators/present.rb +1 -0
  57. data/lib/castle/verdict.rb +13 -0
  58. data/lib/castle/version.rb +1 -1
  59. data/lib/castle/webhooks/verify.rb +43 -0
  60. data/spec/integration/rails/rails_spec.rb +33 -7
  61. data/spec/integration/rails/support/application.rb +3 -1
  62. data/spec/integration/rails/support/home_controller.rb +47 -5
  63. data/spec/lib/castle/api/approve_device_spec.rb +21 -0
  64. data/spec/lib/castle/api/authenticate_spec.rb +140 -0
  65. data/spec/lib/castle/api/end_impersonation_spec.rb +59 -0
  66. data/spec/lib/castle/api/get_device_spec.rb +19 -0
  67. data/spec/lib/castle/api/get_devices_for_user_spec.rb +19 -0
  68. data/spec/lib/castle/api/identify_spec.rb +68 -0
  69. data/spec/lib/castle/api/report_device_spec.rb +21 -0
  70. data/spec/lib/castle/{review_spec.rb → api/review_spec.rb} +3 -3
  71. data/spec/lib/castle/api/start_impersonation_spec.rb +59 -0
  72. data/spec/lib/castle/api/track_spec.rb +68 -0
  73. data/spec/lib/castle/api_spec.rb +16 -1
  74. data/spec/lib/castle/{extractors/client_id_spec.rb → client_id/extract_spec.rb} +2 -2
  75. data/spec/lib/castle/client_spec.rb +39 -21
  76. data/spec/lib/castle/commands/approve_device_spec.rb +24 -0
  77. data/spec/lib/castle/commands/authenticate_spec.rb +7 -16
  78. data/spec/lib/castle/commands/end_impersonation_spec.rb +82 -0
  79. data/spec/lib/castle/commands/get_device_spec.rb +24 -0
  80. data/spec/lib/castle/commands/get_devices_for_user_spec.rb +24 -0
  81. data/spec/lib/castle/commands/identify_spec.rb +5 -16
  82. data/spec/lib/castle/commands/report_device_spec.rb +24 -0
  83. data/spec/lib/castle/commands/review_spec.rb +1 -1
  84. data/spec/lib/castle/commands/{impersonate_spec.rb → start_impersonation_spec.rb} +7 -32
  85. data/spec/lib/castle/commands/track_spec.rb +5 -16
  86. data/spec/lib/castle/configuration_spec.rb +9 -138
  87. data/spec/lib/castle/context/{default_spec.rb → get_default_spec.rb} +1 -2
  88. data/spec/lib/castle/context/{merger_spec.rb → merge_spec.rb} +1 -1
  89. data/spec/lib/castle/context/prepare_spec.rb +44 -0
  90. data/spec/lib/castle/context/{sanitizer_spec.rb → sanitize_spec.rb} +1 -1
  91. data/spec/lib/castle/{api/connection_spec.rb → core/get_connection_spec.rb} +3 -3
  92. data/spec/lib/castle/{api/response_spec.rb → core/process_response_spec.rb} +56 -1
  93. data/spec/lib/castle/core/process_webhook_spec.rb +46 -0
  94. data/spec/lib/castle/{api/request_spec.rb → core/send_request_spec.rb} +20 -16
  95. data/spec/lib/castle/failover/strategy_spec.rb +12 -0
  96. data/spec/lib/castle/{extractors/headers_spec.rb → headers/extract_spec.rb} +7 -7
  97. data/spec/lib/castle/{headers_filter_spec.rb → headers/filter_spec.rb} +3 -3
  98. data/spec/lib/castle/headers/format_spec.rb +25 -0
  99. data/spec/lib/castle/{extractors/ip_spec.rb → ip/extract_spec.rb} +1 -1
  100. data/spec/lib/castle/logger_spec.rb +42 -0
  101. data/spec/lib/castle/payload/prepare_spec.rb +54 -0
  102. data/spec/lib/castle/{api/session_spec.rb → session_spec.rb} +6 -4
  103. data/spec/lib/castle/singleton_configuration_spec.rb +18 -0
  104. data/spec/lib/castle/utils/clean_invalid_chars_spec.rb +69 -0
  105. data/spec/lib/castle/utils/{cloner_spec.rb → clone_spec.rb} +3 -3
  106. data/spec/lib/castle/utils/deep_symbolize_keys_spec.rb +50 -0
  107. data/spec/lib/castle/utils/{timestamp_spec.rb → get_timestamp_spec.rb} +1 -1
  108. data/spec/lib/castle/utils/{merger_spec.rb → merge_spec.rb} +3 -3
  109. data/spec/lib/castle/verdict_spec.rb +9 -0
  110. data/spec/lib/castle/webhooks/verify_spec.rb +69 -0
  111. data/spec/spec_helper.rb +2 -0
  112. data/spec/support/shared_examples/configuration.rb +129 -0
  113. metadata +129 -57
  114. data/lib/castle/api/connection.rb +0 -24
  115. data/lib/castle/api/request.rb +0 -42
  116. data/lib/castle/api/session.rb +0 -20
  117. data/lib/castle/commands/impersonate.rb +0 -26
  118. data/lib/castle/failover_auth_response.rb +0 -21
  119. data/lib/castle/headers_filter.rb +0 -35
  120. data/lib/castle/headers_formatter.rb +0 -22
  121. data/lib/castle/review.rb +0 -11
  122. data/lib/castle/utils.rb +0 -55
  123. data/lib/castle/utils/cloner.rb +0 -11
  124. data/lib/castle/utils/timestamp.rb +0 -12
  125. data/spec/lib/castle/headers_formatter_spec.rb +0 -25
  126. data/spec/lib/castle/utils_spec.rb +0 -156
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # handles verdict consts
5
+ module Verdict
6
+ # allow
7
+ ALLOW = 'allow'
8
+ # deny
9
+ DENY = 'deny'
10
+ # challenge
11
+ CHALLENGE = 'challenge'
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Castle
4
- VERSION = '5.0.0'
4
+ VERSION = '6.0.0'
5
5
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ module Webhooks
5
+ # Verify a webhook
6
+ class Verify
7
+ class << self
8
+ # Checks if webhook is valid
9
+ # @param webhook [Request]
10
+ # @param config [Castle::Configuration, Castle::SingletonConfiguration]
11
+ def call(webhook, config = Castle.config)
12
+ expected_signature = compute_signature(webhook, config.api_secret)
13
+ signature = webhook.env['HTTP_X_CASTLE_SIGNATURE']
14
+ verify_signature(signature, expected_signature)
15
+ end
16
+
17
+ private
18
+
19
+ # Computes a webhook signature using provided user_id
20
+ # @param webhook [Request]
21
+ # @param api_secret [String]
22
+ def compute_signature(webhook, api_secret)
23
+ Base64.encode64(
24
+ OpenSSL::HMAC.digest(
25
+ OpenSSL::Digest.new('sha256'),
26
+ api_secret,
27
+ Castle::Core::ProcessWebhook.call(webhook)
28
+ )
29
+ ).strip
30
+ end
31
+
32
+ # Check if the signatures are matching
33
+ # @param signature [String] first signature to be compared
34
+ # @param expected_signature [String] second signature to be compared
35
+ def verify_signature(signature, expected_signature)
36
+ return if Castle::Utils::SecureCompare.call(signature, expected_signature)
37
+
38
+ raise Castle::WebhookVerificationError, 'Signature not matching the expected signature'
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -4,7 +4,7 @@ require 'spec_helper'
4
4
  require_relative 'support/all'
5
5
 
6
6
  RSpec.describe HomeController, type: :request do
7
- describe '#index' do
7
+ context 'with index pages' do
8
8
  let(:request) do
9
9
  {
10
10
  'event' => '$login.succeeded',
@@ -16,7 +16,6 @@ RSpec.describe HomeController, type: :request do
16
16
  'context' => {
17
17
  'client_id' => '',
18
18
  'active' => true,
19
- 'origin' => 'web',
20
19
  'headers' => {
21
20
  'Accept' => 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5',
22
21
  'Authorization' => true,
@@ -45,17 +44,44 @@ RSpec.describe HomeController, type: :request do
45
44
  before do
46
45
  Timecop.freeze(now)
47
46
  stub_request(:post, 'https://api.castle.io/v1/track')
48
- get '/', headers: headers
49
47
  end
50
48
 
51
49
  after { Timecop.return }
52
50
 
53
- it do
54
- assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req|
55
- JSON.parse(req.body) == request
51
+ describe '#index1' do
52
+ before { get '/index1', headers: headers }
53
+
54
+ it do
55
+ assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req|
56
+ JSON.parse(req.body) == request
57
+ end
56
58
  end
59
+
60
+ it { expect(response).to be_successful }
57
61
  end
58
62
 
59
- it { expect(response).to be_successful }
63
+ describe '#index2' do
64
+ before { get '/index2', headers: headers }
65
+
66
+ it do
67
+ assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req|
68
+ JSON.parse(req.body) == request
69
+ end
70
+ end
71
+
72
+ it { expect(response).to be_successful }
73
+ end
74
+
75
+ describe '#index3' do
76
+ before { get '/index3', headers: headers }
77
+
78
+ it do
79
+ assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req|
80
+ JSON.parse(req.body) == request
81
+ end
82
+ end
83
+
84
+ it { expect(response).to be_successful }
85
+ end
60
86
  end
61
87
  end
@@ -10,6 +10,8 @@ class TestApp < Rails::Application
10
10
  Rails.logger = config.logger
11
11
 
12
12
  routes.draw do
13
- get '/' => 'home#index'
13
+ get '/index1' => 'home#index1'
14
+ get '/index2' => 'home#index2'
15
+ get '/index3' => 'home#index3'
14
16
  end
15
17
  end
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class HomeController < ActionController::Base
4
- def index
5
- request_context = ::Castle::Client.to_context(request)
6
- track_options = ::Castle::Client.to_options(
4
+ # prepare context and calling track with client example
5
+ def index1
6
+ request_context = ::Castle::Context::Prepare.call(request)
7
+ payload = {
7
8
  event: '$login.succeeded',
8
9
  user_id: '123',
9
10
  properties: {
@@ -12,9 +13,50 @@ class HomeController < ActionController::Base
12
13
  user_traits: {
13
14
  key: 'value'
14
15
  }
16
+ }
17
+ client = ::Castle::Client.new(context: request_context)
18
+ client.track(payload)
19
+
20
+ render inline: 'hello'
21
+ end
22
+
23
+ # prepare payload and calling track with client example
24
+ def index2
25
+ payload = ::Castle::Payload::Prepare.call(
26
+ {
27
+ event: '$login.succeeded',
28
+ user_id: '123',
29
+ properties: {
30
+ key: 'value'
31
+ },
32
+ user_traits: {
33
+ key: 'value'
34
+ }
35
+ },
36
+ request
37
+ )
38
+ client = ::Castle::Client.new
39
+ client.track(payload)
40
+
41
+ render inline: 'hello'
42
+ end
43
+
44
+ # prepare payload and calling track with direct API::Track service
45
+ def index3
46
+ payload = ::Castle::Payload::Prepare.call(
47
+ {
48
+ event: '$login.succeeded',
49
+ user_id: '123',
50
+ properties: {
51
+ key: 'value'
52
+ },
53
+ user_traits: {
54
+ key: 'value'
55
+ }
56
+ },
57
+ request
15
58
  )
16
- client = ::Castle::Client.new(request_context)
17
- client.track(track_options)
59
+ Castle::API::Track.call(payload)
18
60
 
19
61
  render inline: 'hello'
20
62
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe Castle::API::ApproveDevice do
4
+ before do
5
+ stub_request(:any, /api.castle.io/).with(
6
+ basic_auth: ['', 'secret']
7
+ ).to_return(status: 200, body: '{}', headers: {})
8
+ end
9
+
10
+ describe '.call' do
11
+ subject(:retrieve) { described_class.call(device_token: device_token) }
12
+
13
+ let(:device_token) { '1234' }
14
+
15
+ before { retrieve }
16
+
17
+ it do
18
+ assert_requested :put, "https://api.castle.io/v1/devices/#{device_token}/approve", times: 1
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe Castle::API::Authenticate do
4
+ subject(:call_subject) { described_class.call(options) }
5
+
6
+ let(:ip) { '1.2.3.4' }
7
+ let(:cookie_id) { 'abcd' }
8
+ let(:ua) { 'Chrome' }
9
+ let(:env) do
10
+ Rack::MockRequest.env_for(
11
+ '/',
12
+ 'HTTP_USER_AGENT' => ua,
13
+ 'HTTP_X_FORWARDED_FOR' => ip,
14
+ 'HTTP_COOKIE' => "__cid=#{cookie_id};other=efgh"
15
+ )
16
+ end
17
+ let(:request) { Rack::Request.new(env) }
18
+ let(:context) { Castle::Context::Prepare.call(request) }
19
+ let(:time_now) { Time.now }
20
+ let(:time_auto) { time_now.utc.iso8601(3) }
21
+ let(:time_user) { (Time.now - 10_000).utc.iso8601(3) }
22
+ let(:response_body) { {}.to_json }
23
+
24
+ before do
25
+ Timecop.freeze(time_now)
26
+ stub_const('Castle::VERSION', '2.2.0')
27
+ end
28
+
29
+ after { Timecop.return }
30
+
31
+ describe '.call' do
32
+ let(:request_body) do
33
+ { event: '$login.succeeded', context: context, user_id: '1234',
34
+ sent_at: time_auto }
35
+ end
36
+
37
+ context 'when used with symbol keys' do
38
+ before do
39
+ stub_request(:any, /api.castle.io/).with(
40
+ basic_auth: ['', 'secret']
41
+ ).to_return(status: 200, body: response_body, headers: {})
42
+ call_subject
43
+ end
44
+
45
+ let(:options) { { event: '$login.succeeded', user_id: '1234', context: context } }
46
+
47
+ it do
48
+ assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req|
49
+ JSON.parse(req.body) == JSON.parse(request_body.to_json)
50
+ end
51
+ end
52
+
53
+ context 'when passed timestamp in options and no defined timestamp' do
54
+ let(:options) do
55
+ { event: '$login.succeeded', user_id: '1234', timestamp: time_user, context: context }
56
+ end
57
+ let(:request_body) do
58
+ { event: '$login.succeeded', user_id: '1234', context: context,
59
+ timestamp: time_user, sent_at: time_auto }
60
+ end
61
+
62
+ it do
63
+ assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req|
64
+ JSON.parse(req.body) == JSON.parse(request_body.to_json)
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ context 'when denied' do
71
+ let(:failover_appendix) { { failover: false, failover_reason: nil } }
72
+
73
+ let(:options) { { event: '$login.succeeded', user_id: '1234', context: context } }
74
+
75
+ context 'when denied without any risk policy' do
76
+ let(:response_body) { deny_response_without_rp.to_json }
77
+ let(:deny_response_without_rp) do
78
+ {
79
+ action: 'deny',
80
+ user_id: '12345',
81
+ device_token: 'abcdefg1234'
82
+ }
83
+ end
84
+ let(:deny_without_rp_failover_result) do
85
+ deny_response_without_rp.merge(failover_appendix)
86
+ end
87
+
88
+ before do
89
+ stub_request(:any, /api.castle.io/).with(
90
+ basic_auth: ['', 'secret']
91
+ ).to_return(status: 200, body: deny_response_without_rp.to_json, headers: {})
92
+ call_subject
93
+ end
94
+
95
+ it do
96
+ assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req|
97
+ JSON.parse(req.body) == JSON.parse(request_body.to_json)
98
+ end
99
+ end
100
+
101
+ it { expect(call_subject).to eql(deny_without_rp_failover_result) }
102
+ end
103
+
104
+ context 'when denied with risk policy' do
105
+ let(:deny_response_with_rp) do
106
+ {
107
+ action: 'deny',
108
+ user_id: '12345',
109
+ device_token: 'abcdefg1234',
110
+ risk_policy: {
111
+ id: 'q-rbeMzBTdW2Fd09sbz55A',
112
+ revision_id: 'pke4zqO2TnqVr-NHJOAHEg',
113
+ name: 'Block Users from X',
114
+ type: 'bot'
115
+ }
116
+ }
117
+ end
118
+ let(:response_body) { deny_response_with_rp.to_json }
119
+ let(:deny_with_rp_failover_result) do
120
+ deny_response_with_rp.merge(failover_appendix)
121
+ end
122
+
123
+ before do
124
+ stub_request(:any, /api.castle.io/).with(
125
+ basic_auth: ['', 'secret']
126
+ ).to_return(status: 200, body: deny_response_with_rp.to_json, headers: {})
127
+ call_subject
128
+ end
129
+
130
+ it do
131
+ assert_requested :post, 'https://api.castle.io/v1/authenticate', times: 1 do |req|
132
+ JSON.parse(req.body) == JSON.parse(request_body.to_json)
133
+ end
134
+ end
135
+
136
+ it { expect(call_subject).to eql(deny_with_rp_failover_result) }
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe Castle::API::EndImpersonation do
4
+ subject(:call) { described_class.call(options) }
5
+
6
+ let(:ip) { '1.2.3.4' }
7
+ let(:cookie_id) { 'abcd' }
8
+ let(:ua) { 'Chrome' }
9
+ let(:env) do
10
+ Rack::MockRequest.env_for(
11
+ '/',
12
+ 'HTTP_USER_AGENT' => ua,
13
+ 'HTTP_X_FORWARDED_FOR' => ip,
14
+ 'HTTP_COOKIE' => "__cid=#{cookie_id};other=efgh"
15
+ )
16
+ end
17
+ let(:request) { Rack::Request.new(env) }
18
+ let(:context) { Castle::Context::Prepare.call(request) }
19
+ let(:time_now) { Time.now }
20
+ let(:time_auto) { time_now.utc.iso8601(3) }
21
+
22
+ before do
23
+ Timecop.freeze(time_now)
24
+ stub_const('Castle::VERSION', '2.2.0')
25
+ stub_request(:any, /api.castle.io/).with(
26
+ basic_auth: ['', 'secret']
27
+ ).to_return(status: 200, body: response_body, headers: {})
28
+ end
29
+
30
+ after { Timecop.return }
31
+
32
+ describe 'call' do
33
+ let(:impersonator) { 'test@castle.io' }
34
+ let(:request_body) do
35
+ { user_id: '1234', sent_at: time_auto,
36
+ properties: { impersonator: impersonator }, context: context }
37
+ end
38
+ let(:response_body) { { success: true }.to_json }
39
+ let(:options) do
40
+ { user_id: '1234', properties: { impersonator: impersonator }, context: context }
41
+ end
42
+
43
+ context 'when used with symbol keys' do
44
+ before { call }
45
+
46
+ it do
47
+ assert_requested :delete, 'https://api.castle.io/v1/impersonate', times: 1 do |req|
48
+ JSON.parse(req.body) == JSON.parse(request_body.to_json)
49
+ end
50
+ end
51
+ end
52
+
53
+ context 'when request is not successful' do
54
+ let(:response_body) { {}.to_json }
55
+
56
+ it { expect { call }.to raise_error(Castle::ImpersonationFailed) }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe Castle::API::GetDevice do
4
+ before do
5
+ stub_request(:any, /api.castle.io/).with(
6
+ basic_auth: ['', 'secret']
7
+ ).to_return(status: 200, body: '{}', headers: {})
8
+ end
9
+
10
+ describe '.call' do
11
+ subject(:retrieve) { described_class.call(device_token: device_token) }
12
+
13
+ let(:device_token) { '1234' }
14
+
15
+ before { retrieve }
16
+
17
+ it { assert_requested :get, "https://api.castle.io/v1/devices/#{device_token}", times: 1 }
18
+ end
19
+ end