cloudstack_client 1.6.0 → 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.
data/test/api_test.rb CHANGED
@@ -91,7 +91,7 @@ describe CloudstackClient::Api do
91
91
  end
92
92
 
93
93
  describe "when asked about all required params" do
94
- it "must respond positively for 'createUser' and params 'account, email, firtsname, lastname, password, username'" do
94
+ it "must respond positively for 'createUser' with all required params" do
95
95
  params = {
96
96
  "account" => "Master",
97
97
  "email" => "me@me.com",
@@ -109,4 +109,26 @@ describe CloudstackClient::Api do
109
109
  end
110
110
  end
111
111
 
112
+ describe "when asked about commands containing acronyms" do
113
+ it "must respond positively for the camel case name 'createSSHKeyPair'" do
114
+ _(@api.command_supported?('createSSHKeyPair')).must_equal true
115
+ end
116
+
117
+ it "must respond positively for the underscored name 'create_ssh_key_pair'" do
118
+ _(@api.command_supported?('create_ssh_key_pair')).must_equal true
119
+ end
120
+
121
+ it "must respond positively for the underscored name 'list_vpc_offerings'" do
122
+ _(@api.command_supported?('list_vpc_offerings')).must_equal true
123
+ end
124
+
125
+ it "must resolve params via the underscored name of an acronym command" do
126
+ _(@api.command_supports_param?('create_ssh_key_pair', 'name')).must_equal true
127
+ end
128
+
129
+ it "must return false rather than raise for an unknown command" do
130
+ _(@api.command_supports_param?('listClowns', 'name')).must_equal false
131
+ end
132
+ end
133
+
112
134
  end
data/test/client_test.rb CHANGED
@@ -1,29 +1,163 @@
1
1
  require "test_helper"
2
2
 
3
3
  describe CloudstackClient::Client do
4
- before do
5
- @client = CloudstackClient::Client.new(
6
- "https://cloudstack.api/client/api",
7
- "test-key",
8
- "test-secret"
4
+ let(:client) do
5
+ CloudstackClient::Client.new(
6
+ TestHelpers::TEST_URL, TestHelpers::TEST_KEY, TestHelpers::TEST_SECRET, quiet: true
9
7
  )
10
8
  end
11
9
 
12
10
  describe "when the client is instantiated" do
13
- it "must respond_to 'list_virtual_machines'" do
14
- _(@client.respond_to?(:list_virtual_machines)).must_equal true
11
+ it "exposes the loaded API definition" do
12
+ _(client.api).must_be_kind_of CloudstackClient::Api
13
+ _(client.api.api_version).must_equal CloudstackClient::Api::DEFAULT_API_VERSION
15
14
  end
16
15
 
17
- it "must respond_to 'deploy_virtual_machine'" do
18
- _(@client.respond_to?(:deploy_virtual_machine)).must_equal true
16
+ it "is not in debug mode by default" do
17
+ _(client.debug).must_equal false
19
18
  end
20
19
 
21
- it "must respond_to 'create_user'" do
22
- _(@client.respond_to?(:create_user)).must_equal true
20
+ it "defines no API methods when no_api_methods is set" do
21
+ bare = bare_client
22
+ _(bare.respond_to?(:list_virtual_machines)).must_equal false
23
+ _(bare.api).must_be_nil
23
24
  end
25
+ end
26
+
27
+ describe "generated command methods" do
28
+ it "sends the CloudStack command name for the underscored method" do
29
+ stub_list("listVirtualMachines", "virtualmachine", [{ "id" => "vm-1" }])
30
+
31
+ client.list_virtual_machines
32
+
33
+ _(last_request_params["command"].first).must_equal "listVirtualMachines"
34
+ end
35
+
36
+ it "returns the unwrapped collection" do
37
+ stub_list("listVirtualMachines", "virtualmachine",
38
+ [{ "id" => "vm-1" }, { "id" => "vm-2" }])
39
+
40
+ _(client.list_virtual_machines).must_equal [{ "id" => "vm-1" }, { "id" => "vm-2" }]
41
+ end
42
+
43
+ it "strips underscores from argument names" do
44
+ stub_list("listVirtualMachines", "virtualmachine", [])
45
+
46
+ client.list_virtual_machines(list_all: true)
47
+
48
+ _(last_request_params["listall"].first).must_equal "true"
49
+ end
50
+
51
+ it "passes through arguments that need no translation" do
52
+ stub_list("listVirtualMachines", "virtualmachine", [])
53
+
54
+ client.list_virtual_machines(state: "running")
55
+
56
+ _(last_request_params["state"].first).must_equal "running"
57
+ end
58
+
59
+ it "drops arguments the command does not support" do
60
+ stub_list("listVirtualMachines", "virtualmachine", [])
61
+
62
+ client.list_virtual_machines(state: "running", hotdog: "mustard")
63
+
64
+ params = last_request_params
65
+ _(params).must_include "state"
66
+ _(params).wont_include "hotdog"
67
+ end
68
+
69
+ it "drops arguments with a nil value" do
70
+ stub_list("listVirtualMachines", "virtualmachine", [])
71
+
72
+ client.list_virtual_machines(state: nil, name: "web01")
73
+
74
+ params = last_request_params
75
+ _(params).must_include "name"
76
+ _(params).wont_include "state"
77
+ end
78
+
79
+ it "accepts String keys as well as Symbol keys" do
80
+ stub_list("listVirtualMachines", "virtualmachine", [])
81
+
82
+ client.list_virtual_machines("state" => "running")
83
+
84
+ _(last_request_params["state"].first).must_equal "running"
85
+ end
86
+ end
87
+
88
+ describe "required parameter validation" do
89
+ it "raises ParameterError when a required parameter is missing" do
90
+ error = _(proc { client.deploy_virtual_machine(zoneid: "1") })
91
+ .must_raise CloudstackClient::ParameterError
92
+
93
+ _(error.message).must_match(/deployVirtualMachine requires/)
94
+ _(error.message).must_match(/serviceofferingid/)
95
+ _(error.message).must_match(/templateid/)
96
+ end
97
+
98
+ it "raises ParameterError for unsupported parameters in strict mode" do
99
+ error = _(proc {
100
+ client.list_virtual_machines({ unsupported: true }, strict_params: true)
101
+ }).must_raise CloudstackClient::ParameterError
102
+
103
+ _(error.message).must_match(/does not support parameter unsupported/)
104
+ end
105
+
106
+ it "does not issue a request when validation fails" do
107
+ _(proc { client.create_user(username: "meme") })
108
+ .must_raise CloudstackClient::ParameterError
109
+
110
+ assert_not_requested(:get, ApiStubs::ANY_REQUEST)
111
+ end
112
+
113
+ it "proceeds when every required parameter is present" do
114
+ stub_command("createUser", { "createuserresponse" => { "user" => { "id" => "u-1" } } })
115
+
116
+ result = client.create_user(
117
+ account: "Master", email: "me@me.com", firstname: "Me",
118
+ lastname: "Me", password: "secret", username: "meme"
119
+ )
120
+
121
+ _(result).must_equal("id" => "u-1")
122
+ end
123
+ end
124
+
125
+ describe "synchronous and asynchronous dispatch" do
126
+ it "sends a synchronous command directly" do
127
+ stub_list("listVirtualMachines", "virtualmachine", [])
128
+
129
+ client.list_virtual_machines
130
+
131
+ assert_not_requested(:get, ApiStubs::ANY_REQUEST,
132
+ query: hash_including("command" => "queryAsyncJobResult"))
133
+ end
134
+
135
+ it "polls for an asynchronous command" do
136
+ stub_async("deployVirtualMachine",
137
+ polls: [job_success("id" => "vm-1")])
138
+
139
+ result = client.stub(:sleep, nil) do
140
+ client.deploy_virtual_machine(
141
+ zoneid: "1", serviceofferingid: "2", templateid: "3"
142
+ )
143
+ end
144
+
145
+ _(result).must_equal("id" => "vm-1")
146
+ assert_requested(:get, ApiStubs::ANY_REQUEST,
147
+ query: hash_including("command" => "queryAsyncJobResult"))
148
+ end
149
+
150
+ it "forces a synchronous request when the sync option is given" do
151
+ stub_command("deployVirtualMachine",
152
+ { "deployvirtualmachineresponse" => { "jobid" => "job-1" } })
153
+
154
+ result = client.deploy_virtual_machine(
155
+ { zoneid: "1", serviceofferingid: "2", templateid: "3" }, { sync: true }
156
+ )
24
157
 
25
- it "must not be in debug mode" do
26
- _(@client.debug).must_equal false
158
+ _(result).must_equal("jobid" => "job-1")
159
+ assert_not_requested(:get, ApiStubs::ANY_REQUEST,
160
+ query: hash_including("command" => "queryAsyncJobResult"))
27
161
  end
28
162
  end
29
163
  end
@@ -31,4 +31,88 @@ describe CloudstackClient::Configuration do
31
31
  end
32
32
  end
33
33
 
34
+ describe "when the configuration cannot be loaded" do
35
+ it "must raise when the file does not exist" do
36
+ error = _(proc {
37
+ CloudstackClient::Configuration.load(config_file: "/nonexistent/cloudstack.yml")
38
+ }).must_raise CloudstackClient::ConfigurationError
39
+
40
+ _(error.message).must_match(/not found/)
41
+ end
42
+
43
+ it "must raise when the file is not valid YAML" do
44
+ error = _(proc {
45
+ CloudstackClient::Configuration.load(
46
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-malformed.yml"
47
+ )
48
+ }).must_raise CloudstackClient::ConfigurationError
49
+
50
+ _(error.message).must_match(/Can't load configuration/)
51
+ end
52
+
53
+ it "must reject unsafe YAML objects" do
54
+ error = _(proc {
55
+ CloudstackClient::Configuration.load(
56
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-unsafe.yml"
57
+ )
58
+ }).must_raise CloudstackClient::ConfigurationError
59
+
60
+ _(error.message).must_match(/Can't load configuration/)
61
+ end
62
+
63
+ it "must include the backtrace in debug mode" do
64
+ error = _(proc {
65
+ CloudstackClient::Configuration.load(
66
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-malformed.yml",
67
+ debug: true
68
+ )
69
+ }).must_raise CloudstackClient::ConfigurationError
70
+
71
+ _(error.message).must_match(/Backtrace/)
72
+ end
73
+
74
+ it "must raise when the requested environment is absent" do
75
+ error = _(proc {
76
+ CloudstackClient::Configuration.load(
77
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-1.yml",
78
+ env: "nope"
79
+ )
80
+ }).must_raise CloudstackClient::ConfigurationError
81
+
82
+ _(error.message).must_match(/Can't find environment nope/)
83
+ end
84
+
85
+ it "must raise when required keys are missing" do
86
+ error = _(proc {
87
+ CloudstackClient::Configuration.load(
88
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-incomplete.yml"
89
+ )
90
+ }).must_raise CloudstackClient::ConfigurationError
91
+
92
+ _(error.message).must_match(/does not contain all required keys/)
93
+ end
94
+
95
+ it "must raise when a required value is empty" do
96
+ error = _(proc {
97
+ CloudstackClient::Configuration.load(
98
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-empty.yml"
99
+ )
100
+ }).must_raise CloudstackClient::ConfigurationError
101
+
102
+ _(error.message).must_match(/does not contain all required keys/)
103
+ end
104
+ end
105
+
106
+ describe "when the configuration is valid" do
107
+ it "must return the url and secret key alongside the environment" do
108
+ config = CloudstackClient::Configuration.load(
109
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-1.yml"
110
+ )
111
+
112
+ _(config[:url]).must_equal "https://cloud.swisstxt.ch/client/api/"
113
+ _(config[:environment]).must_equal "test1"
114
+ _(config[:secret_key]).wont_be_nil
115
+ end
116
+ end
117
+
34
118
  end
@@ -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 {}