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/3] 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/3] 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) + } }) }) From 7e0ef8ec8684ce9e6e7d4f629adf46eb39f6ff88 Mon Sep 17 00:00:00 2001 From: 1fanwang <1fannnw@gmail.com> Date: Tue, 13 Oct 2026 15:32:01 +0100 Subject: [PATCH 3/3] ui: list volumes in the instance's own project projectid=-1 reaches only the caller's own account and the projects they belong to, so a domain admin saw an empty Volumes tab for other users' instances and for projects they had not joined. Send the project only for an instance that belongs to one. Signed-off-by: 1fanwang <1fannnw@gmail.com> --- ui/src/components/view/VolumesTab.vue | 3 +- .../unit/components/view/VolumesTab.spec.js | 43 +++++++++++++------ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/ui/src/components/view/VolumesTab.vue b/ui/src/components/view/VolumesTab.vue index b0a4e799ea4a..845dfffb70a1 100644 --- a/ui/src/components/view/VolumesTab.vue +++ b/ui/src/components/view/VolumesTab.vue @@ -132,7 +132,8 @@ export default { } }, getVolumes () { - getAPI('listVolumes', { listall: true, listsystemvms: true, projectid: '-1', virtualmachineid: this.vm.id }).then(json => { + // Only a project instance sends its project; projectid=-1 would hide other accounts' volumes from a domain admin + getAPI('listVolumes', { listall: true, listsystemvms: true, projectid: this.vm.projectid, 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 78479b1d1f0a..e9f187a15ef3 100644 --- a/ui/tests/unit/components/view/VolumesTab.spec.js +++ b/ui/tests/unit/components/view/VolumesTab.spec.js @@ -37,15 +37,29 @@ 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 caller = 'domain admin' let requests = [] + +// Mirrors buildACLSearchParameters in server/src/main/java/com/cloud/user/AccountManagerImpl.java for +// listVolumes with listall. The domain admin owns none of the instances and belongs to no project. +// Without projectid, project resources are skipped. projectid=-1 reaches only the caller's own account +// and project accounts, so it hides every instance from a domain admin, while a root admin is not +// restricted. projectid= reaches that project, which a domain admin may open. +const canList = (vm, projectid) => { + if (!projectid) { + return !vm.projectid + } + if (projectid === '-1') { + return caller === 'root admin' + } + return projectid === vm.projectid +} + 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 - ) + const volume = vm && canList(vm, params.projectid) ? [ { id: 'data-volume', name: 'Data volume', deviceid: 1 }, { id: 'root-volume', name: 'Root volume', deviceid: 0 } @@ -68,6 +82,7 @@ describe('Components > View > VolumesTab.vue', () => { afterAll(() => new Promise(resolve => server.close(resolve))) beforeEach(() => { + caller = 'domain admin' requests = [] jest.clearAllMocks() vueProps.$localStorage.get.mockReturnValue(null) @@ -104,14 +119,14 @@ describe('Components > View > VolumesTab.vue', () => { }) it.each([ - ['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) => { + ['project instance in Default view', 'domain admin', projectVm, null], + ['project instance in Default view', 'root admin', projectVm, null], + ['project instance in its project', 'domain admin', projectVm, projectVm.projectid], + ['project instance in another project', 'domain admin', projectVm, 'project-2'], + ['account instance in Default view', 'domain admin', accountVm, null], + ['system VM in Default view', 'root admin', systemVm, null] + ])('shows volume rows for %s as a %s', async (name, role, resource, selectedProjectId) => { + caller = role vueProps.$localStorage.get.mockImplementation(key => { return key === CURRENT_PROJECT && selectedProjectId ? { id: selectedProjectId } : null }) @@ -141,8 +156,8 @@ describe('Components > View > VolumesTab.vue', () => { response: 'json', listall: 'true', listsystemvms: 'true', - projectid: '-1', - virtualmachineid: resource.id + virtualmachineid: resource.id, + ...(resource.projectid && { projectid: resource.projectid }) }]) expect(wrapper.vm.volumes.map(volume => volume.id)).toEqual(['root-volume', 'data-volume']) expect(wrapper.text()).toContain('Root volume')