Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
14 changes: 14 additions & 0 deletions migrations/20260527141812-add-photos-bucket-to-users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use strict';

module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'photos_bucket', {
type: Sequelize.STRING,
allowNull: true,
Comment thread
sg-gs marked this conversation as resolved.
});
},

down: async (queryInterface) => {
await queryInterface.removeColumn('users', 'photos_bucket');
},
};
15 changes: 15 additions & 0 deletions migrations/20260527141826-recreate-index-folders-bucket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use strict';

module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS bucket_index ON folders (bucket) WHERE deleted = false AND removed = false`,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The index was removed from prod, but we look by bucket when fetching any device as folder.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the device have parent folder ? If not, add the parent_id NULL condition to the index, so we avoid indexing the whole folders-existing part just for a minority of records that act as 'devices'

);
},

async down(queryInterface) {
await queryInterface.sequelize.query(
`DROP INDEX CONCURRENTLY IF EXISTS bucket_index`,
);
},
};
9 changes: 2 additions & 7 deletions src/modules/auth/conditional-captcha.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,5 @@ const createMockExecutionContext = (request: Partial<any>): ExecutionContext =>
}),
}) as unknown as ExecutionContext;

const buildUserWithErrorLoginCount = (count: number) => ({
...newUser(),
errorLoginCount: count,
isGuestOnSharedWorkspace: () => false,
toJSON: undefined,
hasBackupsEnabled: () => false,
});
const buildUserWithErrorLoginCount = (count: number) =>
newUser({ attributes: { errorLoginCount: count } });
58 changes: 58 additions & 0 deletions src/modules/backups/backup.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,64 @@ export class BackupController {
return this.backupUseCases.getDevicesAsFolder(user);
}

@Post('/photos/devices')
Comment thread
sg-gs marked this conversation as resolved.
Outdated
@ApiOperation({ summary: 'Create a photo device as folder' })
@ApiOkResponse({ type: DeviceAsFolder })
@ApiBearerAuth()
async createPhotoDeviceAsFolder(
@UserDecorator() user: User,
@Body() body: CreateDeviceAsFolderDto,
): Promise<DeviceAsFolder> {
return this.backupUseCases.createPhotoDeviceAsFolder(user, body.deviceName);
}

@Get('/photos/devices')
@ApiOperation({ summary: 'Get all photo devices as folder' })
@ApiOkResponse({ type: DeviceAsFolder, isArray: true })
@ApiBearerAuth()
async getPhotoDevicesAsFolder(
@UserDecorator() user: User,
): Promise<DeviceAsFolder[]> {
return this.backupUseCases.getPhotoDevicesAsFolder(user);
}

@Get('/photos/devices/:uuid')
@ApiOperation({ summary: 'Get photo device as folder by uuid' })
@ApiOkResponse({ type: DeviceAsFolder })
@ApiBearerAuth()
async getPhotoDeviceAsFolder(
@UserDecorator() user: User,
@Param('uuid', ValidateUUIDPipe) uuid: string,
): Promise<DeviceAsFolder> {
return this.backupUseCases.getPhotoDeviceAsFolder(user, uuid);
}

@Delete('/photos/devices/:uuid')
@ApiOperation({ summary: 'Delete photo device as folder by uuid' })
@ApiBearerAuth()
async deletePhotoDeviceAsFolder(
@UserDecorator() user: User,
@Param('uuid', ValidateUUIDPipe) uuid: string,
) {
return this.backupUseCases.deletePhotoDeviceAsFolder(user, uuid);
}

@Patch('/photos/devices/:uuid')
@ApiOperation({ summary: 'Update photo device as folder by uuid' })
@ApiOkResponse({ type: DeviceAsFolder })
@ApiBearerAuth()
async updatePhotoDeviceAsFolder(
@UserDecorator() user: User,
@Param('uuid', ValidateUUIDPipe) uuid: string,
@Body() body: CreateDeviceAsFolderDto,
): Promise<DeviceAsFolder> {
return this.backupUseCases.updatePhotoDeviceAsFolder(
user,
uuid,
body.deviceName,
);
}

@Get('/devices')
@ApiOperation({
summary:
Expand Down
260 changes: 260 additions & 0 deletions src/modules/backups/backup.usecase.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { newDevice, newFolder, newUser } from './../../../test/fixtures';
import { v4 } from 'uuid';
import { Test, type TestingModule } from '@nestjs/testing';
import { createMock } from '@golevelup/ts-jest';
import { BackupUseCase } from './backup.usecase';
Expand Down Expand Up @@ -953,6 +954,265 @@ describe('BackupUseCase', () => {
});
});

describe('activatePhotos', () => {
it('When photos bucket already exists, then it should return it', async () => {
const existingBucket = v4();
const user = newUser({ attributes: { photosBucket: existingBucket } });
const result = await backupUseCase.activatePhotos(user);
expect(result).toEqual({ photosBucket: existingBucket });
});

it('When photos bucket does not exist, then it should create one', async () => {
const newBucket = v4();
const user = newUser({ attributes: { photosBucket: null } });
jest
.spyOn(bridgeService, 'createBucket')
.mockResolvedValue({ id: newBucket } as any);

const result = await backupUseCase.activatePhotos(user);
expect(result).toEqual({ photosBucket: newBucket });
});
});

describe('createPhotoDeviceAsFolder', () => {
const userWithPhotos = newUser({
attributes: { photosBucket: v4() },
});

it('When folder with same name exists, then it should throw ConflictException', async () => {
jest.spyOn(folderRepository, 'findOne').mockResolvedValue(newFolder());

await expect(
backupUseCase.createPhotoDeviceAsFolder(userWithPhotos, 'My Phone'),
).rejects.toThrow(ConflictException);
});

it('When no folder with same name exists, then it should create the folder', async () => {
const mockFolder = newFolder();
jest.spyOn(folderRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(folderRepository, 'createFolder')
.mockResolvedValue(mockFolder);
jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true);

const result = await backupUseCase.createPhotoDeviceAsFolder(
userWithPhotos,
'My Phone',
);
expect(result).toEqual(newBackupFolder(mockFolder));
});

it('When user has no photos bucket, then it should activate photos first', async () => {
const userWithoutPhotos = newUser({ attributes: { photosBucket: null } });
const mockFolder = newFolder();

jest
.spyOn(backupUseCase, 'activatePhotos')
.mockResolvedValue({ photosBucket: v4() });
jest.spyOn(folderRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(folderRepository, 'createFolder')
.mockResolvedValue(mockFolder);
jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true);

await backupUseCase.createPhotoDeviceAsFolder(userWithoutPhotos, 'Phone');

expect(backupUseCase.activatePhotos).toHaveBeenCalledWith(
userWithoutPhotos,
);
});
});

describe('getPhotoDevicesAsFolder', () => {
const userWithPhotos = newUser({
attributes: { photosBucket: v4() },
});

it('When photos are not activated, then it should throw BadRequestException', async () => {
const userWithoutPhotos = newUser({ attributes: { photosBucket: null } });

await expect(
backupUseCase.getPhotoDevicesAsFolder(userWithoutPhotos),
).rejects.toThrow(BadRequestException);
});

it('When photos are activated, then it should return all photo devices as folders', async () => {
const mockFolder = newFolder();
jest
.spyOn(folderUseCases, 'getFoldersByUserId')
.mockResolvedValue([mockFolder]);
jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true);

const result =
await backupUseCase.getPhotoDevicesAsFolder(userWithPhotos);

expect(result[0]).toEqual({
...newBackupFolder(mockFolder),
plainName: mockFolder.plainName,
});
});

it('When folder has no plainName, then it should decrypt using bucket', async () => {
const mockFolder = newFolder({ attributes: { plainName: null } });
jest
.spyOn(folderUseCases, 'getFoldersByUserId')
.mockResolvedValue([mockFolder]);
jest
.spyOn(cryptoService, 'decryptName')
.mockReturnValueOnce('Decrypted Phone');

const result =
await backupUseCase.getPhotoDevicesAsFolder(userWithPhotos);

expect(cryptoService.decryptName).toHaveBeenCalledWith(
mockFolder.name,
mockFolder.bucket,
);
expect(result[0].plainName).toBe('Decrypted Phone');
});
});

describe('getPhotoDeviceAsFolder', () => {
const userWithPhotos = newUser({
attributes: { photosBucket: v4() },
});

it('When folder does not exist, then it should throw NotFoundException', async () => {
jest.spyOn(folderUseCases, 'getFolderByUuid').mockResolvedValue(null);

await expect(
backupUseCase.getPhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'),
).rejects.toThrow(NotFoundException);
});

it('When folder is not in the photos bucket, then it should throw', async () => {
const mockFolder = newFolder({ attributes: { bucket: v4() } });
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);

await expect(
backupUseCase.getPhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'),
).rejects.toThrow(BadRequestException);
});

it('When folder exists and is in the photos bucket, then it should return it', async () => {
const mockFolder = newFolder({
attributes: { bucket: userWithPhotos.photosBucket },
});
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);
jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true);

const result = await backupUseCase.getPhotoDeviceAsFolder(
userWithPhotos,
'folder-uuid',
);
expect(result).toEqual(newBackupFolder(mockFolder));
});
});

describe('deletePhotoDeviceAsFolder', () => {
const userWithPhotos = newUser({
attributes: { photosBucket: v4() },
});

it('When folder does not exist, then it should throw NotFoundException', async () => {
jest.spyOn(folderUseCases, 'getFolderByUuid').mockResolvedValue(null);

await expect(
backupUseCase.deletePhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'),
).rejects.toThrow(NotFoundException);
});

it('When folder is not in the photos bucket, then it should throw BadRequestException', async () => {
const mockFolder = newFolder({ attributes: { bucket: 'other-bucket' } });
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);

await expect(
backupUseCase.deletePhotoDeviceAsFolder(userWithPhotos, 'folder-uuid'),
).rejects.toThrow(BadRequestException);
});

it('When folder is in the photos bucket, then it should delete it', async () => {
const mockFolder = newFolder({
attributes: { bucket: userWithPhotos.photosBucket },
});
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);
jest.spyOn(folderUseCases, 'deleteByUser').mockResolvedValue(undefined);

await backupUseCase.deletePhotoDeviceAsFolder(
userWithPhotos,
'folder-uuid',
);

expect(folderUseCases.deleteByUser).toHaveBeenCalledWith(userWithPhotos, [
mockFolder,
]);
});
});

describe('updatePhotoDeviceAsFolder', () => {
const userWithPhotos = newUser({
attributes: { photosBucket: v4() },
});

it('When folder does not exist, then it should throw NotFoundException', async () => {
jest.spyOn(folderUseCases, 'getFolderByUuid').mockResolvedValue(null);

await expect(
backupUseCase.updatePhotoDeviceAsFolder(
userWithPhotos,
'folder-uuid',
'New Name',
),
).rejects.toThrow(NotFoundException);
});

it('When folder is not in the photos bucket, then it should throw', async () => {
const mockFolder = newFolder({ attributes: { bucket: v4() } });
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);

await expect(
backupUseCase.updatePhotoDeviceAsFolder(
userWithPhotos,
'folder-uuid',
'New Name',
),
).rejects.toThrow(BadRequestException);
});

it('When folder exists and is in the photos bucket, then it should update and return it', async () => {
const mockFolder = newFolder({
attributes: { bucket: userWithPhotos.photosBucket },
});
const updatedFolder = newFolder({
attributes: { ...mockFolder, plainName: 'New Name' },
});
jest
.spyOn(folderUseCases, 'getFolderByUuid')
.mockResolvedValue(mockFolder);
jest
.spyOn(folderUseCases, 'updateByFolderIdAndForceUpdatedAt')
.mockResolvedValue(updatedFolder);
jest.spyOn(backupUseCase, 'isFolderEmpty').mockResolvedValue(true);

const result = await backupUseCase.updatePhotoDeviceAsFolder(
userWithPhotos,
'folder-uuid',
'New Name',
);
expect(result).toEqual(newBackupFolder(updatedFolder));
});
});

describe('updateDeviceAndFolderName', () => {
const updateDeviceDto = { name: 'New Device Name' };

Expand Down
Loading
Loading