diff --git a/apps/api/README.md b/apps/api/README.md index 5e970fa..58c0ef6 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -64,7 +64,7 @@ El frontend arma los parámetros OData y el cuerpo; el backend solo valida y eje | `GET /api/sl/:recurso?$select&$filter&$orderby&$top&$skip` | Lista. Devuelve `value` y `odata.count`. `$top` por defecto 50, máx. 500. | | `GET /api/sl/:recurso/:clave?$select` | Un registro. La clave se valida y escapa según su tipo. | | `POST /api/sl/:recurso` | Crea; devuelve lo que responde SAP (ej. `DocEntry`, `DocNum`). | -| `PATCH /api/sl/:recurso/:clave` | Modifica (204). | +| `PATCH /api/sl/:recurso/:clave` | Modifica (204). Acepta `If-Match` con el `odata.etag` leído (concurrencia optimista: SAP responde 412 si el documento cambió). | Reglas: - **Solo recursos registrados** en `src/sl/sl-resources.ts`; el resto es 404. Nunca registrar `EmployeesInfo` (contiene `U_WEB_PASS`). diff --git a/apps/api/src/sl/sl-request.spec.ts b/apps/api/src/sl/sl-request.spec.ts index 5c4096f..3b3c891 100644 --- a/apps/api/src/sl/sl-request.spec.ts +++ b/apps/api/src/sl/sl-request.spec.ts @@ -71,6 +71,13 @@ describe('entityPath', () => { }); describe('pickSlHeaders', () => { + it('deja pasar If-Match solo al modificar', () => { + expect(pickSlHeaders({ 'if-match': ' W/"1A2B" ' }, 'PATCH')).toEqual({ 'If-Match': 'W/"1A2B"' }); + expect(() => pickSlHeaders({ 'if-match': 'W/"1"' }, 'POST')).toThrow(BadRequestException); + expect(() => pickSlHeaders({ 'if-match': 'a\r\nb' }, 'PATCH')).toThrow(BadRequestException); + expect(() => pickSlHeaders({ 'if-match': '' }, 'PATCH')).toThrow(BadRequestException); + }); + it('deja pasar solo las B1S-* permitidas para el método', () => { expect(pickSlHeaders({ 'b1s-replacecollectionsonpatch': 'true', host: 'x', cookie: 'y' }, 'PATCH')).toEqual({ 'B1S-ReplaceCollectionsOnPatch': 'true', diff --git a/apps/api/src/sl/sl-request.ts b/apps/api/src/sl/sl-request.ts index 3bcf431..c7f8979 100644 --- a/apps/api/src/sl/sl-request.ts +++ b/apps/api/src/sl/sl-request.ts @@ -49,7 +49,7 @@ export type GetQuery = z.infer; export const bodySchema = z.record(z.string(), z.unknown()); -/** Cabeceras B1S-* que el frontend puede decidir; cualquier otra B1S-* se rechaza. */ +/** Cabeceras B1S-* que el frontend puede decidir (más `If-Match` al modificar); cualquier otra B1S-* se rechaza. */ const B1S_HEADERS: Record boolean }> = { 'b1s-replacecollectionsonpatch': { name: 'B1S-ReplaceCollectionsOnPatch', @@ -64,11 +64,21 @@ const B1S_HEADERS: Record v.length >= 1 && v.length <= 200 && noControlChars(v); + export function pickSlHeaders( incoming: Record, method: string, ): Record { const out: Record = {}; + const ifMatch = incoming['if-match']; + if (ifMatch !== undefined) { + if (method !== 'PATCH' || typeof ifMatch !== 'string' || !isEtag(ifMatch.trim())) { + throw new BadRequestException('Cabecera no permitida o inválida: if-match'); + } + out['If-Match'] = ifMatch.trim(); + } for (const [rawName, value] of Object.entries(incoming)) { if (!rawName.startsWith('b1s-')) continue; const rule = B1S_HEADERS[rawName];