1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-28 09:33:27 +02:00

test(server): all the tests (#911)

This commit is contained in:
Jason Rasmussen 2022-11-03 19:55:13 -04:00 committed by GitHub
parent db0a55cd65
commit 296a5e786e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 558 additions and 96 deletions

View File

@ -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);
});
});

View File

@ -1,7 +1,9 @@
import { IsNotEmpty } from 'class-validator';
import { ArrayNotEmpty, IsNotEmpty, IsString } from 'class-validator';
export class CheckExistingAssetsDto {
@IsNotEmpty()
@ArrayNotEmpty()
@IsString({ each: true })
@IsNotEmpty({ each: true })
deviceAssetIds!: string[];
@IsNotEmpty()

View File

@ -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');
});
});

View File

@ -1,13 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNotEmpty } from 'class-validator';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginCredentialDto {
@IsNotEmpty()
@IsEmail()
@ApiProperty({ example: 'testuser@email.com' })
@Transform(({ value }) => value?.toLowerCase())
@Transform(({ value }) => value.toLowerCase())
email!: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: 'password' })
password!: string;

View File

@ -1,27 +1,44 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { validateSync } from 'class-validator';
import { SignUpDto } from './sign-up.dto';
describe('sign up DTO', () => {
it('validates the email', async () => {
const params: Partial<SignUpDto> = {
email: undefined,
describe('SignUpDto', () => {
it('should require all fields', () => {
const dto = plainToInstance(SignUpDto, {
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',
firstName: 'first name',
lastName: 'last name',
};
let dto: SignUpDto = plainToInstance(SignUpDto, params);
let errors = await validate(dto);
});
const errors = validateSync(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toEqual('email');
});
params.email = 'invalid email';
dto = plainToInstance(SignUpDto, params);
errors = await validate(dto);
expect(errors).toHaveLength(1);
params.email = 'valid@email.com';
dto = plainToInstance(SignUpDto, params);
errors = await validate(dto);
it('should make the email all lowercase', () => {
const dto = plainToInstance(SignUpDto, {
email: 'TeSt@ImMiCh.com',
password: 'password',
firstName: 'first name',
lastName: 'last name',
});
const errors = validateSync(dto);
expect(errors).toHaveLength(0);
expect(dto.email).toEqual('test@immich.com');
});
});

View File

@ -1,21 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsEmail } from 'class-validator';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class SignUpDto {
@IsEmail()
@ApiProperty({ example: 'testuser@email.com' })
@Transform(({ value }) => value?.toLowerCase())
@Transform(({ value }) => value.toLowerCase())
email!: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: 'password' })
password!: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: 'Admin' })
firstName!: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: 'Doe' })
lastName!: string;

View 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();
});
});
});

View File

@ -1,32 +1,42 @@
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 { randomUUID } from 'crypto';
import { Request } from 'express';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { Request } from 'express';
import { randomUUID } from 'crypto';
import sanitize from 'sanitize-filename';
export const assetUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {
fileFilter,
storage: diskStorage({
destination,
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|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);
cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false);
}
}
},
storage: diskStorage({
destination: (req: Request, file: Express.Multer.File, cb: any) => {
const basePath = APP_UPLOAD_LOCATION;
function destination(req: Request, file: Express.Multer.File, cb: any) {
if (!req.user) {
return;
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);
@ -36,13 +46,15 @@ export const assetUploadOption: MulterOptions = {
// Save original to disk
cb(null, originalUploadFolder);
},
}
function filename(req: Request, file: Express.Multer.File, cb: any) {
if (!req.user) {
return cb(new UnauthorizedException());
}
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);
},
}),
};
}

View File

@ -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');
});
});
});

View File

@ -1,26 +1,39 @@
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 { Request } from 'express';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { Request } from 'express';
import sanitize from 'sanitize-filename';
export const profileImageUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {
fileFilter,
storage: diskStorage({
destination,
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 HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false);
}
}
},
storage: diskStorage({
destination: (req: Request, file: Express.Multer.File, cb: any) => {
function destination(req: Request, file: Express.Multer.File, cb: any) {
if (!req.user) {
return;
return cb(new UnauthorizedException());
}
const basePath = APP_UPLOAD_LOCATION;
const profileImageLocation = `${basePath}/${req.user.id}/profile`;
@ -29,16 +42,15 @@ export const profileImageUploadOption: MulterOptions = {
}
cb(null, profileImageLocation);
},
filename: (req: Request, file: Express.Multer.File, cb: any) => {
if (!req.user) {
return;
}
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)));
},
}),
};
}

View File

@ -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');
});
});
});

View File

@ -13,8 +13,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
@InjectRepository(UserEntity)
private usersRepository: Repository<UserEntity>,
private immichJwtService: ImmichJwtService,
immichJwtService: ImmichJwtService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([