@spinnaker/google 2026.3.2 → 2026.4.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.
Files changed (108) hide show
  1. package/dist/autoscalingPolicy/GceAutoScalingFieldLayout.d.ts +2 -2
  2. package/dist/domain/loadBalancer.d.ts +6 -3
  3. package/dist/image/ImageSelect.d.ts +1 -1
  4. package/dist/index.js +159 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/loadBalancer/configure/common/gceLoadBalancerModels.d.ts +13 -2
  7. package/dist/loadBalancer/configure/http/GceHttpLoadBalancerEditor.d.ts +3 -0
  8. package/dist/loadBalancer/configure/http/GceHttpLoadBalancerModal.d.ts +1 -1
  9. package/dist/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerEditor.d.ts +30 -0
  10. package/dist/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerModal.d.ts +53 -0
  11. package/dist/loadBalancer/httpLoadBalancerUtils.service.d.ts +3 -1
  12. package/dist/serverGroup/configure/wizard/GceCloneServerGroupModal.d.ts +3 -0
  13. package/dist/serverGroup/configure/wizard/GceServerGroupWizard.types.d.ts +1 -0
  14. package/dist/serverGroup/configure/wizard/customInstance/CustomInstanceConfigurer.d.ts +1 -1
  15. package/dist/serverGroup/configure/wizard/customInstance/GceCustomInstanceBuilder.d.ts +2 -2
  16. package/dist/serverGroup/configure/wizard/pages/AdvancedSettings.d.ts +3 -0
  17. package/dist/serverGroup/configure/wizard/pages/GceServerGroupLoadBalancers.d.ts +1 -0
  18. package/dist/serverGroup/configure/wizard/pages/Policies.d.ts +21 -3
  19. package/dist/serverGroup/configure/wizard/pages/ServerGroupBasicSettings.d.ts +1 -0
  20. package/dist/serverGroup/configure/wizard/pages/ServerGroupCapacity.d.ts +1 -0
  21. package/dist/serverGroup/configure/wizard/pages/ServerGroupImageSettings.d.ts +1 -0
  22. package/dist/serverGroup/configure/wizard/pages/ServerGroupInstanceType.d.ts +1 -0
  23. package/dist/serverGroup/configure/wizard/zones/GceInstanceFlexibilityConfigurer.d.ts +2 -2
  24. package/dist/serverGroup/details/GceInstanceFlexibilityPolicyDetails.d.ts +2 -2
  25. package/dist/serverGroup/details/stateful/MarkDiskStatefulButton.d.ts +2 -2
  26. package/dist/serverGroup/details/stateful/UpdateBootImageButton.d.ts +2 -2
  27. package/package.json +5 -5
  28. package/src/autoHealingPolicy/GceAutoHealingPolicyEditor.spec.tsx +68 -52
  29. package/src/autoscalingPolicy/GceAutoscalingPolicyEditor.spec.tsx +43 -50
  30. package/src/autoscalingPolicy/GceAutoscalingPolicyEditor.tsx +1 -1
  31. package/src/autoscalingPolicy/GceAutoscalingPolicyWriter.spec.ts +1 -1
  32. package/src/domain/loadBalancer.ts +6 -3
  33. package/src/gce.module.spec.ts +132 -153
  34. package/src/instance/details/GceInstanceDetails.spec.tsx +86 -83
  35. package/src/loadBalancer/configure/choice/GceLoadBalancerChoiceModal.spec.tsx +162 -121
  36. package/src/loadBalancer/configure/choice/GceLoadBalancerChoiceModal.tsx +13 -1
  37. package/src/loadBalancer/configure/common/GceProxyLoadBalancerEditor.spec.tsx +47 -30
  38. package/src/loadBalancer/configure/common/GceProxyLoadBalancerEditor.tsx +2 -1
  39. package/src/loadBalancer/configure/common/GceProxyLoadBalancerModal.spec.tsx +85 -57
  40. package/src/loadBalancer/configure/common/gceLoadBalancerData.spec.ts +17 -16
  41. package/src/loadBalancer/configure/common/gceLoadBalancerModels.spec.ts +230 -5
  42. package/src/loadBalancer/configure/common/gceLoadBalancerModels.ts +186 -22
  43. package/src/loadBalancer/configure/common/gceLoadBalancerSubmission.spec.ts +298 -2
  44. package/src/loadBalancer/configure/common/gceLoadBalancerSubmission.ts +35 -5
  45. package/src/loadBalancer/configure/common/useGceLoadBalancerData.spec.tsx +37 -46
  46. package/src/loadBalancer/configure/http/GceHttpLoadBalancerEditor.spec.tsx +399 -58
  47. package/src/loadBalancer/configure/http/GceHttpLoadBalancerEditor.tsx +148 -41
  48. package/src/loadBalancer/configure/http/GceHttpLoadBalancerListenerEditor.spec.tsx +169 -36
  49. package/src/loadBalancer/configure/http/GceHttpLoadBalancerListenerEditor.tsx +80 -12
  50. package/src/loadBalancer/configure/http/GceHttpLoadBalancerModal.spec.tsx +224 -124
  51. package/src/loadBalancer/configure/http/GceHttpLoadBalancerModal.tsx +8 -2
  52. package/src/loadBalancer/configure/http/GceHttpLoadBalancerResourceEditors.spec.tsx +57 -22
  53. package/src/loadBalancer/configure/http/GceHttpLoadBalancerResourceEditors.tsx +21 -0
  54. package/src/loadBalancer/configure/http/GceHttpLoadBalancerRoutingEditor.spec.tsx +24 -20
  55. package/src/loadBalancer/configure/network/GceNetworkLoadBalancerEditor.spec.tsx +41 -32
  56. package/src/loadBalancer/configure/network/GceNetworkLoadBalancerModal.spec.tsx +35 -30
  57. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerEditor.spec.tsx +443 -0
  58. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerEditor.tsx +401 -0
  59. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerModal.spec.tsx +330 -0
  60. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerModal.tsx +348 -0
  61. package/src/loadBalancer/details/gceLoadBalancerDetails.spec.tsx +439 -35
  62. package/src/loadBalancer/details/gceLoadBalancerDetails.tsx +104 -21
  63. package/src/loadBalancer/httpLoadBalancerUtils.service.spec.ts +126 -0
  64. package/src/loadBalancer/httpLoadBalancerUtils.service.ts +27 -11
  65. package/src/loadBalancer/loadBalancer.setTransformer.spec.ts +165 -0
  66. package/src/loadBalancer/loadBalancer.setTransformer.ts +15 -2
  67. package/src/pipeline/stages/googleStageConfig.spec.js +22 -16
  68. package/src/securityGroup/configure/GceSecurityGroupModal.spec.tsx +177 -193
  69. package/src/securityGroup/configure/GceSecurityGroupModal.tsx +14 -2
  70. package/src/securityGroup/details/GceSecurityGroupDetails.spec.tsx +69 -78
  71. package/src/serverGroup/configure/serverGroupCommandBuilder.service.spec.js +35 -15
  72. package/src/serverGroup/configure/serverGroupConfiguration.service.js +7 -1
  73. package/src/serverGroup/configure/serverGroupConfiguration.service.spec.js +74 -4
  74. package/src/serverGroup/configure/wizard/GceCloneServerGroupModal.spec.tsx +593 -855
  75. package/src/serverGroup/configure/wizard/GceCloneServerGroupModal.tsx +120 -38
  76. package/src/serverGroup/configure/wizard/GceServerGroupWizard.types.ts +1 -0
  77. package/src/serverGroup/configure/wizard/GceServerGroupWizardPage.spec.tsx +53 -50
  78. package/src/serverGroup/configure/wizard/customInstance/GceCustomInstanceBuilder.spec.tsx +65 -49
  79. package/src/serverGroup/configure/wizard/hiddenMetadataKeys.value.js +1 -0
  80. package/src/serverGroup/configure/wizard/pages/AdvancedSettings.spec.tsx +101 -108
  81. package/src/serverGroup/configure/wizard/pages/AdvancedSettings.tsx +32 -28
  82. package/src/serverGroup/configure/wizard/pages/GceServerGroupFirewalls.spec.tsx +60 -59
  83. package/src/serverGroup/configure/wizard/pages/GceServerGroupLoadBalancers.spec.tsx +240 -114
  84. package/src/serverGroup/configure/wizard/pages/GceServerGroupLoadBalancers.tsx +152 -63
  85. package/src/serverGroup/configure/wizard/pages/Policies.spec.tsx +93 -48
  86. package/src/serverGroup/configure/wizard/pages/Policies.tsx +80 -55
  87. package/src/serverGroup/configure/wizard/pages/ServerGroupBasicSettings.spec.tsx +123 -94
  88. package/src/serverGroup/configure/wizard/pages/ServerGroupBasicSettings.tsx +24 -18
  89. package/src/serverGroup/configure/wizard/pages/ServerGroupCapacity.spec.tsx +152 -133
  90. package/src/serverGroup/configure/wizard/pages/ServerGroupCapacity.tsx +40 -37
  91. package/src/serverGroup/configure/wizard/pages/ServerGroupImageSettings.spec.tsx +66 -60
  92. package/src/serverGroup/configure/wizard/pages/ServerGroupImageSettings.tsx +8 -2
  93. package/src/serverGroup/configure/wizard/pages/ServerGroupInstanceType.spec.tsx +131 -101
  94. package/src/serverGroup/configure/wizard/pages/ServerGroupInstanceType.tsx +29 -28
  95. package/src/serverGroup/configure/wizard/zones/GceInstanceFlexibilityConfigurer.spec.tsx +104 -81
  96. package/src/serverGroup/details/GceInstanceFlexibilityPolicyDetails.spec.tsx +10 -11
  97. package/src/serverGroup/details/autoHealingPolicy/GceAutoHealingPolicyDetails.spec.tsx +51 -59
  98. package/src/serverGroup/details/autoHealingPolicy/modal/GceUpsertAutoHealingPolicyModal.spec.tsx +3 -3
  99. package/src/serverGroup/details/autoscalingPolicy/GceAutoscalingPolicyDetails.spec.tsx +49 -58
  100. package/src/serverGroup/details/autoscalingPolicy/modal/GceUpsertAutoscalingPolicyModal.spec.tsx +3 -3
  101. package/src/serverGroup/details/gceServerGroupDetails.spec.tsx +162 -141
  102. package/src/serverGroup/details/resize/GceResizeServerGroupModal.spec.tsx +102 -86
  103. package/src/serverGroup/details/resize/GceResizeServerGroupModal.tsx +4 -0
  104. package/src/serverGroup/details/rollback/GceRollbackServerGroupModal.spec.tsx +40 -31
  105. package/src/serverGroup/serverGroup.transformer.js +1 -0
  106. package/src/serverGroup/serverGroup.transformer.spec.js +48 -0
  107. package/src/validation/ApplicationNameValidator.spec.ts +1 -1
  108. /package/dist/{gce.logo2ae85d8704b1d6b8.png → gce.logoChI3w_A5.png} +0 -0
@@ -1,17 +1,14 @@
1
+ import { fireEvent, render, within } from '@testing-library/react';
1
2
  import React from 'react';
2
- import { shallow } from 'enzyme';
3
3
 
4
- import type { IGceLoadBalancerData } from '../common';
5
- import { normalizeGceLoadBalancerCommand } from '../common';
6
-
7
- import * as editorModule from './GceHttpLoadBalancerEditor';
8
4
  import {
9
5
  buildGceHttpLoadBalancerOptions,
10
6
  constrainGceHttpLoadBalancerCommand,
11
- FormRow,
12
7
  GceHttpLoadBalancerEditor,
8
+ validateGceHttpLoadBalancerCommand,
13
9
  } from './GceHttpLoadBalancerEditor';
14
- import { GceHttpLoadBalancerListenerEditor } from './GceHttpLoadBalancerListenerEditor';
10
+ import type { IGceLoadBalancerData } from '../common';
11
+ import { normalizeGceLoadBalancerCommand } from '../common';
15
12
 
16
13
  describe('GceHttpLoadBalancerEditor', () => {
17
14
  const emptyData: IGceLoadBalancerData = {
@@ -40,7 +37,7 @@ describe('GceHttpLoadBalancerEditor', () => {
40
37
  );
41
38
 
42
39
  expect(constrainGceHttpLoadBalancerCommand(http)).toEqual(
43
- jasmine.objectContaining({
40
+ expect.objectContaining({
44
41
  loadBalancerType: 'HTTP',
45
42
  network: undefined,
46
43
  region: 'global',
@@ -112,19 +109,17 @@ describe('GceHttpLoadBalancerEditor', () => {
112
109
  });
113
110
 
114
111
  it('renders only protocols supported by the selected composite type', () => {
115
- const onChange = jasmine.createSpy('onChange');
112
+ const onChange = vi.fn();
116
113
  const httpCommand = normalizeGceLoadBalancerCommand(
117
114
  { account: 'account-a', listeners: [{ name: 'frontend', port: 80 }], loadBalancerType: 'HTTP', name: 'web' },
118
115
  'create',
119
116
  );
120
- const http = shallow(<GceHttpLoadBalancerEditor command={httpCommand} data={emptyData} onChange={onChange} />);
117
+ const http = render(<GceHttpLoadBalancerEditor command={httpCommand} data={emptyData} onChange={onChange} />);
121
118
 
122
- const httpListener = shallow(http.find(GceHttpLoadBalancerListenerEditor).getElement());
123
- expect(
124
- httpListener.find('[data-testid="listener-protocol"] option').map((option) => option.prop('value')),
125
- ).toEqual(['HTTP', 'HTTPS']);
119
+ expect(optionValues(http.getByTestId('listener-protocol'))).toEqual(['HTTP', 'HTTPS']);
120
+ http.unmount();
126
121
 
127
- const internal = shallow(
122
+ const internal = render(
128
123
  <GceHttpLoadBalancerEditor
129
124
  command={{ ...httpCommand, loadBalancerType: 'INTERNAL_MANAGED', region: 'europe-west1' }}
130
125
  data={emptyData}
@@ -132,10 +127,7 @@ describe('GceHttpLoadBalancerEditor', () => {
132
127
  />,
133
128
  );
134
129
 
135
- const internalListener = shallow(internal.find(GceHttpLoadBalancerListenerEditor).getElement());
136
- expect(
137
- internalListener.find('[data-testid="listener-protocol"] option').map((option) => option.prop('value')),
138
- ).toEqual(['HTTP', 'HTTPS']);
130
+ expect(optionValues(internal.getByTestId('listener-protocol'))).toEqual(['HTTP', 'HTTPS']);
139
131
  });
140
132
 
141
133
  it('renders location controls as form rows instead of nesting them inside bold labels', () => {
@@ -143,10 +135,8 @@ describe('GceHttpLoadBalancerEditor', () => {
143
135
  { account: 'account-a', listeners: [{ name: 'frontend', port: 80 }], loadBalancerType: 'HTTP', name: 'web' },
144
136
  'create',
145
137
  );
146
- const wrapper = shallow(
147
- <GceHttpLoadBalancerEditor command={command} data={emptyData} onChange={jasmine.createSpy()} />,
148
- );
149
- expect(wrapper.find(FormRow).length).toBeGreaterThan(2);
138
+ const { container } = render(<GceHttpLoadBalancerEditor command={command} data={emptyData} onChange={vi.fn()} />);
139
+ expect(container.querySelectorAll('.form-group').length).toBeGreaterThan(2);
150
140
  });
151
141
 
152
142
  it('validates account, name, location, listeners, backends, health checks, and routing', () => {
@@ -175,10 +165,8 @@ describe('GceHttpLoadBalancerEditor', () => {
175
165
  { healthCheckType: 'HTTP', name: '', port: 70000, requestPath: '' },
176
166
  { healthCheckType: 'UDP', name: 'unsupported-check', port: 80 },
177
167
  ];
178
- const validate = (editorModule as any).validateGceHttpLoadBalancerCommand || (() => []);
179
-
180
- expect(validate(invalid)).toEqual(
181
- jasmine.arrayContaining([
168
+ expect(validateGceHttpLoadBalancerCommand(invalid)).toEqual(
169
+ expect.arrayContaining([
182
170
  'Name is required.',
183
171
  'Account is required.',
184
172
  'Region is required for INTERNAL_MANAGED load balancers.',
@@ -204,7 +192,7 @@ describe('GceHttpLoadBalancerEditor', () => {
204
192
  });
205
193
 
206
194
  it('initializes path matchers from the current composite default without coupling later changes', () => {
207
- const onChange = jasmine.createSpy('onChange');
195
+ const onChange = vi.fn();
208
196
  const command = normalizeGceLoadBalancerCommand(
209
197
  {
210
198
  account: 'account-a',
@@ -217,15 +205,12 @@ describe('GceHttpLoadBalancerEditor', () => {
217
205
  'create',
218
206
  );
219
207
  const data = { ...emptyData, backendServices: command.backendServices };
220
- const wrapper = shallow(<GceHttpLoadBalancerEditor command={command} data={data} onChange={onChange} />);
208
+ const initial = render(<GceHttpLoadBalancerEditor command={command} data={data} onChange={onChange} />);
221
209
 
222
- wrapper
223
- .find('button')
224
- .filterWhere((button) => button.text() === 'Add host rule')
225
- .simulate('click');
210
+ fireEvent.click(initial.getByRole('button', { name: 'Add host rule' }));
226
211
 
227
212
  expect(onChange).toHaveBeenCalledWith(
228
- jasmine.objectContaining({
213
+ expect.objectContaining({
229
214
  hostRules: [
230
215
  {
231
216
  hostPatterns: [],
@@ -244,14 +229,13 @@ describe('GceHttpLoadBalancerEditor', () => {
244
229
  },
245
230
  ],
246
231
  };
247
- const stableWrapper = shallow(<GceHttpLoadBalancerEditor command={withMatcher} data={data} onChange={onChange} />);
232
+ initial.unmount();
233
+ const stable = render(<GceHttpLoadBalancerEditor command={withMatcher} data={data} onChange={onChange} />);
248
234
 
249
- stableWrapper
250
- .find('[data-testid="default-backend-service"]')
251
- .simulate('change', { target: { value: 'backend-b' } });
235
+ fireEvent.change(stable.getByTestId('default-backend-service'), { target: { value: 'backend-b' } });
252
236
 
253
237
  expect(onChange).toHaveBeenCalledWith(
254
- jasmine.objectContaining({
238
+ expect.objectContaining({
255
239
  defaultService: { name: 'backend-b' },
256
240
  hostRules: [
257
241
  {
@@ -292,8 +276,8 @@ describe('GceHttpLoadBalancerEditor', () => {
292
276
  } as any);
293
277
 
294
278
  expect(options.addresses.map(({ name }) => name)).toEqual(['current-address', 'removed-address']);
295
- expect(options.certificates).toContain(
296
- jasmine.objectContaining({ name: 'removed-cert', selfLink: 'https://compute/sslCertificates/removed-cert' }),
279
+ expect(options.certificates).toContainEqual(
280
+ expect.objectContaining({ name: 'removed-cert', selfLink: 'https://compute/sslCertificates/removed-cert' }),
297
281
  );
298
282
  expect(options.healthChecks.map(({ name }) => name)).toEqual(['current-check', 'removed-check']);
299
283
  expect(options.backendServices.map(({ name }) => name)).toEqual(['current-backend', 'removed-backend']);
@@ -399,7 +383,7 @@ describe('GceHttpLoadBalancerEditor', () => {
399
383
  });
400
384
 
401
385
  it('locks infrastructure identity controls only while editing', () => {
402
- const onChange = jasmine.createSpy('onChange');
386
+ const onChange = vi.fn();
403
387
  const internal = normalizeGceLoadBalancerCommand(
404
388
  {
405
389
  account: 'account-a',
@@ -411,22 +395,94 @@ describe('GceHttpLoadBalancerEditor', () => {
411
395
  },
412
396
  'edit',
413
397
  );
414
- const editWrapper = shallow(<GceHttpLoadBalancerEditor command={internal} data={emptyData} onChange={onChange} />);
398
+ const rendered = render(<GceHttpLoadBalancerEditor command={internal} data={emptyData} onChange={onChange} />);
415
399
 
416
400
  ['load-balancer-name', 'load-balancer-type', 'credentials', 'region'].forEach((testId) =>
417
- expect(editWrapper.find(`[data-testid="${testId}"]`).prop('disabled')).toBe(true),
401
+ expect(rendered.getByTestId(testId)).toBeDisabled(),
418
402
  );
419
403
 
420
- const createWrapper = shallow(
404
+ rendered.rerender(
421
405
  <GceHttpLoadBalancerEditor command={{ ...internal, mode: 'create' }} data={emptyData} onChange={onChange} />,
422
406
  );
423
407
  ['load-balancer-name', 'load-balancer-type', 'credentials', 'region'].forEach((testId) =>
424
- expect(createWrapper.find(`[data-testid="${testId}"]`).prop('disabled')).not.toBe(true),
408
+ expect(rendered.getByTestId(testId)).not.toBeDisabled(),
409
+ );
410
+ });
411
+
412
+ it('locks the EXTERNAL_MANAGED network while editing because existing listeners keep their network', () => {
413
+ const onChange = vi.fn();
414
+ const command = (loadBalancerType: 'EXTERNAL_MANAGED' | 'INTERNAL_MANAGED', mode: 'create' | 'edit') =>
415
+ normalizeGceLoadBalancerCommand(
416
+ {
417
+ account: 'account-a',
418
+ loadBalancerType,
419
+ name: 'web',
420
+ network: 'network-a',
421
+ region: 'europe-west1',
422
+ subnet: 'subnet-a',
423
+ },
424
+ mode,
425
+ );
426
+ const networkSelect = (loadBalancerType: 'EXTERNAL_MANAGED' | 'INTERNAL_MANAGED', mode: 'create' | 'edit') => {
427
+ const { getByTestId, unmount } = render(
428
+ <GceHttpLoadBalancerEditor command={command(loadBalancerType, mode)} data={emptyData} onChange={onChange} />,
429
+ );
430
+ const network = getByTestId('network');
431
+ return { network, unmount };
432
+ };
433
+
434
+ const externalEdit = networkSelect('EXTERNAL_MANAGED', 'edit');
435
+ expect(externalEdit.network).toBeDisabled();
436
+ externalEdit.unmount();
437
+ const externalCreate = networkSelect('EXTERNAL_MANAGED', 'create');
438
+ expect(externalCreate.network).toBeEnabled();
439
+ externalCreate.unmount();
440
+ const internalEdit = networkSelect('INTERNAL_MANAGED', 'edit');
441
+ expect(internalEdit.network).toBeEnabled();
442
+ });
443
+
444
+ it('drops EXTERNAL_MANAGED listener addresses when the account or region changes', () => {
445
+ const scopedCommand = (loadBalancerType: 'EXTERNAL_MANAGED' | 'INTERNAL_MANAGED') =>
446
+ normalizeGceLoadBalancerCommand(
447
+ {
448
+ account: 'account-a',
449
+ listeners: [{ ipAddress: '203.0.113.10', name: 'frontend', port: 80, protocol: 'HTTP', subnet: 'subnet-a' }],
450
+ loadBalancerType,
451
+ name: 'web',
452
+ network: 'network-a',
453
+ region: 'europe-west1',
454
+ subnet: 'subnet-a',
455
+ },
456
+ 'create',
457
+ );
458
+ const onChange = vi.fn();
459
+ const data = {
460
+ ...emptyData,
461
+ accounts: [{ name: 'account-a' }, { name: 'account-b' }],
462
+ regions: [{ name: 'europe-west1' }, { name: 'us-central1' }],
463
+ };
464
+ const external = render(
465
+ <GceHttpLoadBalancerEditor command={scopedCommand('EXTERNAL_MANAGED')} data={data} onChange={onChange} />,
466
+ );
467
+
468
+ fireEvent.change(external.getByTestId('credentials'), { target: { value: 'account-b' } });
469
+ expect(onChange.mock.lastCall[0].credentials).toBe('account-b');
470
+ expect(onChange.mock.lastCall[0].listeners[0].address).toBeUndefined();
471
+ fireEvent.change(external.getByTestId('region'), { target: { value: 'us-central1' } });
472
+ expect(onChange.mock.lastCall[0].region).toBe('us-central1');
473
+ expect(onChange.mock.lastCall[0].listeners[0].address).toBeUndefined();
474
+ external.unmount();
475
+
476
+ const internal = render(
477
+ <GceHttpLoadBalancerEditor command={scopedCommand('INTERNAL_MANAGED')} data={data} onChange={onChange} />,
425
478
  );
479
+ fireEvent.change(internal.getByTestId('region'), { target: { value: 'us-central1' } });
480
+ expect(onChange.mock.lastCall[0].region).toBe('us-central1');
481
+ expect(onChange.mock.lastCall[0].listeners[0].address).toEqual({ name: '203.0.113.10' });
426
482
  });
427
483
 
428
484
  it('restores account, region, network, subnet, and composite type controls', () => {
429
- const onChange = jasmine.createSpy('onChange');
485
+ const onChange = vi.fn();
430
486
  const command = normalizeGceLoadBalancerCommand(
431
487
  {
432
488
  account: 'account-a',
@@ -439,7 +495,7 @@ describe('GceHttpLoadBalancerEditor', () => {
439
495
  },
440
496
  'create',
441
497
  );
442
- const wrapper = shallow(
498
+ const rendered = render(
443
499
  <GceHttpLoadBalancerEditor
444
500
  command={command}
445
501
  data={{
@@ -453,20 +509,305 @@ describe('GceHttpLoadBalancerEditor', () => {
453
509
  />,
454
510
  );
455
511
 
456
- expect(wrapper.find('[data-testid="load-balancer-type"]').prop('value')).toBe('INTERNAL_MANAGED');
457
- expect(wrapper.find('[data-testid="load-balancer-name"]').prop('value')).toBe('web');
458
- expect(wrapper.find('[data-testid="credentials"]').prop('value')).toBe('account-a');
459
- expect(wrapper.find('[data-testid="region"]').prop('value')).toBe('europe-west1');
460
- expect(wrapper.find('[data-testid="network"]').prop('value')).toBe('network-a');
461
- expect(wrapper.find('[data-testid="subnet"]').prop('value')).toBe('subnet-a');
512
+ expect(rendered.getByTestId('load-balancer-type')).toHaveValue('INTERNAL_MANAGED');
513
+ expect(rendered.getByTestId('load-balancer-name')).toHaveValue('web');
514
+ expect(rendered.getByTestId('credentials')).toHaveValue('account-a');
515
+ expect(rendered.getByTestId('region')).toHaveValue('europe-west1');
516
+ expect(rendered.getByTestId('network')).toHaveValue('network-a');
517
+ expect(rendered.getByTestId('subnet')).toHaveValue('subnet-a');
462
518
 
463
- wrapper.find('[data-testid="load-balancer-name"]').simulate('change', { target: { value: 'web-updated' } });
464
- expect(onChange).toHaveBeenCalledWith(jasmine.objectContaining({ name: 'web-updated' }));
519
+ fireEvent.change(rendered.getByTestId('load-balancer-name'), { target: { value: 'web-updated' } });
520
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'web-updated' }));
465
521
 
466
- wrapper.find('[data-testid="load-balancer-type"]').simulate('change', { target: { value: 'HTTP' } });
522
+ fireEvent.change(rendered.getByTestId('load-balancer-type'), { target: { value: 'HTTP' } });
467
523
 
468
524
  expect(onChange).toHaveBeenCalledWith(
469
- jasmine.objectContaining({ loadBalancerType: 'HTTP', network: undefined, region: 'global', subnet: undefined }),
525
+ expect.objectContaining({ loadBalancerType: 'HTTP', network: undefined, region: 'global', subnet: undefined }),
470
526
  );
471
527
  });
528
+
529
+ it('constrains EXTERNAL_MANAGED to regional scope with network and listener tier', () => {
530
+ const command = normalizeGceLoadBalancerCommand(
531
+ {
532
+ account: 'account-a',
533
+ listeners: [
534
+ {
535
+ certificate: 'regional-cert',
536
+ ipAddress: '203.0.113.10',
537
+ name: 'app-https',
538
+ networkTier: 'STANDARD',
539
+ port: 443,
540
+ protocol: 'HTTPS',
541
+ },
542
+ ],
543
+ loadBalancerType: 'EXTERNAL_MANAGED',
544
+ name: 'web',
545
+ network: 'network-a',
546
+ region: 'europe-west1',
547
+ },
548
+ 'create',
549
+ );
550
+
551
+ expect(constrainGceHttpLoadBalancerCommand(command)).toEqual(
552
+ expect.objectContaining({
553
+ loadBalancerType: 'EXTERNAL_MANAGED',
554
+ network: { name: 'network-a' },
555
+ region: 'europe-west1',
556
+ subnet: undefined,
557
+ }),
558
+ );
559
+ expect(constrainGceHttpLoadBalancerCommand(command).listeners).toEqual([
560
+ {
561
+ certificate: { name: 'regional-cert' },
562
+ address: { name: '203.0.113.10' },
563
+ name: 'app-https',
564
+ networkTier: 'STANDARD',
565
+ portRange: '443',
566
+ protocol: 'HTTPS',
567
+ },
568
+ ]);
569
+ });
570
+
571
+ it('retains networkTier when constraining an EXTERNAL_MANAGED plaintext HTTP listener', () => {
572
+ const command = normalizeGceLoadBalancerCommand(
573
+ {
574
+ account: 'account-a',
575
+ listeners: [
576
+ {
577
+ ipAddress: '203.0.113.10',
578
+ name: 'app-http',
579
+ networkTier: 'STANDARD',
580
+ port: 80,
581
+ protocol: 'HTTP',
582
+ },
583
+ ],
584
+ loadBalancerType: 'EXTERNAL_MANAGED',
585
+ name: 'web',
586
+ network: 'network-a',
587
+ region: 'europe-west1',
588
+ },
589
+ 'create',
590
+ );
591
+
592
+ expect(constrainGceHttpLoadBalancerCommand(command).listeners).toEqual([
593
+ {
594
+ address: { name: '203.0.113.10' },
595
+ name: 'app-http',
596
+ networkTier: 'STANDARD',
597
+ portRange: '80',
598
+ protocol: 'HTTP',
599
+ },
600
+ ]);
601
+ });
602
+
603
+ it('scopes EXTERNAL_MANAGED resources by account and region and filters proxy-only networks', () => {
604
+ const data = {
605
+ ...emptyData,
606
+ addresses: [
607
+ { account: 'account-a', address: '203.0.113.10', addressType: 'EXTERNAL', region: 'europe-west1' },
608
+ { account: 'account-a', address: '10.0.0.1', addressType: 'INTERNAL', region: 'europe-west1' },
609
+ { account: 'account-a', address: '198.51.100.1', addressType: 'EXTERNAL', region: 'us-central1' },
610
+ { account: 'account-b', address: '203.0.113.11', addressType: 'EXTERNAL', region: 'europe-west1' },
611
+ ],
612
+ backendServices: [
613
+ { account: 'account-a', name: 'regional-backend', region: 'europe-west1' },
614
+ { account: 'account-a', name: 'global-backend', region: 'global' },
615
+ ],
616
+ certificates: [
617
+ { account: 'account-a', name: 'regional-cert', region: 'europe-west1' },
618
+ { account: 'account-b', name: 'wrong-account-cert', region: 'europe-west1' },
619
+ ],
620
+ healthChecks: [
621
+ { account: 'account-a', name: 'regional-check', region: 'europe-west1' },
622
+ { account: 'account-a', name: 'global-check', region: 'global' },
623
+ ],
624
+ networks: [
625
+ { account: 'account-a', id: 'network-a', name: 'network-a', region: 'global' },
626
+ { account: 'account-a', id: 'network-b', name: 'network-b', region: 'global' },
627
+ ],
628
+ subnets: [
629
+ {
630
+ account: 'account-a',
631
+ name: 'proxy-subnet',
632
+ network: 'network-a',
633
+ purpose: 'REGIONAL_MANAGED_PROXY',
634
+ region: 'europe-west1',
635
+ },
636
+ {
637
+ account: 'account-a',
638
+ name: 'internal-subnet',
639
+ network: 'network-b',
640
+ purpose: 'INTERNAL_HTTPS_LOAD_BALANCER',
641
+ region: 'europe-west1',
642
+ },
643
+ ],
644
+ } as any;
645
+ const command = normalizeGceLoadBalancerCommand(
646
+ {
647
+ account: 'account-a',
648
+ backendServices: [{ healthCheck: 'regional-check', name: 'regional-backend' }],
649
+ certificate: 'regional-cert',
650
+ ipAddress: '203.0.113.10',
651
+ loadBalancerType: 'EXTERNAL_MANAGED',
652
+ name: 'external',
653
+ network: 'network-a',
654
+ region: 'europe-west1',
655
+ },
656
+ 'create',
657
+ );
658
+
659
+ const options = buildGceHttpLoadBalancerOptions(command, data);
660
+ expect(options.addresses.filter(({ address }) => address).map(({ address }) => address)).toEqual(['203.0.113.10']);
661
+ // The listener select value is the persisted IP, so it must stay selectable next to the named address.
662
+ expect(options.addresses.map(({ name }) => name)).toContain('203.0.113.10');
663
+ expect(options.certificates.map(({ name }) => name)).toEqual(['regional-cert']);
664
+ expect(options.healthChecks.map(({ name }) => name)).toEqual(['regional-check']);
665
+ expect(options.backendServices.map(({ name }) => name)).toEqual(['regional-backend']);
666
+ expect(options.networks.map(({ name }) => name)).toEqual(['network-a']);
667
+ expect(options.subnets).toEqual([]);
668
+ });
669
+
670
+ it('rejects Shared VPCs, unsupported backend protocols, and destructive same-name listener edits', () => {
671
+ const command = normalizeGceLoadBalancerCommand(
672
+ {
673
+ account: 'account-a',
674
+ backendServices: [{ healthCheck: 'check-a', name: 'backend-a', portName: 'http', protocol: 'HTTP2' }],
675
+ defaultService: 'backend-a',
676
+ healthChecks: [{ healthCheckType: 'HTTP', name: 'check-a', port: 80, requestPath: '/' }],
677
+ listeners: [{ name: 'frontend', networkTier: 'PREMIUM', port: 80, protocol: 'HTTP' }],
678
+ loadBalancerType: 'EXTERNAL_MANAGED',
679
+ name: 'web',
680
+ network: 'host-project/shared-network',
681
+ region: 'europe-west1',
682
+ },
683
+ 'edit',
684
+ );
685
+ command.listeners[0].portRange = '8080';
686
+
687
+ expect(validateGceHttpLoadBalancerCommand(command)).toEqual(
688
+ expect.arrayContaining([
689
+ 'Shared VPC networks are not supported for EXTERNAL_MANAGED load balancers.',
690
+ 'Backend service protocol must be HTTP or HTTPS.',
691
+ 'Rename the listener to change its port, address, network tier, or HTTP/HTTPS protocol.',
692
+ ]),
693
+ );
694
+
695
+ command.network = { name: 'local-network' };
696
+ command.backendServices[0].protocol = 'HTTPS';
697
+ command.listeners[0] = { ...command.listeners[0], name: 'replacement' };
698
+ expect(validateGceHttpLoadBalancerCommand(command)).not.toEqual(
699
+ expect.arrayContaining([
700
+ 'Shared VPC networks are not supported for EXTERNAL_MANAGED load balancers.',
701
+ 'Backend service protocol must be HTTP or HTTPS.',
702
+ 'Rename the listener to change its port, address, network tier, or HTTP/HTTPS protocol.',
703
+ ]),
704
+ );
705
+ });
706
+
707
+ it('reports a missing network for a new EXTERNAL_MANAGED load balancer instead of throwing', () => {
708
+ const command = normalizeGceLoadBalancerCommand(
709
+ { account: 'account-a', loadBalancerType: 'EXTERNAL_MANAGED', region: 'europe-west1' },
710
+ 'create',
711
+ );
712
+ command.network = undefined;
713
+
714
+ const errors = validateGceHttpLoadBalancerCommand(command);
715
+
716
+ expect(errors).toContain('Network is required for EXTERNAL_MANAGED load balancers.');
717
+ expect(errors).not.toContain('Shared VPC networks are not supported for EXTERNAL_MANAGED load balancers.');
718
+ });
719
+
720
+ it('offers only account-local networks for EXTERNAL_MANAGED', () => {
721
+ const command = normalizeGceLoadBalancerCommand(
722
+ {
723
+ account: 'account-a',
724
+ loadBalancerType: 'EXTERNAL_MANAGED',
725
+ name: 'web',
726
+ region: 'europe-west1',
727
+ },
728
+ 'create',
729
+ );
730
+ const options = buildGceHttpLoadBalancerOptions(command, {
731
+ ...emptyData,
732
+ networks: [
733
+ { account: 'account-a', id: 'local-network', name: 'local-network' },
734
+ { account: 'account-a', id: 'host-project/shared-network', name: 'shared-network' },
735
+ ],
736
+ subnets: [
737
+ {
738
+ account: 'account-a',
739
+ name: 'local-proxy',
740
+ network: 'local-network',
741
+ purpose: 'REGIONAL_MANAGED_PROXY',
742
+ region: 'europe-west1',
743
+ },
744
+ {
745
+ account: 'account-a',
746
+ name: 'shared-proxy',
747
+ network: 'host-project/shared-network',
748
+ purpose: 'REGIONAL_MANAGED_PROXY',
749
+ region: 'europe-west1',
750
+ },
751
+ ],
752
+ } as any);
753
+
754
+ expect(options.networks.map(({ name }) => name)).toEqual(['local-network']);
755
+ });
756
+
757
+ it('rejects EXTERNAL_MANAGED listeners that use certificate maps', () => {
758
+ const command = normalizeGceLoadBalancerCommand(
759
+ {
760
+ account: 'account-a',
761
+ listeners: [{ certificateMap: 'shared-map', name: 'frontend', port: 443, protocol: 'HTTPS' }],
762
+ loadBalancerType: 'EXTERNAL_MANAGED',
763
+ name: 'web',
764
+ network: 'network-a',
765
+ region: 'europe-west1',
766
+ },
767
+ 'create',
768
+ );
769
+ expect(validateGceHttpLoadBalancerCommand(command)).toContain(
770
+ 'Certificate maps are not supported for EXTERNAL_MANAGED load balancers.',
771
+ );
772
+ });
773
+
774
+ (['HTTP', 'INTERNAL_MANAGED'] as const).forEach((loadBalancerType) => {
775
+ it(`strips stale networkTier when constraining ${loadBalancerType} from EXTERNAL_MANAGED`, () => {
776
+ const external = normalizeGceLoadBalancerCommand(
777
+ {
778
+ account: 'account-a',
779
+ listeners: [
780
+ {
781
+ ipAddress: '203.0.113.10',
782
+ name: 'frontend',
783
+ networkTier: 'STANDARD',
784
+ port: 443,
785
+ protocol: 'HTTPS',
786
+ },
787
+ ],
788
+ loadBalancerType: 'EXTERNAL_MANAGED',
789
+ name: 'web',
790
+ network: 'network-a',
791
+ region: 'europe-west1',
792
+ },
793
+ 'create',
794
+ );
795
+
796
+ const constrained = constrainGceHttpLoadBalancerCommand({
797
+ ...external,
798
+ loadBalancerType,
799
+ ...(loadBalancerType === 'INTERNAL_MANAGED'
800
+ ? { network: { name: 'network-a' }, region: 'europe-west1', subnet: { name: 'subnet-a' } }
801
+ : { network: undefined, region: 'global', subnet: undefined }),
802
+ });
803
+
804
+ expect(constrained.listeners.every(({ networkTier }) => networkTier === undefined)).toBe(true);
805
+ });
806
+ });
472
807
  });
808
+
809
+ function optionValues(select: HTMLElement): string[] {
810
+ return within(select)
811
+ .getAllByRole('option')
812
+ .map((option) => (option as HTMLOptionElement).value);
813
+ }