1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02: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:
Jason Rasmussen 2023-01-18 09:40:15 -05:00 committed by GitHub
parent 0c469cc712
commit 92972ac776
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 538 additions and 312 deletions

View File

@ -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 {}

View File

@ -2,7 +2,6 @@ import { immichAppConfig, immichBullAsyncConfig } from '@app/common/config';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AssetModule } from './api-v1/asset/asset.module'; import { AssetModule } from './api-v1/asset/asset.module';
import { AuthModule } from './api-v1/auth/auth.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 { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
import { DeviceInfoModule } from './api-v1/device-info/device-info.module'; import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
@ -22,7 +21,7 @@ import { ImmichConfigModule } from '@app/immich-config';
import { ShareModule } from './api-v1/share/share.module'; import { ShareModule } from './api-v1/share/share.module';
import { DomainModule } from '@app/domain'; import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra'; import { InfraModule } from '@app/infra';
import { UserController } from './controllers'; import { APIKeyController, UserController } from './controllers';
@Module({ @Module({
imports: [ imports: [
@ -32,8 +31,6 @@ import { UserController } from './controllers';
imports: [InfraModule], imports: [InfraModule],
}), }),
APIKeyModule,
AssetModule, AssetModule,
AuthModule, AuthModule,
@ -69,6 +66,7 @@ import { UserController } from './controllers';
controllers: [ controllers: [
// //
AppController, AppController,
APIKeyController,
UserController, UserController,
], ],
providers: [], providers: [],

View File

@ -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 { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.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';
@ApiTags('API Key') @ApiTags('API Key')
@Controller('api-key') @Controller('api-key')

View File

@ -1 +1,2 @@
export * from './api-key.controller';
export * from './user.controller'; export * from './user.controller';

View File

@ -3,13 +3,12 @@ import { ImmichJwtService } from './immich-jwt.service';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config'; import { jwtConfig } from '../../config/jwt.config';
import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtStrategy } from './strategies/jwt.strategy';
import { APIKeyModule } from '../../api-v1/api-key/api-key.module';
import { APIKeyStrategy } from './strategies/api-key.strategy'; import { APIKeyStrategy } from './strategies/api-key.strategy';
import { ShareModule } from '../../api-v1/share/share.module'; import { ShareModule } from '../../api-v1/share/share.module';
import { PublicShareStrategy } from './strategies/public-share.strategy'; import { PublicShareStrategy } from './strategies/public-share.strategy';
@Module({ @Module({
imports: [JwtModule.register(jwtConfig), APIKeyModule, ShareModule], imports: [JwtModule.register(jwtConfig), ShareModule],
providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy, PublicShareStrategy], providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy, PublicShareStrategy],
exports: [ImmichJwtService], exports: [ImmichJwtService],
}) })

View File

@ -1,8 +1,7 @@
import { APIKeyService, AuthUserDto } from '@app/domain';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; 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'; export const API_KEY_STRATEGY = 'api-key';
@ -16,16 +15,7 @@ export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY)
super(options); super(options);
} }
async validate(token: string): Promise<AuthUserDto> { validate(token: string): Promise<AuthUserDto> {
const user = await this.apiKeyService.validate(token); return 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;
} }
} }

View File

@ -4,14 +4,6 @@
"declaration": false, "declaration": false,
"outDir": "../../dist/apps/immich" "outDir": "../../dist/apps/immich"
}, },
"include": [ "include": ["src/**/*"],
"src/**/*", "exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
"../../libs/**/*"
],
"exclude": [
"node_modules",
"dist",
"test",
"**/*spec.ts"
]
} }

View File

@ -1,6 +1,153 @@
{ {
"openapi": "3.0.0", "openapi": "3.0.0",
"paths": { "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": { "/user": {
"get": { "get": {
"operationId": "getAllUsers", "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": { "/asset/upload": {
"post": { "post": {
"operationId": "uploadFile", "operationId": "uploadFile",
@ -2825,6 +2825,63 @@
} }
}, },
"schemas": { "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": { "UserResponseDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -2969,63 +3026,6 @@
"profileImagePath" "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": { "AssetFileUploadDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View 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[]>;
}

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

View File

@ -1,21 +1,22 @@
import { UserEntity } from '@app/infra'; import { UserEntity } from '@app/infra';
import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt'; import { AuthUserDto, ICryptoRepository } from '../auth';
import { randomBytes } from 'node:crypto';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { IKeyRepository } from './api-key.repository'; import { IKeyRepository } from './api-key.repository';
import { APIKeyCreateDto } from './dto/api-key-create.dto'; import { APIKeyCreateDto } from './dto/api-key-create.dto';
import { APIKeyCreateResponseDto } from './repsonse-dto/api-key-create-response.dto'; import { APIKeyCreateResponseDto } from './response-dto/api-key-create-response.dto';
import { APIKeyResponseDto, mapKey } from './repsonse-dto/api-key-response.dto'; import { APIKeyResponseDto, mapKey } from './response-dto/api-key-response.dto';
@Injectable() @Injectable()
export class APIKeyService { 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> { 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({ const entity = await this.repository.create({
key: await hash(key, 10), key: await this.crypto.hash(key, 10),
name: dto.name || 'API Key', name: dto.name || 'API Key',
userId: authUser.id, userId: authUser.id,
}); });
@ -58,14 +59,22 @@ export class APIKeyService {
return keys.map(mapKey); 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, key] = Buffer.from(token, 'base64').toString('utf8').split(':');
const id = Number(_id); const id = Number(_id);
if (id && key) { if (id && key) {
const entity = await this.repository.getKey(id); const entity = await this.repository.getKey(id);
if (entity?.user && entity?.key && compareSync(key, entity.key)) { if (entity?.user && entity?.key && this.crypto.compareSync(key, entity.key)) {
return entity.user as UserEntity; const user = entity.user as UserEntity;
return {
id: user.id,
email: user.email,
isAdmin: user.isAdmin,
isPublicUser: false,
isAllowUpload: true,
};
} }
} }

View File

@ -0,0 +1,2 @@
export * from './api-key-create.dto';
export * from './api-key-update.dto';

View File

@ -0,0 +1,4 @@
export * from './api-key.repository';
export * from './api-key.service';
export * from './dto';
export * from './response-dto';

View File

@ -0,0 +1,2 @@
export * from './api-key-create-response.dto';
export * from './api-key-response.dto';

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

View File

@ -1 +1,2 @@
export * from './crypto.repository';
export * from './dto'; export * from './dto';

View File

@ -1,8 +1,10 @@
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common'; import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
import { APIKeyService } from './api-key';
import { UserService } from './user'; import { UserService } from './user';
const providers: Provider[] = [ const providers: Provider[] = [
// //
APIKeyService,
UserService, UserService,
]; ];

View File

@ -1,3 +1,4 @@
export * from './api-key';
export * from './auth'; export * from './auth';
export * from './domain.module'; export * from './domain.module';
export * from './user'; export * from './user';

View File

@ -1,10 +1,11 @@
import { IUserRepository } from '@app/domain';
import { UserEntity } from '@app/infra'; import { UserEntity } from '@app/infra';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { IUserRepository } from '@app/domain';
import { when } from 'jest-when'; 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 { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service';
const adminUserAuth: AuthUserDto = Object.freeze({ const adminUserAuth: AuthUserDto = Object.freeze({
id: 'admin_id', id: 'admin_id',
@ -73,28 +74,18 @@ const adminUserResponse = Object.freeze({
createdAt: '2021-01-01', createdAt: '2021-01-01',
}); });
describe('UserService', () => { describe(UserService.name, () => {
let sut: UserService; let sut: UserService;
let userRepositoryMock: jest.Mocked<IUserRepository>; let userRepositoryMock: jest.Mocked<IUserRepository>;
beforeEach(() => { beforeEach(async () => {
userRepositoryMock = { userRepositoryMock = newUserRepositoryMock();
get: jest.fn(), sut = new UserService(userRepositoryMock);
getAdmin: jest.fn(),
getByEmail: jest.fn(),
getByOAuthId: jest.fn(),
getList: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser); when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser);
when(userRepositoryMock.get).calledWith(adminUser.id, undefined).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).mockResolvedValue(immichUser);
when(userRepositoryMock.get).calledWith(immichUser.id, undefined).mockResolvedValue(immichUser); when(userRepositoryMock.get).calledWith(immichUser.id, undefined).mockResolvedValue(immichUser);
sut = new UserService(userRepositoryMock);
}); });
describe('getAllUsers', () => { describe('getAllUsers', () => {
@ -285,9 +276,7 @@ describe('UserService', () => {
describe('deleteUser', () => { describe('deleteUser', () => {
it('cannot delete admin user', async () => { it('cannot delete admin user', async () => {
const result = sut.deleteUser(adminUserAuth, adminUserAuth.id); await expect(sut.deleteUser(adminUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException);
await expect(result).rejects.toBeInstanceOf(ForbiddenException);
}); });
it('should require the auth user be an admin', async () => { it('should require the auth user be an admin', async () => {

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

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

View 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: [],
}),
};

View File

@ -0,0 +1,4 @@
export * from './api-key.repository.mock';
export * from './crypto.repository.mock';
export * from './fixtures';
export * from './user.repository.mock';

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

View 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,
};

View File

@ -1,22 +1,8 @@
import { APIKeyEntity } from '@app/infra'; import { IKeyRepository } from '@app/domain';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { APIKeyEntity } from '../entities';
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[]>;
}
@Injectable() @Injectable()
export class APIKeyRepository implements IKeyRepository { export class APIKeyRepository implements IKeyRepository {

View File

@ -1 +1,2 @@
export * from './api-key.repository';
export * from './user.repository'; export * from './user.repository';

View File

@ -1,11 +1,15 @@
import { ICryptoRepository, IKeyRepository, IUserRepository } from '@app/domain';
import { databaseConfig, UserEntity } from '@app/infra'; import { databaseConfig, UserEntity } from '@app/infra';
import { IUserRepository } from '@app/domain';
import { Global, Module, Provider } from '@nestjs/common'; import { Global, Module, Provider } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; 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[] = [ const providers: Provider[] = [
// //
{ provide: ICryptoRepository, useValue: cryptoRepository },
{ provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IUserRepository, useClass: UserRepository }, { provide: IUserRepository, useClass: UserRepository },
]; ];
@ -14,7 +18,7 @@ const providers: Provider[] = [
imports: [ imports: [
// //
TypeOrmModule.forRoot(databaseConfig), TypeOrmModule.forRoot(databaseConfig),
TypeOrmModule.forFeature([UserEntity]), TypeOrmModule.forFeature([APIKeyEntity, UserEntity]),
], ],
providers: [...providers], providers: [...providers],
exports: [...providers], exports: [...providers],

View File

@ -145,10 +145,10 @@
"statements": 20 "statements": 20
}, },
"./libs/domain/": { "./libs/domain/": {
"branches": 60, "branches": 70,
"functions": 80, "functions": 85,
"lines": 80, "lines": 85,
"statements": 80 "statements": 85
} }
}, },
"testEnvironment": "node", "testEnvironment": "node",