feat: secure custom field value routing

This commit is contained in:
MatthieuTD
2025-09-22 10:20:49 +02:00
parent b6ca9ae54b
commit c8cc15c907
4 changed files with 274 additions and 22 deletions

View File

@@ -1,6 +1,11 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
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')
export class CustomFieldsController {
@@ -12,11 +17,11 @@ export class CustomFieldsController {
}
@Get('values/:entityType/:entityId')
findCustomFieldValuesByEntity(
@Param('entityType') entityType: string,
@Param('entityId') entityId: string,
) {
return this.customFieldsService.findCustomFieldValuesByEntity(entityType, entityId);
findCustomFieldValuesByEntity(@Param() params: CustomFieldEntityParamsDto) {
return this.customFieldsService.findCustomFieldValuesByEntity(
params.entityType,
params.entityId,
);
}
@Get('values/:id')
@@ -38,12 +43,7 @@ export class CustomFieldsController {
}
@Post('values/upsert')
upsertCustomFieldValue(@Body() body: {
customFieldId: string;
entityType: string;
entityId: string;
value: string;
}) {
upsertCustomFieldValue(@Body() body: UpsertCustomFieldValueDto) {
return this.customFieldsService.upsertCustomFieldValue(
body.customFieldId,
body.entityType,
@@ -51,4 +51,4 @@ export class CustomFieldsController {
body.value,
);
}
}
}

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

View File

@@ -1,6 +1,10 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
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()
export class CustomFieldsService {
@@ -17,9 +21,99 @@ export class CustomFieldsService {
}
// 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 = {
[entityType + 'Id']: entityId,
[key]: entityId,
};
return this.prisma.customFieldValue.findMany({
@@ -59,12 +153,35 @@ export class CustomFieldsService {
}
// 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
const existingValue = await this.prisma.customFieldValue.findFirst({
where: {
customFieldId,
[entityType + 'Id']: entityId,
[valueKey]: entityId,
},
});
@@ -83,7 +200,7 @@ export class CustomFieldsService {
data: {
customFieldId,
value,
[entityType + 'Id']: entityId,
[valueKey]: entityId,
},
include: {
customField: true,
@@ -91,4 +208,4 @@ export class CustomFieldsService {
});
}
}
}
}

View File

@@ -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 {
@IsString()