From 88a0d002dbdf6b2a2339c93d146713ef8c0b62b8 Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Mon, 7 Sep 2026 10:22:45 +0200 Subject: [PATCH] fix(files_external): show that an unrestricted storage applies to all accounts Signed-off-by: Peter Ringelmann --- .../ApplicableEntities.spec.ts | 50 ++++++++++ .../ApplicableEntities.vue | 25 +++-- .../ExternalStorageTableRow.spec.ts | 97 +++++++++++++++++++ .../components/ExternalStorageTableRow.vue | 10 +- .../src/utils/externalStorageUtils.spec.ts | 20 +++- .../src/utils/externalStorageUtils.ts | 13 +++ 6 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 apps/files_external/src/components/AddExternalStorageDialog/ApplicableEntities.spec.ts create mode 100644 apps/files_external/src/components/ExternalStorageTableRow.spec.ts diff --git a/apps/files_external/src/components/AddExternalStorageDialog/ApplicableEntities.spec.ts b/apps/files_external/src/components/AddExternalStorageDialog/ApplicableEntities.spec.ts new file mode 100644 index 0000000000000..1f046f4b77368 --- /dev/null +++ b/apps/files_external/src/components/AddExternalStorageDialog/ApplicableEntities.spec.ts @@ -0,0 +1,50 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { cleanup, render } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ApplicableEntities from './ApplicableEntities.vue' + +vi.mock('@nextcloud/axios') + +describe('ApplicableEntities.vue', () => { + beforeEach(() => { + cleanup() + // useGroups and useUsers resolve display names over axios + vi.spyOn(axios, 'get').mockResolvedValue({ data: { groups: {}, users: {} } }) + vi.spyOn(axios, 'post').mockResolvedValue({ data: { users: {} } }) + }) + + it('warns that an empty restriction applies to every account', () => { + const component = render(ApplicableEntities, { props: { groups: [], users: [] } }) + + expect(component.getByRole('note')).toHaveTextContent(/available to every account/) + }) + + it('does not warn once a group restricts the storage', () => { + const component = render(ApplicableEntities, { props: { groups: ['admin'], users: [] } }) + + expect(component.queryByRole('note')).toBeNull() + }) + + it('does not warn once a user restricts the storage', () => { + const component = render(ApplicableEntities, { props: { groups: [], users: ['alice'] } }) + + expect(component.queryByRole('note')).toBeNull() + }) + + it('warns again as soon as the last entry is removed', async () => { + const component = render(ApplicableEntities, { props: { groups: ['admin'], users: ['alice'] } }) + + expect(component.queryByRole('note')).toBeNull() + + await component.rerender({ users: [] }) + expect(component.queryByRole('note')).toBeNull() + + await component.rerender({ groups: [] }) + expect(component.getByRole('note')).toHaveTextContent(/available to every account/) + }) +}) diff --git a/apps/files_external/src/components/AddExternalStorageDialog/ApplicableEntities.vue b/apps/files_external/src/components/AddExternalStorageDialog/ApplicableEntities.vue index 394eb85b13f5b..8d552a3733503 100644 --- a/apps/files_external/src/components/AddExternalStorageDialog/ApplicableEntities.vue +++ b/apps/files_external/src/components/AddExternalStorageDialog/ApplicableEntities.vue @@ -9,8 +9,10 @@ import { t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { useDebounceFn } from '@vueuse/core' import { computed, ref } from 'vue' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcSelectUsers from '@nextcloud/vue/components/NcSelectUsers' import { mapGroupToUserData, useGroups, useUsers } from '../../composables/useEntities.ts' +import { appliesToAllAccounts } from '../../utils/externalStorageUtils.ts' type IUserData = InstanceType['$props']['options'][number] @@ -31,6 +33,8 @@ const model = computed({ }, }) +const isUnrestricted = computed(() => appliesToAllAccounts(users.value, groups.value)) + const debouncedSearch = useDebounceFn(onSearch, 500) /** @@ -57,11 +61,18 @@ async function onSearch(pattern: string) { diff --git a/apps/files_external/src/components/ExternalStorageTableRow.spec.ts b/apps/files_external/src/components/ExternalStorageTableRow.spec.ts new file mode 100644 index 0000000000000..f8c2e96db8241 --- /dev/null +++ b/apps/files_external/src/components/ExternalStorageTableRow.spec.ts @@ -0,0 +1,97 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IStorage } from '../types.ts' + +import axios from '@nextcloud/axios' +import { cleanup, render } from '@testing-library/vue' +import { createPinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@nextcloud/axios') + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: (app: string, key: string) => { + switch (key) { + case 'backends': + return [{ identifier: 'local', name: 'Local' }] + case 'authMechanisms': + return [{ identifier: 'null::null', name: 'None', scheme: 'null' }] + case 'allowedBackends': + return ['local'] + default: + return { isAdmin: true, hasEncryption: false } + } + }, +})) + +const { default: ExternalStorageTableRow } = await import('./ExternalStorageTableRow.vue') + +const pinia = createPinia() + +const storage: IStorage = { + id: 1, + mountPoint: '/mount', + backend: 'local', + authMechanism: 'null::null', + backendOptions: {}, + userProvided: false, + type: 'system', +} + +// Without a table ancestor the tds get no `cell` role, so getByRole cannot find them. +function renderRow(props: { storage: IStorage, isAdmin: boolean }) { + const table = document.body.appendChild(document.createElement('table')) + const tbody = table.appendChild(document.createElement('tbody')) + + return render(ExternalStorageTableRow, { + container: tbody, + props, + global: { plugins: [pinia] }, + }) +} + +describe('ExternalStorageTableRow.vue', () => { + beforeEach(() => { + cleanup() + // cleanup() only drops containers it owns, not the tables renderRow appends + document.body.replaceChildren() + // useGroups and useUsers resolve display names over axios + vi.spyOn(axios, 'get').mockResolvedValue({ data: { groups: {} } }) + vi.spyOn(axios, 'post').mockResolvedValue({ data: { users: {} } }) + }) + + it('labels a storage without any restriction as applying to all accounts', () => { + const component = renderRow({ storage, isAdmin: true }) + + expect(component.getByRole('cell', { name: 'All accounts' })).toBeInTheDocument() + }) + + it('lists the groups a storage is restricted to', () => { + const component = renderRow({ + storage: { ...storage, applicableGroups: ['developers'] }, + isAdmin: true, + }) + + expect(component.getByRole('cell', { name: 'developers' })).toBeInTheDocument() + expect(component.queryByRole('cell', { name: 'All accounts' })).toBeNull() + }) + + it('lists the users a storage is restricted to', () => { + const component = renderRow({ + storage: { ...storage, applicableUsers: ['alice'] }, + isAdmin: true, + }) + + expect(component.getByRole('cell', { name: 'alice' })).toBeInTheDocument() + expect(component.queryByRole('cell', { name: 'All accounts' })).toBeNull() + }) + + it('omits the applicable cell for non-admins', () => { + const component = renderRow({ storage, isAdmin: false }) + + expect(component.queryByRole('cell', { name: 'All accounts' })).toBeNull() + }) +}) diff --git a/apps/files_external/src/components/ExternalStorageTableRow.vue b/apps/files_external/src/components/ExternalStorageTableRow.vue index 213d8e0691e16..d12e1d2e668bb 100644 --- a/apps/files_external/src/components/ExternalStorageTableRow.vue +++ b/apps/files_external/src/components/ExternalStorageTableRow.vue @@ -6,7 +6,7 @@