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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +23 -0
- data/.github/workflows/ci.yml +57 -0
- data/.github/workflows/release.yml +60 -0
- data/.gitignore +4 -0
- data/.rubocop.yml +33 -0
- data/.rubocop_todo.yml +572 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +5 -0
- data/README.md +118 -23
- data/Rakefile +17 -2
- data/cloudstack_client.gemspec +23 -4
- data/lib/cloudstack_client/api.rb +39 -14
- data/lib/cloudstack_client/cli.rb +1 -1
- data/lib/cloudstack_client/client.rb +4 -1
- data/lib/cloudstack_client/configuration.rb +14 -2
- data/lib/cloudstack_client/connection.rb +26 -62
- data/lib/cloudstack_client/error.rb +1 -0
- data/lib/cloudstack_client/request_handling.rb +91 -0
- data/lib/cloudstack_client/version.rb +1 -1
- data/test/api_test.rb +23 -1
- data/test/client_test.rb +147 -13
- data/test/configuration_test.rb +84 -0
- data/test/connection_test.rb +364 -0
- data/test/data/cloudstack-empty.yml +3 -0
- data/test/data/cloudstack-incomplete.yml +5 -0
- data/test/data/cloudstack-malformed.yml +5 -0
- data/test/data/cloudstack-unsafe.yml +1 -0
- data/test/support/api_stubs.rb +85 -0
- data/test/test_helper.rb +38 -1
- data/test/utils_test.rb +62 -0
- metadata +131 -19
- data/.travis.yml +0 -8
- data/Gemfile.lock +0 -27
|
@@ -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 @@
|
|
|
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
|
-
|
|
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
|
data/test/utils_test.rb
ADDED
|
@@ -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
|