Skip to content
Merged
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,91 @@
/*!
* 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')

// The dialog's auth/backend config children resolve custom field handlers from
// this global, which the app registers at runtime.
vi.hoisted(() => {
const registry = { getHandler: () => undefined }
// @ts-expect-error minimal stub of the runtime global
window.OCA = { FilesExternal: { AuthMechanism: registry, Backend: registry } }
})

vi.mock('@nextcloud/initial-state', () => ({
loadState: (app: string, key: string) => {
switch (key) {
case 'backends':
return [{ identifier: 'local', name: 'Local', configuration: {}, authSchemes: { null: true } }]
case 'authMechanisms':
return [{ identifier: 'null::null', name: 'None', scheme: 'null', configuration: {} }]
case 'allowedBackends':
return ['local']
default:
return { isAdmin: true, hasEncryption: false }
}
},
}))

const { default: AddExternalStorageDialog } = await import('./AddExternalStorageDialog.vue')

const pinia = createPinia()

const storage: Partial<IStorage> = {
mountPoint: '/mount',
backend: 'local',
authMechanism: 'null::null',
backendOptions: {},
mountOptions: {},
type: 'system',
}

/**
* Render the dialog for a given storage
*
* @param overrides - Storage fields to override
*/
function renderDialog(overrides: Partial<IStorage> = {}) {
return render(AddExternalStorageDialog, {
props: { storage: { ...storage, ...overrides } },
global: { plugins: [pinia] },
})
}

const WARNING = /available to every account/

describe('AddExternalStorageDialog.vue', () => {
beforeEach(() => {
cleanup()
vi.spyOn(axios, 'get').mockResolvedValue({ data: { groups: {}, users: {} } })
vi.spyOn(axios, 'post').mockResolvedValue({ data: { users: {} } })
})

// An empty applicable list is not a restriction to nobody: the storage is
// mounted for everyone, so the dialog has to say so before it is saved.
it('warns that an empty restriction applies to every account', () => {
const component = renderDialog({ applicableUsers: [], applicableGroups: [] })

expect(component.getByText(WARNING)).toBeInTheDocument()
})

it('does not warn once a group restricts the storage', () => {
const component = renderDialog({ applicableGroups: ['developers'] })

expect(component.queryByText(WARNING)).toBeNull()
})

it('does not warn once a user restricts the storage', () => {
const component = renderDialog({ applicableUsers: ['alice'] })

expect(component.queryByText(WARNING)).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import { t } from '@nextcloud/l10n'
import { computed, ref, toRaw, watch, watchEffect } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import ApplicableEntities from './ApplicableEntities.vue'
import AuthMechanismConfiguration from './AuthMechanismConfiguration.vue'
import BackendConfiguration from './BackendConfiguration.vue'
import MountOptions from './MountOptions.vue'
import { DEFAULT_MOUNT_OPTIONS } from '../../store/storages.ts'
import { appliesToAllAccounts } from '../../utils/externalStorageUtils.ts'

const open = defineModel<boolean>('open', { default: true })

Expand All @@ -48,6 +50,11 @@ watchEffect(() => {
}
})

const isUnrestricted = computed(() => appliesToAllAccounts(
internalStorage.value.applicableUsers,
internalStorage.value.applicableGroups,
))

const backend = computed({
get() {
return backends.find((b) => b.identifier === internalStorage.value.backend)
Expand Down Expand Up @@ -97,6 +104,11 @@ watch(authMechanisms, () => {
v-model:users="internalStorage.applicableUsers"
:class="$style.externalStorageDialog__dropdown" />

<NcNoteCard
v-if="isAdmin && isUnrestricted"
type="info"
:text="t('files_external', 'Without a restriction this storage is available to every account on this server.')" />

<NcSelect
v-model="backend"
:options="backends"
Expand Down
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, mdiEarth, 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="mdiEarth"
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 } from './externalStorageUtils.ts'
import { appliesToAllAccounts, isNodeExternalStorage } 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 @@ -78,3 +78,21 @@ describe('Is node an external storage', () => {
expect(isNodeExternalStorage(folder)).toBe(false)
})
})

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 @@ -28,3 +28,16 @@ export function isNodeExternalStorage(node: INode) {
// Specific markers that we're sure are ext storage only
return attributes.scope === 'personal' || attributes.scope === 'system'
}

/**
* 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
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import{b as g,q as _,u as o,o as r,c as p,E as x,n as d,w as v,g as V,t as b,z as k,v as E,j as c,F as M,A as q,Z as K,_ as U,e as f,r as j}from"./runtime-dom.esm-bundler-CQzuAdNx.chunk.mjs";import{c as w}from"./index-BJGtU_h0.chunk.mjs";import{a as A}from"./index-Bz6ONguh.chunk.mjs";import{t as s}from"./translation-DoG5ZELJ-Bq3q05SR.chunk.mjs";import{b as N}from"./index-DgEyzq8A.chunk.mjs";import{N as S}from"./logger-D3RVzcfQ-lldSTH4n.chunk.mjs";import{N as z}from"./NcSelect--kERLlBK-DM882Uzw.chunk.mjs";import{N as C}from"./NcCheckboxRadioSwitch-DVdt5Hkq-DnSfaILp.chunk.mjs";import{N as L}from"./NcPasswordField-BFyzHTOO-Dt3fY26N.chunk.mjs";import{_ as $}from"./NcDateTime.vue_vue_type_script_setup_true_lang-BJuPH7S7-vKEnMoDm.chunk.mjs";import{a as y,C as O}from"./types-BMHwWKlU.chunk.mjs";import{a as B}from"./index-DidlHano.chunk.mjs";import{l as P}from"./logger-DTrLKxyb.chunk.mjs";const R=g({__name:"ConfigurationEntry",props:k({configKey:{},configOption:{}},{modelValue:{type:[String,Boolean],default:""},modelModifiers:{}}),emits:["update:modelValue"],setup(e){const a=_(e,"modelValue");return(l,i)=>e.configOption.type!==o(y).Boolean?(r(),p(x(e.configOption.type===o(y).Password?o(L):o($)),{key:0,modelValue:a.value,"onUpdate:modelValue":i[0]||(i[0]=t=>a.value=t),name:e.configKey,required:!(e.configOption.flags&o(O).Optional),label:e.configOption.value,title:e.configOption.tooltip,class:d(l.$style.configurationEntry)},null,8,["modelValue","name","required","label","title","class"])):(r(),p(o(C),{key:1,modelValue:a.value,"onUpdate:modelValue":i[1]||(i[1]=t=>a.value=t),type:"switch",title:e.configOption.tooltip,class:d(l.$style.configurationEntry)},{default:v(()=>[V(b(e.configOption.value),1)]),_:1},8,["modelValue","title","class"]))}}),F="_configurationEntry_1cmcq_2",G={configurationEntry:F},H={$style:G},T=B(R,[["__cssModules",H]]),Z=g({__name:"AuthMechanismRsa",props:k({authMechanism:{}},{modelValue:{required:!0},modelModifiers:{}}),emits:["update:modelValue"],setup(e){const a=_(e,"modelValue"),l=j();E(l,()=>{l.value&&(a.value.private_key="",a.value.public_key="")});async function i(){try{const{data:t}=await w.post(N("/apps/files_external/ajax/public_key.php"),{keyLength:l.value});a.value.private_key=t.data.private_key,a.value.public_key=t.data.public_key}catch(t){P.error("Error generating RSA key pair",{error:t}),A(s("files_external","Error generating key pair"))}}return(t,m)=>(r(),c("div",null,[(r(!0),c(M,null,q(e.authMechanism.configuration,(n,u)=>K((r(),p(T,{key:n.value,modelValue:a.value[u],"onUpdate:modelValue":h=>a.value[u]=h,configKey:u,configOption:n},null,8,["modelValue","onUpdate:modelValue","configKey","configOption"])),[[U,!(n.flags&o(O).Hidden)]])),128)),f(o(z),{modelValue:l.value,"onUpdate:modelValue":m[0]||(m[0]=n=>l.value=n),clearable:!1,inputLabel:o(s)("files_external","Key size"),options:[1024,2048,4096],required:""},null,8,["modelValue","inputLabel"]),f(o(S),{disabled:!l.value,wide:"",onClick:i},{default:v(()=>[V(b(o(s)("files_external","Generate keys")),1)]),_:1},8,["disabled"])]))}}),ne=Object.freeze(Object.defineProperty({__proto__:null,default:Z},Symbol.toStringTag,{value:"Module"}));export{ne as A,T as C};
//# sourceMappingURL=AuthMechanismRsa-Co0VBPIi.chunk.mjs.map
import{b as g,q as _,u as o,o as r,c as p,E as x,n as d,w as v,g as V,t as b,z as k,v as E,j as c,F as M,A as q,Z as K,_ as U,e as f,r as j}from"./runtime-dom.esm-bundler-CQzuAdNx.chunk.mjs";import{c as w}from"./index-BJGtU_h0.chunk.mjs";import{a as A}from"./index-BTwMe_OF.chunk.mjs";import{t as s}from"./translation-DoG5ZELJ-Bq3q05SR.chunk.mjs";import{b as N}from"./index-DgEyzq8A.chunk.mjs";import{N as S}from"./logger-D3RVzcfQ-lldSTH4n.chunk.mjs";import{N as z}from"./NcSelect--kERLlBK-DP4Xu8Rw.chunk.mjs";import{N as C}from"./NcCheckboxRadioSwitch-DVdt5Hkq-CS_LMKfY.chunk.mjs";import{N as L}from"./NcPasswordField-BFyzHTOO-DKq2XVqe.chunk.mjs";import{_ as $}from"./NcDateTime.vue_vue_type_script_setup_true_lang-BJuPH7S7-BxvSoPPE.chunk.mjs";import{a as y,C as O}from"./types-CTOkJGwV.chunk.mjs";import{a as B}from"./index-C3KLQ_oe.chunk.mjs";import{l as P}from"./logger-DTrLKxyb.chunk.mjs";const R=g({__name:"ConfigurationEntry",props:k({configKey:{},configOption:{}},{modelValue:{type:[String,Boolean],default:""},modelModifiers:{}}),emits:["update:modelValue"],setup(e){const a=_(e,"modelValue");return(l,i)=>e.configOption.type!==o(y).Boolean?(r(),p(x(e.configOption.type===o(y).Password?o(L):o($)),{key:0,modelValue:a.value,"onUpdate:modelValue":i[0]||(i[0]=t=>a.value=t),name:e.configKey,required:!(e.configOption.flags&o(O).Optional),label:e.configOption.value,title:e.configOption.tooltip,class:d(l.$style.configurationEntry)},null,8,["modelValue","name","required","label","title","class"])):(r(),p(o(C),{key:1,modelValue:a.value,"onUpdate:modelValue":i[1]||(i[1]=t=>a.value=t),type:"switch",title:e.configOption.tooltip,class:d(l.$style.configurationEntry)},{default:v(()=>[V(b(e.configOption.value),1)]),_:1},8,["modelValue","title","class"]))}}),F="_configurationEntry_1cmcq_2",G={configurationEntry:F},H={$style:G},T=B(R,[["__cssModules",H]]),Z=g({__name:"AuthMechanismRsa",props:k({authMechanism:{}},{modelValue:{required:!0},modelModifiers:{}}),emits:["update:modelValue"],setup(e){const a=_(e,"modelValue"),l=j();E(l,()=>{l.value&&(a.value.private_key="",a.value.public_key="")});async function i(){try{const{data:t}=await w.post(N("/apps/files_external/ajax/public_key.php"),{keyLength:l.value});a.value.private_key=t.data.private_key,a.value.public_key=t.data.public_key}catch(t){P.error("Error generating RSA key pair",{error:t}),A(s("files_external","Error generating key pair"))}}return(t,m)=>(r(),c("div",null,[(r(!0),c(M,null,q(e.authMechanism.configuration,(n,u)=>K((r(),p(T,{key:n.value,modelValue:a.value[u],"onUpdate:modelValue":h=>a.value[u]=h,configKey:u,configOption:n},null,8,["modelValue","onUpdate:modelValue","configKey","configOption"])),[[U,!(n.flags&o(O).Hidden)]])),128)),f(o(z),{modelValue:l.value,"onUpdate:modelValue":m[0]||(m[0]=n=>l.value=n),clearable:!1,inputLabel:o(s)("files_external","Key size"),options:[1024,2048,4096],required:""},null,8,["modelValue","inputLabel"]),f(o(S),{disabled:!l.value,wide:"",onClick:i},{default:v(()=>[V(b(o(s)("files_external","Generate keys")),1)]),_:1},8,["disabled"])]))}}),ne=Object.freeze(Object.defineProperty({__proto__:null,default:Z},Symbol.toStringTag,{value:"Module"}));export{ne as A,T as C};
//# sourceMappingURL=AuthMechanismRsa-Dvv3vMqG.chunk.mjs.map
Loading
Loading