From 1da4322591511c6b996ffe874b126b932baaf35e Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:25:21 +0100 Subject: [PATCH 1/4] UI: add VPC tier network ACL replacement --- ui/public/locales/en.json | 3 + ui/src/views/network/VpcTiersTab.vue | 111 ++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 775de26103a0..d7513c9f2c13 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -3968,6 +3968,9 @@ "message.remove.sticky.policy.failed": "Failed to remove sticky policy.", "message.remove.sticky.policy.processing": "Removing sticky policy...", "message.remove.vpc": "Please confirm that you want to remove the VPC", +"message.replace.acl.failed": "Failed to replace the Network ACL list", +"message.replace.acl.processing": "Replacing the Network ACL list...", +"message.replace.acl.success": "Successfully replaced the Network ACL list", "message.request.failed": "Request failed.", "message.request.no.data": "There is no data to show.", "message.required.add.least.ip": "Please add at least 1 IP Range", diff --git a/ui/src/views/network/VpcTiersTab.vue b/ui/src/views/network/VpcTiersTab.vue index 4a689f13c34d..92a9f8b83142 100644 --- a/ui/src/views/network/VpcTiersTab.vue +++ b/ui/src/views/network/VpcTiersTab.vue @@ -56,6 +56,12 @@ {{ network.aclname }} + @@ -302,6 +308,56 @@ + + + +

{{ $t('message.confirm.replace.acl.new.one') }}

+ + + + + {{ item.name }} ({{ item.description }}) + + + + + + + + + +
+ {{ $t('label.cancel') }} + {{ $t('label.ok') }} +
+
+
+
+ { this.networkAclList = json.listnetworkacllistsresponse.networkacllist || [] - this.handleNetworkAclChange(null) + this.form.acl = selectedAclId + this.handleNetworkAclChange(selectedAclId) }).catch(error => { this.$notifyError(error) }).finally(() => { @@ -695,7 +755,7 @@ export default { }, handleNetworkAclChange (aclId) { if (aclId) { - this.selectedNetworkAcl = this.networkAclList.filter(acl => acl.id === aclId)[0] + this.selectedNetworkAcl = this.networkAclList.filter(acl => acl.id === aclId)[0] || {} } else { this.selectedNetworkAcl = {} } @@ -724,6 +784,51 @@ export default { vlan: [{ required: true, message: this.$t('message.please.enter.value') }] } }, + handleOpenReplaceAclModal (network) { + this.initForm() + this.networkid = network.id + this.fetchNetworkAclList(network.aclid) + this.showReplaceAclModal = true + this.rules = { + acl: [{ required: true, message: this.$t('label.required') }] + } + }, + handleReplaceAclSubmit () { + if (this.modalLoading) return + + this.formRef.value.validate().then(() => { + const values = this.handleRemoveFields(toRaw(this.form)) + + this.fetchLoading = true + this.modalLoading = true + this.showReplaceAclModal = false + + postAPI('replaceNetworkACLList', { + aclid: values.acl, + networkid: this.networkid + }).then(response => { + this.$pollJob({ + jobId: response.replacenetworkacllistresponse.jobid, + title: this.$t('label.replace.acl'), + description: this.networkid, + successMessage: this.$t('message.replace.acl.success'), + successMethod: () => { + this.parentFetchData() + }, + errorMessage: this.$t('message.replace.acl.failed'), + loadingMessage: this.$t('message.replace.acl.processing'), + catchMessage: this.$t('error.fetching.async.job.result') + }) + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.fetchLoading = false + this.modalLoading = false + }) + }).catch((error) => { + this.formRef.value.scrollToField(error.errorFields[0].name) + }) + }, handleAddInternalLB (id) { this.initForm() this.showAddInternalLB = true From abdded0988b85c853da69b1d9db41a8ef490b99b Mon Sep 17 00:00:00 2001 From: Brad <100990646+Dogface2k@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:16:51 +0100 Subject: [PATCH 2/4] UI: isolate VPC tier ACL replacement state --- ui/src/views/network/VpcTiersTab.vue | 122 ++++++-- .../unit/views/network/VpcTiersTab.spec.js | 285 ++++++++++++++++++ 2 files changed, 377 insertions(+), 30 deletions(-) create mode 100644 ui/tests/unit/views/network/VpcTiersTab.spec.js diff --git a/ui/src/views/network/VpcTiersTab.vue b/ui/src/views/network/VpcTiersTab.vue index 92a9f8b83142..7e84975b57e2 100644 --- a/ui/src/views/network/VpcTiersTab.vue +++ b/ui/src/views/network/VpcTiersTab.vue @@ -315,43 +315,43 @@ :closable="true" :footer="null" :destroyOnClose="true" - @cancel="showReplaceAclModal = false"> - + @cancel="handleCloseReplaceAclModal"> +

{{ $t('message.confirm.replace.acl.new.one') }}

- + - + {{ item.name }} ({{ item.description }}) - + - +
- {{ $t('label.cancel') }} + {{ $t('label.cancel') }} {{ $t('label.ok') }}
@@ -458,6 +458,15 @@ export default { networkOfferings: [], networkAclList: [], selectedNetworkAcl: {}, + replaceAclFormRef: null, + replaceAclForm: {}, + replaceAclRules: {}, + replaceAclList: [], + replaceAclSelected: {}, + replaceAclNetworkId: '', + replaceAclLoading: false, + replaceAclFetchLoading: false, + replaceAclInteractionGeneration: 0, modalLoading: false, internalLB: {}, LBPublicIPs: {}, @@ -619,6 +628,25 @@ export default { this.modalLoading = false }) }, + fetchReplaceAclList (interactionGeneration, selectedAclId) { + this.replaceAclFetchLoading = true + return getAPI('listNetworkACLLists', { vpcid: this.resource.id }).then(json => { + if (interactionGeneration !== this.replaceAclInteractionGeneration) { + return + } + this.replaceAclList = json.listnetworkacllistsresponse.networkacllist || [] + this.replaceAclForm.acl = selectedAclId + this.handleReplaceAclChange(selectedAclId) + }).catch(error => { + if (interactionGeneration === this.replaceAclInteractionGeneration) { + this.$notifyError(error) + } + }).finally(() => { + if (interactionGeneration === this.replaceAclInteractionGeneration) { + this.replaceAclFetchLoading = false + } + }) + }, getNetworkOffering (networkId) { return new Promise((resolve, reject) => { getAPI('listNetworkOfferings', { @@ -760,6 +788,13 @@ export default { this.selectedNetworkAcl = {} } }, + handleReplaceAclChange (aclId) { + if (aclId) { + this.replaceAclSelected = this.replaceAclList.filter(acl => acl.id === aclId)[0] || {} + } else { + this.replaceAclSelected = {} + } + }, closeModal () { this.$emit('close-action') }, @@ -785,32 +820,52 @@ export default { } }, handleOpenReplaceAclModal (network) { - this.initForm() - this.networkid = network.id - this.fetchNetworkAclList(network.aclid) - this.showReplaceAclModal = true - this.rules = { + const interactionGeneration = ++this.replaceAclInteractionGeneration + this.replaceAclFormRef = ref() + this.replaceAclForm = reactive({ acl: network.aclid }) + this.replaceAclRules = reactive({ acl: [{ required: true, message: this.$t('label.required') }] - } + }) + this.replaceAclList = [] + this.replaceAclSelected = {} + this.replaceAclNetworkId = network.id + this.replaceAclLoading = false + this.replaceAclFetchLoading = false + this.showReplaceAclModal = true + return this.fetchReplaceAclList(interactionGeneration, network.aclid) + }, + handleCloseReplaceAclModal () { + this.showReplaceAclModal = false + this.replaceAclInteractionGeneration += 1 + this.replaceAclList = [] + this.replaceAclSelected = {} + this.replaceAclNetworkId = '' + this.replaceAclLoading = false + this.replaceAclFetchLoading = false }, handleReplaceAclSubmit () { - if (this.modalLoading) return + if (this.replaceAclLoading || this.replaceAclFetchLoading) return - this.formRef.value.validate().then(() => { - const values = this.handleRemoveFields(toRaw(this.form)) + const interactionGeneration = this.replaceAclInteractionGeneration + const networkId = this.replaceAclNetworkId + const aclId = this.replaceAclForm.acl + const formRef = this.replaceAclFormRef + this.replaceAclLoading = true - this.fetchLoading = true - this.modalLoading = true + return formRef.value.validate().then(() => { + if (interactionGeneration !== this.replaceAclInteractionGeneration) { + return + } this.showReplaceAclModal = false - postAPI('replaceNetworkACLList', { - aclid: values.acl, - networkid: this.networkid + return postAPI('replaceNetworkACLList', { + aclid: aclId, + networkid: networkId }).then(response => { this.$pollJob({ jobId: response.replacenetworkacllistresponse.jobid, title: this.$t('label.replace.acl'), - description: this.networkid, + description: networkId, successMessage: this.$t('message.replace.acl.success'), successMethod: () => { this.parentFetchData() @@ -822,11 +877,18 @@ export default { }).catch(error => { this.$notifyError(error) }).finally(() => { - this.fetchLoading = false - this.modalLoading = false + if (interactionGeneration === this.replaceAclInteractionGeneration) { + this.replaceAclLoading = false + } }) }).catch((error) => { - this.formRef.value.scrollToField(error.errorFields[0].name) + if (interactionGeneration !== this.replaceAclInteractionGeneration) { + return + } + this.replaceAclLoading = false + if (error?.errorFields?.length > 0) { + formRef.value.scrollToField(error.errorFields[0].name) + } }) }, handleAddInternalLB (id) { diff --git a/ui/tests/unit/views/network/VpcTiersTab.spec.js b/ui/tests/unit/views/network/VpcTiersTab.spec.js new file mode 100644 index 000000000000..4fc3fa1631d8 --- /dev/null +++ b/ui/tests/unit/views/network/VpcTiersTab.spec.js @@ -0,0 +1,285 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { flushPromises, shallowMount } from '@vue/test-utils' + +import common from '../../../common' +import mockAxios from '../../../mock/mockAxios' +import TooltipButton from '@/components/widgets/TooltipButton' +import VpcTiersTab from '@/views/network/VpcTiersTab' + +jest.mock('axios', () => mockAxios) +jest.mock('@/vue-app', () => ({ + vueProps: { + $localStorage: { + get: jest.fn(() => null) + } + } +})) + +const deferred = () => { + let resolve + let reject + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + +const aclListResponse = (id, name = id) => ({ + listnetworkacllistsresponse: { + networkacllist: [{ id, name, description: `${name} description` }] + } +}) + +const createContext = () => { + const context = { + resource: { id: 'vpc-id' }, + form: { acl: 'create-acl' }, + networkAclList: [{ id: 'create-acl', name: 'create ACL' }], + selectedNetworkAcl: { id: 'create-acl', name: 'create ACL' }, + replaceAclFormRef: { + value: { + validate: jest.fn().mockResolvedValue(), + scrollToField: jest.fn() + } + }, + replaceAclForm: {}, + replaceAclRules: {}, + replaceAclList: [], + replaceAclSelected: {}, + replaceAclNetworkId: '', + replaceAclLoading: false, + replaceAclFetchLoading: false, + replaceAclInteractionGeneration: 0, + showReplaceAclModal: false, + $t: key => key, + $notifyError: jest.fn(), + $pollJob: jest.fn(), + parentFetchData: jest.fn() + } + Object.assign(context, VpcTiersTab.methods) + return context +} + +describe('Views > network > VpcTiersTab.vue', () => { + beforeEach(() => { + mockAxios.mockReset() + }) + + it('does not let a closed replacement request mutate create-tier state', async () => { + const request = deferred() + const context = createContext() + mockAxios.mockImplementationOnce(() => request.promise) + + const openPromise = context.handleOpenReplaceAclModal({ id: 'tier-a', aclid: 'acl-a' }) + context.handleCloseReplaceAclModal() + request.resolve(aclListResponse('acl-a')) + await openPromise + + expect(context.form).toEqual({ acl: 'create-acl' }) + expect(context.networkAclList).toEqual([{ id: 'create-acl', name: 'create ACL' }]) + expect(context.selectedNetworkAcl).toEqual({ id: 'create-acl', name: 'create ACL' }) + expect(context.replaceAclList).toEqual([]) + expect(context.replaceAclSelected).toEqual({}) + }) + + it('keeps the latest tier state when an older ACL request completes last', async () => { + const requestA = deferred() + const requestB = deferred() + const context = createContext() + mockAxios + .mockImplementationOnce(() => requestA.promise) + .mockImplementationOnce(() => requestB.promise) + + const openA = context.handleOpenReplaceAclModal({ id: 'tier-a', aclid: 'acl-a' }) + const openB = context.handleOpenReplaceAclModal({ id: 'tier-b', aclid: 'acl-b' }) + + requestB.resolve(aclListResponse('acl-b', 'ACL B')) + await openB + requestA.resolve(aclListResponse('acl-a', 'ACL A')) + await openA + + expect(context.replaceAclNetworkId).toBe('tier-b') + expect(context.replaceAclForm.acl).toBe('acl-b') + expect(context.replaceAclList).toEqual([{ + id: 'acl-b', + name: 'ACL B', + description: 'ACL B description' + }]) + expect(context.replaceAclSelected.id).toBe('acl-b') + expect(context.replaceAclFetchLoading).toBe(false) + }) + + it('does not submit after the interaction is closed during validation', async () => { + const validation = deferred() + const context = createContext() + context.replaceAclInteractionGeneration = 1 + context.replaceAclNetworkId = 'tier-a' + context.replaceAclForm = { acl: 'acl-a' } + context.replaceAclFormRef = { + value: { + validate: jest.fn(() => validation.promise), + scrollToField: jest.fn() + } + } + context.showReplaceAclModal = true + + const submission = context.handleReplaceAclSubmit() + context.handleCloseReplaceAclModal() + validation.resolve() + await submission + + expect(mockAxios).not.toHaveBeenCalled() + expect(context.$pollJob).not.toHaveBeenCalled() + expect(context.replaceAclLoading).toBe(false) + }) + + it('submits captured IDs without clearing a newer replacement interaction', async () => { + const firstSubmissionRequest = deferred() + const nextAclRequest = deferred() + const nextSubmissionRequest = deferred() + const context = createContext() + context.replaceAclInteractionGeneration = 1 + context.replaceAclNetworkId = 'tier-a' + context.replaceAclForm = { acl: 'acl-a' } + context.replaceAclFormRef = { + value: { + validate: jest.fn().mockResolvedValue(), + scrollToField: jest.fn() + } + } + context.showReplaceAclModal = true + mockAxios + .mockImplementationOnce(() => firstSubmissionRequest.promise) + .mockImplementationOnce(() => nextAclRequest.promise) + .mockImplementationOnce(() => nextSubmissionRequest.promise) + + const firstSubmission = context.handleReplaceAclSubmit() + await flushPromises() + + const firstPostRequest = mockAxios.mock.calls[0][0] + expect(firstPostRequest.method).toBe('POST') + expect(firstPostRequest.data.get('command')).toBe('replaceNetworkACLList') + expect(firstPostRequest.data.get('aclid')).toBe('acl-a') + expect(firstPostRequest.data.get('networkid')).toBe('tier-a') + + const nextInteraction = context.handleOpenReplaceAclModal({ id: 'tier-b', aclid: 'acl-b' }) + expect(context.replaceAclLoading).toBe(false) + expect(context.replaceAclFetchLoading).toBe(true) + + nextAclRequest.resolve(aclListResponse('acl-b', 'ACL B')) + await nextInteraction + expect(context.replaceAclFetchLoading).toBe(false) + context.replaceAclFormRef.value = { + validate: jest.fn().mockResolvedValue(), + scrollToField: jest.fn() + } + + const nextSubmission = context.handleReplaceAclSubmit() + await flushPromises() + + const nextPostRequest = mockAxios.mock.calls[2][0] + expect(nextPostRequest.method).toBe('POST') + expect(nextPostRequest.data.get('aclid')).toBe('acl-b') + expect(nextPostRequest.data.get('networkid')).toBe('tier-b') + expect(context.replaceAclLoading).toBe(true) + + firstSubmissionRequest.resolve({ replacenetworkacllistresponse: { jobid: 'job-a' } }) + await firstSubmission + + expect(context.$pollJob).toHaveBeenCalledWith(expect.objectContaining({ + jobId: 'job-a', + description: 'tier-a' + })) + expect(context.replaceAclNetworkId).toBe('tier-b') + expect(context.replaceAclLoading).toBe(true) + + nextSubmissionRequest.resolve({ replacenetworkacllistresponse: { jobid: 'job-b' } }) + await nextSubmission + expect(context.replaceAclLoading).toBe(false) + }) + + it('keeps the overview replacement action disabled without API permission', async () => { + mockAxios.mockImplementation(request => { + const command = request.params?.command + switch (command) { + case 'listZones': + return Promise.resolve({ listzonesresponse: { zone: [{}] } }) + case 'listVPCOfferings': + return Promise.resolve({ listvpcofferingsresponse: { vpcoffering: [{}] } }) + case 'listLoadBalancers': + return Promise.resolve({ listloadbalancersresponse: { loadbalancer: [], count: 0 } }) + case 'listVirtualMachines': + return Promise.resolve({ listvirtualmachinesresponse: { virtualmachine: [], count: 0 } }) + case 'listNetworkOfferings': + return Promise.resolve({ listnetworkofferingsresponse: { networkoffering: [{ supportsinternallb: false }] } }) + case 'listNetworks': + return Promise.resolve({ listnetworksresponse: { network: [] } }) + default: + return Promise.resolve({}) + } + }) + const store = common.createMockStore({ + user: { + apis: {}, + info: {} + } + }) + const wrapper = shallowMount(VpcTiersTab, { + props: { + resource: { + id: 'vpc-id', + zoneid: 'zone-id', + vpcofferingid: 'vpc-offering-id', + network: [{ + id: 'tier-id', + zoneid: 'zone-id', + networkofferingid: 'network-offering-id', + name: 'tier', + state: 'Implemented', + cidr: '10.0.0.0/24', + aclid: 'acl-id', + aclname: 'ACL', + service: [] + }] + } + }, + global: { + plugins: [store], + mocks: { + $t: key => key, + $router: { + push: jest.fn() + } + }, + provide: { + parentFetchData: jest.fn() + } + } + }) + + await flushPromises() + const replaceButton = wrapper.findAllComponents(TooltipButton) + .find(button => button.props('tooltip') === 'label.replace.acl') + + expect(replaceButton).toBeDefined() + expect(replaceButton.props('disabled')).toBe(true) + wrapper.unmount() + }) +}) From 2b2224fa442c12ac9a644970a37d1a2c04800dd7 Mon Sep 17 00:00:00 2001 From: Brad <100990646+Dogface2k@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:18:52 +0100 Subject: [PATCH 3/4] UI: preserve VPC tier ACL replacement lifecycle --- ui/src/views/network/VpcTiersTab.vue | 34 +++-- .../unit/views/network/VpcTiersTab.spec.js | 120 ++++++++++++++++++ 2 files changed, 140 insertions(+), 14 deletions(-) diff --git a/ui/src/views/network/VpcTiersTab.vue b/ui/src/views/network/VpcTiersTab.vue index 7e84975b57e2..d6b4f0c29b9d 100644 --- a/ui/src/views/network/VpcTiersTab.vue +++ b/ui/src/views/network/VpcTiersTab.vue @@ -458,7 +458,6 @@ export default { networkOfferings: [], networkAclList: [], selectedNetworkAcl: {}, - replaceAclFormRef: null, replaceAclForm: {}, replaceAclRules: {}, replaceAclList: [], @@ -544,6 +543,7 @@ export default { }, created () { this.initForm() + this.replaceAclFormRef = ref() this.fetchData() }, watch: { @@ -551,6 +551,11 @@ export default { if (!newData && this.resource.id) { this.fetchData() } + }, + 'resource.id' (newId, oldId) { + if (oldId && newId !== oldId) { + this.handleCloseReplaceAclModal() + } } }, methods: { @@ -614,13 +619,12 @@ export default { this.zoneExtNetProvider = json?.listzonesresponse?.zone?.[0]?.provider || null }) }, - fetchNetworkAclList (selectedAclId) { + fetchNetworkAclList () { this.fetchLoading = true this.modalLoading = true getAPI('listNetworkACLLists', { vpcid: this.resource.id }).then(json => { this.networkAclList = json.listnetworkacllistsresponse.networkacllist || [] - this.form.acl = selectedAclId - this.handleNetworkAclChange(selectedAclId) + this.handleNetworkAclChange(null) }).catch(error => { this.$notifyError(error) }).finally(() => { @@ -628,21 +632,21 @@ export default { this.modalLoading = false }) }, - fetchReplaceAclList (interactionGeneration, selectedAclId) { + fetchReplaceAclList (interactionGeneration, vpcId, selectedAclId) { this.replaceAclFetchLoading = true - return getAPI('listNetworkACLLists', { vpcid: this.resource.id }).then(json => { - if (interactionGeneration !== this.replaceAclInteractionGeneration) { + return getAPI('listNetworkACLLists', { vpcid: vpcId }).then(json => { + if (interactionGeneration !== this.replaceAclInteractionGeneration || vpcId !== this.resource.id) { return } this.replaceAclList = json.listnetworkacllistsresponse.networkacllist || [] this.replaceAclForm.acl = selectedAclId this.handleReplaceAclChange(selectedAclId) }).catch(error => { - if (interactionGeneration === this.replaceAclInteractionGeneration) { + if (interactionGeneration === this.replaceAclInteractionGeneration && vpcId === this.resource.id) { this.$notifyError(error) } }).finally(() => { - if (interactionGeneration === this.replaceAclInteractionGeneration) { + if (interactionGeneration === this.replaceAclInteractionGeneration && vpcId === this.resource.id) { this.replaceAclFetchLoading = false } }) @@ -783,7 +787,7 @@ export default { }, handleNetworkAclChange (aclId) { if (aclId) { - this.selectedNetworkAcl = this.networkAclList.filter(acl => acl.id === aclId)[0] || {} + this.selectedNetworkAcl = this.networkAclList.filter(acl => acl.id === aclId)[0] } else { this.selectedNetworkAcl = {} } @@ -821,6 +825,7 @@ export default { }, handleOpenReplaceAclModal (network) { const interactionGeneration = ++this.replaceAclInteractionGeneration + const vpcId = this.resource.id this.replaceAclFormRef = ref() this.replaceAclForm = reactive({ acl: network.aclid }) this.replaceAclRules = reactive({ @@ -832,7 +837,7 @@ export default { this.replaceAclLoading = false this.replaceAclFetchLoading = false this.showReplaceAclModal = true - return this.fetchReplaceAclList(interactionGeneration, network.aclid) + return this.fetchReplaceAclList(interactionGeneration, vpcId, network.aclid) }, handleCloseReplaceAclModal () { this.showReplaceAclModal = false @@ -847,13 +852,14 @@ export default { if (this.replaceAclLoading || this.replaceAclFetchLoading) return const interactionGeneration = this.replaceAclInteractionGeneration + const vpcId = this.resource.id const networkId = this.replaceAclNetworkId const aclId = this.replaceAclForm.acl const formRef = this.replaceAclFormRef this.replaceAclLoading = true return formRef.value.validate().then(() => { - if (interactionGeneration !== this.replaceAclInteractionGeneration) { + if (interactionGeneration !== this.replaceAclInteractionGeneration || vpcId !== this.resource.id) { return } this.showReplaceAclModal = false @@ -877,12 +883,12 @@ export default { }).catch(error => { this.$notifyError(error) }).finally(() => { - if (interactionGeneration === this.replaceAclInteractionGeneration) { + if (interactionGeneration === this.replaceAclInteractionGeneration && vpcId === this.resource.id) { this.replaceAclLoading = false } }) }).catch((error) => { - if (interactionGeneration !== this.replaceAclInteractionGeneration) { + if (interactionGeneration !== this.replaceAclInteractionGeneration || vpcId !== this.resource.id) { return } this.replaceAclLoading = false diff --git a/ui/tests/unit/views/network/VpcTiersTab.spec.js b/ui/tests/unit/views/network/VpcTiersTab.spec.js index 4fc3fa1631d8..56073fa32e87 100644 --- a/ui/tests/unit/views/network/VpcTiersTab.spec.js +++ b/ui/tests/unit/views/network/VpcTiersTab.spec.js @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +import { isRef } from 'vue' import { flushPromises, shallowMount } from '@vue/test-utils' import common from '../../../common' @@ -126,6 +127,30 @@ describe('Views > network > VpcTiersTab.vue', () => { expect(context.replaceAclFetchLoading).toBe(false) }) + it('ignores a stale VPC response and closes the interaction when the parent VPC changes', async () => { + const request = deferred() + const context = createContext() + context.resource.id = 'vpc-a' + mockAxios.mockImplementationOnce(() => request.promise) + + const openPromise = context.handleOpenReplaceAclModal({ id: 'tier-a', aclid: 'acl-a' }) + const interactionGeneration = context.replaceAclInteractionGeneration + + context.resource.id = 'vpc-b' + request.resolve(aclListResponse('acl-a')) + await openPromise + + expect(context.replaceAclList).toEqual([]) + expect(context.replaceAclSelected).toEqual({}) + + VpcTiersTab.watch['resource.id'].call(context, 'vpc-b', 'vpc-a') + expect(context.showReplaceAclModal).toBe(false) + expect(context.replaceAclInteractionGeneration).toBe(interactionGeneration + 1) + expect(context.replaceAclNetworkId).toBe('') + expect(context.replaceAclFetchLoading).toBe(false) + expect(context.replaceAclLoading).toBe(false) + }) + it('does not submit after the interaction is closed during validation', async () => { const validation = deferred() const context = createContext() @@ -215,6 +240,101 @@ describe('Views > network > VpcTiersTab.vue', () => { expect(context.replaceAclLoading).toBe(false) }) + it('keeps the replacement form reference as a Vue ref and submits captured IDs', async () => { + const pollJob = jest.fn() + mockAxios.mockImplementation(request => { + const command = request.params?.command || request.data?.get('command') + switch (command) { + case 'listZones': + return Promise.resolve({ listzonesresponse: { zone: [{}] } }) + case 'listVPCOfferings': + return Promise.resolve({ listvpcofferingsresponse: { vpcoffering: [{}] } }) + case 'listLoadBalancers': + return Promise.resolve({ listloadbalancersresponse: { loadbalancer: [], count: 0 } }) + case 'listVirtualMachines': + return Promise.resolve({ listvirtualmachinesresponse: { virtualmachine: [], count: 0 } }) + case 'listNetworkOfferings': + return Promise.resolve({ listnetworkofferingsresponse: { networkoffering: [{ supportsinternallb: false }] } }) + case 'listNetworks': + return Promise.resolve({ listnetworksresponse: { network: [] } }) + case 'listNetworkACLLists': + return Promise.resolve(aclListResponse('acl-id', 'ACL')) + case 'replaceNetworkACLList': + return Promise.resolve({ replacenetworkacllistresponse: { jobid: 'job-id' } }) + default: + return Promise.resolve({}) + } + }) + const store = common.createMockStore({ + user: { + apis: { replaceNetworkACLList: {} }, + info: {} + } + }) + const wrapper = shallowMount(VpcTiersTab, { + props: { + resource: { + id: 'vpc-id', + zoneid: 'zone-id', + vpcofferingid: 'vpc-offering-id', + network: [{ + id: 'tier-id', + zoneid: 'zone-id', + networkofferingid: 'network-offering-id', + name: 'tier', + state: 'Implemented', + cidr: '10.0.0.0/24', + aclid: 'acl-id', + aclname: 'ACL', + service: [] + }] + } + }, + global: { + plugins: [store], + mocks: { + $t: key => key, + $router: { + push: jest.fn() + }, + $pollJob: pollJob, + $notifyError: jest.fn() + }, + provide: { + parentFetchData: jest.fn() + } + } + }) + + await flushPromises() + expect(isRef(wrapper.vm.replaceAclFormRef)).toBe(true) + + await wrapper.vm.handleOpenReplaceAclModal({ id: 'tier-id', aclid: 'acl-id' }) + await flushPromises() + expect(isRef(wrapper.vm.replaceAclFormRef)).toBe(true) + + const form = { + validate: jest.fn().mockResolvedValue(), + scrollToField: jest.fn() + } + wrapper.vm.replaceAclFormRef.value = form + await wrapper.vm.handleReplaceAclSubmit() + + const replaceRequest = mockAxios.mock.calls + .map(call => call[0]) + .find(request => request.data?.get('command') === 'replaceNetworkACLList') + + expect(form.validate).toHaveBeenCalled() + expect(replaceRequest).toBeDefined() + expect(replaceRequest.data.get('aclid')).toBe('acl-id') + expect(replaceRequest.data.get('networkid')).toBe('tier-id') + expect(pollJob).toHaveBeenCalledWith(expect.objectContaining({ + jobId: 'job-id', + description: 'tier-id' + })) + wrapper.unmount() + }) + it('keeps the overview replacement action disabled without API permission', async () => { mockAxios.mockImplementation(request => { const command = request.params?.command From 4f9b7ea6c53c21b3a7ba158e59fd1dc68d967f2e Mon Sep 17 00:00:00 2001 From: Brad <100990646+Dogface2k@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:37:58 +0100 Subject: [PATCH 4/4] test: render VPC tier ACL test slots --- ui/tests/unit/views/network/VpcTiersTab.spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/tests/unit/views/network/VpcTiersTab.spec.js b/ui/tests/unit/views/network/VpcTiersTab.spec.js index 56073fa32e87..ce6117c40893 100644 --- a/ui/tests/unit/views/network/VpcTiersTab.spec.js +++ b/ui/tests/unit/views/network/VpcTiersTab.spec.js @@ -291,6 +291,7 @@ describe('Views > network > VpcTiersTab.vue', () => { } }, global: { + renderStubDefaultSlot: true, plugins: [store], mocks: { $t: key => key, @@ -312,6 +313,7 @@ describe('Views > network > VpcTiersTab.vue', () => { await wrapper.vm.handleOpenReplaceAclModal({ id: 'tier-id', aclid: 'acl-id' }) await flushPromises() expect(isRef(wrapper.vm.replaceAclFormRef)).toBe(true) + expect(wrapper.vm.replaceAclFormRef.value).toBeTruthy() const form = { validate: jest.fn().mockResolvedValue(), @@ -381,6 +383,7 @@ describe('Views > network > VpcTiersTab.vue', () => { } }, global: { + renderStubDefaultSlot: true, plugins: [store], mocks: { $t: key => key,