From 390c2fe79f2051ff7b3be3ee2fc380ac88ae2c12 Mon Sep 17 00:00:00 2001 From: 1fanwang <1fannnw@gmail.com> Date: Fri, 11 Sep 2026 15:57:18 -0700 Subject: [PATCH 1/2] ui: show project instance volumes outside project view Fixes: https://github.com/apache/cloudstack/issues/14070 Signed-off-by: 1fanwang <1fannnw@gmail.com> --- ui/src/components/view/VolumesTab.vue | 2 +- .../unit/components/view/VolumesTab.spec.js | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/ui/src/components/view/VolumesTab.vue b/ui/src/components/view/VolumesTab.vue index 8bb8c0bb88fa..b0a4e799ea4a 100644 --- a/ui/src/components/view/VolumesTab.vue +++ b/ui/src/components/view/VolumesTab.vue @@ -132,7 +132,7 @@ export default { } }, getVolumes () { - getAPI('listVolumes', { listall: true, listsystemvms: true, virtualmachineid: this.vm.id }).then(json => { + getAPI('listVolumes', { listall: true, listsystemvms: true, projectid: '-1', virtualmachineid: this.vm.id }).then(json => { this.volumes = json.listvolumesresponse.volume if (this.volumes) { this.volumes.sort((a, b) => { return a.deviceid - b.deviceid }) diff --git a/ui/tests/unit/components/view/VolumesTab.spec.js b/ui/tests/unit/components/view/VolumesTab.spec.js index 6c333453ffb5..899efc6796da 100644 --- a/ui/tests/unit/components/view/VolumesTab.spec.js +++ b/ui/tests/unit/components/view/VolumesTab.spec.js @@ -17,14 +17,25 @@ import { flushPromises } from '@vue/test-utils' +import mockAxios from '../../../mock/mockAxios' import common from '../../../common' import VolumesTab from '@/components/view/VolumesTab.vue' +jest.mock('axios', () => mockAxios) +jest.mock('@/vue-app', () => ({ + vueProps: { + $localStorage: { + get: jest.fn(() => null) + } + } +})) + const router = common.createMockRouter() const i18n = common.createMockI18n('en') describe('Components > View > VolumesTab.vue', () => { beforeEach(() => { + jest.clearAllMocks() jest.spyOn(console, 'warn').mockImplementation(() => {}) }) @@ -52,6 +63,47 @@ describe('Components > View > VolumesTab.vue', () => { expect(wrapper.text()).toContain('2.00 GiB') expect(wrapper.text()).not.toContain('2.00 GB') + expect(mockAxios).not.toHaveBeenCalled() + + wrapper.unmount() + }) + + it.each([ + ['project instance', { id: 'vm-1', projectid: 'project-1' }], + ['account instance', { id: 'vm-2' }], + ['system VM', { id: 'vm-3', systemvmtype: 'secondarystoragevm' }] + ])('requests volumes across project scopes for %s', async (name, resource) => { + mockAxios.mockResolvedValue({ + listvolumesresponse: { + volume: [ + { id: 'data-volume', name: 'Data volume', deviceid: 1 }, + { id: 'root-volume', name: 'Root volume', deviceid: 0 } + ] + } + }) + const wrapper = common.createFactory(VolumesTab, { + router, + i18n, + props: { resource } + }) + + await flushPromises() + + expect(mockAxios).toHaveBeenCalledWith({ + url: '/', + method: 'GET', + params: { + command: 'listVolumes', + response: 'json', + listall: true, + listsystemvms: true, + projectid: '-1', + virtualmachineid: resource.id + } + }) + expect(wrapper.vm.volumes.map(volume => volume.id)).toEqual(['root-volume', 'data-volume']) + expect(wrapper.text()).toContain('Root volume') + expect(wrapper.text()).toContain('Data volume') wrapper.unmount() }) From 2d9d1b3ebd1d3c7527e25d50515fb910be4ed401 Mon Sep 17 00:00:00 2001 From: 1fanwang <1fannnw@gmail.com> Date: Fri, 11 Sep 2026 16:13:05 -0700 Subject: [PATCH 2/2] test: exercise volume project scopes through HTTP Signed-off-by: 1fanwang <1fannnw@gmail.com> --- .../unit/components/view/VolumesTab.spec.js | 101 +++++++++++++----- 1 file changed, 73 insertions(+), 28 deletions(-) diff --git a/ui/tests/unit/components/view/VolumesTab.spec.js b/ui/tests/unit/components/view/VolumesTab.spec.js index 899efc6796da..78479b1d1f0a 100644 --- a/ui/tests/unit/components/view/VolumesTab.spec.js +++ b/ui/tests/unit/components/view/VolumesTab.spec.js @@ -15,13 +15,15 @@ // specific language governing permissions and limitations // under the License. +import { createServer } from 'http' import { flushPromises } from '@vue/test-utils' -import mockAxios from '../../../mock/mockAxios' import common from '../../../common' import VolumesTab from '@/components/view/VolumesTab.vue' +import { axios } from '@/utils/request' +import { CURRENT_PROJECT } from '@/store/mutation-types' +import { vueProps } from '@/vue-app' -jest.mock('axios', () => mockAxios) jest.mock('@/vue-app', () => ({ vueProps: { $localStorage: { @@ -32,10 +34,43 @@ jest.mock('@/vue-app', () => ({ const router = common.createMockRouter() const i18n = common.createMockI18n('en') +const projectVm = { id: 'vm-1', projectid: 'project-1' } +const accountVm = { id: 'vm-2' } +const systemVm = { id: 'vm-3', systemvmtype: 'secondarystoragevm' } +let requests = [] +const server = createServer((request, response) => { + const params = Object.fromEntries(new URL(request.url, 'http://localhost').searchParams) + requests.push(params) + const vm = [projectVm, accountVm, systemVm].find(vm => vm.id === params.virtualmachineid) + const volume = vm && ( + (params.projectid === '-1' && params.listall === 'true') || + params.projectid === vm.projectid + ) + ? [ + { id: 'data-volume', name: 'Data volume', deviceid: 1 }, + { id: 'root-volume', name: 'Root volume', deviceid: 0 } + ] + : [] + response.setHeader('Content-Type', 'application/json') + response.end(JSON.stringify({ listvolumesresponse: { count: volume.length, volume } })) +}) describe('Components > View > VolumesTab.vue', () => { + beforeAll(async () => { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + axios.defaults.baseURL = `http://127.0.0.1:${server.address().port}` + axios.defaults.adapter = require('axios/lib/adapters/http') + }) + + afterAll(() => new Promise(resolve => server.close(resolve))) + beforeEach(() => { + requests = [] jest.clearAllMocks() + vueProps.$localStorage.get.mockReturnValue(null) jest.spyOn(console, 'warn').mockImplementation(() => {}) }) @@ -63,23 +98,32 @@ describe('Components > View > VolumesTab.vue', () => { expect(wrapper.text()).toContain('2.00 GiB') expect(wrapper.text()).not.toContain('2.00 GB') - expect(mockAxios).not.toHaveBeenCalled() + expect(requests).toHaveLength(0) wrapper.unmount() }) it.each([ - ['project instance', { id: 'vm-1', projectid: 'project-1' }], - ['account instance', { id: 'vm-2' }], - ['system VM', { id: 'vm-3', systemvmtype: 'secondarystoragevm' }] - ])('requests volumes across project scopes for %s', async (name, resource) => { - mockAxios.mockResolvedValue({ - listvolumesresponse: { - volume: [ - { id: 'data-volume', name: 'Data volume', deviceid: 1 }, - { id: 'root-volume', name: 'Root volume', deviceid: 0 } - ] - } + ['project instance in Default view', projectVm, null], + ['project instance in its project', projectVm, projectVm.projectid], + ['project instance in another project', projectVm, 'project-2'], + ['account instance in Default view', accountVm, null], + ['account instance in another project', accountVm, 'project-2'], + ['system VM in Default view', systemVm, null], + ['system VM in another project', systemVm, 'project-2'] + ])('shows volume rows for %s', async (name, resource, selectedProjectId) => { + vueProps.$localStorage.get.mockImplementation(key => { + return key === CURRENT_PROJECT && selectedProjectId ? { id: selectedProjectId } : null + }) + let interceptor + const response = new Promise((resolve, reject) => { + interceptor = axios.interceptors.response.use(data => { + resolve(data) + return data + }, error => { + reject(error) + return Promise.reject(error) + }) }) const wrapper = common.createFactory(VolumesTab, { router, @@ -87,24 +131,25 @@ describe('Components > View > VolumesTab.vue', () => { props: { resource } }) - await flushPromises() + try { + await response + await flushPromises() - expect(mockAxios).toHaveBeenCalledWith({ - url: '/', - method: 'GET', - params: { + expect(wrapper.findAll('tbody tr[data-row-key]')).toHaveLength(2) + expect(requests).toEqual([{ command: 'listVolumes', response: 'json', - listall: true, - listsystemvms: true, + listall: 'true', + listsystemvms: 'true', projectid: '-1', virtualmachineid: resource.id - } - }) - expect(wrapper.vm.volumes.map(volume => volume.id)).toEqual(['root-volume', 'data-volume']) - expect(wrapper.text()).toContain('Root volume') - expect(wrapper.text()).toContain('Data volume') - - wrapper.unmount() + }]) + expect(wrapper.vm.volumes.map(volume => volume.id)).toEqual(['root-volume', 'data-volume']) + expect(wrapper.text()).toContain('Root volume') + expect(wrapper.text()).toContain('Data volume') + } finally { + wrapper.unmount() + axios.interceptors.response.eject(interceptor) + } }) })