castle-rb 5.0.0 → 6.0.0

Sign up to get free protection for your applications and to get access to all the features.
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