mirror of
https://github.com/immich-app/immich.git
synced 2024-11-24 08:52:28 +02:00
test(server): all the tests (#911)
This commit is contained in:
parent
db0a55cd65
commit
296a5e786e
@ -0,0 +1,28 @@
|
|||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validateSync } from 'class-validator';
|
||||||
|
import { CheckExistingAssetsDto } from './check-existing-assets.dto';
|
||||||
|
|
||||||
|
describe('CheckExistingAssetsDto', () => {
|
||||||
|
it('should fail with an empty list', () => {
|
||||||
|
const dto = plainToInstance(CheckExistingAssetsDto, { deviceAssetIds: [], deviceId: 'test-device' });
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].property).toEqual('deviceAssetIds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with an empty string', () => {
|
||||||
|
const dto = plainToInstance(CheckExistingAssetsDto, { deviceAssetIds: [''], deviceId: 'test-device' });
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].property).toEqual('deviceAssetIds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with valid asset ids', () => {
|
||||||
|
const dto = plainToInstance(CheckExistingAssetsDto, {
|
||||||
|
deviceAssetIds: ['asset-1', 'asset-2'],
|
||||||
|
deviceId: 'test-device',
|
||||||
|
});
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
@ -1,9 +1,11 @@
|
|||||||
import { IsNotEmpty } from 'class-validator';
|
import { ArrayNotEmpty, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CheckExistingAssetsDto {
|
export class CheckExistingAssetsDto {
|
||||||
@IsNotEmpty()
|
@ArrayNotEmpty()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsNotEmpty({ each: true })
|
||||||
deviceAssetIds!: string[];
|
deviceAssetIds!: string[];
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
deviceId!: string;
|
deviceId!: string;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,33 @@
|
|||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validateSync } from 'class-validator';
|
||||||
|
import { LoginCredentialDto } from './login-credential.dto';
|
||||||
|
|
||||||
|
describe('LoginCredentialDto', () => {
|
||||||
|
it('should fail without an email', () => {
|
||||||
|
const dto = plainToInstance(LoginCredentialDto, { password: 'password' });
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].property).toEqual('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with an invalid email', () => {
|
||||||
|
const dto = plainToInstance(LoginCredentialDto, { email: 'invalid.com', password: 'password' });
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].property).toEqual('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make the email all lowercase', () => {
|
||||||
|
const dto = plainToInstance(LoginCredentialDto, { email: 'TeSt@ImMiCh.com', password: 'password' });
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(dto.email).toEqual('test@immich.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail without a password', () => {
|
||||||
|
const dto = plainToInstance(LoginCredentialDto, { email: 'test@immich.com', password: '' });
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].property).toEqual('password');
|
||||||
|
});
|
||||||
|
});
|
@ -1,13 +1,14 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsNotEmpty } from 'class-validator';
|
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class LoginCredentialDto {
|
export class LoginCredentialDto {
|
||||||
@IsNotEmpty()
|
@IsEmail()
|
||||||
@ApiProperty({ example: 'testuser@email.com' })
|
@ApiProperty({ example: 'testuser@email.com' })
|
||||||
@Transform(({ value }) => value?.toLowerCase())
|
@Transform(({ value }) => value.toLowerCase())
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ApiProperty({ example: 'password' })
|
@ApiProperty({ example: 'password' })
|
||||||
password!: string;
|
password!: string;
|
||||||
|
@ -1,27 +1,44 @@
|
|||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { validate } from 'class-validator';
|
import { validateSync } from 'class-validator';
|
||||||
import { SignUpDto } from './sign-up.dto';
|
import { SignUpDto } from './sign-up.dto';
|
||||||
|
|
||||||
describe('sign up DTO', () => {
|
describe('SignUpDto', () => {
|
||||||
it('validates the email', async () => {
|
it('should require all fields', () => {
|
||||||
const params: Partial<SignUpDto> = {
|
const dto = plainToInstance(SignUpDto, {
|
||||||
email: undefined,
|
email: '',
|
||||||
|
password: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
});
|
||||||
|
const errors = validateSync(dto);
|
||||||
|
expect(errors).toHaveLength(4);
|
||||||
|
expect(errors[0].property).toEqual('email');
|
||||||
|
expect(errors[1].property).toEqual('password');
|
||||||
|
expect(errors[2].property).toEqual('firstName');
|
||||||
|
expect(errors[3].property).toEqual('lastName');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid email', () => {
|
||||||
|
const dto = plainToInstance(SignUpDto, {
|
||||||
|
email: 'immich.com',
|
||||||
password: 'password',
|
password: 'password',
|
||||||
firstName: 'first name',
|
firstName: 'first name',
|
||||||
lastName: 'last name',
|
lastName: 'last name',
|
||||||
};
|
});
|
||||||
let dto: SignUpDto = plainToInstance(SignUpDto, params);
|
const errors = validateSync(dto);
|
||||||
let errors = await validate(dto);
|
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].property).toEqual('email');
|
||||||
|
});
|
||||||
|
|
||||||
params.email = 'invalid email';
|
it('should make the email all lowercase', () => {
|
||||||
dto = plainToInstance(SignUpDto, params);
|
const dto = plainToInstance(SignUpDto, {
|
||||||
errors = await validate(dto);
|
email: 'TeSt@ImMiCh.com',
|
||||||
expect(errors).toHaveLength(1);
|
password: 'password',
|
||||||
|
firstName: 'first name',
|
||||||
params.email = 'valid@email.com';
|
lastName: 'last name',
|
||||||
dto = plainToInstance(SignUpDto, params);
|
});
|
||||||
errors = await validate(dto);
|
const errors = validateSync(dto);
|
||||||
expect(errors).toHaveLength(0);
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(dto.email).toEqual('test@immich.com');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,21 +1,24 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsNotEmpty, IsEmail } from 'class-validator';
|
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class SignUpDto {
|
export class SignUpDto {
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
@ApiProperty({ example: 'testuser@email.com' })
|
@ApiProperty({ example: 'testuser@email.com' })
|
||||||
@Transform(({ value }) => value?.toLowerCase())
|
@Transform(({ value }) => value.toLowerCase())
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ApiProperty({ example: 'password' })
|
@ApiProperty({ example: 'password' })
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ApiProperty({ example: 'Admin' })
|
@ApiProperty({ example: 'Admin' })
|
||||||
firstName!: string;
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ApiProperty({ example: 'Doe' })
|
@ApiProperty({ example: 'Doe' })
|
||||||
lastName!: string;
|
lastName!: string;
|
||||||
|
141
server/apps/immich/src/config/asset-upload.config.spec.ts
Normal file
141
server/apps/immich/src/config/asset-upload.config.spec.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { multerUtils } from './asset-upload.config';
|
||||||
|
|
||||||
|
const { fileFilter, destination, filename } = multerUtils;
|
||||||
|
|
||||||
|
const mock = {
|
||||||
|
req: {} as Request,
|
||||||
|
userRequest: {
|
||||||
|
user: {
|
||||||
|
id: 'test-user',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
deviceId: 'test-device',
|
||||||
|
fileExtension: '.jpg',
|
||||||
|
},
|
||||||
|
} as Request,
|
||||||
|
file: { originalname: 'test.jpg' } as Express.Multer.File,
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('fs');
|
||||||
|
|
||||||
|
describe('assetUploadOption', () => {
|
||||||
|
let callback: jest.Mock;
|
||||||
|
let existsSync: jest.Mock;
|
||||||
|
let mkdirSync: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mock('fs');
|
||||||
|
mkdirSync = fs.mkdirSync as jest.Mock;
|
||||||
|
existsSync = fs.existsSync as jest.Mock;
|
||||||
|
callback = jest.fn();
|
||||||
|
|
||||||
|
existsSync.mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fileFilter', () => {
|
||||||
|
it('should require a user', () => {
|
||||||
|
fileFilter(mock.req, mock.file, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
const [error, name] = callback.mock.calls[0];
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow images', async () => {
|
||||||
|
const file = { mimetype: 'image/jpeg', originalname: 'test.jpg' } as any;
|
||||||
|
fileFilter(mock.userRequest, file, callback);
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow videos', async () => {
|
||||||
|
const file = { mimetype: 'image/mp4', originalname: 'test.mp4' } as any;
|
||||||
|
fileFilter(mock.userRequest, file, callback);
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow unknown types', async () => {
|
||||||
|
const file = { mimetype: 'application/html', originalname: 'test.html' } as any;
|
||||||
|
const callback = jest.fn();
|
||||||
|
fileFilter(mock.userRequest, file, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
const [error, accepted] = callback.mock.calls[0];
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(accepted).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destination', () => {
|
||||||
|
it('should require a user', () => {
|
||||||
|
destination(mock.req, mock.file, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
const [error, name] = callback.mock.calls[0];
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create non-existing directories', () => {
|
||||||
|
existsSync.mockImplementation(() => false);
|
||||||
|
|
||||||
|
destination(mock.userRequest, mock.file, callback);
|
||||||
|
|
||||||
|
expect(existsSync).toHaveBeenCalled();
|
||||||
|
expect(mkdirSync).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the destination', () => {
|
||||||
|
destination(mock.userRequest, mock.file, callback);
|
||||||
|
|
||||||
|
expect(mkdirSync).not.toHaveBeenCalled();
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, 'upload/test-user/original/test-device');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize the deviceId', () => {
|
||||||
|
const request = { ...mock.userRequest, body: { deviceId: 'test-devi\u0000ce' } } as Request;
|
||||||
|
destination(request, mock.file, callback);
|
||||||
|
|
||||||
|
const [folderName] = existsSync.mock.calls[0];
|
||||||
|
expect(folderName.endsWith('test-device')).toBeTruthy();
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, 'upload/test-user/original/test-device');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filename', () => {
|
||||||
|
it('should require a user', () => {
|
||||||
|
filename(mock.req, mock.file, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
const [error, name] = callback.mock.calls[0];
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the filename', () => {
|
||||||
|
filename(mock.userRequest, mock.file, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
const [error, name] = callback.mock.calls[0];
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(name.endsWith('.jpg')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize the filename', () => {
|
||||||
|
const body = { ...mock.userRequest.body, fileExtension: '.jp\u0000g' };
|
||||||
|
const request = { ...mock.userRequest, body } as Request;
|
||||||
|
filename(request, mock.file, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
const [error, name] = callback.mock.calls[0];
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(name.endsWith(mock.userRequest.body.fileExtension)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,48 +1,60 @@
|
|||||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { Request } from 'express';
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname, join } from 'path';
|
import { extname, join } from 'path';
|
||||||
import { Request } from 'express';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import sanitize from 'sanitize-filename';
|
import sanitize from 'sanitize-filename';
|
||||||
|
|
||||||
export const assetUploadOption: MulterOptions = {
|
export const assetUploadOption: MulterOptions = {
|
||||||
fileFilter: (req: Request, file: any, cb: any) => {
|
fileFilter,
|
||||||
if (
|
|
||||||
file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff|3gpp|nef)$/)
|
|
||||||
) {
|
|
||||||
cb(null, true);
|
|
||||||
} else {
|
|
||||||
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: (req: Request, file: Express.Multer.File, cb: any) => {
|
destination,
|
||||||
const basePath = APP_UPLOAD_LOCATION;
|
filename,
|
||||||
|
|
||||||
if (!req.user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitizedDeviceId = sanitize(String(req.body['deviceId']));
|
|
||||||
const originalUploadFolder = join(basePath, req.user.id, 'original', sanitizedDeviceId);
|
|
||||||
|
|
||||||
if (!existsSync(originalUploadFolder)) {
|
|
||||||
mkdirSync(originalUploadFolder, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save original to disk
|
|
||||||
cb(null, originalUploadFolder);
|
|
||||||
},
|
|
||||||
|
|
||||||
filename: (req: Request, file: Express.Multer.File, cb: any) => {
|
|
||||||
const fileNameUUID = randomUUID();
|
|
||||||
const fileName = `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`;
|
|
||||||
const sanitizedFileName = sanitize(fileName);
|
|
||||||
cb(null, sanitizedFileName);
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const multerUtils = { fileFilter, filename, destination };
|
||||||
|
|
||||||
|
function fileFilter(req: Request, file: any, cb: any) {
|
||||||
|
if (!req.user) {
|
||||||
|
return cb(new UnauthorizedException());
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff|3gpp|nef)$/)
|
||||||
|
) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destination(req: Request, file: Express.Multer.File, cb: any) {
|
||||||
|
if (!req.user) {
|
||||||
|
return cb(new UnauthorizedException());
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = APP_UPLOAD_LOCATION;
|
||||||
|
const sanitizedDeviceId = sanitize(String(req.body['deviceId']));
|
||||||
|
const originalUploadFolder = join(basePath, req.user.id, 'original', sanitizedDeviceId);
|
||||||
|
|
||||||
|
if (!existsSync(originalUploadFolder)) {
|
||||||
|
mkdirSync(originalUploadFolder, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save original to disk
|
||||||
|
cb(null, originalUploadFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filename(req: Request, file: Express.Multer.File, cb: any) {
|
||||||
|
if (!req.user) {
|
||||||
|
return cb(new UnauthorizedException());
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileNameUUID = randomUUID();
|
||||||
|
const fileName = `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`;
|
||||||
|
const sanitizedFileName = sanitize(fileName);
|
||||||
|
cb(null, sanitizedFileName);
|
||||||
|
}
|
||||||
|
@ -0,0 +1,114 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { multerUtils } from './profile-image-upload.config';
|
||||||
|
|
||||||
|
const { fileFilter, destination, filename } = multerUtils;
|
||||||
|
|
||||||
|
const mock = {
|
||||||
|
req: {} as Request,
|
||||||
|
userRequest: {
|
||||||
|
user: {
|
||||||
|
id: 'test-user',
|
||||||
|
},
|
||||||
|
} as Request,
|
||||||
|
file: { originalname: 'test.jpg' } as Express.Multer.File,
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('fs');
|
||||||
|
|
||||||
|
describe('profileImageUploadOption', () => {
|
||||||
|
let callback: jest.Mock;
|
||||||
|
let existsSync: jest.Mock;
|
||||||
|
let mkdirSync: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mock('fs');
|
||||||
|
mkdirSync = fs.mkdirSync as jest.Mock;
|
||||||
|
existsSync = fs.existsSync as jest.Mock;
|
||||||
|
callback = jest.fn();
|
||||||
|
|
||||||
|
existsSync.mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fileFilter', () => {
|
||||||
|
it('should require a user', () => {
|
||||||
|
fileFilter(mock.req, mock.file, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
const [error, name] = callback.mock.calls[0];
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow images', async () => {
|
||||||
|
const file = { mimetype: 'image/jpeg', originalname: 'test.jpg' } as any;
|
||||||
|
fileFilter(mock.userRequest, file, callback);
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow gifs', async () => {
|
||||||
|
const file = { mimetype: 'image/gif', originalname: 'test.gif' } as any;
|
||||||
|
const callback = jest.fn();
|
||||||
|
fileFilter(mock.userRequest, file, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
const [error, accepted] = callback.mock.calls[0];
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(accepted).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destination', () => {
|
||||||
|
it('should require a user', () => {
|
||||||
|
destination(mock.req, mock.file, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
const [error, name] = callback.mock.calls[0];
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create non-existing directories', () => {
|
||||||
|
existsSync.mockImplementation(() => false);
|
||||||
|
|
||||||
|
destination(mock.userRequest, mock.file, callback);
|
||||||
|
|
||||||
|
expect(existsSync).toHaveBeenCalled();
|
||||||
|
expect(mkdirSync).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the destination', () => {
|
||||||
|
destination(mock.userRequest, mock.file, callback);
|
||||||
|
|
||||||
|
expect(mkdirSync).not.toHaveBeenCalled();
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, './upload/test-user/profile');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filename', () => {
|
||||||
|
it('should require a user', () => {
|
||||||
|
filename(mock.req, mock.file, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
const [error, name] = callback.mock.calls[0];
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the filename', () => {
|
||||||
|
filename(mock.userRequest, mock.file, callback);
|
||||||
|
|
||||||
|
expect(mkdirSync).not.toHaveBeenCalled();
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, 'test-user.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize the filename', () => {
|
||||||
|
filename(mock.userRequest, { ...mock.file, originalname: 'test.j\u0000pg' }, callback);
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, 'test-user.jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,44 +1,56 @@
|
|||||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||||
|
import { Request } from 'express';
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
import { Request } from 'express';
|
|
||||||
import sanitize from 'sanitize-filename';
|
import sanitize from 'sanitize-filename';
|
||||||
|
|
||||||
export const profileImageUploadOption: MulterOptions = {
|
export const profileImageUploadOption: MulterOptions = {
|
||||||
fileFilter: (req: Request, file: any, cb: any) => {
|
fileFilter,
|
||||||
if (file.mimetype.match(/\/(jpg|jpeg|png|heic|heif|dng|webp)$/)) {
|
|
||||||
cb(null, true);
|
|
||||||
} else {
|
|
||||||
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: (req: Request, file: Express.Multer.File, cb: any) => {
|
destination,
|
||||||
if (!req.user) {
|
filename,
|
||||||
return;
|
|
||||||
}
|
|
||||||
const basePath = APP_UPLOAD_LOCATION;
|
|
||||||
const profileImageLocation = `${basePath}/${req.user.id}/profile`;
|
|
||||||
|
|
||||||
if (!existsSync(profileImageLocation)) {
|
|
||||||
mkdirSync(profileImageLocation, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
cb(null, profileImageLocation);
|
|
||||||
},
|
|
||||||
|
|
||||||
filename: (req: Request, file: Express.Multer.File, cb: any) => {
|
|
||||||
if (!req.user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const userId = req.user.id;
|
|
||||||
const fileName = `${userId}${extname(file.originalname)}`;
|
|
||||||
|
|
||||||
cb(null, sanitize(String(fileName)));
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const multerUtils = { fileFilter, filename, destination };
|
||||||
|
|
||||||
|
function fileFilter(req: Request, file: any, cb: any) {
|
||||||
|
if (!req.user) {
|
||||||
|
return cb(new UnauthorizedException());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.mimetype.match(/\/(jpg|jpeg|png|heic|heif|dng|webp)$/)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destination(req: Request, file: Express.Multer.File, cb: any) {
|
||||||
|
if (!req.user) {
|
||||||
|
return cb(new UnauthorizedException());
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = APP_UPLOAD_LOCATION;
|
||||||
|
const profileImageLocation = `${basePath}/${req.user.id}/profile`;
|
||||||
|
|
||||||
|
if (!existsSync(profileImageLocation)) {
|
||||||
|
mkdirSync(profileImageLocation, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, profileImageLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filename(req: Request, file: Express.Multer.File, cb: any) {
|
||||||
|
if (!req.user) {
|
||||||
|
return cb(new UnauthorizedException());
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user.id;
|
||||||
|
const fileName = `${userId}${extname(file.originalname)}`;
|
||||||
|
|
||||||
|
cb(null, sanitize(String(fileName)));
|
||||||
|
}
|
||||||
|
@ -0,0 +1,100 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { ImmichJwtService } from './immich-jwt.service';
|
||||||
|
|
||||||
|
describe('ImmichJwtService', () => {
|
||||||
|
let jwtService: JwtService;
|
||||||
|
let service: ImmichJwtService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jwtService = new JwtService();
|
||||||
|
service = new ImmichJwtService(jwtService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateToken', () => {
|
||||||
|
it('should generate the token', async () => {
|
||||||
|
const spy = jest.spyOn(jwtService, 'sign');
|
||||||
|
spy.mockImplementation((value) => value as string);
|
||||||
|
const dto = { userId: 'test-user', email: 'test-user@immich.com' };
|
||||||
|
const token = await service.generateToken(dto);
|
||||||
|
expect(token).toEqual(dto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateToken', () => {
|
||||||
|
it('should validate the token', async () => {
|
||||||
|
const dto = { userId: 'test-user', email: 'test-user@immich.com' };
|
||||||
|
const spy = jest.spyOn(jwtService, 'verifyAsync');
|
||||||
|
spy.mockImplementation(() => dto as any);
|
||||||
|
const response = await service.validateToken('access-token');
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response).toEqual({ userId: 'test-user', status: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle an invalid token', async () => {
|
||||||
|
const verifyAsync = jest.spyOn(jwtService, 'verifyAsync');
|
||||||
|
verifyAsync.mockImplementation(() => {
|
||||||
|
throw new Error('Invalid token!');
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = jest.spyOn(Logger, 'error');
|
||||||
|
error.mockImplementation(() => null);
|
||||||
|
const response = await service.validateToken('access-token');
|
||||||
|
|
||||||
|
expect(verifyAsync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(error).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response).toEqual({ userId: null, status: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractJwtFromHeader', () => {
|
||||||
|
it('should handle no authorization header', () => {
|
||||||
|
const request = {
|
||||||
|
headers: {},
|
||||||
|
} as Request;
|
||||||
|
const token = service.extractJwtFromHeader(request);
|
||||||
|
expect(token).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get the token from the authorization header', () => {
|
||||||
|
const upper = {
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer token',
|
||||||
|
},
|
||||||
|
} as Request;
|
||||||
|
|
||||||
|
const lower = {
|
||||||
|
headers: {
|
||||||
|
authorization: 'bearer token',
|
||||||
|
},
|
||||||
|
} as Request;
|
||||||
|
|
||||||
|
expect(service.extractJwtFromHeader(upper)).toBe('token');
|
||||||
|
expect(service.extractJwtFromHeader(lower)).toBe('token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extracJwtFromCookie', () => {
|
||||||
|
it('should handle no cookie', () => {
|
||||||
|
const request = {} as Request;
|
||||||
|
const token = service.extractJwtFromCookie(request);
|
||||||
|
expect(token).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get the token from the immich cookie', () => {
|
||||||
|
const request = {
|
||||||
|
cookies: {
|
||||||
|
immich_access_token: 'cookie',
|
||||||
|
},
|
||||||
|
} as Request;
|
||||||
|
const token = service.extractJwtFromCookie(request);
|
||||||
|
expect(token).toBe('cookie');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -13,8 +13,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserEntity)
|
@InjectRepository(UserEntity)
|
||||||
private usersRepository: Repository<UserEntity>,
|
private usersRepository: Repository<UserEntity>,
|
||||||
|
immichJwtService: ImmichJwtService,
|
||||||
private immichJwtService: ImmichJwtService,
|
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
|
Loading…
Reference in New Issue
Block a user