nextcloud/apps/files_sharing/src/services/SharingService.spec.ts
nfebe 06636d2ad0 fix(files_sharing): Drop permissions on unmounted pending and deleted shares
Pending and deleted shares are not mounted into the user's filesystem, so
generic file operations like delete or download produced a misleading
"file is not available" error.

These shares now carry no permissions, so every permission-aware action
hides itself automatically, without the files app having to special-case
each view. Conversion additionally requires read permission, matching the
server-side readability check.

Signed-off-by: nfebe <fenn25.fn@gmail.com>
2026-06-17 18:59:47 +01:00

595 lines
15 KiB
TypeScript

/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { OCSResponse } from '@nextcloud/typings/ocs'
import * as auth from '@nextcloud/auth'
import { File, Folder } from '@nextcloud/files'
import { ShareType } from '@nextcloud/sharing'
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import logger from './logger.ts'
import { getContents } from './SharingService.ts'
const TAG_FAVORITE = '_$!<Favorite>!$_'
const axios = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('@nextcloud/auth')
vi.mock('@nextcloud/axios', () => ({ default: axios }))
// Mock TAG
beforeAll(() => {
window.OC = {
...window.OC,
TAG_FAVORITE,
}
})
describe('SharingService methods definitions', () => {
beforeEach(() => {
vi.resetAllMocks()
axios.get.mockImplementation(async (): Promise<unknown> => {
return {
data: {
ocs: {
meta: {
status: 'ok',
statuscode: 200,
message: 'OK',
},
data: [],
},
} as OCSResponse,
}
})
})
test('Shared with you', async () => {
await getContents(true, false, false, false, [])
expect(axios.get).toHaveBeenCalledTimes(2)
expect(axios.get).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', {
headers: {
'Content-Type': 'application/json',
},
params: {
shared_with_me: true,
include_tags: true,
},
})
expect(axios.get).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares', {
headers: {
'Content-Type': 'application/json',
},
params: {
include_tags: true,
},
})
})
test('Shared with others', async () => {
await getContents(false, true, false, false, [])
expect(axios.get).toHaveBeenCalledTimes(1)
expect(axios.get).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', {
headers: {
'Content-Type': 'application/json',
},
params: {
shared_with_me: false,
include_tags: true,
},
})
})
test('Pending shares', async () => {
await getContents(false, false, true, false, [])
expect(axios.get).toHaveBeenCalledTimes(2)
expect(axios.get).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending', {
headers: {
'Content-Type': 'application/json',
},
params: {
include_tags: true,
},
})
expect(axios.get).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending', {
headers: {
'Content-Type': 'application/json',
},
params: {
include_tags: true,
},
})
})
test('Deleted shares', async () => {
await getContents(false, true, false, false, [])
expect(axios.get).toHaveBeenCalledTimes(1)
expect(axios.get).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', {
headers: {
'Content-Type': 'application/json',
},
params: {
shared_with_me: false,
include_tags: true,
},
})
})
test('Unknown owner', async () => {
vi.spyOn(auth, 'getCurrentUser').mockReturnValue(null)
const results = await getContents(false, true, false, false, [])
expect(results.folder.owner).toEqual(null)
})
})
describe('SharingService filtering', () => {
beforeEach(() => {
vi.resetAllMocks()
axios.get.mockImplementation(async (): Promise<unknown> => {
return {
data: {
ocs: {
meta: {
status: 'ok',
statuscode: 200,
message: 'OK',
},
data: [
{
id: '62',
share_type: ShareType.User,
uid_owner: 'test',
displayname_owner: 'test',
permissions: 31,
stime: 1688666292,
expiration: '2023-07-13 00:00:00',
token: null,
path: '/Collaborators',
item_type: 'folder',
item_permissions: 31,
mimetype: 'httpd/unix-directory',
storage: 224,
item_source: 419413,
file_source: 419413,
file_parent: 419336,
file_target: '/Collaborators',
item_size: 41434,
item_mtime: 1688662980,
},
],
},
},
}
})
})
test('Shared with others filtering', async () => {
const shares = await getContents(false, true, false, false, [ShareType.User])
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(1)
expect(shares.contents[0].fileid).toBe(419413)
expect(shares.contents[0]).toBeInstanceOf(Folder)
})
test('Shared with others filtering empty', async () => {
const shares = await getContents(false, true, false, false, [ShareType.Link])
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(0)
})
})
describe('SharingService share to Node mapping', () => {
const shareFile = {
id: '66',
share_type: 0,
uid_owner: 'test',
displayname_owner: 'test',
permissions: 19,
can_edit: true,
can_delete: true,
stime: 1688721609,
parent: null,
expiration: '2023-07-14 00:00:00',
token: null,
uid_file_owner: 'test',
note: '',
label: null,
displayname_file_owner: 'test',
path: '/document.md',
item_type: 'file',
item_permissions: 27,
mimetype: 'text/markdown',
has_preview: true,
storage_id: 'home::test',
storage: 224,
item_source: 530936,
file_source: 530936,
file_parent: 419336,
file_target: '/document.md',
item_size: 123,
item_mtime: 1688721600,
share_with: 'user00',
share_with_displayname: 'User00',
share_with_displayname_unique: 'user00@domain.com',
status: {
status: 'away',
message: null,
icon: null,
clearAt: null,
},
mail_send: 0,
hide_download: 0,
attributes: null,
tags: [],
}
const shareFolder = {
id: '67',
share_type: 0,
uid_owner: 'test',
displayname_owner: 'test',
permissions: 31,
can_edit: true,
can_delete: true,
stime: 1688721629,
parent: null,
expiration: '2023-07-14 00:00:00',
token: null,
uid_file_owner: 'test',
note: '',
label: null,
displayname_file_owner: 'test',
path: '/Folder',
item_type: 'folder',
item_permissions: 31,
mimetype: 'httpd/unix-directory',
has_preview: false,
storage_id: 'home::test',
storage: 224,
item_source: 531080,
file_source: 531080,
file_parent: 419336,
file_target: '/Folder',
item_size: 0,
item_mtime: 1688721623,
share_with: 'user00',
share_with_displayname: 'User00',
share_with_displayname_unique: 'user00@domain.com',
status: {
status: 'away',
message: null,
icon: null,
clearAt: null,
},
mail_send: 0,
hide_download: 0,
attributes: null,
tags: [TAG_FAVORITE],
}
const remoteFileAccepted = {
mimetype: 'text/markdown',
mtime: 1688721600,
permissions: 19,
type: 'file',
file_id: 1234,
id: 4,
share_type: ShareType.User,
parent: null,
remote: 'http://exampe.com',
remote_id: '12345',
share_token: 'share-token',
name: '/test.md',
mountpoint: '/shares/test.md',
owner: 'owner-uid',
user: 'sharee-uid',
accepted: true,
}
const remoteFilePending = {
mimetype: 'text/markdown',
mtime: 1688721600,
permissions: 19,
type: 'file',
file_id: 1234,
id: 4,
share_type: ShareType.User,
parent: null,
remote: 'http://exampe.com',
remote_id: '12345',
share_token: 'share-token',
name: '/test.md',
mountpoint: '/shares/test.md',
owner: 'owner-uid',
user: 'sharee-uid',
accepted: false,
}
const remoteFolderAccepted = {
mimetype: 'httpd/unix-directory',
mtime: 1688721600,
permissions: 31,
type: 'dir',
file_id: 5678,
id: 5,
share_type: ShareType.User,
parent: null,
remote: 'http://example.com',
remote_id: '12346',
share_token: 'share-token-folder',
name: '/testfolder',
mountpoint: '/shares/testfolder',
owner: 'owner-uid',
user: 'sharee-uid',
accepted: true,
}
const tempExternalFile = {
id: 65,
share_type: 0,
parent: -1,
remote: 'http://nextcloud1.local/',
remote_id: '71',
share_token: '9GpiAmTIjayclrE',
name: '/test.md',
owner: 'owner-uid',
user: 'sharee-uid',
mountpoint: '{{TemporaryMountPointName#/test.md}}',
accepted: 0,
}
beforeEach(() => {
vi.resetAllMocks()
})
test('File', async () => {
axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [shareFile],
},
},
}))
const shares = await getContents(false, true, false, false)
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(1)
const file = shares.contents[0] as File
expect(file).toBeInstanceOf(File)
expect(file.fileid).toBe(530936)
expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/document.md')
expect(file.owner).toBe('test')
expect(file.mime).toBe('text/markdown')
expect(file.mtime).toBeInstanceOf(Date)
expect(file.size).toBe(123)
expect(file.permissions).toBe(27)
expect(file.root).toBe('/files/test')
expect(file.attributes).toBeInstanceOf(Object)
expect(file.attributes['has-preview']).toBe(true)
expect(file.attributes.sharees).toEqual({
sharee: {
id: 'user00',
'display-name': 'User00',
type: 0,
},
})
expect(file.attributes.favorite).toBe(0)
})
test('Folder', async () => {
axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [shareFolder],
},
},
}))
const shares = await getContents(false, true, false, false)
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(1)
const folder = shares.contents[0] as Folder
expect(folder).toBeInstanceOf(Folder)
expect(folder.fileid).toBe(531080)
expect(folder.source).toBe('http://nextcloud.local/remote.php/dav/files/test/Folder')
expect(folder.owner).toBe('test')
expect(folder.mime).toBe('httpd/unix-directory')
expect(folder.mtime).toBeInstanceOf(Date)
expect(folder.size).toBe(0)
expect(folder.permissions).toBe(31)
expect(folder.root).toBe('/files/test')
expect(folder.attributes).toBeInstanceOf(Object)
expect(folder.attributes['has-preview']).toBe(false)
expect(folder.attributes.previewUrl).toBeUndefined()
expect(folder.attributes.favorite).toBe(1)
})
describe('Remote file', () => {
test('Accepted', async () => {
axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [remoteFileAccepted],
},
},
}))
const shares = await getContents(false, true, false, false)
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(1)
const file = shares.contents[0] as File
expect(file).toBeInstanceOf(File)
expect(file.fileid).toBe(1234)
expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/shares/test.md')
expect(file.owner).toBe('owner-uid')
expect(file.mime).toBe('text/markdown')
expect(file.mtime?.getTime()).toBe(remoteFileAccepted.mtime * 1000)
// not available for remote shares
expect(file.size).toBe(undefined)
expect(file.permissions).toBe(19)
expect(file.root).toBe('/files/test')
expect(file.attributes).toBeInstanceOf(Object)
expect(file.attributes.favorite).toBe(0)
})
test('Pending', async () => {
axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [remoteFilePending],
},
},
}))
const shares = await getContents(false, true, false, false)
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(1)
const file = shares.contents[0] as File
expect(file).toBeInstanceOf(File)
expect(file.fileid).toBe(1234)
expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/shares/test.md')
expect(file.owner).toBe('owner-uid')
expect(file.mime).toBe('text/markdown')
expect(file.mtime?.getTime()).toBe(remoteFilePending.mtime * 1000)
// not available for remote shares
expect(file.size).toBe(undefined)
expect(file.permissions).toBe(0)
expect(file.root).toBe('/files/test')
expect(file.attributes).toBeInstanceOf(Object)
expect(file.attributes.favorite).toBe(0)
})
})
describe('Remote folder', () => {
test('Accepted with type dir', async () => {
axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [remoteFolderAccepted],
},
},
}))
const shares = await getContents(false, true, false, false)
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(1)
const folder = shares.contents[0] as Folder
expect(folder).toBeInstanceOf(Folder)
expect(folder.fileid).toBe(5678)
expect(folder.source).toBe('http://nextcloud.local/remote.php/dav/files/test/shares/testfolder')
expect(folder.owner).toBe('owner-uid')
expect(folder.mime).toBe('httpd/unix-directory')
expect(folder.mtime?.getTime()).toBe(remoteFolderAccepted.mtime * 1000)
expect(folder.size).toBe(undefined)
expect(folder.permissions).toBe(31)
expect(folder.root).toBe('/files/test')
expect(folder.attributes).toBeInstanceOf(Object)
expect(folder.attributes.favorite).toBe(0)
})
})
test('External temp file', async () => {
axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [tempExternalFile],
},
},
}))
const shares = await getContents(false, true, false, false)
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(1)
const file = shares.contents[0] as File
expect(file).toBeInstanceOf(File)
expect(file.fileid).toBe(65)
expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/test.md')
expect(file.owner).toBe('owner-uid')
expect(file.mime).toBe('text/markdown')
expect(file.mtime?.getTime()).toBe(undefined)
// not available for remote shares
expect(file.size).toBe(undefined)
expect(file.permissions).toBe(0)
expect(file.root).toBe('/files/test')
expect(file.attributes).toBeInstanceOf(Object)
expect(file.attributes.favorite).toBe(0)
})
test('Pending share has no permissions', async () => {
axios.get
.mockReturnValueOnce(Promise.resolve({
data: { ocs: { data: [shareFile] } },
}))
.mockReturnValueOnce(Promise.resolve({
data: { ocs: { data: [] } },
}))
const shares = await getContents(false, false, true, false)
expect(axios.get).toHaveBeenCalledTimes(2)
expect(shares.contents).toHaveLength(1)
expect(shares.contents[0].permissions).toBe(0)
})
test('Deleted share has no permissions', async () => {
axios.get.mockReturnValueOnce(Promise.resolve({
data: { ocs: { data: [shareFolder] } },
}))
const shares = await getContents(false, false, false, true)
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(1)
expect(shares.contents[0].permissions).toBe(0)
})
test('Empty', async () => {
vi.spyOn(logger, 'error').mockImplementationOnce(() => {})
axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [],
},
},
}))
const shares = await getContents(false, true, false, false)
expect(shares.contents).toHaveLength(0)
expect(logger.error).toHaveBeenCalledTimes(0)
})
test('Error', async () => {
vi.spyOn(logger, 'error').mockImplementationOnce(() => {})
axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [null],
},
},
}))
const shares = await getContents(false, true, false, false)
expect(shares.contents).toHaveLength(0)
expect(logger.error).toHaveBeenCalledTimes(1)
})
})