Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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/)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof NcSelectUsers>['$props']['options'][number]

Expand All @@ -31,6 +33,8 @@ const model = computed({
},
})

const isUnrestricted = computed(() => appliesToAllAccounts(users.value, groups.value))

const debouncedSearch = useDebounceFn(onSearch, 500)

/**
Expand All @@ -57,11 +61,18 @@ async function onSearch(pattern: string) {
</script>

<template>
<NcSelectUsers
v-model="model"
keepOpen
multiple
:options="entities"
:inputLabel="t('files_external', 'Restrict to')"
@search="debouncedSearch" />
<div>
<NcSelectUsers
v-model="model"
keepOpen
multiple
:options="entities"
:inputLabel="t('files_external', 'Restrict to')"
@search="debouncedSearch" />

<NcNoteCard
v-if="isUnrestricted"
type="warning"
:text="t('files_external', 'Without a restriction this storage is available to every account on this server.')" />
</div>
</template>
Original file line number Diff line number Diff line change
@@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<script setup lang="ts">
import type { IBackend, IStorage } from '../types.ts'

import { mdiAccountGroupOutline, mdiInformationOutline, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'
import { mdiAccountGroupOutline, mdiAccountMultipleOutline, mdiInformationOutline, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { NcChip, NcLoadingIcon, NcUserBubble, spawnDialog } from '@nextcloud/vue'
Expand All @@ -17,6 +17,7 @@ import AddExternalStorageDialog from './AddExternalStorageDialog/AddExternalStor
import { useGroups, useUsers } from '../composables/useEntities.ts'
import { useStorages } from '../store/storages.ts'
import { StorageStatus, StorageStatusIcons, StorageStatusMessage } from '../types.ts'
import { appliesToAllAccounts } from '../utils/externalStorageUtils.ts'

const props = defineProps<{
storage: IStorage
Expand Down Expand Up @@ -53,6 +54,8 @@ const status = computed(() => {
const users = useUsers(() => props.storage.applicableUsers || [])
const groups = useGroups(() => props.storage.applicableGroups || [])

const isUnrestricted = computed(() => appliesToAllAccounts(props.storage.applicableUsers, props.storage.applicableGroups))

/**
* Handle deletion of the external storage mount point
*/
Expand Down Expand Up @@ -113,6 +116,11 @@ async function reloadStatus() {
<td>{{ authMechanismName }}</td>
<td v-if="isAdmin">
<div :class="$style.storageTableRow__cellApplicable">
<NcChip
v-if="isUnrestricted"
:iconPath="mdiAccountMultipleOutline"
noClose
:text="t('files_external', 'All accounts')" />
<NcChip
v-for="group of groups"
:key="group.id"
Expand Down
20 changes: 19 additions & 1 deletion apps/files_external/src/utils/externalStorageUtils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { File, Folder, Permission } from '@nextcloud/files'
import { describe, expect, test } from 'vitest'
import { isNodeExternalStorage, pruneUnusedAuthMechanismOptions } from './externalStorageUtils.ts'
import { appliesToAllAccounts, isNodeExternalStorage, pruneUnusedAuthMechanismOptions } from './externalStorageUtils.ts'

describe('Is node an external storage', () => {
test('A Folder with a backend and a valid scope is an external storage', () => {
Expand Down Expand Up @@ -116,3 +116,21 @@ describe('Prune unused authentication mechanism options', () => {
expect(backendOptions).toEqual({ user: 'alice' })
})
})

describe('Does a storage apply to all accounts', () => {
test('A storage without any applicable user or group applies to all accounts', () => {
expect(appliesToAllAccounts([], [])).toBe(true)
})

test('Missing applicable lists apply to all accounts', () => {
expect(appliesToAllAccounts(undefined, undefined)).toBe(true)
})

test('A storage restricted to a user does not apply to all accounts', () => {
expect(appliesToAllAccounts(['alice'], [])).toBe(false)
})

test('A storage restricted to a group does not apply to all accounts', () => {
expect(appliesToAllAccounts([], ['developers'])).toBe(false)
})
})
13 changes: 13 additions & 0 deletions apps/files_external/src/utils/externalStorageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,16 @@ export function pruneUnusedAuthMechanismOptions(
}
}
}

/**
* Check whether a storage is available to every account.
*
* An empty applicable list means "no restriction", not "nobody".
* See UserGlobalStoragesService::isApplicable().
*
* @param applicableUsers - Ids of the accounts the storage is restricted to
* @param applicableGroups - Ids of the groups the storage is restricted to
*/
export function appliesToAllAccounts(applicableUsers?: string[], applicableGroups?: string[]): boolean {
return !applicableUsers?.length && !applicableGroups?.length
}
Loading