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..01646dcff5ab 100644
--- a/ui/src/views/compute/InstanceTab.vue
+++ b/ui/src/views/compute/InstanceTab.vue
@@ -51,7 +51,7 @@
@@ -59,7 +59,7 @@
@@ -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)
+ }
+ })
+})