diff --git a/ui/src/components/header/ProjectMenu.vue b/ui/src/components/header/ProjectMenu.vue index 32a77723825f..1f2818269b31 100644 --- a/ui/src/components/header/ProjectMenu.vue +++ b/ui/src/components/header/ProjectMenu.vue @@ -70,7 +70,7 @@ export default { this.unwatchProject = this.$store.watch( (state, getters) => getters.project?.id, (newId) => { - this.selectedProjectId = newId + this.selectedProjectId = newId || this.defaultOption.id } ) eventBus.on('projects-updated', (args) => { @@ -85,7 +85,7 @@ export default { methods: { changeProject (project) { this.$store.dispatch('ProjectView', project.id) - this.$store.dispatch('SetProject', project) + this.$store.dispatch('SetProject', project.id ? project : {}) this.$store.dispatch('ToggleTheme', project.id ? 'dark' : 'light') this.$message.success(`${this.$t('message.switch.to')} "${project.displaytext || project.name}"`) if (this.$route.name !== 'dashboard') { diff --git a/ui/src/components/view/InstanceVolumesStoragePoolSelectListView.vue b/ui/src/components/view/InstanceVolumesStoragePoolSelectListView.vue index 67a2bceb23e5..0befea938e58 100644 --- a/ui/src/components/view/InstanceVolumesStoragePoolSelectListView.vue +++ b/ui/src/components/view/InstanceVolumesStoragePoolSelectListView.vue @@ -170,7 +170,8 @@ export default { this.volumes = [] api('listVolumes', { listAll: true, - virtualmachineid: this.resource.id + virtualmachineid: this.resource.id, + projectid: this.resource.projectid }).then(response => { var volumes = response.listvolumesresponse.volume if (volumes && volumes.length > 0) { diff --git a/ui/src/components/view/VolumesTab.vue b/ui/src/components/view/VolumesTab.vue index 498640dcd460..22ed90d59878 100644 --- a/ui/src/components/view/VolumesTab.vue +++ b/ui/src/components/view/VolumesTab.vue @@ -132,7 +132,8 @@ export default { } }, getVolumes () { - api('listVolumes', { listall: true, listsystemvms: true, virtualmachineid: this.vm.id }).then(json => { + // Only a project instance sends its project; projectid=-1 would hide other accounts' volumes from a domain admin + api('listVolumes', { listall: true, listsystemvms: true, virtualmachineid: this.vm.id, projectid: this.vm.projectid }).then(json => { this.volumes = json.listvolumesresponse.volume if (this.volumes) { this.volumes.sort((a, b) => { return a.deviceid - b.deviceid }) diff --git a/ui/src/views/compute/CreateSnapshotWizard.vue b/ui/src/views/compute/CreateSnapshotWizard.vue index d19208a18085..fbfec565cff6 100644 --- a/ui/src/views/compute/CreateSnapshotWizard.vue +++ b/ui/src/views/compute/CreateSnapshotWizard.vue @@ -124,7 +124,7 @@ export default { this.hypervisorSupportsQuiesceVm = true } - api('listVolumes', { virtualMachineId: this.resource.id, listall: true }) + api('listVolumes', { virtualMachineId: this.resource.id, projectid: this.resource.projectid, listall: true }) .then(json => { this.listVolumes = json.listvolumesresponse.volume || [] }) diff --git a/ui/src/views/compute/DestroyVM.vue b/ui/src/views/compute/DestroyVM.vue index 13448252fb85..38a5a90fddea 100644 --- a/ui/src/views/compute/DestroyVM.vue +++ b/ui/src/views/compute/DestroyVM.vue @@ -209,7 +209,8 @@ export default { loading: true, opts: [] } - promises.push(this.callListVolume(vmId)) + const vm = this.selectedItems.find(item => item.id === vmId) + promises.push(this.callListVolume(vmId, vm?.projectid)) }) Promise.all(promises).then((data) => { data.forEach(item => { @@ -221,15 +222,16 @@ export default { }, async fetchVolumes () { this.loading = true - const data = await this.callListVolume(this.resource.id) + const data = await this.callListVolume(this.resource.id, this.resource.projectid) this.volumes = data.volumes || [] this.loading = false }, - callListVolume (vmId) { + callListVolume (vmId, projectId) { return new Promise((resolve) => { this.volumes = [] api('listVolumes', { virtualMachineId: vmId, + projectid: projectId, type: 'DATADISK', details: 'min', listall: 'true' diff --git a/ui/src/views/compute/EditVM.vue b/ui/src/views/compute/EditVM.vue index 9e60175f2a93..3afbb3e9644a 100644 --- a/ui/src/views/compute/EditVM.vue +++ b/ui/src/views/compute/EditVM.vue @@ -229,7 +229,8 @@ export default { fetchSecurityGroups () { this.securitygroups.loading = true api('listSecurityGroups', { - zoneid: this.resource.zoneid + zoneid: this.resource.zoneid, + projectid: this.resource.projectid }).then(json => { const items = json.listsecuritygroupsresponse.securitygroup || [] if (items && items.length > 0) { @@ -286,8 +287,9 @@ export default { domainid: this.$store.getters.userInfo.domainid, listall: true } - if (this.$store.getters.project && this.$store.getters.project.id) { - params.projectid = this.$store.getters.project.id + const projectId = this.resource.projectid || this.$store.getters.project?.id + if (projectId) { + params.projectid = projectId } else { params.account = this.$store.getters.userInfo.account } diff --git a/ui/src/views/compute/InstanceTab.vue b/ui/src/views/compute/InstanceTab.vue index 925f707591ad..4507ab97b6be 100644 --- a/ui/src/views/compute/InstanceTab.vue +++ b/ui/src/views/compute/InstanceTab.vue @@ -106,6 +106,7 @@ @cancel="closeModals"> { this.volumes = response?.listvolumesresponse?.volume || [] }).finally(() => { diff --git a/ui/src/views/compute/wizard/SecurityGroupSelection.vue b/ui/src/views/compute/wizard/SecurityGroupSelection.vue index 87bf62ddac21..b898375a5851 100644 --- a/ui/src/views/compute/wizard/SecurityGroupSelection.vue +++ b/ui/src/views/compute/wizard/SecurityGroupSelection.vue @@ -75,6 +75,10 @@ export default { preFillContent: { type: Object, default: () => {} + }, + projectId: { + type: String, + default: null } }, data () { @@ -139,10 +143,11 @@ export default { }, methods: { fetchData () { + const projectId = this.projectId || this.$store.getters.project?.id const params = { - projectid: this.$store.getters.project ? this.$store.getters.project.id : null, - domainid: this.$store.getters.project && this.$store.getters.project.id ? null : this.$store.getters.userInfo.domainid, - account: this.$store.getters.project && this.$store.getters.project.id ? null : this.$store.getters.userInfo.account, + projectid: projectId || null, + domainid: projectId ? null : this.$store.getters.userInfo.domainid, + account: projectId ? null : this.$store.getters.userInfo.account, page: this.page, pageSize: this.pageSize } diff --git a/ui/src/views/network/NicsTab.vue b/ui/src/views/network/NicsTab.vue index 5da9ca4bb2b2..7c130871f866 100644 --- a/ui/src/views/network/NicsTab.vue +++ b/ui/src/views/network/NicsTab.vue @@ -308,10 +308,11 @@ export default { api('listNetworks', { listAll: 'true', showicon: true, - zoneid: this.vm.zoneid + zoneid: this.vm.zoneid, + projectid: this.vm.projectid }).then(response => { - this.addNetworkData.allNetworks = response.listnetworksresponse.network.filter(network => !this.vm.nic.map(nic => nic.networkid).includes(network.id)) - this.addNetworkData.network = this.addNetworkData.allNetworks[0].id + this.addNetworkData.allNetworks = (response.listnetworksresponse.network || []).filter(network => !this.vm.nic.map(nic => nic.networkid).includes(network.id)) + this.addNetworkData.network = this.addNetworkData.allNetworks[0]?.id }) }, fetchSecondaryIPs (nicId) { diff --git a/ui/tests/unit/components/header/ProjectMenu.spec.js b/ui/tests/unit/components/header/ProjectMenu.spec.js new file mode 100644 index 000000000000..1c89da9cfd99 --- /dev/null +++ b/ui/tests/unit/components/header/ProjectMenu.spec.js @@ -0,0 +1,94 @@ +// 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 { mount, flushPromises } from '@vue/test-utils' +import { createStore } from 'vuex' + +import common from '../../../common' +import ProjectMenu from '@/components/header/ProjectMenu.vue' + +const i18n = common.createMockI18n('en', { + en: { 'label.default.view': 'Default view', 'message.switch.to': 'Switched to' } +}) +const project = { id: 'project-1', name: 'Project 1', displaytext: 'Project 1' } +let store +let actions + +const createStoreWithProject = (current) => { + actions = { + ProjectView: jest.fn(), + ToggleTheme: jest.fn() + } + return createStore({ + state: { user: { project: current, apis: { listProjects: {} } } }, + getters: { + project: state => state.user.project, + apis: state => state.user.apis + }, + mutations: { + SET_PROJECT: (state, project) => { state.user.project = project } + }, + actions: { + ...actions, + SetProject: ({ commit }, project) => commit('SET_PROJECT', project) + } + }) +} + +const mountMenu = () => mount(ProjectMenu, { + global: { + plugins: [store, i18n], + stubs: { InfiniteScrollSelect: true }, + mocks: { + $message: { success: jest.fn() }, + $route: { name: 'dashboard' }, + $router: { push: jest.fn() } + } + } +}) + +const selectOption = async (wrapper, option) => { + wrapper.findComponent({ name: 'InfiniteScrollSelect' }).vm.$emit('change-option', option) + await flushPromises() +} + +describe('Components > Header > ProjectMenu.vue', () => { + it('stores the selected project', async () => { + store = createStoreWithProject({}) + const wrapper = mountMenu() + + await selectOption(wrapper, project) + + expect(store.getters.project).toEqual(project) + expect(wrapper.vm.selectedProjectId).toBe(project.id) + wrapper.unmount() + }) + + it('stores no project when switching back to Default view', async () => { + store = createStoreWithProject(project) + const wrapper = mountMenu() + expect(wrapper.vm.selectedProjectId).toBe(project.id) + + await selectOption(wrapper, wrapper.vm.defaultOption) + + expect(store.getters.project).toEqual({}) + expect(store.getters.project.id).toBeUndefined() + expect(wrapper.vm.selectedProjectId).toBe(wrapper.vm.defaultOption.id) + expect(actions.ToggleTheme).toHaveBeenLastCalledWith(expect.anything(), 'light') + wrapper.unmount() + }) +}) diff --git a/ui/tests/unit/components/view/VolumesTab.spec.js b/ui/tests/unit/components/view/VolumesTab.spec.js new file mode 100644 index 000000000000..2bda806ae99b --- /dev/null +++ b/ui/tests/unit/components/view/VolumesTab.spec.js @@ -0,0 +1,145 @@ +// 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 { createServer } from 'http' +import { flushPromises } from '@vue/test-utils' + +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('@/vue-app', () => ({ + vueProps: { + $localStorage: { + get: jest.fn(() => null) + } + } +})) + +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 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 && canList(vm, params.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(() => { + caller = 'domain admin' + requests = [] + jest.clearAllMocks() + vueProps.$localStorage.get.mockReturnValue(null) + jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it.each([ + ['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 + }) + 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, + i18n, + props: { resource } + }) + + try { + await response + await flushPromises() + + expect(wrapper.findAll('tbody tr[data-row-key]')).toHaveLength(2) + expect(requests).toEqual([{ + command: 'listVolumes', + response: 'json', + listall: 'true', + listsystemvms: 'true', + 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') + expect(wrapper.text()).toContain('Data volume') + } finally { + wrapper.unmount() + axios.interceptors.response.eject(interceptor) + } + }) +})