diff --git a/server/apps/immich/src/api-v1/asset/dto/check-existing-assets.dto.spec.ts b/server/apps/immich/src/api-v1/asset/dto/check-existing-assets.dto.spec.ts new file mode 100644 index 0000000000..dd0eca03fa --- /dev/null +++ b/server/apps/immich/src/api-v1/asset/dto/check-existing-assets.dto.spec.ts @@ -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); + }); +}); diff --git a/server/apps/immich/src/api-v1/asset/dto/check-existing-assets.dto.ts b/server/apps/immich/src/api-v1/asset/dto/check-existing-assets.dto.ts index 6101afcdf7..65740ab899 100644 --- a/server/apps/immich/src/api-v1/asset/dto/check-existing-assets.dto.ts +++ b/server/apps/immich/src/api-v1/asset/dto/check-existing-assets.dto.ts @@ -1,9 +1,11 @@ -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() deviceId!: string; -} \ No newline at end of file +} diff --git a/server/apps/immich/src/api-v1/auth/dto/login-credential.dto.spec.ts b/server/apps/immich/src/api-v1/auth/dto/login-credential.dto.spec.ts new file mode 100644 index 0000000000..2caaefcd99 --- /dev/null +++ b/server/apps/immich/src/api-v1/auth/dto/login-credential.dto.spec.ts @@ -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'); + }); +}); diff --git a/server/apps/immich/src/api-v1/auth/dto/login-credential.dto.ts b/server/apps/immich/src/api-v1/auth/dto/login-credential.dto.ts index 7426303528..e13badafa3 100644 --- a/server/apps/immich/src/api-v1/auth/dto/login-credential.dto.ts +++ b/server/apps/immich/src/api-v1/auth/dto/login-credential.dto.ts @@ -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; diff --git a/server/apps/immich/src/api-v1/auth/dto/sign-up.dto.spec.ts b/server/apps/immich/src/api-v1/auth/dto/sign-up.dto.spec.ts index 5f93fa0a19..c2e9d7f479 100644 --- a/server/apps/immich/src/api-v1/auth/dto/sign-up.dto.spec.ts +++ b/server/apps/immich/src/api-v1/auth/dto/sign-up.dto.spec.ts @@ -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 = { - 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'); }); }); diff --git a/server/apps/immich/src/api-v1/auth/dto/sign-up.dto.ts b/server/apps/immich/src/api-v1/auth/dto/sign-up.dto.ts index 3358ddac31..353e9cd8d3 100644 --- a/server/apps/immich/src/api-v1/auth/dto/sign-up.dto.ts +++ b/server/apps/immich/src/api-v1/auth/dto/sign-up.dto.ts @@ -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; diff --git a/server/apps/immich/src/config/asset-upload.config.spec.ts b/server/apps/immich/src/config/asset-upload.config.spec.ts new file mode 100644 index 0000000000..9d7d4e70d8 --- /dev/null +++ b/server/apps/immich/src/config/asset-upload.config.spec.ts @@ -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(); + }); + }); +}); diff --git a/server/apps/immich/src/config/asset-upload.config.ts b/server/apps/immich/src/config/asset-upload.config.ts index af9fedad47..7309a89244 100644 --- a/server/apps/immich/src/config/asset-upload.config.ts +++ b/server/apps/immich/src/config/asset-upload.config.ts @@ -1,48 +1,60 @@ 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) => { - 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); - } - }, - + fileFilter, storage: diskStorage({ - destination: (req: Request, file: Express.Multer.File, cb: any) => { - const basePath = APP_UPLOAD_LOCATION; - - 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); - }, + 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 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); +} diff --git a/server/apps/immich/src/config/profile-image-upload.config.spec.ts b/server/apps/immich/src/config/profile-image-upload.config.spec.ts new file mode 100644 index 0000000000..b483c7d0d0 --- /dev/null +++ b/server/apps/immich/src/config/profile-image-upload.config.spec.ts @@ -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'); + }); + }); +}); diff --git a/server/apps/immich/src/config/profile-image-upload.config.ts b/server/apps/immich/src/config/profile-image-upload.config.ts index 0fdb7767b6..af4255d3fb 100644 --- a/server/apps/immich/src/config/profile-image-upload.config.ts +++ b/server/apps/immich/src/config/profile-image-upload.config.ts @@ -1,44 +1,56 @@ 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) => { - 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); - } - }, - + fileFilter, storage: diskStorage({ - destination: (req: Request, file: Express.Multer.File, cb: any) => { - if (!req.user) { - 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))); - }, + 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 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))); +} diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts new file mode 100644 index 0000000000..1e26fc7ff1 --- /dev/null +++ b/server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts @@ -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'); + }); + }); +}); diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts index a4bded2953..a9d7d35f45 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts +++ b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts @@ -13,8 +13,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { constructor( @InjectRepository(UserEntity) private usersRepository: Repository, - - private immichJwtService: ImmichJwtService, + immichJwtService: ImmichJwtService, ) { super({ jwtFromRequest: ExtractJwt.fromExtractors([