feat: secure custom field value routing
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||||
import { CustomFieldsService } from './custom-fields.service';
|
import { CustomFieldsService } from './custom-fields.service';
|
||||||
import { CreateCustomFieldValueDto, UpdateCustomFieldValueDto } from '../shared/dto/custom-field.dto';
|
import {
|
||||||
|
CreateCustomFieldValueDto,
|
||||||
|
UpdateCustomFieldValueDto,
|
||||||
|
CustomFieldEntityParamsDto,
|
||||||
|
UpsertCustomFieldValueDto,
|
||||||
|
} from '../shared/dto/custom-field.dto';
|
||||||
|
|
||||||
@Controller('custom-fields')
|
@Controller('custom-fields')
|
||||||
export class CustomFieldsController {
|
export class CustomFieldsController {
|
||||||
@@ -12,11 +17,11 @@ export class CustomFieldsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('values/:entityType/:entityId')
|
@Get('values/:entityType/:entityId')
|
||||||
findCustomFieldValuesByEntity(
|
findCustomFieldValuesByEntity(@Param() params: CustomFieldEntityParamsDto) {
|
||||||
@Param('entityType') entityType: string,
|
return this.customFieldsService.findCustomFieldValuesByEntity(
|
||||||
@Param('entityId') entityId: string,
|
params.entityType,
|
||||||
) {
|
params.entityId,
|
||||||
return this.customFieldsService.findCustomFieldValuesByEntity(entityType, entityId);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('values/:id')
|
@Get('values/:id')
|
||||||
@@ -38,12 +43,7 @@ export class CustomFieldsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('values/upsert')
|
@Post('values/upsert')
|
||||||
upsertCustomFieldValue(@Body() body: {
|
upsertCustomFieldValue(@Body() body: UpsertCustomFieldValueDto) {
|
||||||
customFieldId: string;
|
|
||||||
entityType: string;
|
|
||||||
entityId: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return this.customFieldsService.upsertCustomFieldValue(
|
return this.customFieldsService.upsertCustomFieldValue(
|
||||||
body.customFieldId,
|
body.customFieldId,
|
||||||
body.entityType,
|
body.entityType,
|
||||||
|
|||||||
103
src/custom-fields/custom-fields.service.spec.ts
Normal file
103
src/custom-fields/custom-fields.service.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { CustomFieldsService } from './custom-fields.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CustomFieldEntityType } from '../shared/dto/custom-field.dto';
|
||||||
|
|
||||||
|
describe('CustomFieldsService', () => {
|
||||||
|
let service: CustomFieldsService;
|
||||||
|
let prisma: {
|
||||||
|
machine: { findUnique: jest.Mock };
|
||||||
|
composant: { findUnique: jest.Mock };
|
||||||
|
piece: { findUnique: jest.Mock };
|
||||||
|
customField: { findFirst: jest.Mock };
|
||||||
|
customFieldValue: {
|
||||||
|
findFirst: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
machine: { findUnique: jest.fn() },
|
||||||
|
composant: { findUnique: jest.fn() },
|
||||||
|
piece: { findUnique: jest.fn() },
|
||||||
|
customField: { findFirst: jest.fn() },
|
||||||
|
customFieldValue: {
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new CustomFieldsService(prisma as unknown as PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upsertCustomFieldValue', () => {
|
||||||
|
it('should reject when the custom field is not allowed for the machine', async () => {
|
||||||
|
prisma.machine.findUnique.mockResolvedValue({ typeMachineId: 'type-1' });
|
||||||
|
prisma.customField.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.upsertCustomFieldValue(
|
||||||
|
'custom-field-1',
|
||||||
|
CustomFieldEntityType.MACHINE,
|
||||||
|
'machine-1',
|
||||||
|
'value',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(prisma.customField.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: 'custom-field-1',
|
||||||
|
typeMachineId: 'type-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(prisma.customFieldValue.findFirst).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.customFieldValue.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.customFieldValue.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update an existing value when the custom field is allowed', async () => {
|
||||||
|
prisma.machine.findUnique.mockResolvedValue({ typeMachineId: 'type-1' });
|
||||||
|
prisma.customField.findFirst.mockResolvedValue({ id: 'custom-field-1' });
|
||||||
|
prisma.customFieldValue.findFirst.mockResolvedValue({ id: 'value-1' });
|
||||||
|
prisma.customFieldValue.update.mockResolvedValue({
|
||||||
|
id: 'value-1',
|
||||||
|
value: 'updated',
|
||||||
|
customField: { id: 'custom-field-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.upsertCustomFieldValue(
|
||||||
|
'custom-field-1',
|
||||||
|
CustomFieldEntityType.MACHINE,
|
||||||
|
'machine-1',
|
||||||
|
'updated',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prisma.customField.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: 'custom-field-1',
|
||||||
|
typeMachineId: 'type-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(prisma.customFieldValue.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
customFieldId: 'custom-field-1',
|
||||||
|
machineId: 'machine-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(prisma.customFieldValue.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'value-1' },
|
||||||
|
data: { value: 'updated' },
|
||||||
|
include: { customField: true },
|
||||||
|
});
|
||||||
|
expect(prisma.customFieldValue.create).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 'value-1',
|
||||||
|
value: 'updated',
|
||||||
|
customField: { id: 'custom-field-1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateCustomFieldValueDto, UpdateCustomFieldValueDto } from '../shared/dto/custom-field.dto';
|
import {
|
||||||
|
CreateCustomFieldValueDto,
|
||||||
|
UpdateCustomFieldValueDto,
|
||||||
|
CustomFieldEntityType,
|
||||||
|
} from '../shared/dto/custom-field.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomFieldsService {
|
export class CustomFieldsService {
|
||||||
@@ -17,9 +21,99 @@ export class CustomFieldsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trouver toutes les valeurs de champs personnalisés pour une entité
|
// Trouver toutes les valeurs de champs personnalisés pour une entité
|
||||||
async findCustomFieldValuesByEntity(entityType: string, entityId: string) {
|
private getCustomFieldValueKey(entityType: CustomFieldEntityType) {
|
||||||
|
switch (entityType) {
|
||||||
|
case CustomFieldEntityType.MACHINE:
|
||||||
|
return 'machineId' as const;
|
||||||
|
case CustomFieldEntityType.COMPOSANT:
|
||||||
|
return 'composantId' as const;
|
||||||
|
case CustomFieldEntityType.PIECE:
|
||||||
|
return 'pieceId' as const;
|
||||||
|
default:
|
||||||
|
throw new BadRequestException('Type d\'entité de champ personnalisé invalide.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveEntityContext(entityType: CustomFieldEntityType, entityId: string) {
|
||||||
|
switch (entityType) {
|
||||||
|
case CustomFieldEntityType.MACHINE: {
|
||||||
|
const machine = await this.prisma.machine.findUnique({
|
||||||
|
where: { id: entityId },
|
||||||
|
select: { typeMachineId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!machine) {
|
||||||
|
throw new NotFoundException('Machine introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!machine.typeMachineId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'La machine ne possède pas de type associé pour les champs personnalisés.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
typeId: machine.typeMachineId,
|
||||||
|
customFieldTypeField: 'typeMachineId' as const,
|
||||||
|
valueKey: 'machineId' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CustomFieldEntityType.COMPOSANT: {
|
||||||
|
const composant = await this.prisma.composant.findUnique({
|
||||||
|
where: { id: entityId },
|
||||||
|
select: { typeComposantId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!composant) {
|
||||||
|
throw new NotFoundException('Composant introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!composant.typeComposantId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Le composant ne possède pas de type associé pour les champs personnalisés.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
typeId: composant.typeComposantId,
|
||||||
|
customFieldTypeField: 'typeComposantId' as const,
|
||||||
|
valueKey: 'composantId' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case CustomFieldEntityType.PIECE: {
|
||||||
|
const piece = await this.prisma.piece.findUnique({
|
||||||
|
where: { id: entityId },
|
||||||
|
select: { typePieceId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!piece) {
|
||||||
|
throw new NotFoundException('Pièce introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!piece.typePieceId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'La pièce ne possède pas de type associé pour les champs personnalisés.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
typeId: piece.typePieceId,
|
||||||
|
customFieldTypeField: 'typePieceId' as const,
|
||||||
|
valueKey: 'pieceId' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new BadRequestException('Type d\'entité de champ personnalisé invalide.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCustomFieldValuesByEntity(
|
||||||
|
entityType: CustomFieldEntityType,
|
||||||
|
entityId: string,
|
||||||
|
) {
|
||||||
|
const key = this.getCustomFieldValueKey(entityType);
|
||||||
const whereClause = {
|
const whereClause = {
|
||||||
[entityType + 'Id']: entityId,
|
[key]: entityId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.prisma.customFieldValue.findMany({
|
return this.prisma.customFieldValue.findMany({
|
||||||
@@ -59,12 +153,35 @@ export class CustomFieldsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Créer ou mettre à jour une valeur de champ personnalisé
|
// Créer ou mettre à jour une valeur de champ personnalisé
|
||||||
async upsertCustomFieldValue(customFieldId: string, entityType: string, entityId: string, value: string) {
|
async upsertCustomFieldValue(
|
||||||
|
customFieldId: string,
|
||||||
|
entityType: CustomFieldEntityType,
|
||||||
|
entityId: string,
|
||||||
|
value: string,
|
||||||
|
) {
|
||||||
|
const { typeId, customFieldTypeField, valueKey } = await this.resolveEntityContext(
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowedCustomField = await this.prisma.customField.findFirst({
|
||||||
|
where: {
|
||||||
|
id: customFieldId,
|
||||||
|
[customFieldTypeField]: typeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allowedCustomField) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Le champ personnalisé n\'est pas autorisé pour cette entité.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// D'abord, essayer de trouver une valeur existante
|
// D'abord, essayer de trouver une valeur existante
|
||||||
const existingValue = await this.prisma.customFieldValue.findFirst({
|
const existingValue = await this.prisma.customFieldValue.findFirst({
|
||||||
where: {
|
where: {
|
||||||
customFieldId,
|
customFieldId,
|
||||||
[entityType + 'Id']: entityId,
|
[valueKey]: entityId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +200,7 @@ export class CustomFieldsService {
|
|||||||
data: {
|
data: {
|
||||||
customFieldId,
|
customFieldId,
|
||||||
value,
|
value,
|
||||||
[entityType + 'Id']: entityId,
|
[valueKey]: entityId,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
customField: true,
|
customField: true,
|
||||||
|
|||||||
@@ -1,4 +1,36 @@
|
|||||||
import { IsString, IsOptional, IsNotEmpty } from 'class-validator';
|
import { IsString, IsOptional, IsNotEmpty, IsEnum } from 'class-validator';
|
||||||
|
|
||||||
|
export enum CustomFieldEntityType {
|
||||||
|
MACHINE = 'machine',
|
||||||
|
COMPOSANT = 'composant',
|
||||||
|
PIECE = 'piece',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CustomFieldEntityParamsDto {
|
||||||
|
@IsEnum(CustomFieldEntityType)
|
||||||
|
entityType: CustomFieldEntityType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
entityId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpsertCustomFieldValueDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
customFieldId: string;
|
||||||
|
|
||||||
|
@IsEnum(CustomFieldEntityType)
|
||||||
|
entityType: CustomFieldEntityType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
entityId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateCustomFieldValueDto {
|
export class CreateCustomFieldValueDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
Reference in New Issue
Block a user