Skip to content

Commit

Permalink
feat: Support move for encrypted files
Browse files Browse the repository at this point in the history
This adds the possiilbity to move files from/to an encrypted folder.

3 scenarios are supported:
- From a non-encrypted folder to an encrypted folder
- From an encrypted folder to a non-encrypted folder
- From an encrypted folder to another encrypted folder

Note we do not support the moving of non-encrypted folder to an
encrypted one, because of the potential cost if it has a deep hierarchy
and/or many files. However, the moving of an encrypted folder to is
supported for both non-encrypted and encrypted folder, as the files remain
encrypted with the same encryption key.
  • Loading branch information
paultranvan committed May 27, 2022
1 parent 9ab58f9 commit c170874
Show file tree
Hide file tree
Showing 15 changed files with 519 additions and 103 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

# 1.42.0

## ✨ Features

* Support moving files from/to encrypted folder

## 🐛 Bug Fixes

* Disable sharing on public file viewer
Expand Down
8 changes: 8 additions & 0 deletions jestHelpers/setup.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react'
import Enzyme from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'

Expand All @@ -10,6 +11,13 @@ jest.mock('cozy-ui/transpiled/react/utils/color', () => ({
getCssVariableValue: () => '#fff'
}))

jest.mock('cozy-keys-lib', () => ({
withVaultUnlockContext: jest.fn().mockReturnValue(<></>),
withVaultClient: jest.fn().mockReturnValue(<></>),
useVaultUnlockContext: jest.fn().mockReturnValue(jest.fn()),
useVaultClient: jest.fn()
}))

global.cozy = {
bar: {
BarLeft: () => null,
Expand Down
3 changes: 1 addition & 2 deletions src/drive/mobile/modules/upload/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,8 @@ export class DumbUpload extends Component {
>
<div>
<FileList
folder={folder}
files={data}
targets={items}
entries={items}
navigateTo={this.navigateTo}
/>
<LoadMore hasMore={hasMore} fetchMore={fetchMore} />
Expand Down
3 changes: 2 additions & 1 deletion src/drive/mobile/modules/upload/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { shallow } from 'enzyme'
import { DumbUpload, generateForQueue } from './'

jest.mock('cozy-keys-lib', () => ({
withVaultClient: jest.fn().mockReturnValue({})
withVaultClient: jest.fn().mockReturnValue({}),
withVaultUnlockContext: jest.fn().mockReturnValue({})
}))

const tSpy = jest.fn()
Expand Down
4 changes: 4 additions & 0 deletions src/drive/web/modules/actions/index.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { download } from './index'
import { DOCTYPE_FILES_ENCRYPTION } from 'drive/lib/doctypes'

jest.mock('cozy-keys-lib', () => ({
withVaultUnlockContext: jest.fn().mockReturnValue({})
}))

describe('download', () => {
it('should not display when an encrypted folder is selected', () => {
const files = [
Expand Down
16 changes: 8 additions & 8 deletions src/drive/web/modules/actions/utils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import {
getEncryptionKeyFromDirId,
downloadEncryptedFile,
decryptFile
decryptFileToBlob
} from 'drive/lib/encryption'
import { DOCTYPE_FILES_ENCRYPTION } from 'drive/lib/doctypes'
import { TRASH_DIR_ID } from 'drive/constants/config'
Expand Down Expand Up @@ -50,7 +50,7 @@ jest.mock('drive/lib/encryption', () => ({
...jest.requireActual('drive/lib/encryption'),
getEncryptionKeyFromDirId: jest.fn(),
downloadEncryptedFile: jest.fn(),
decryptFile: jest.fn()
decryptFileToBlob: jest.fn()
}))

describe('trashFiles', () => {
Expand Down Expand Up @@ -140,7 +140,7 @@ describe('downloadFiles', () => {
expect(downloadEncryptedFile).toHaveBeenCalledWith(
mockClient,
{},
{ file, encryptionKey: 'encryption-key' }
{ file, decryptionKey: 'encryption-key' }
)
})

Expand Down Expand Up @@ -254,14 +254,14 @@ describe('openFileWith', () => {

it('open an encrypted file', async () => {
getEncryptionKeyFromDirId.mockResolvedValueOnce('encryption-key')
decryptFile.mockResolvedValueOnce('fake file blob')
decryptFileToBlob.mockResolvedValueOnce('fake file blob')
await openFileWith(mockClient, encryptedFile, { vaultClient })
expect(decryptFile).toHaveBeenCalledWith(
expect(decryptFileToBlob).toHaveBeenCalledWith(
mockClient,
{},
{
file: encryptedFile,
encryptionKey: 'encryption-key'
decryptionKey: 'encryption-key'
}
)
expect(saveAndOpenWithCordova).toHaveBeenCalledWith('fake file blob', {
Expand Down Expand Up @@ -344,12 +344,12 @@ describe('exportFilesNative', () => {
await exportFilesNative(mockClient, encryptedFiles, { vaultClient })

encryptedFiles.forEach(file =>
expect(decryptFile).toHaveBeenCalledWith(
expect(decryptFileToBlob).toHaveBeenCalledWith(
mockClient,
{},
{
file,
encryptionKey: 'encryption-key'
decryptionKey: 'encryption-key'
}
)
)
Expand Down
3 changes: 2 additions & 1 deletion src/drive/web/modules/filelist/AddFolder.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ jest.mock('cozy-flags', () => jest.fn())
jest.mock('cozy-keys-lib', () => ({
withVaultClient: jest.fn().mockReturnValue({}),
useVaultClient: jest.fn(),
WebVaultClient: jest.fn().mockReturnValue({})
WebVaultClient: jest.fn().mockReturnValue({}),
withVaultUnlockContext: jest.fn().mockReturnValue({})
}))
describe('AddFolder', () => {
const setup = () => {
Expand Down
115 changes: 72 additions & 43 deletions src/drive/web/modules/move/FileList.jsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,86 @@
import React, { useState } from 'react'
import React from 'react'
import PropTypes from 'prop-types'
import { DumbFile as File } from 'drive/web/modules/filelist/File'
import { VaultUnlocker } from 'cozy-keys-lib'
import { ROOT_DIR_ID } from 'drive/constants/config'
import { useVaultUnlockContext } from 'cozy-keys-lib'
import { isEncryptedFolder } from 'drive/lib/encryption'

const isInvalidMoveTarget = (subjects, target) => {
const isASubject = subjects.find(subject => subject._id === target._id)
const isAFile = target.type === 'file'
const getFoldersInEntries = entries => {
return entries.filter(entry => entry.type === 'directory')
}

const getEncryptedFolders = entries => {
return entries.filter(entry => {
if (entry.type !== 'directory') {
return false
}
return isEncryptedFolder(entry)
})
}

export const isInvalidMoveTarget = (entries, target) => {
const isTargetAnEntry = entries.find(subject => subject._id === target._id)
const isTargetAFile = target.type === 'file'
if (isTargetAFile || isTargetAnEntry) {
return true
}
const dirs = getFoldersInEntries(entries)
if (dirs.length > 0) {
const encryptedFoldersEntries = getEncryptedFolders(dirs)
const hasEncryptedFolderEntries = encryptedFoldersEntries.length > 0
const hasEncryptedAndNonEncryptedFolderEntries =
hasEncryptedFolderEntries &&
encryptedFoldersEntries.length !== dirs.length
const isTargetEncrypted = isEncryptedFolder(target)

return isAFile || isASubject
if (isTargetEncrypted && !hasEncryptedFolderEntries) {
// Do not allow moving a non-encrypted folder to an encrypted one
return true
}
if (isTargetEncrypted && hasEncryptedAndNonEncryptedFolderEntries) {
// Do not allow moving encrypted + non encrypted folders
return true
}
}
return false
}

const FileList = ({ targets, files, folder, navigateTo }) => {
const [shouldUnlock, setShouldUnlock] = useState(true)
const isEncFolder = isEncryptedFolder(folder)

if (isEncFolder && shouldUnlock) {
return (
<VaultUnlocker
onDismiss={() => {
setShouldUnlock(false)
return navigateTo(ROOT_DIR_ID)
}}
onUnlock={() => setShouldUnlock(false)}
/>
)
} else {
return (
<>
{files.map(file => (
<File
key={file.id}
disabled={isInvalidMoveTarget(targets, file)}
styleDisabled={isInvalidMoveTarget(targets, file)}
attributes={file}
displayedFolder={null}
actions={null}
isRenaming={false}
onFolderOpen={id => navigateTo(files.find(f => f.id === id))}
onFileOpen={null}
withSelectionCheckbox={false}
withFilePath={false}
withSharedBadge
/>
))}
</>
)
const FileList = ({ entries, files, folder, navigateTo }) => {
const { showUnlockForm } = useVaultUnlockContext()

const onFolderOpen = folderId => {
const dir = folder ? folder._id : files.find(f => f._id === folderId)
const shouldUnlock = isEncryptedFolder(dir)
if (shouldUnlock) {
return showUnlockForm({ onUnlock: () => navigateTo(dir) })
} else {
return navigateTo(dir)
}
}

return (
<>
{files.map(file => (
<File
key={file.id}
disabled={isInvalidMoveTarget(entries, file)}
styleDisabled={isInvalidMoveTarget(entries, file)}
attributes={file}
displayedFolder={null}
actions={null}
isRenaming={false}
onFolderOpen={id => onFolderOpen(id)}
onFileOpen={null}
withSelectionCheckbox={false}
withFilePath={false}
withSharedBadge
/>
))}
</>
)
}

FileList.propTypes = {
targets: PropTypes.array.isRequired,
entries: PropTypes.array.isRequired,
files: PropTypes.array.isRequired,
navigateTo: PropTypes.func.isRequired,
folder: PropTypes.object
Expand Down
119 changes: 119 additions & 0 deletions src/drive/web/modules/move/FileList.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React from 'react'
import { render } from '@testing-library/react'
import { createMockClient } from 'cozy-client'
import { DOCTYPE_FILES_ENCRYPTION } from 'drive/lib/doctypes'
import AppLike from 'test/components/AppLike'
import { generateFile } from 'test/generate'
import FileList, { isInvalidMoveTarget } from 'drive/web/modules/move/FileList'

jest.mock('cozy-keys-lib', () => ({
useVaultUnlockContext: jest
.fn()
.mockReturnValue({ showUnlockForm: jest.fn() })
}))
const client = createMockClient({})

const setup = ({ files, entries, navigateTo }) => {
const root = render(
<AppLike client={client}>
<FileList files={files} entries={entries} navigateTo={navigateTo} />
</AppLike>
)

return { root }
}

describe('FileList', () => {
it('should display files', () => {
const entries = [generateFile({ i: '1', type: 'file' })]
const files = [
generateFile({ i: '1', type: 'directory', prefix: '' }),
generateFile({ i: '2', type: 'file', prefix: '' })
]

const { root } = setup({ files, entries, navigateTo: () => null })
const { queryAllByText } = root

expect(queryAllByText('directory-1')).toBeTruthy()
expect(queryAllByText('file-2')).toBeTruthy()
})
})

describe('isInvalidMoveTarget', () => {
it('should return true when target is a file', () => {
const target = { _id: '1', type: 'file' }
const entries = [{ _id: 'dir1', type: 'directory' }]
expect(isInvalidMoveTarget(entries, target)).toBe(true)
})

it('should return true when subject is the target', () => {
const target = { _id: '1', type: 'directory' }
const entries = [{ _id: '1', type: 'directory' }]
expect(isInvalidMoveTarget(entries, target)).toBe(true)
})

it('should return true when target is an encrypted directory and entries has non-encrypted folder', () => {
const target = {
_id: '1',
type: 'directory',
referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: 'encrypted-key-1' }]
}
const entries = [{ _id: 'dir1', type: 'directory' }]
expect(isInvalidMoveTarget(entries, target)).toBe(true)
})

it('shold return true when target is an encrypted dir and entries include both encrytped and non-encrypted dir', () => {
const target = {
_id: '1',
type: 'directory',
referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: 'encrypted-key-1' }]
}
const entries = [
{
_id: 'dir1',
type: 'directory',
referenced_by: [
{ type: DOCTYPE_FILES_ENCRYPTION, id: 'encrypted-key-1' }
]
},
{
_id: 'dir2',
type: 'directory'
}
]
expect(isInvalidMoveTarget(entries, target)).toBe(true)
})

it('should return false when both target and entries are encrypted', () => {
const target = {
_id: '1',
type: 'directory',
referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: 'encrypted-key-1' }]
}
const entries = [
{
_id: 'dir1',
type: 'directory',
referenced_by: [
{ type: DOCTYPE_FILES_ENCRYPTION, id: 'encrypted-key-1' }
]
}
]
expect(isInvalidMoveTarget(entries, target)).toBe(false)
})

it('should return false when both target and entries are regular folders', () => {
const target = { _id: '1', type: 'directory' }
const entries = [{ _id: 'dir1', type: 'directory' }]
expect(isInvalidMoveTarget(entries, target)).toBe(false)
})

it('should return false when subject is a mix of regular file and folder and target a regular folder', () => {
const target = { _id: '1', type: 'directory' }
const entries = [
{ _id: 'dir1', type: 'file' },
{ _id: 'file1', type: 'file' }
]
expect(isInvalidMoveTarget(entries, target)).toBe(false)
})
})
Loading

0 comments on commit c170874

Please sign in to comment.