aptible-cli 0.24.10 → 0.26.1

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +27 -0
  3. data/.github/workflows/test.yml +1 -1
  4. data/Dockerfile +8 -5
  5. data/Gemfile.lock +28 -17
  6. data/Makefile +50 -2
  7. data/README.md +2 -1
  8. data/aptible-cli.gemspec +5 -2
  9. data/docker-compose.yml +5 -1
  10. data/lib/aptible/cli/agent.rb +9 -0
  11. data/lib/aptible/cli/helpers/aws_account.rb +158 -0
  12. data/lib/aptible/cli/helpers/database.rb +182 -2
  13. data/lib/aptible/cli/helpers/token.rb +14 -0
  14. data/lib/aptible/cli/helpers/vhost/option_set_builder.rb +8 -15
  15. data/lib/aptible/cli/renderer/text.rb +33 -2
  16. data/lib/aptible/cli/subcommands/aws_accounts.rb +252 -0
  17. data/lib/aptible/cli/subcommands/db.rb +67 -3
  18. data/lib/aptible/cli/subcommands/deploy.rb +45 -11
  19. data/lib/aptible/cli/subcommands/endpoints.rb +0 -2
  20. data/lib/aptible/cli/subcommands/organizations.rb +55 -0
  21. data/lib/aptible/cli/subcommands/services.rb +20 -8
  22. data/lib/aptible/cli/version.rb +1 -1
  23. data/spec/aptible/cli/helpers/database_spec.rb +118 -0
  24. data/spec/aptible/cli/helpers/token_spec.rb +70 -0
  25. data/spec/aptible/cli/subcommands/db_spec.rb +553 -0
  26. data/spec/aptible/cli/subcommands/deploy_spec.rb +42 -7
  27. data/spec/aptible/cli/subcommands/external_aws_accounts_spec.rb +737 -0
  28. data/spec/aptible/cli/subcommands/organizations_spec.rb +90 -0
  29. data/spec/aptible/cli/subcommands/services_spec.rb +77 -0
  30. data/spec/fabricators/app_external_aws_rds_connection_fabricator.rb +55 -0
  31. data/spec/fabricators/external_aws_account_fabricator.rb +49 -0
  32. data/spec/fabricators/external_aws_database_credential_fabricator.rb +46 -0
  33. data/spec/fabricators/external_aws_resource_fabricator.rb +72 -0
  34. metadata +64 -6
@@ -0,0 +1,737 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Aptible::CLI::Agent do
6
+ let(:token) { double('token') }
7
+ before { allow(subject).to receive(:fetch_token).and_return(token) }
8
+
9
+ describe '#aws_accounts' do
10
+ it 'lists external AWS accounts' do
11
+ a1 = Fabricate(:external_aws_account,
12
+ account_name: 'Dev',
13
+ aws_account_id: '111111111111',
14
+ discovery_role_arn: 'arn:aws:iam::111111111111:role/' \
15
+ 'DevRole')
16
+ a2 = Fabricate(:external_aws_account,
17
+ account_name: 'Prod',
18
+ aws_account_id: '222222222222',
19
+ discovery_role_arn: 'arn:aws:iam::222222222222:role/' \
20
+ 'ProdRole')
21
+
22
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:all)
23
+ .with(token: token,
24
+ href: '/external_aws_accounts?per_page=5000&no_embed=true')
25
+ .and_return([a1, a2])
26
+
27
+ subject.send('aws_accounts')
28
+
29
+ # Spot-check a few rendered fields
30
+ expect(captured_output_text).to include("Id: #{a1.id}")
31
+ expect(captured_output_text).to include('Account Name: Dev')
32
+ expect(captured_output_text).to include('Aws Account: 111111111111')
33
+ expect(captured_output_text).to(
34
+ include('Discovery Role Arn: arn:aws:iam::111111111111:role/DevRole')
35
+ )
36
+
37
+ expect(captured_output_text).to include("Id: #{a2.id}")
38
+ expect(captured_output_text).to include('Account Name: Prod')
39
+ expect(captured_output_text).to include('Aws Account: 222222222222')
40
+ expect(captured_output_text).to(
41
+ include('Discovery Role Arn: arn:aws:iam::222222222222:role/ProdRole')
42
+ )
43
+ end
44
+
45
+ it 'handles empty list' do
46
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:all)
47
+ .with(token: token,
48
+ href: '/external_aws_accounts?per_page=5000&no_embed=true')
49
+ .and_return([])
50
+
51
+ subject.send('aws_accounts')
52
+
53
+ expect(captured_output_text).to eq('')
54
+ end
55
+
56
+ it 'renders JSON output and uses JSON href' do
57
+ a1 = Fabricate(
58
+ :external_aws_account,
59
+ account_name: 'Dev',
60
+ aws_account_id: '111111111111',
61
+ discovery_role_arn: 'arn:aws:iam::111111111111:role/DevRole'
62
+ )
63
+
64
+ allow(Aptible::CLI::Renderer).to receive(:format).and_return('json')
65
+
66
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:all)
67
+ .with(token: token, href: '/external_aws_accounts')
68
+ .and_return([a1])
69
+
70
+ subject.send('aws_accounts')
71
+
72
+ json = captured_output_json
73
+ expect(json).to be_a(Array)
74
+ expect(json.first['id']).to eq(a1.id)
75
+ expect(json.first['account_name']).to eq('Dev')
76
+ expect(json.first['aws_account_id']).to eq('111111111111')
77
+ expect(json.first['discovery_role_arn']).to(
78
+ eq('arn:aws:iam::111111111111:role/DevRole')
79
+ )
80
+ end
81
+ end
82
+
83
+ describe '#aws_accounts:add' do
84
+ it 'creates an external AWS account' do
85
+ org = double('org', id: 'org-123')
86
+ allow(Aptible::Auth::Organization).to receive(:all)
87
+ .with(token: token).and_return([org])
88
+
89
+ created = Fabricate(
90
+ :external_aws_account,
91
+ account_name: 'Staging',
92
+ aws_account_id: '123456789012',
93
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/' \
94
+ 'StagingRole',
95
+ discovery_enabled: true,
96
+ discovery_frequency: 'daily',
97
+ aws_region_primary: 'us-east-1'
98
+ )
99
+
100
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:create).with(
101
+ token: token,
102
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/StagingRole',
103
+ account_name: 'Staging',
104
+ aws_account_id: '123456789012',
105
+ organization_id: 'org-123',
106
+ aws_region_primary: 'us-east-1',
107
+ discovery_enabled: true,
108
+ discovery_frequency: 'daily'
109
+ ).and_return(created)
110
+
111
+ subject.options = {
112
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/StagingRole',
113
+ account_name: 'Staging',
114
+ aws_account_id: '123456789012',
115
+ aws_region_primary: 'us-east-1',
116
+ discovery_enabled: true,
117
+ discovery_frequency: 'daily'
118
+ }
119
+ subject.send('aws_accounts:add')
120
+
121
+ expect(captured_output_text).to include("Id: #{created.id}")
122
+ expect(captured_output_text).to include('Account Name: Staging')
123
+ expect(captured_output_text).to include('Aws Account: 123456789012')
124
+ expect(captured_output_text).to include(
125
+ 'Discovery Role Arn: arn:aws:iam::123456789012:role/StagingRole'
126
+ )
127
+ end
128
+
129
+ it 'creates with minimal options (discovery_role_arn only)' do
130
+ org = double('org', id: 'org-123')
131
+ allow(Aptible::Auth::Organization).to receive(:all)
132
+ .with(token: token).and_return([org])
133
+
134
+ created = Fabricate(
135
+ :external_aws_account,
136
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/MinRole'
137
+ )
138
+
139
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:create).with(
140
+ token: token,
141
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/MinRole',
142
+ organization_id: 'org-123'
143
+ ).and_return(created)
144
+
145
+ subject.options = {
146
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/MinRole'
147
+ }
148
+ subject.send('aws_accounts:add')
149
+
150
+ expect(captured_output_text).to include("Id: #{created.id}")
151
+ expect(captured_output_text).to include(
152
+ 'Discovery Role Arn: arn:aws:iam::123456789012:role/MinRole'
153
+ )
154
+ end
155
+
156
+ it 'creates with organization_id provided' do
157
+ created = Fabricate(
158
+ :external_aws_account,
159
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/AliasRole'
160
+ )
161
+
162
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:create).with(
163
+ token: token,
164
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/AliasRole',
165
+ organization_id: 'explicit-org-id'
166
+ ).and_return(created)
167
+
168
+ subject.options = {
169
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/AliasRole',
170
+ organization_id: 'explicit-org-id'
171
+ }
172
+ subject.send('aws_accounts:add')
173
+
174
+ expect(captured_output_text).to include(
175
+ 'Discovery Role Arn: arn:aws:iam::123456789012:role/AliasRole'
176
+ )
177
+ end
178
+
179
+ it 'creates with all optional fields' do
180
+ created = Fabricate(
181
+ :external_aws_account,
182
+ account_name: 'Full',
183
+ aws_account_id: '123456789012',
184
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/FullRole',
185
+ organization_id: 'o-123',
186
+ aws_region_primary: 'us-west-2',
187
+ discovery_enabled: false,
188
+ discovery_frequency: 'weekly',
189
+ status: 'active'
190
+ )
191
+
192
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:create).with(
193
+ token: token,
194
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/FullRole',
195
+ account_name: 'Full',
196
+ aws_account_id: '123456789012',
197
+ organization_id: 'o-123',
198
+ aws_region_primary: 'us-west-2',
199
+ discovery_enabled: false,
200
+ discovery_frequency: 'weekly',
201
+ status: 'active'
202
+ ).and_return(created)
203
+
204
+ subject.options = {
205
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/FullRole',
206
+ account_name: 'Full',
207
+ aws_account_id: '123456789012',
208
+ organization_id: 'o-123',
209
+ aws_region_primary: 'us-west-2',
210
+ discovery_enabled: false,
211
+ discovery_frequency: 'weekly',
212
+ status: 'active'
213
+ }
214
+ subject.send('aws_accounts:add')
215
+
216
+ expect(captured_output_text).to include('Account Name: Full')
217
+ expect(captured_output_text).to include('Aws Account: 123456789012')
218
+ end
219
+
220
+ it 'honors --no-discovery-enabled (false case)' do
221
+ org = double('org', id: 'org-123')
222
+ allow(Aptible::Auth::Organization).to receive(:all)
223
+ .with(token: token).and_return([org])
224
+
225
+ created = Fabricate(
226
+ :external_aws_account,
227
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/NoDisc',
228
+ discovery_enabled: false
229
+ )
230
+
231
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:create).with(
232
+ token: token,
233
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/NoDisc',
234
+ organization_id: 'org-123',
235
+ discovery_enabled: false
236
+ ).and_return(created)
237
+
238
+ subject.options = {
239
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/NoDisc',
240
+ discovery_enabled: false
241
+ }
242
+ subject.send('aws_accounts:add')
243
+
244
+ expect(captured_output_text).to include('Discovery Enabled: false')
245
+ end
246
+
247
+ it 'bubbles API errors during create' do
248
+ org = double('org', id: 'org-123')
249
+ allow(Aptible::Auth::Organization).to receive(:all)
250
+ .with(token: token).and_return([org])
251
+
252
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:create).and_raise(
253
+ HyperResource::ClientError.new(
254
+ 'Boom',
255
+ response: Faraday::Response.new(status: 500)
256
+ )
257
+ )
258
+
259
+ subject.options = {
260
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/Error'
261
+ }
262
+ expect { subject.send('aws_accounts:add') }.to(
263
+ raise_error(Thor::Error, /Boom/)
264
+ )
265
+ end
266
+
267
+ it 'renders JSON output for create' do
268
+ org = double('org', id: 'org-123')
269
+ allow(Aptible::Auth::Organization).to receive(:all)
270
+ .with(token: token).and_return([org])
271
+
272
+ created = Fabricate(
273
+ :external_aws_account,
274
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/JsonRole',
275
+ account_name: 'JsonName'
276
+ )
277
+
278
+ allow(Aptible::CLI::Renderer).to receive(:format).and_return('json')
279
+
280
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:create).with(
281
+ token: token,
282
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/JsonRole',
283
+ account_name: 'JsonName',
284
+ organization_id: 'org-123'
285
+ ).and_return(created)
286
+
287
+ subject.options = {
288
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/JsonRole',
289
+ account_name: 'JsonName'
290
+ }
291
+ subject.send('aws_accounts:add')
292
+
293
+ json = captured_output_json
294
+ expect(json['id']).to eq(created.id)
295
+ expect(json['account_name']).to eq('JsonName')
296
+ expect(json['discovery_role_arn']).to(
297
+ eq('arn:aws:iam::123456789012:role/JsonRole')
298
+ )
299
+ end
300
+
301
+ it 'fails when no organizations found and no org_id provided' do
302
+ allow(Aptible::Auth::Organization).to receive(:all)
303
+ .with(token: token).and_return([])
304
+
305
+ subject.options = {
306
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/TestRole'
307
+ }
308
+
309
+ expect { subject.send('aws_accounts:add') }.to(
310
+ raise_error(Thor::Error, /No organizations found/)
311
+ )
312
+ end
313
+
314
+ it 'fails when multiple organizations found and no org_id provided' do
315
+ org1 = double('org1', id: 'org-1', name: 'org one')
316
+ org2 = double('org2', id: 'org-2', name: 'org two')
317
+ allow(Aptible::Auth::Organization).to receive(:all)
318
+ .with(token: token).and_return([org1, org2])
319
+
320
+ subject.options = {
321
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/TestRole'
322
+ }
323
+
324
+ expect { subject.send('aws_accounts:add') }.to(
325
+ raise_error(Thor::Error, /Multiple organizations found/)
326
+ )
327
+ end
328
+ end
329
+
330
+ describe '#aws_accounts:update' do
331
+ it 'updates an external AWS account' do
332
+ errors = Aptible::Resource::Errors.new
333
+ ext = double(
334
+ 'ext',
335
+ id: 42,
336
+ errors: errors,
337
+ attributes: {
338
+ 'account_name' => 'New Name',
339
+ 'aws_account_id' => '999999999999',
340
+ 'discovery_role_arn' =>
341
+ 'arn:aws:iam::999999999999:role/NewRole'
342
+ }
343
+ )
344
+
345
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
346
+ .with('42', token: token).and_return(ext)
347
+ expect(ext).to receive(:update!).with(
348
+ discovery_role_arn: 'arn:aws:iam::999999999999:role/NewRole',
349
+ account_name: 'New Name',
350
+ aws_account_id: '999999999999'
351
+ ).and_return(true)
352
+
353
+ subject.options = {
354
+ discovery_role_arn: 'arn:aws:iam::999999999999:role/NewRole',
355
+ account_name: 'New Name',
356
+ aws_account_id: '999999999999'
357
+ }
358
+ subject.send('aws_accounts:update', '42')
359
+
360
+ expect(captured_output_text).to include('Id: 42')
361
+ expect(captured_output_text).to include('Account Name: New Name')
362
+ expect(captured_output_text).to include('Aws Account: 999999999999')
363
+ expect(captured_output_text).to(
364
+ include('Discovery Role Arn: arn:aws:iam::999999999999:role/NewRole')
365
+ )
366
+ end
367
+
368
+ it 'fails when account not found' do
369
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
370
+ .with('999', token: token).and_return(nil)
371
+
372
+ expect { subject.send('aws_accounts:update', '999') }
373
+ .to raise_error(Thor::Error, /External AWS account not found: 999/)
374
+ end
375
+
376
+ it 'updates only one field (account_name)' do
377
+ errors = Aptible::Resource::Errors.new
378
+ ext = double(
379
+ 'ext',
380
+ id: 42,
381
+ errors: errors,
382
+ attributes: {
383
+ 'account_name' => 'Updated Name',
384
+ 'aws_account_id' => '111111111111',
385
+ 'discovery_role_arn' => 'arn:aws:iam::111111111111:role/Role'
386
+ }
387
+ )
388
+
389
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
390
+ .with('42', token: token).and_return(ext)
391
+ expect(ext).to receive(:update!).with(
392
+ account_name: 'Updated Name'
393
+ ).and_return(true)
394
+
395
+ subject.options = {
396
+ account_name: 'Updated Name'
397
+ }
398
+ subject.send('aws_accounts:update', '42')
399
+
400
+ expect(captured_output_text).to include('Account Name: Updated Name')
401
+ end
402
+
403
+ it 'updates discovery settings separately' do
404
+ errors = Aptible::Resource::Errors.new
405
+ ext = double('ext',
406
+ id: 42,
407
+ errors: errors,
408
+ attributes: {
409
+ 'discovery_enabled' => true,
410
+ 'discovery_frequency' => 'hourly'
411
+ })
412
+
413
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
414
+ .with('42', token: token).and_return(ext)
415
+ expect(ext).to receive(:update!).with(
416
+ discovery_enabled: true,
417
+ discovery_frequency: 'hourly'
418
+ ).and_return(true)
419
+
420
+ subject.options = {
421
+ discovery_enabled: true,
422
+ discovery_frequency: 'hourly'
423
+ }
424
+ subject.send('aws_accounts:update', '42')
425
+
426
+ expect(captured_output_text).to include('Discovery Enabled: true')
427
+ expect(captured_output_text).to include('Discovery Frequency: hourly')
428
+ end
429
+
430
+ it 'handles empty update gracefully (no changes)' do
431
+ ext = double('ext',
432
+ id: 42,
433
+ attributes: {
434
+ 'account_name' => 'Name',
435
+ 'aws_account_id' => '111111111111'
436
+ })
437
+
438
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
439
+ .with('42', token: token).and_return(ext)
440
+ expect(ext).not_to receive(:update!)
441
+
442
+ subject.options = {}
443
+ subject.send('aws_accounts:update', '42')
444
+
445
+ expect(captured_output_text).to include('Id: 42')
446
+ end
447
+
448
+ it 'bubbles API errors during update' do
449
+ ext = double('ext', id: 42)
450
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
451
+ .with('42', token: token).and_return(ext)
452
+
453
+ expect(ext).to receive(:update!).and_raise(
454
+ HyperResource::ClientError.new(
455
+ 'Boom',
456
+ response: Faraday::Response.new(status: 422)
457
+ )
458
+ )
459
+
460
+ subject.options = { account_name: 'X' }
461
+ expect { subject.send('aws_accounts:update', '42') }.to(
462
+ raise_error(Thor::Error, /Boom/)
463
+ )
464
+ end
465
+
466
+ it 'renders JSON output for update' do
467
+ errors = Aptible::Resource::Errors.new
468
+ ext = double('ext',
469
+ id: 7,
470
+ errors: errors,
471
+ attributes: {
472
+ 'account_name' => 'JsonUpdated',
473
+ 'aws_account_id' => '123'
474
+ })
475
+
476
+ allow(Aptible::CLI::Renderer).to receive(:format).and_return('json')
477
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
478
+ .with('7', token: token).and_return(ext)
479
+ expect(ext).to(
480
+ receive(:update!).with(account_name: 'JsonUpdated').and_return(true)
481
+ )
482
+
483
+ subject.options = { account_name: 'JsonUpdated' }
484
+ subject.send('aws_accounts:update', '7')
485
+
486
+ json = captured_output_json
487
+ expect(json['id']).to eq(7)
488
+ expect(json['account_name']).to eq('JsonUpdated')
489
+ end
490
+
491
+ it 'removes discovery_role_arn with --remove-discovery-role-arn' do
492
+ errors = Aptible::Resource::Errors.new
493
+ ext = double(
494
+ 'ext',
495
+ id: 42,
496
+ errors: errors,
497
+ attributes: {
498
+ 'account_name' => 'Test',
499
+ 'aws_account_id' => '111111111111'
500
+ }
501
+ )
502
+
503
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
504
+ .with('42', token: token).and_return(ext)
505
+ expect(ext).to receive(:update!).with(
506
+ discovery_role_arn: ''
507
+ ).and_return(true)
508
+
509
+ subject.options = { remove_discovery_role_arn: true }
510
+ subject.send('aws_accounts:update', '42')
511
+
512
+ expect(captured_output_text).to include('Id: 42')
513
+ end
514
+
515
+ it 'ignores --discovery-role-arn when --remove-discovery-role-arn is set' do
516
+ errors = Aptible::Resource::Errors.new
517
+ ext = double(
518
+ 'ext',
519
+ id: 42,
520
+ errors: errors,
521
+ attributes: {
522
+ 'account_name' => 'Test',
523
+ 'aws_account_id' => '111111111111'
524
+ }
525
+ )
526
+
527
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
528
+ .with('42', token: token).and_return(ext)
529
+ # Should send empty string, not the provided ARN
530
+ expect(ext).to receive(:update!).with(
531
+ discovery_role_arn: ''
532
+ ).and_return(true)
533
+
534
+ subject.options = {
535
+ remove_discovery_role_arn: true,
536
+ discovery_role_arn: 'arn:aws:iam::111111111111:role/ShouldBeIgnored'
537
+ }
538
+ subject.send('aws_accounts:update', '42')
539
+
540
+ expect(captured_output_text).to include('Id: 42')
541
+ end
542
+ end
543
+
544
+ describe '#aws_accounts:show' do
545
+ it 'shows an external AWS account' do
546
+ ext = Fabricate(
547
+ :external_aws_account,
548
+ id: 42,
549
+ account_name: 'ShowTest',
550
+ aws_account_id: '123456789012',
551
+ discovery_role_arn: 'arn:aws:iam::123456789012:role/TestRole',
552
+ discovery_enabled: true,
553
+ discovery_frequency: 'daily'
554
+ )
555
+
556
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
557
+ .with('42', token: token).and_return(ext)
558
+
559
+ subject.send('aws_accounts:show', '42')
560
+
561
+ expect(captured_output_text).to include('Id: 42')
562
+ expect(captured_output_text).to include('Account Name: ShowTest')
563
+ expect(captured_output_text).to include('Aws Account: 123456789012')
564
+ expect(captured_output_text).to(
565
+ include('Discovery Role Arn: arn:aws:iam::123456789012:role/TestRole')
566
+ )
567
+ expect(captured_output_text).to include('Discovery Enabled: true')
568
+ expect(captured_output_text).to include('Discovery Frequency: daily')
569
+ end
570
+
571
+ it 'fails when account not found' do
572
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
573
+ .with('999', token: token).and_return(nil)
574
+
575
+ expect { subject.send('aws_accounts:show', '999') }
576
+ .to raise_error(Thor::Error, /External AWS account not found: 999/)
577
+ end
578
+
579
+ it 'renders JSON output' do
580
+ ext = Fabricate(
581
+ :external_aws_account,
582
+ id: 7,
583
+ account_name: 'JsonShow',
584
+ aws_account_id: '987654321098',
585
+ discovery_role_arn: 'arn:aws:iam::987654321098:role/JsonRole'
586
+ )
587
+
588
+ allow(Aptible::CLI::Renderer).to receive(:format).and_return('json')
589
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
590
+ .with('7', token: token).and_return(ext)
591
+
592
+ subject.send('aws_accounts:show', '7')
593
+
594
+ json = captured_output_json
595
+ expect(json['id']).to eq(7)
596
+ expect(json['account_name']).to eq('JsonShow')
597
+ expect(json['aws_account_id']).to eq('987654321098')
598
+ expect(json['discovery_role_arn']).to(
599
+ eq('arn:aws:iam::987654321098:role/JsonRole')
600
+ )
601
+ end
602
+ end
603
+
604
+ describe '#aws_accounts:delete' do
605
+ it 'deletes an external AWS account' do
606
+ ext = double('ext', id: 24)
607
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
608
+ .with('24', token: token).and_return(ext)
609
+ expect(ext).to receive(:destroy!).and_return(true)
610
+
611
+ subject.send('aws_accounts:delete', '24')
612
+
613
+ expect(captured_output_text).to include('Id: 24')
614
+ expect(captured_output_text).to include('Deleted: true')
615
+ end
616
+
617
+ it 'fails when account not found' do
618
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
619
+ .with('999', token: token).and_return(nil)
620
+
621
+ expect { subject.send('aws_accounts:delete', '999') }
622
+ .to raise_error(Thor::Error, /External AWS account not found: 999/)
623
+ end
624
+
625
+ it 'supports alternative delete methods' do
626
+ ext = double('ext', id: 24)
627
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
628
+ .with('24', token: token).and_return(ext)
629
+
630
+ expect(ext).to receive(:respond_to?).with(:destroy!).and_return(false)
631
+ expect(ext).to receive(:respond_to?).with(:destroy).and_return(false)
632
+ expect(ext).to receive(:respond_to?).with(:delete!).and_return(true)
633
+ expect(ext).to receive(:delete!).and_return(true)
634
+
635
+ subject.send('aws_accounts:delete', '24')
636
+
637
+ expect(captured_output_text).to include('Id: 24')
638
+ expect(captured_output_text).to include('Deleted: true')
639
+ end
640
+
641
+ it 'raises when delete is not supported' do
642
+ ext = double('ext', id: 24)
643
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
644
+ .with('24', token: token).and_return(ext)
645
+
646
+ expect(ext).to receive(:respond_to?).with(:destroy!).and_return(false)
647
+ expect(ext).to receive(:respond_to?).with(:destroy).and_return(false)
648
+ expect(ext).to receive(:respond_to?).with(:delete!).and_return(false)
649
+ expect(ext).to receive(:respond_to?).with(:delete).and_return(false)
650
+
651
+ expect { subject.send('aws_accounts:delete', '24') }
652
+ .to raise_error(Thor::Error, /Delete is not supported/)
653
+ end
654
+
655
+ it 'renders JSON output for delete' do
656
+ ext = double('ext', id: 33)
657
+ allow(Aptible::CLI::Renderer).to receive(:format).and_return('json')
658
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
659
+ .with('33', token: token).and_return(ext)
660
+ expect(ext).to receive(:destroy!).and_return(true)
661
+
662
+ subject.send('aws_accounts:delete', '33')
663
+
664
+ json = captured_output_json
665
+ expect(json['id']).to eq('33')
666
+ expect(json['deleted']).to eq(true)
667
+ end
668
+ end
669
+
670
+ describe '#aws_accounts:check' do
671
+ it 'raises error when account not found' do
672
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
673
+ .with('42', token: token).and_return(nil)
674
+
675
+ expect { subject.send('aws_accounts:check', '42') }.to(
676
+ raise_error(Thor::Error, /External AWS account not found: 42/)
677
+ )
678
+ end
679
+
680
+ it 'checks an external AWS account successfully' do
681
+ ext = double('ext', id: 42)
682
+ check_result = double(
683
+ 'check_result',
684
+ state: 'success',
685
+ checks: [
686
+ double('check', check_name: 'role_access', state: 'success',
687
+ details: nil)
688
+ ]
689
+ )
690
+
691
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
692
+ .with('42', token: token).and_return(ext)
693
+ expect(ext).to receive(:check!).and_return(check_result)
694
+
695
+ subject.send('aws_accounts:check', '42')
696
+
697
+ expect(captured_output_text).to match(/State:.*success/m)
698
+ end
699
+
700
+ it 'raises error on check failure' do
701
+ ext = double('ext', id: 42)
702
+ check_result = double(
703
+ 'check_result',
704
+ state: 'failed',
705
+ checks: [
706
+ double('check', check_name: 'role_access', state: 'failed',
707
+ details: 'Access denied')
708
+ ]
709
+ )
710
+
711
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
712
+ .with('42', token: token).and_return(ext)
713
+ expect(ext).to receive(:check!).and_return(check_result)
714
+
715
+ expect { subject.send('aws_accounts:check', '42') }.to(
716
+ raise_error(Thor::Error, /AWS account check failed/)
717
+ )
718
+ end
719
+
720
+ it 'handles API errors during check' do
721
+ ext = double('ext', id: 42)
722
+
723
+ expect(Aptible::Api::ExternalAwsAccount).to receive(:find)
724
+ .with('42', token: token).and_return(ext)
725
+ expect(ext).to receive(:check!).and_raise(
726
+ HyperResource::ClientError.new(
727
+ 'Check failed',
728
+ response: Faraday::Response.new(status: 500)
729
+ )
730
+ )
731
+
732
+ expect { subject.send('aws_accounts:check', '42') }.to(
733
+ raise_error(Thor::Error, /Check failed/)
734
+ )
735
+ end
736
+ end
737
+ end