mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	refactor(server): api keys (#1339)
* refactor: api keys * refactor: test module * chore: tests * chore: fix provider * refactor: test mock repos
This commit is contained in:
		| @@ -1,16 +0,0 @@ | ||||
| import { APIKeyEntity } from '@app/infra'; | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | ||||
| import { APIKeyController } from './api-key.controller'; | ||||
| import { APIKeyRepository, IKeyRepository } from './api-key.repository'; | ||||
| import { APIKeyService } from './api-key.service'; | ||||
|  | ||||
| const KEY_REPOSITORY = { provide: IKeyRepository, useClass: APIKeyRepository }; | ||||
|  | ||||
| @Module({ | ||||
|   imports: [TypeOrmModule.forFeature([APIKeyEntity])], | ||||
|   controllers: [APIKeyController], | ||||
|   providers: [APIKeyService, KEY_REPOSITORY], | ||||
|   exports: [APIKeyService, KEY_REPOSITORY], | ||||
| }) | ||||
| export class APIKeyModule {} | ||||
| @@ -2,7 +2,6 @@ import { immichAppConfig, immichBullAsyncConfig } from '@app/common/config'; | ||||
| import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; | ||||
| import { AssetModule } from './api-v1/asset/asset.module'; | ||||
| import { AuthModule } from './api-v1/auth/auth.module'; | ||||
| import { APIKeyModule } from './api-v1/api-key/api-key.module'; | ||||
| import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module'; | ||||
| import { DeviceInfoModule } from './api-v1/device-info/device-info.module'; | ||||
| import { ConfigModule } from '@nestjs/config'; | ||||
| @@ -22,7 +21,7 @@ import { ImmichConfigModule } from '@app/immich-config'; | ||||
| import { ShareModule } from './api-v1/share/share.module'; | ||||
| import { DomainModule } from '@app/domain'; | ||||
| import { InfraModule } from '@app/infra'; | ||||
| import { UserController } from './controllers'; | ||||
| import { APIKeyController, UserController } from './controllers'; | ||||
|  | ||||
| @Module({ | ||||
|   imports: [ | ||||
| @@ -32,8 +31,6 @@ import { UserController } from './controllers'; | ||||
|       imports: [InfraModule], | ||||
|     }), | ||||
|  | ||||
|     APIKeyModule, | ||||
|  | ||||
|     AssetModule, | ||||
|  | ||||
|     AuthModule, | ||||
| @@ -69,6 +66,7 @@ import { UserController } from './controllers'; | ||||
|   controllers: [ | ||||
|     // | ||||
|     AppController, | ||||
|     APIKeyController, | ||||
|     UserController, | ||||
|   ], | ||||
|   providers: [], | ||||
|   | ||||
| @@ -1,12 +1,15 @@ | ||||
| import { | ||||
|   APIKeyCreateDto, | ||||
|   APIKeyCreateResponseDto, | ||||
|   APIKeyResponseDto, | ||||
|   APIKeyService, | ||||
|   APIKeyUpdateDto, | ||||
|   AuthUserDto, | ||||
| } from '@app/domain'; | ||||
| import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, ValidationPipe } from '@nestjs/common'; | ||||
| import { ApiTags } from '@nestjs/swagger'; | ||||
| import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; | ||||
| import { Authenticated } from '../../decorators/authenticated.decorator'; | ||||
| import { APIKeyService } from './api-key.service'; | ||||
| import { APIKeyCreateDto } from './dto/api-key-create.dto'; | ||||
| import { APIKeyUpdateDto } from './dto/api-key-update.dto'; | ||||
| import { APIKeyCreateResponseDto } from './repsonse-dto/api-key-create-response.dto'; | ||||
| import { APIKeyResponseDto } from './repsonse-dto/api-key-response.dto'; | ||||
| import { GetAuthUser } from '../decorators/auth-user.decorator'; | ||||
| import { Authenticated } from '../decorators/authenticated.decorator'; | ||||
| 
 | ||||
| @ApiTags('API Key') | ||||
| @Controller('api-key') | ||||
| @@ -1 +1,2 @@ | ||||
| export * from './api-key.controller'; | ||||
| export * from './user.controller'; | ||||
|   | ||||
| @@ -3,13 +3,12 @@ import { ImmichJwtService } from './immich-jwt.service'; | ||||
| import { JwtModule } from '@nestjs/jwt'; | ||||
| import { jwtConfig } from '../../config/jwt.config'; | ||||
| import { JwtStrategy } from './strategies/jwt.strategy'; | ||||
| import { APIKeyModule } from '../../api-v1/api-key/api-key.module'; | ||||
| import { APIKeyStrategy } from './strategies/api-key.strategy'; | ||||
| import { ShareModule } from '../../api-v1/share/share.module'; | ||||
| import { PublicShareStrategy } from './strategies/public-share.strategy'; | ||||
|  | ||||
| @Module({ | ||||
|   imports: [JwtModule.register(jwtConfig), APIKeyModule, ShareModule], | ||||
|   imports: [JwtModule.register(jwtConfig), ShareModule], | ||||
|   providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy, PublicShareStrategy], | ||||
|   exports: [ImmichJwtService], | ||||
| }) | ||||
|   | ||||
| @@ -1,8 +1,7 @@ | ||||
| import { APIKeyService, AuthUserDto } from '@app/domain'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { PassportStrategy } from '@nestjs/passport'; | ||||
| import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; | ||||
| import { APIKeyService } from '../../../api-v1/api-key/api-key.service'; | ||||
| import { AuthUserDto } from '../../../decorators/auth-user.decorator'; | ||||
|  | ||||
| export const API_KEY_STRATEGY = 'api-key'; | ||||
|  | ||||
| @@ -16,16 +15,7 @@ export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY) | ||||
|     super(options); | ||||
|   } | ||||
|  | ||||
|   async validate(token: string): Promise<AuthUserDto> { | ||||
|     const user = await this.apiKeyService.validate(token); | ||||
|  | ||||
|     const authUser = new AuthUserDto(); | ||||
|     authUser.id = user.id; | ||||
|     authUser.email = user.email; | ||||
|     authUser.isAdmin = user.isAdmin; | ||||
|     authUser.isPublicUser = false; | ||||
|     authUser.isAllowUpload = true; | ||||
|  | ||||
|     return authUser; | ||||
|   validate(token: string): Promise<AuthUserDto> { | ||||
|     return this.apiKeyService.validate(token); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,14 +4,6 @@ | ||||
|     "declaration": false, | ||||
|     "outDir": "../../dist/apps/immich" | ||||
|   }, | ||||
|   "include": [ | ||||
|     "src/**/*", | ||||
|     "../../libs/**/*" | ||||
|   ], | ||||
|   "exclude": [ | ||||
|     "node_modules", | ||||
|     "dist", | ||||
|     "test", | ||||
|     "**/*spec.ts" | ||||
|   ] | ||||
| } | ||||
|   "include": ["src/**/*"], | ||||
|   "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,153 @@ | ||||
| { | ||||
|   "openapi": "3.0.0", | ||||
|   "paths": { | ||||
|     "/api-key": { | ||||
|       "post": { | ||||
|         "operationId": "createKey", | ||||
|         "description": "", | ||||
|         "parameters": [], | ||||
|         "requestBody": { | ||||
|           "required": true, | ||||
|           "content": { | ||||
|             "application/json": { | ||||
|               "schema": { | ||||
|                 "$ref": "#/components/schemas/APIKeyCreateDto" | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "responses": { | ||||
|           "201": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "$ref": "#/components/schemas/APIKeyCreateResponseDto" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "API Key" | ||||
|         ] | ||||
|       }, | ||||
|       "get": { | ||||
|         "operationId": "getKeys", | ||||
|         "description": "", | ||||
|         "parameters": [], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "type": "array", | ||||
|                   "items": { | ||||
|                     "$ref": "#/components/schemas/APIKeyResponseDto" | ||||
|                   } | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "API Key" | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/api-key/{id}": { | ||||
|       "get": { | ||||
|         "operationId": "getKey", | ||||
|         "description": "", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "id", | ||||
|             "required": true, | ||||
|             "in": "path", | ||||
|             "schema": { | ||||
|               "type": "number" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "$ref": "#/components/schemas/APIKeyResponseDto" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "API Key" | ||||
|         ] | ||||
|       }, | ||||
|       "put": { | ||||
|         "operationId": "updateKey", | ||||
|         "description": "", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "id", | ||||
|             "required": true, | ||||
|             "in": "path", | ||||
|             "schema": { | ||||
|               "type": "number" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "requestBody": { | ||||
|           "required": true, | ||||
|           "content": { | ||||
|             "application/json": { | ||||
|               "schema": { | ||||
|                 "$ref": "#/components/schemas/APIKeyUpdateDto" | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "$ref": "#/components/schemas/APIKeyResponseDto" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "API Key" | ||||
|         ] | ||||
|       }, | ||||
|       "delete": { | ||||
|         "operationId": "deleteKey", | ||||
|         "description": "", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "id", | ||||
|             "required": true, | ||||
|             "in": "path", | ||||
|             "schema": { | ||||
|               "type": "number" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "" | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "API Key" | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/user": { | ||||
|       "get": { | ||||
|         "operationId": "getAllUsers", | ||||
| @@ -341,153 +488,6 @@ | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/api-key": { | ||||
|       "post": { | ||||
|         "operationId": "createKey", | ||||
|         "description": "", | ||||
|         "parameters": [], | ||||
|         "requestBody": { | ||||
|           "required": true, | ||||
|           "content": { | ||||
|             "application/json": { | ||||
|               "schema": { | ||||
|                 "$ref": "#/components/schemas/APIKeyCreateDto" | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "responses": { | ||||
|           "201": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "$ref": "#/components/schemas/APIKeyCreateResponseDto" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "API Key" | ||||
|         ] | ||||
|       }, | ||||
|       "get": { | ||||
|         "operationId": "getKeys", | ||||
|         "description": "", | ||||
|         "parameters": [], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "type": "array", | ||||
|                   "items": { | ||||
|                     "$ref": "#/components/schemas/APIKeyResponseDto" | ||||
|                   } | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "API Key" | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/api-key/{id}": { | ||||
|       "get": { | ||||
|         "operationId": "getKey", | ||||
|         "description": "", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "id", | ||||
|             "required": true, | ||||
|             "in": "path", | ||||
|             "schema": { | ||||
|               "type": "number" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "$ref": "#/components/schemas/APIKeyResponseDto" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "API Key" | ||||
|         ] | ||||
|       }, | ||||
|       "put": { | ||||
|         "operationId": "updateKey", | ||||
|         "description": "", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "id", | ||||
|             "required": true, | ||||
|             "in": "path", | ||||
|             "schema": { | ||||
|               "type": "number" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "requestBody": { | ||||
|           "required": true, | ||||
|           "content": { | ||||
|             "application/json": { | ||||
|               "schema": { | ||||
|                 "$ref": "#/components/schemas/APIKeyUpdateDto" | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "$ref": "#/components/schemas/APIKeyResponseDto" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "API Key" | ||||
|         ] | ||||
|       }, | ||||
|       "delete": { | ||||
|         "operationId": "deleteKey", | ||||
|         "description": "", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "id", | ||||
|             "required": true, | ||||
|             "in": "path", | ||||
|             "schema": { | ||||
|               "type": "number" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "" | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "API Key" | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/asset/upload": { | ||||
|       "post": { | ||||
|         "operationId": "uploadFile", | ||||
| @@ -2825,6 +2825,63 @@ | ||||
|       } | ||||
|     }, | ||||
|     "schemas": { | ||||
|       "APIKeyCreateDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "name": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       "APIKeyResponseDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "id": { | ||||
|             "type": "integer" | ||||
|           }, | ||||
|           "name": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "createdAt": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "updatedAt": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "id", | ||||
|           "name", | ||||
|           "createdAt", | ||||
|           "updatedAt" | ||||
|         ] | ||||
|       }, | ||||
|       "APIKeyCreateResponseDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "secret": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "apiKey": { | ||||
|             "$ref": "#/components/schemas/APIKeyResponseDto" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "secret", | ||||
|           "apiKey" | ||||
|         ] | ||||
|       }, | ||||
|       "APIKeyUpdateDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "name": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "name" | ||||
|         ] | ||||
|       }, | ||||
|       "UserResponseDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
| @@ -2969,63 +3026,6 @@ | ||||
|           "profileImagePath" | ||||
|         ] | ||||
|       }, | ||||
|       "APIKeyCreateDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "name": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       "APIKeyResponseDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "id": { | ||||
|             "type": "integer" | ||||
|           }, | ||||
|           "name": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "createdAt": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "updatedAt": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "id", | ||||
|           "name", | ||||
|           "createdAt", | ||||
|           "updatedAt" | ||||
|         ] | ||||
|       }, | ||||
|       "APIKeyCreateResponseDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "secret": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "apiKey": { | ||||
|             "$ref": "#/components/schemas/APIKeyResponseDto" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "secret", | ||||
|           "apiKey" | ||||
|         ] | ||||
|       }, | ||||
|       "APIKeyUpdateDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "name": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "name" | ||||
|         ] | ||||
|       }, | ||||
|       "AssetFileUploadDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|   | ||||
							
								
								
									
										16
									
								
								server/libs/domain/src/api-key/api-key.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								server/libs/domain/src/api-key/api-key.repository.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import { APIKeyEntity } from '@app/infra'; | ||||
|  | ||||
| export const IKeyRepository = 'IKeyRepository'; | ||||
|  | ||||
| export interface IKeyRepository { | ||||
|   create(dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>; | ||||
|   update(userId: string, id: number, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>; | ||||
|   delete(userId: string, id: number): Promise<void>; | ||||
|   /** | ||||
|    * Includes the hashed `key` for verification | ||||
|    * @param id | ||||
|    */ | ||||
|   getKey(id: number): Promise<APIKeyEntity | null>; | ||||
|   getById(userId: string, id: number): Promise<APIKeyEntity | null>; | ||||
|   getByUserId(userId: string): Promise<APIKeyEntity[]>; | ||||
| } | ||||
							
								
								
									
										142
									
								
								server/libs/domain/src/api-key/api-key.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								server/libs/domain/src/api-key/api-key.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | ||||
| import { APIKeyEntity } from '@app/infra'; | ||||
| import { BadRequestException, UnauthorizedException } from '@nestjs/common'; | ||||
| import { authStub, entityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test'; | ||||
| import { ICryptoRepository } from '../auth'; | ||||
| import { IKeyRepository } from './api-key.repository'; | ||||
| import { APIKeyService } from './api-key.service'; | ||||
|  | ||||
| const adminKey = Object.freeze({ | ||||
|   id: 1, | ||||
|   name: 'My Key', | ||||
|   key: 'my-api-key (hashed)', | ||||
|   userId: authStub.admin.id, | ||||
|   user: entityStub.admin, | ||||
| } as APIKeyEntity); | ||||
|  | ||||
| const token = Buffer.from('1:my-api-key', 'utf8').toString('base64'); | ||||
|  | ||||
| describe(APIKeyService.name, () => { | ||||
|   let sut: APIKeyService; | ||||
|   let keyMock: jest.Mocked<IKeyRepository>; | ||||
|   let cryptoMock: jest.Mocked<ICryptoRepository>; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     cryptoMock = newCryptoRepositoryMock(); | ||||
|     keyMock = newKeyRepositoryMock(); | ||||
|     sut = new APIKeyService(cryptoMock, keyMock); | ||||
|   }); | ||||
|  | ||||
|   describe('create', () => { | ||||
|     it('should create a new key', async () => { | ||||
|       keyMock.create.mockResolvedValue(adminKey); | ||||
|  | ||||
|       await sut.create(authStub.admin, { name: 'Test Key' }); | ||||
|  | ||||
|       expect(keyMock.create).toHaveBeenCalledWith({ | ||||
|         key: 'cmFuZG9tLWJ5dGVz (hashed)', | ||||
|         name: 'Test Key', | ||||
|         userId: authStub.admin.id, | ||||
|       }); | ||||
|       expect(cryptoMock.randomBytes).toHaveBeenCalled(); | ||||
|       expect(cryptoMock.hash).toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should not require a name', async () => { | ||||
|       keyMock.create.mockResolvedValue(adminKey); | ||||
|  | ||||
|       await sut.create(authStub.admin, {}); | ||||
|  | ||||
|       expect(keyMock.create).toHaveBeenCalledWith({ | ||||
|         key: 'cmFuZG9tLWJ5dGVz (hashed)', | ||||
|         name: 'API Key', | ||||
|         userId: authStub.admin.id, | ||||
|       }); | ||||
|       expect(cryptoMock.randomBytes).toHaveBeenCalled(); | ||||
|       expect(cryptoMock.hash).toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('update', () => { | ||||
|     it('should throw an error if the key is not found', async () => { | ||||
|       keyMock.getById.mockResolvedValue(null); | ||||
|  | ||||
|       await expect(sut.update(authStub.admin, 1, { name: 'New Name' })).rejects.toBeInstanceOf(BadRequestException); | ||||
|  | ||||
|       expect(keyMock.update).not.toHaveBeenCalledWith(1); | ||||
|     }); | ||||
|  | ||||
|     it('should update a key', async () => { | ||||
|       keyMock.getById.mockResolvedValue(adminKey); | ||||
|  | ||||
|       await sut.update(authStub.admin, 1, { name: 'New Name' }); | ||||
|  | ||||
|       expect(keyMock.update).toHaveBeenCalledWith(authStub.admin.id, 1, { name: 'New Name' }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('delete', () => { | ||||
|     it('should throw an error if the key is not found', async () => { | ||||
|       keyMock.getById.mockResolvedValue(null); | ||||
|  | ||||
|       await expect(sut.delete(authStub.admin, 1)).rejects.toBeInstanceOf(BadRequestException); | ||||
|  | ||||
|       expect(keyMock.delete).not.toHaveBeenCalledWith(1); | ||||
|     }); | ||||
|  | ||||
|     it('should delete a key', async () => { | ||||
|       keyMock.getById.mockResolvedValue(adminKey); | ||||
|  | ||||
|       await sut.delete(authStub.admin, 1); | ||||
|  | ||||
|       expect(keyMock.delete).toHaveBeenCalledWith(authStub.admin.id, 1); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getById', () => { | ||||
|     it('should throw an error if the key is not found', async () => { | ||||
|       keyMock.getById.mockResolvedValue(null); | ||||
|  | ||||
|       await expect(sut.getById(authStub.admin, 1)).rejects.toBeInstanceOf(BadRequestException); | ||||
|  | ||||
|       expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.id, 1); | ||||
|     }); | ||||
|  | ||||
|     it('should get a key by id', async () => { | ||||
|       keyMock.getById.mockResolvedValue(adminKey); | ||||
|  | ||||
|       await sut.getById(authStub.admin, 1); | ||||
|  | ||||
|       expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.id, 1); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getAll', () => { | ||||
|     it('should return all the keys for a user', async () => { | ||||
|       keyMock.getByUserId.mockResolvedValue([adminKey]); | ||||
|  | ||||
|       await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1); | ||||
|  | ||||
|       expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.id); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('validate', () => { | ||||
|     it('should throw an error for an invalid id', async () => { | ||||
|       keyMock.getKey.mockResolvedValue(null); | ||||
|  | ||||
|       await expect(sut.validate(token)).rejects.toBeInstanceOf(UnauthorizedException); | ||||
|  | ||||
|       expect(keyMock.getKey).toHaveBeenCalledWith(1); | ||||
|       expect(cryptoMock.compareSync).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should validate the token', async () => { | ||||
|       keyMock.getKey.mockResolvedValue(adminKey); | ||||
|  | ||||
|       await expect(sut.validate(token)).resolves.toEqual(authStub.admin); | ||||
|  | ||||
|       expect(keyMock.getKey).toHaveBeenCalledWith(1); | ||||
|       expect(cryptoMock.compareSync).toHaveBeenCalledWith('my-api-key', 'my-api-key (hashed)'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,21 +1,22 @@ | ||||
| import { UserEntity } from '@app/infra'; | ||||
| import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; | ||||
| import { compareSync, hash } from 'bcrypt'; | ||||
| import { randomBytes } from 'node:crypto'; | ||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||
| import { AuthUserDto, ICryptoRepository } from '../auth'; | ||||
| import { IKeyRepository } from './api-key.repository'; | ||||
| import { APIKeyCreateDto } from './dto/api-key-create.dto'; | ||||
| import { APIKeyCreateResponseDto } from './repsonse-dto/api-key-create-response.dto'; | ||||
| import { APIKeyResponseDto, mapKey } from './repsonse-dto/api-key-response.dto'; | ||||
| import { APIKeyCreateResponseDto } from './response-dto/api-key-create-response.dto'; | ||||
| import { APIKeyResponseDto, mapKey } from './response-dto/api-key-response.dto'; | ||||
| 
 | ||||
| @Injectable() | ||||
| export class APIKeyService { | ||||
|   constructor(@Inject(IKeyRepository) private repository: IKeyRepository) {} | ||||
|   constructor( | ||||
|     @Inject(ICryptoRepository) private crypto: ICryptoRepository, | ||||
|     @Inject(IKeyRepository) private repository: IKeyRepository, | ||||
|   ) {} | ||||
| 
 | ||||
|   async create(authUser: AuthUserDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> { | ||||
|     const key = randomBytes(24).toString('base64').replace(/\W/g, ''); | ||||
|     const key = this.crypto.randomBytes(24).toString('base64').replace(/\W/g, ''); | ||||
|     const entity = await this.repository.create({ | ||||
|       key: await hash(key, 10), | ||||
|       key: await this.crypto.hash(key, 10), | ||||
|       name: dto.name || 'API Key', | ||||
|       userId: authUser.id, | ||||
|     }); | ||||
| @@ -58,14 +59,22 @@ export class APIKeyService { | ||||
|     return keys.map(mapKey); | ||||
|   } | ||||
| 
 | ||||
|   async validate(token: string): Promise<UserEntity> { | ||||
|   async validate(token: string): Promise<AuthUserDto> { | ||||
|     const [_id, key] = Buffer.from(token, 'base64').toString('utf8').split(':'); | ||||
|     const id = Number(_id); | ||||
| 
 | ||||
|     if (id && key) { | ||||
|       const entity = await this.repository.getKey(id); | ||||
|       if (entity?.user && entity?.key && compareSync(key, entity.key)) { | ||||
|         return entity.user as UserEntity; | ||||
|       if (entity?.user && entity?.key && this.crypto.compareSync(key, entity.key)) { | ||||
|         const user = entity.user as UserEntity; | ||||
| 
 | ||||
|         return { | ||||
|           id: user.id, | ||||
|           email: user.email, | ||||
|           isAdmin: user.isAdmin, | ||||
|           isPublicUser: false, | ||||
|           isAllowUpload: true, | ||||
|         }; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
							
								
								
									
										2
									
								
								server/libs/domain/src/api-key/dto/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/libs/domain/src/api-key/dto/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| export * from './api-key-create.dto'; | ||||
| export * from './api-key-update.dto'; | ||||
							
								
								
									
										4
									
								
								server/libs/domain/src/api-key/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								server/libs/domain/src/api-key/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| export * from './api-key.repository'; | ||||
| export * from './api-key.service'; | ||||
| export * from './dto'; | ||||
| export * from './response-dto'; | ||||
							
								
								
									
										2
									
								
								server/libs/domain/src/api-key/response-dto/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/libs/domain/src/api-key/response-dto/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| export * from './api-key-create-response.dto'; | ||||
| export * from './api-key-response.dto'; | ||||
							
								
								
									
										7
									
								
								server/libs/domain/src/auth/crypto.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								server/libs/domain/src/auth/crypto.repository.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| export const ICryptoRepository = 'ICryptoRepository'; | ||||
|  | ||||
| export interface ICryptoRepository { | ||||
|   randomBytes(size: number): Buffer; | ||||
|   hash(data: string | Buffer, saltOrRounds: string | number): Promise<string>; | ||||
|   compareSync(data: Buffer | string, encrypted: string): boolean; | ||||
| } | ||||
| @@ -1 +1,2 @@ | ||||
| export * from './crypto.repository'; | ||||
| export * from './dto'; | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common'; | ||||
| import { APIKeyService } from './api-key'; | ||||
| import { UserService } from './user'; | ||||
|  | ||||
| const providers: Provider[] = [ | ||||
|   // | ||||
|   APIKeyService, | ||||
|   UserService, | ||||
| ]; | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| export * from './api-key'; | ||||
| export * from './auth'; | ||||
| export * from './domain.module'; | ||||
| export * from './user'; | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| import { IUserRepository } from '@app/domain'; | ||||
| import { UserEntity } from '@app/infra'; | ||||
| import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; | ||||
| import { AuthUserDto } from '../auth'; | ||||
| import { IUserRepository } from '@app/domain'; | ||||
| import { when } from 'jest-when'; | ||||
| import { UserService } from './user.service'; | ||||
| import { newUserRepositoryMock } from '../../test'; | ||||
| import { AuthUserDto } from '../auth'; | ||||
| import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| import { UserService } from './user.service'; | ||||
|  | ||||
| const adminUserAuth: AuthUserDto = Object.freeze({ | ||||
|   id: 'admin_id', | ||||
| @@ -73,28 +74,18 @@ const adminUserResponse = Object.freeze({ | ||||
|   createdAt: '2021-01-01', | ||||
| }); | ||||
|  | ||||
| describe('UserService', () => { | ||||
| describe(UserService.name, () => { | ||||
|   let sut: UserService; | ||||
|   let userRepositoryMock: jest.Mocked<IUserRepository>; | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     userRepositoryMock = { | ||||
|       get: jest.fn(), | ||||
|       getAdmin: jest.fn(), | ||||
|       getByEmail: jest.fn(), | ||||
|       getByOAuthId: jest.fn(), | ||||
|       getList: jest.fn(), | ||||
|       create: jest.fn(), | ||||
|       update: jest.fn(), | ||||
|       delete: jest.fn(), | ||||
|       restore: jest.fn(), | ||||
|     }; | ||||
|   beforeEach(async () => { | ||||
|     userRepositoryMock = newUserRepositoryMock(); | ||||
|     sut = new UserService(userRepositoryMock); | ||||
|  | ||||
|     when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser); | ||||
|     when(userRepositoryMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser); | ||||
|     when(userRepositoryMock.get).calledWith(immichUser.id).mockResolvedValue(immichUser); | ||||
|     when(userRepositoryMock.get).calledWith(immichUser.id, undefined).mockResolvedValue(immichUser); | ||||
|  | ||||
|     sut = new UserService(userRepositoryMock); | ||||
|   }); | ||||
|  | ||||
|   describe('getAllUsers', () => { | ||||
| @@ -285,9 +276,7 @@ describe('UserService', () => { | ||||
|  | ||||
|   describe('deleteUser', () => { | ||||
|     it('cannot delete admin user', async () => { | ||||
|       const result = sut.deleteUser(adminUserAuth, adminUserAuth.id); | ||||
|  | ||||
|       await expect(result).rejects.toBeInstanceOf(ForbiddenException); | ||||
|       await expect(sut.deleteUser(adminUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException); | ||||
|     }); | ||||
|  | ||||
|     it('should require the auth user be an admin', async () => { | ||||
|   | ||||
							
								
								
									
										12
									
								
								server/libs/domain/test/api-key.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								server/libs/domain/test/api-key.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { IKeyRepository } from '../src'; | ||||
|  | ||||
| export const newKeyRepositoryMock = (): jest.Mocked<IKeyRepository> => { | ||||
|   return { | ||||
|     create: jest.fn(), | ||||
|     update: jest.fn(), | ||||
|     delete: jest.fn(), | ||||
|     getKey: jest.fn(), | ||||
|     getById: jest.fn(), | ||||
|     getByUserId: jest.fn(), | ||||
|   }; | ||||
| }; | ||||
							
								
								
									
										9
									
								
								server/libs/domain/test/crypto.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								server/libs/domain/test/crypto.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import { ICryptoRepository } from '../src'; | ||||
|  | ||||
| export const newCryptoRepositoryMock = (): jest.Mocked<ICryptoRepository> => { | ||||
|   return { | ||||
|     randomBytes: jest.fn().mockReturnValue(Buffer.from('random-bytes', 'utf8')), | ||||
|     compareSync: jest.fn().mockReturnValue(true), | ||||
|     hash: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)), | ||||
|   }; | ||||
| }; | ||||
							
								
								
									
										44
									
								
								server/libs/domain/test/fixtures.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								server/libs/domain/test/fixtures.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| import { UserEntity } from '@app/infra'; | ||||
| import { AuthUserDto } from '../src'; | ||||
|  | ||||
| export const authStub = { | ||||
|   admin: Object.freeze<AuthUserDto>({ | ||||
|     id: 'admin_id', | ||||
|     email: 'admin@test.com', | ||||
|     isAdmin: true, | ||||
|     isPublicUser: false, | ||||
|     isAllowUpload: true, | ||||
|   }), | ||||
|   user1: Object.freeze<AuthUserDto>({ | ||||
|     id: 'immich_id', | ||||
|     email: 'immich@test.com', | ||||
|     isAdmin: false, | ||||
|     isPublicUser: false, | ||||
|     isAllowUpload: true, | ||||
|   }), | ||||
| }; | ||||
|  | ||||
| export const entityStub = { | ||||
|   admin: Object.freeze<UserEntity>({ | ||||
|     ...authStub.admin, | ||||
|     password: 'admin_password', | ||||
|     firstName: 'admin_first_name', | ||||
|     lastName: 'admin_last_name', | ||||
|     oauthId: '', | ||||
|     shouldChangePassword: false, | ||||
|     profileImagePath: '', | ||||
|     createdAt: '2021-01-01', | ||||
|     tags: [], | ||||
|   }), | ||||
|   user1: Object.freeze<UserEntity>({ | ||||
|     ...authStub.user1, | ||||
|     password: 'immich_password', | ||||
|     firstName: 'immich_first_name', | ||||
|     lastName: 'immich_last_name', | ||||
|     oauthId: '', | ||||
|     shouldChangePassword: false, | ||||
|     profileImagePath: '', | ||||
|     createdAt: '2021-01-01', | ||||
|     tags: [], | ||||
|   }), | ||||
| }; | ||||
							
								
								
									
										4
									
								
								server/libs/domain/test/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								server/libs/domain/test/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| export * from './api-key.repository.mock'; | ||||
| export * from './crypto.repository.mock'; | ||||
| export * from './fixtures'; | ||||
| export * from './user.repository.mock'; | ||||
							
								
								
									
										15
									
								
								server/libs/domain/test/user.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								server/libs/domain/test/user.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| import { IUserRepository } from '../src'; | ||||
|  | ||||
| export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => { | ||||
|   return { | ||||
|     get: jest.fn(), | ||||
|     getAdmin: jest.fn(), | ||||
|     getByEmail: jest.fn(), | ||||
|     getByOAuthId: jest.fn(), | ||||
|     getList: jest.fn(), | ||||
|     create: jest.fn(), | ||||
|     update: jest.fn(), | ||||
|     delete: jest.fn(), | ||||
|     restore: jest.fn(), | ||||
|   }; | ||||
| }; | ||||
							
								
								
									
										9
									
								
								server/libs/infra/src/auth/crypto.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								server/libs/infra/src/auth/crypto.repository.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import { ICryptoRepository } from '@app/domain'; | ||||
| import { compareSync, hash } from 'bcrypt'; | ||||
| import { randomBytes } from 'crypto'; | ||||
|  | ||||
| export const cryptoRepository: ICryptoRepository = { | ||||
|   randomBytes, | ||||
|   hash, | ||||
|   compareSync, | ||||
| }; | ||||
| @@ -1,22 +1,8 @@ | ||||
| import { APIKeyEntity } from '@app/infra'; | ||||
| import { IKeyRepository } from '@app/domain'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { Repository } from 'typeorm'; | ||||
| 
 | ||||
| export const IKeyRepository = 'IKeyRepository'; | ||||
| 
 | ||||
| export interface IKeyRepository { | ||||
|   create(dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>; | ||||
|   update(userId: string, id: number, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>; | ||||
|   delete(userId: string, id: number): Promise<void>; | ||||
|   /** | ||||
|    * Includes the hashed `key` for verification | ||||
|    * @param id | ||||
|    */ | ||||
|   getKey(id: number): Promise<APIKeyEntity | null>; | ||||
|   getById(userId: string, id: number): Promise<APIKeyEntity | null>; | ||||
|   getByUserId(userId: string): Promise<APIKeyEntity[]>; | ||||
| } | ||||
| import { APIKeyEntity } from '../entities'; | ||||
| 
 | ||||
| @Injectable() | ||||
| export class APIKeyRepository implements IKeyRepository { | ||||
| @@ -1 +1,2 @@ | ||||
| export * from './api-key.repository'; | ||||
| export * from './user.repository'; | ||||
|   | ||||
| @@ -1,11 +1,15 @@ | ||||
| import { ICryptoRepository, IKeyRepository, IUserRepository } from '@app/domain'; | ||||
| import { databaseConfig, UserEntity } from '@app/infra'; | ||||
| import { IUserRepository } from '@app/domain'; | ||||
| import { Global, Module, Provider } from '@nestjs/common'; | ||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | ||||
| import { UserRepository } from './db'; | ||||
| import { cryptoRepository } from './auth/crypto.repository'; | ||||
| import { APIKeyEntity, UserRepository } from './db'; | ||||
| import { APIKeyRepository } from './db/repository'; | ||||
|  | ||||
| const providers: Provider[] = [ | ||||
|   // | ||||
|   { provide: ICryptoRepository, useValue: cryptoRepository }, | ||||
|   { provide: IKeyRepository, useClass: APIKeyRepository }, | ||||
|   { provide: IUserRepository, useClass: UserRepository }, | ||||
| ]; | ||||
|  | ||||
| @@ -14,7 +18,7 @@ const providers: Provider[] = [ | ||||
|   imports: [ | ||||
|     // | ||||
|     TypeOrmModule.forRoot(databaseConfig), | ||||
|     TypeOrmModule.forFeature([UserEntity]), | ||||
|     TypeOrmModule.forFeature([APIKeyEntity, UserEntity]), | ||||
|   ], | ||||
|   providers: [...providers], | ||||
|   exports: [...providers], | ||||
|   | ||||
| @@ -145,10 +145,10 @@ | ||||
|         "statements": 20 | ||||
|       }, | ||||
|       "./libs/domain/": { | ||||
|         "branches": 60, | ||||
|         "functions": 80, | ||||
|         "lines": 80, | ||||
|         "statements": 80 | ||||
|         "branches": 70, | ||||
|         "functions": 85, | ||||
|         "lines": 85, | ||||
|         "statements": 85 | ||||
|       } | ||||
|     }, | ||||
|     "testEnvironment": "node", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user