cloudstack_client 1.5.12 → 2.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.
@@ -0,0 +1,364 @@
1
+ require "test_helper"
2
+
3
+ describe CloudstackClient::Connection do
4
+ let(:connection) do
5
+ CloudstackClient::Connection.new(
6
+ TestHelpers::TEST_URL,
7
+ TestHelpers::TEST_KEY,
8
+ TestHelpers::TEST_SECRET,
9
+ quiet: true
10
+ )
11
+ end
12
+
13
+ describe "input validation" do
14
+ it "raises when the API URL is missing" do
15
+ _(proc { CloudstackClient::Connection.new(nil, "k", "s") })
16
+ .must_raise CloudstackClient::InputError
17
+ end
18
+
19
+ it "raises when the API key is missing" do
20
+ _(proc { CloudstackClient::Connection.new("http://url", nil, "s") })
21
+ .must_raise CloudstackClient::InputError
22
+ end
23
+
24
+ it "raises when the secret key is missing" do
25
+ _(proc { CloudstackClient::Connection.new("http://url", "k", nil) })
26
+ .must_raise CloudstackClient::InputError
27
+ end
28
+
29
+ it "raises when the async poll interval is below 1 second" do
30
+ error = _(proc {
31
+ CloudstackClient::Connection.new("http://url", "k", "s", async_poll_interval: 0.5)
32
+ }).must_raise CloudstackClient::InputError
33
+ _(error.message).must_match(/POLL INTERVAL/)
34
+ end
35
+
36
+ it "raises when the async timeout is below 60 seconds" do
37
+ error = _(proc {
38
+ CloudstackClient::Connection.new("http://url", "k", "s", async_timeout: 30)
39
+ }).must_raise CloudstackClient::InputError
40
+ _(error.message).must_match(/ASYNC TIMEOUT/)
41
+ end
42
+
43
+ it "raises when request retries is below 1" do
44
+ error = _(proc {
45
+ CloudstackClient::Connection.new("http://url", "k", "s", request_retries: 0)
46
+ }).must_raise CloudstackClient::InputError
47
+ _(error.message).must_match(/REQUEST RETRIES/)
48
+ end
49
+
50
+ it "applies the documented defaults" do
51
+ _(connection.read_timeout).must_equal CloudstackClient::Connection::DEF_REQ_TIMEOUT
52
+ _(connection.async_timeout).must_equal CloudstackClient::Connection::DEF_ASYNC_TIMEOUT
53
+ _(connection.async_poll_interval).must_equal CloudstackClient::Connection::DEF_POLL_INTERVAL
54
+ _(connection.request_retries).must_equal CloudstackClient::Connection::DEF_REQUEST_RETRIES
55
+ _(connection.verify_ssl).must_equal true
56
+ _(connection).wont_respond_to :secret_key=
57
+ end
58
+
59
+ it "allows SSL verification to be disabled explicitly" do
60
+ configured = CloudstackClient::Connection.new(
61
+ TestHelpers::TEST_URL,
62
+ TestHelpers::TEST_KEY,
63
+ TestHelpers::TEST_SECRET,
64
+ verify_ssl: false,
65
+ ca_file: "/tmp/cloudstack-ca.pem"
66
+ )
67
+
68
+ _(configured.verify_ssl).must_equal false
69
+ _(configured.ca_file).must_equal "/tmp/cloudstack-ca.pem"
70
+ end
71
+ end
72
+
73
+ describe "request signing" do
74
+ # Golden value: HMAC-SHA1 of the downcased payload, Base64 encoded and then
75
+ # URL escaped. Pinned so a change to the signing algorithm fails loudly.
76
+ it "signs the downcased payload with HMAC-SHA1" do
77
+ expected = CGI.escape(
78
+ Base64.encode64(
79
+ OpenSSL::HMAC.digest("sha1", TestHelpers::TEST_SECRET, "apikey=test-key&command=listzones")
80
+ ).chomp
81
+ )
82
+
83
+ _(connection.send(:create_signature, "apiKey=test-key&command=listZones"))
84
+ .must_equal expected
85
+ end
86
+
87
+ it "is case insensitive, because the payload is downcased before signing" do
88
+ _(connection.send(:create_signature, "command=listZones"))
89
+ .must_equal connection.send(:create_signature, "COMMAND=LISTZONES")
90
+ end
91
+
92
+ it "sends the signature and apiKey with the request" do
93
+ stub_list("listZones", "zone", [{ "id" => "1" }])
94
+ connection.send_request("command" => "listZones")
95
+
96
+ params = last_request_params
97
+ _(params["apiKey"].first).must_equal TestHelpers::TEST_KEY
98
+ _(params["response"].first).must_equal "json"
99
+ _(params["signature"].first).wont_be_nil
100
+ end
101
+ end
102
+
103
+ describe "parameter serialization" do
104
+ it "sorts parameters, since the signature depends on their order" do
105
+ _(connection.send(:params_to_data, "z" => "1", "a" => "2", "m" => "3"))
106
+ .must_equal "a=2&m=3&z=1"
107
+ end
108
+
109
+ it "serializes an Array of Hashes as an indexed map" do
110
+ data = connection.send(:params_to_data,
111
+ "tags" => [{ "key" => "role", "value" => "web" }])
112
+
113
+ _(data).must_equal "tags[0].key=role&tags[0].value=web"
114
+ end
115
+
116
+ it "serializes a Hash as an indexed key/value map" do
117
+ data = connection.send(:params_to_data, "details" => { "cpu" => "2" })
118
+
119
+ _(data).must_equal "details[0].key=cpu&details[0].value=2"
120
+ end
121
+
122
+ it "escapes spaces as %20 rather than +" do
123
+ _(connection.send(:escape, "my machine")).must_equal "my%20machine"
124
+ end
125
+
126
+ it "leaves asterisks unescaped" do
127
+ _(connection.send(:escape, "*")).must_equal "*"
128
+ end
129
+
130
+ it "escapes reserved characters" do
131
+ _(connection.send(:escape, "a&b=c")).must_equal "a%26b%3Dc"
132
+ end
133
+ end
134
+
135
+ describe "response unwrapping" do
136
+ it "returns the collection from a count plus collection response" do
137
+ stub_list("listZones", "zone", [{ "id" => "1" }, { "id" => "2" }])
138
+
139
+ _(connection.send_request("command" => "listZones"))
140
+ .must_equal [{ "id" => "1" }, { "id" => "2" }]
141
+ end
142
+
143
+ it "keeps the count when include_count is requested" do
144
+ stub_list("listZones", "zone", [{ "id" => "1" }])
145
+
146
+ result = connection.send_request({ "command" => "listZones" }, include_count: true)
147
+ _(result["count"]).must_equal 1
148
+ _(result["zone"]).must_equal [{ "id" => "1" }]
149
+ end
150
+
151
+ it "returns an empty Array for a zero count response" do
152
+ stub_command("listZones", { "listzonesresponse" => { "count" => 0 } })
153
+
154
+ _(connection.send_request("command" => "listZones")).must_equal []
155
+ end
156
+
157
+ it "unwraps a single nested Hash" do
158
+ stub_command("createUser", { "createuserresponse" => { "user" => { "id" => "42" } } })
159
+
160
+ _(connection.send_request("command" => "createUser")).must_equal("id" => "42")
161
+ end
162
+
163
+ it "returns a scalar-valued body unchanged" do
164
+ stub_command("someCommand", { "somecommandresponse" => { "jobid" => "job-9" } })
165
+
166
+ _(connection.send_request("command" => "someCommand")).must_equal("jobid" => "job-9")
167
+ end
168
+
169
+ it "returns an empty body as an empty Array" do
170
+ stub_command("listZones", { "listzonesresponse" => {} })
171
+
172
+ _(connection.send_request("command" => "listZones")).must_equal []
173
+ end
174
+ end
175
+
176
+ describe "symbolized keys" do
177
+ let(:connection) do
178
+ CloudstackClient::Connection.new(
179
+ TestHelpers::TEST_URL, TestHelpers::TEST_KEY, TestHelpers::TEST_SECRET,
180
+ quiet: true, symbolize_keys: true
181
+ )
182
+ end
183
+
184
+ it "returns Symbol keys throughout the response" do
185
+ stub_list("listZones", "zone", [{ "id" => "1", "name" => "zone1" }])
186
+
187
+ _(connection.send_request("command" => "listZones"))
188
+ .must_equal [{ id: "1", name: "zone1" }]
189
+ end
190
+
191
+ it "still strips the count key when it is symbolized" do
192
+ stub_list("listZones", "zone", [{ "id" => "1" }])
193
+ result = connection.send_request("command" => "listZones")
194
+
195
+ _(result).must_equal [{ id: "1" }]
196
+ end
197
+ end
198
+
199
+ describe "error handling" do
200
+ it "raises ApiError with the errortext for a non-200 response" do
201
+ stub_command("listZones",
202
+ { "listzonesresponse" => { "errortext" => "Unable to execute" } },
203
+ status: 431)
204
+
205
+ error = _(proc { connection.send_request("command" => "listZones") })
206
+ .must_raise CloudstackClient::ApiError
207
+ _(error.message).must_match(/431/)
208
+ _(error.message).must_match(/Unable to execute/)
209
+ end
210
+
211
+ it "raises ParseError when the body is not JSON" do
212
+ stub_request(:get, ApiStubs::ANY_REQUEST).to_return(status: 200, body: "<html>oops</html>")
213
+
214
+ error = _(proc { connection.send_request("command" => "listZones") })
215
+ .must_raise CloudstackClient::ParseError
216
+ _(error.message).must_match(/not readable/)
217
+ end
218
+
219
+ it "raises ConnectionError when the endpoint is unreachable" do
220
+ stub_request(:get, ApiStubs::ANY_REQUEST).to_raise(Errno::ECONNREFUSED)
221
+
222
+ error = _(proc { connection.send_request("command" => "listZones") })
223
+ .must_raise CloudstackClient::ConnectionError
224
+ _(error.message).must_match(/is not reachable/)
225
+ end
226
+ end
227
+
228
+ describe "retries" do
229
+ it "makes exactly one attempt with the default retry setting" do
230
+ stub_request(:get, ApiStubs::ANY_REQUEST).to_raise(Errno::ECONNREFUSED)
231
+
232
+ error = _(proc { connection.send_request("command" => "listZones") })
233
+ .must_raise CloudstackClient::ConnectionError
234
+ _(error.message).must_match(/after 1 attempt/)
235
+ assert_requested(:get, ApiStubs::ANY_REQUEST, times: 1)
236
+ end
237
+
238
+ it "retries up to request_retries attempts before giving up" do
239
+ connection = CloudstackClient::Connection.new(
240
+ TestHelpers::TEST_URL, TestHelpers::TEST_KEY, TestHelpers::TEST_SECRET,
241
+ quiet: true, request_retries: 3
242
+ )
243
+ stub_request(:get, ApiStubs::ANY_REQUEST).to_raise(Errno::ECONNREFUSED)
244
+
245
+ error = connection.stub(:sleep, nil) do
246
+ _(proc { connection.send_request("command" => "listZones") })
247
+ .must_raise CloudstackClient::ConnectionError
248
+ end
249
+
250
+ _(error.message).must_match(/after 3 attempts/)
251
+ assert_requested(:get, ApiStubs::ANY_REQUEST, times: 3)
252
+ end
253
+
254
+ it "returns the result when a retry succeeds" do
255
+ connection = CloudstackClient::Connection.new(
256
+ TestHelpers::TEST_URL, TestHelpers::TEST_KEY, TestHelpers::TEST_SECRET,
257
+ quiet: true, request_retries: 3
258
+ )
259
+ stub_request(:get, ApiStubs::ANY_REQUEST)
260
+ .to_raise(Errno::ECONNREFUSED)
261
+ .then.to_return(
262
+ status: 200,
263
+ body: JSON.generate("listzonesresponse" => { "count" => 1, "zone" => [{ "id" => "1" }] })
264
+ )
265
+
266
+ result = connection.stub(:sleep, nil) do
267
+ connection.send_request("command" => "listZones")
268
+ end
269
+
270
+ _(result).must_equal [{ "id" => "1" }]
271
+ end
272
+ end
273
+
274
+ describe "asynchronous requests" do
275
+ it "returns the jobresult once the job succeeds" do
276
+ stub_async("deployVirtualMachine",
277
+ polls: [job_success("virtualmachine" => { "id" => "vm-1" })])
278
+
279
+ result = connection.stub(:sleep, nil) do
280
+ connection.send_async_request("command" => "deployVirtualMachine")
281
+ end
282
+
283
+ _(result).must_equal("virtualmachine" => { "id" => "vm-1" })
284
+ end
285
+
286
+ it "polls until the job leaves the pending state" do
287
+ stub_async("deployVirtualMachine",
288
+ polls: [job_pending, job_pending, job_success("id" => "vm-1")])
289
+
290
+ result = connection.stub(:sleep, nil) do
291
+ connection.send_async_request("command" => "deployVirtualMachine")
292
+ end
293
+
294
+ _(result).must_equal("id" => "vm-1")
295
+ end
296
+
297
+ it "raises JobError when the job fails" do
298
+ stub_async("deployVirtualMachine",
299
+ polls: [job_failure("Insufficient capacity")])
300
+
301
+ error = connection.stub(:sleep, nil) do
302
+ _(proc { connection.send_async_request("command" => "deployVirtualMachine") })
303
+ .must_raise CloudstackClient::JobError
304
+ end
305
+
306
+ _(error.message).must_match(/Insufficient capacity/)
307
+ _(error.message).must_match(/530/)
308
+ end
309
+
310
+ it "raises JobError when a failed job has no error text" do
311
+ stub_async(
312
+ "deployVirtualMachine",
313
+ polls: [{ "jobstatus" => 2, "jobresultcode" => 530 }]
314
+ )
315
+
316
+ error = connection.stub(:sleep, nil) do
317
+ _(proc { connection.send_async_request("command" => "deployVirtualMachine") })
318
+ .must_raise CloudstackClient::JobError
319
+ end
320
+
321
+ _(error.message).must_match(/Unknown error/)
322
+ end
323
+
324
+ it "raises TimeoutError when the job never completes" do
325
+ stub_async("deployVirtualMachine", polls: [job_pending])
326
+
327
+ error = connection.stub(:sleep, nil) do
328
+ _(proc { connection.send_async_request("command" => "deployVirtualMachine") })
329
+ .must_raise CloudstackClient::TimeoutError
330
+ end
331
+
332
+ _(error.message).must_match(/timed out/)
333
+ end
334
+
335
+ it "rejects per-request async options that are out of range" do
336
+ _(proc {
337
+ connection.send_async_request({ "command" => "x" }, async_timeout: 5)
338
+ }).must_raise CloudstackClient::InputError
339
+ end
340
+ end
341
+
342
+ describe "the host option" do
343
+ it "sets the Host header when a host is given" do
344
+ connection = CloudstackClient::Connection.new(
345
+ TestHelpers::TEST_URL, TestHelpers::TEST_KEY, TestHelpers::TEST_SECRET,
346
+ quiet: true, host: "cloudstack.internal"
347
+ )
348
+ stub_list("listZones", "zone", [{ "id" => "1" }])
349
+
350
+ connection.send_request("command" => "listZones")
351
+
352
+ assert_requested(:get, ApiStubs::ANY_REQUEST,
353
+ headers: { "Host" => "cloudstack.internal" }, times: 1)
354
+ end
355
+
356
+ it "omits the Host header when no host is given" do
357
+ stub_list("listZones", "zone", [{ "id" => "1" }])
358
+ connection.send_request("command" => "listZones")
359
+
360
+ signature = WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last
361
+ _(signature.headers.to_h.keys.map(&:downcase)).wont_include "host"
362
+ end
363
+ end
364
+ end
@@ -0,0 +1,3 @@
1
+ :url: ""
2
+ :api_key: test-key
3
+ :secret_key: test-secret
@@ -0,0 +1,5 @@
1
+ ---
2
+ :default: test1
3
+ test1:
4
+ :url: https://cloud.test/client/api
5
+ :api_key: only-an-api-key-no-secret
@@ -0,0 +1,5 @@
1
+ ---
2
+ :default: test1
3
+ test1:
4
+ :url: "unterminated
5
+ :api_key: [1, 2
@@ -0,0 +1 @@
1
+ --- !ruby/object:Object {}
@@ -0,0 +1,85 @@
1
+ require "json"
2
+
3
+ # Helpers for stubbing CloudStack HTTP responses.
4
+ #
5
+ # Every CloudStack request carries a generated `signature` query parameter, so
6
+ # stubs match on the `command` parameter with `hash_including` rather than on a
7
+ # full URL.
8
+ module ApiStubs
9
+ API_ENDPOINT = "https://cloudstack.test/client/api".freeze
10
+
11
+ # WebMock matches a bare URL only against requests with no query string, and
12
+ # every CloudStack request carries one. Match on the endpoint prefix instead.
13
+ ANY_REQUEST = %r{\Ahttps://cloudstack\.test/client/api}.freeze
14
+
15
+ # Stub a CloudStack command with an already-shaped response body.
16
+ #
17
+ # stub_command("listZones", "listzonesresponse" => { "count" => 1, ... })
18
+ def stub_command(command, body, status: 200)
19
+ stub_request(:get, API_ENDPOINT)
20
+ .with(query: hash_including("command" => command))
21
+ .to_return(
22
+ status: status,
23
+ body: JSON.generate(body),
24
+ headers: { "Content-Type" => "application/json" }
25
+ )
26
+ end
27
+
28
+ # Stub a command using CloudStack's usual envelope:
29
+ # { "<command>response" => { "count" => n, "<entity>" => [...] } }
30
+ def stub_list(command, entity, items, count: nil, extra: {})
31
+ inner = { "count" => count || items.size, entity => items }.merge(extra)
32
+ stub_command(command, { "#{command.downcase}response" => inner })
33
+ end
34
+
35
+ # Stub the initial async command, then the queryAsyncJobResult polls.
36
+ #
37
+ # `polls` is an array of jobstatus payloads returned in order.
38
+ def stub_async(command, job_id: "job-1", polls: [])
39
+ stub_command(command, { "#{command.downcase}response" => { "jobid" => job_id } })
40
+
41
+ responses = polls.map do |poll|
42
+ {
43
+ status: 200,
44
+ body: JSON.generate("queryasyncjobresultresponse" => poll),
45
+ headers: { "Content-Type" => "application/json" }
46
+ }
47
+ end
48
+
49
+ stub_request(:get, API_ENDPOINT)
50
+ .with(query: hash_including("command" => "queryAsyncJobResult"))
51
+ .to_return(*responses)
52
+ end
53
+
54
+ # A completed async job payload.
55
+ def job_success(result)
56
+ { "jobstatus" => 1, "jobresult" => result }
57
+ end
58
+
59
+ # A failed async job payload.
60
+ def job_failure(errortext, code: 530)
61
+ {
62
+ "jobstatus" => 2,
63
+ "jobresultcode" => code,
64
+ "jobresult" => { "errortext" => errortext }
65
+ }
66
+ end
67
+
68
+ # An in-flight async job payload.
69
+ def job_pending
70
+ { "jobstatus" => 0 }
71
+ end
72
+
73
+ # Parsed query parameters of the most recent request, for asserting on how
74
+ # a request was built rather than only on what came back.
75
+ def last_request_params
76
+ signature = WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last
77
+ raise "no request was made" if signature.nil?
78
+
79
+ # CGI.parse was removed in Ruby 4.0; only CGI.escape/unescape remain.
80
+ URI.decode_www_form(signature.uri.query.to_s)
81
+ .each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(key, value), acc|
82
+ acc[key] << value
83
+ end
84
+ end
85
+ end
data/test/test_helper.rb CHANGED
@@ -2,4 +2,41 @@ require "cloudstack_client"
2
2
 
3
3
  require "minitest/spec"
4
4
  require "minitest/autorun"
5
- require "minitest/pride"
5
+ # Minitest 6 extracted Object#stub into the minitest-mock gem.
6
+ require "minitest/mock"
7
+ require "minitest/reporters"
8
+
9
+ require "webmock/minitest"
10
+
11
+ # No test may reach the network. An accidental live request should fail loudly
12
+ # rather than turn into a slow or flaky test.
13
+ WebMock.disable_net_connect!(allow_localhost: false)
14
+
15
+ Minitest::Reporters.use!(
16
+ ENV["CI"] ? Minitest::Reporters::ProgressReporter.new : Minitest::Reporters::SpecReporter.new
17
+ )
18
+
19
+ require_relative "support/api_stubs"
20
+
21
+ module TestHelpers
22
+ TEST_URL = "https://cloudstack.test/client/api".freeze
23
+ TEST_KEY = "test-key".freeze
24
+ TEST_SECRET = "test-secret".freeze
25
+
26
+ def fixture_path(*parts)
27
+ File.join(File.expand_path("data", __dir__), *parts)
28
+ end
29
+
30
+ # A client with no dynamically defined API methods: useful when the test
31
+ # targets Connection behaviour rather than the generated command methods.
32
+ def bare_client(options = {})
33
+ CloudstackClient::Client.new(
34
+ TEST_URL, TEST_KEY, TEST_SECRET, { no_api_methods: true }.merge(options)
35
+ )
36
+ end
37
+ end
38
+
39
+ class Minitest::Spec
40
+ include TestHelpers
41
+ include ApiStubs
42
+ end
@@ -0,0 +1,62 @@
1
+ require "test_helper"
2
+
3
+ describe CloudstackClient::Utils do
4
+ let(:utils) { Object.new.extend(CloudstackClient::Utils) }
5
+
6
+ describe "camel_case_to_underscore" do
7
+ it "converts a simple camel case command" do
8
+ _(utils.camel_case_to_underscore("listVirtualMachines"))
9
+ .must_equal "list_virtual_machines"
10
+ end
11
+
12
+ it "splits a leading acronym from the following word" do
13
+ _(utils.camel_case_to_underscore("listVPCOfferings"))
14
+ .must_equal "list_vpc_offerings"
15
+ end
16
+
17
+ it "handles a trailing acronym" do
18
+ _(utils.camel_case_to_underscore("listOsTypes")).must_equal "list_os_types"
19
+ end
20
+
21
+ it "handles consecutive capitals followed by a word" do
22
+ _(utils.camel_case_to_underscore("createSSHKeyPair"))
23
+ .must_equal "create_ssh_key_pair"
24
+ end
25
+
26
+ it "converts hyphens to underscores" do
27
+ _(utils.camel_case_to_underscore("some-command")).must_equal "some_command"
28
+ end
29
+
30
+ it "leaves an already underscored name unchanged" do
31
+ _(utils.camel_case_to_underscore("list_apis")).must_equal "list_apis"
32
+ end
33
+ end
34
+
35
+ describe "underscore_to_camel_case" do
36
+ it "converts an underscored name" do
37
+ _(utils.underscore_to_camel_case("list_virtual_machines"))
38
+ .must_equal "listVirtualMachines"
39
+ end
40
+
41
+ it "returns a name without underscores unchanged" do
42
+ _(utils.underscore_to_camel_case("listApis")).must_equal "listApis"
43
+ end
44
+
45
+ it "cannot restore acronym casing, because the conversion is lossy" do
46
+ # Documents a known limitation: "ssh" carries no record of "SSH".
47
+ # Api resolves underscored names through an index instead of relying
48
+ # on this method being a true inverse.
49
+ _(utils.underscore_to_camel_case("create_ssh_key_pair"))
50
+ .must_equal "createSshKeyPair"
51
+ end
52
+ end
53
+
54
+ describe "round tripping real command names" do
55
+ it "round trips names without acronyms" do
56
+ %w[listVirtualMachines deployVirtualMachine listOsTypes listApis].each do |name|
57
+ underscored = utils.camel_case_to_underscore(name)
58
+ _(utils.underscore_to_camel_case(underscored)).must_equal name
59
+ end
60
+ end
61
+ end
62
+ end