feat(api): permitir If-Match en PATCH del proxy para concurrencia optimista

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 21:57:01 -05:00
1 parent d49b87a2ce
commit c66ad96e4a
3 files changed
+19 -2

No files matched your search

+1 -1
View File
@@ -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`).
+7
View File
@@ -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',
+11 -1
View File
@@ -49,7 +49,7 @@ export type GetQuery = z.infer<typeof getQuerySchema>;
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<string, { name: string; methods: string[]; valid: (v: string) => boolean }> = {
'b1s-replacecollectionsonpatch': {
name: 'B1S-ReplaceCollectionsOnPatch',
@@ -64,11 +64,21 @@ const B1S_HEADERS: Record<string, { name: string; methods: string[]; valid: (v:
},
};
/** Etag de concurrencia optimista (`odata.etag` del documento leído, ej. `W/"1A2B"`): solo al modificar. */
const isEtag = (v: string) => v.length >= 1 && v.length <= 200 && noControlChars(v);
export function pickSlHeaders(
incoming: Record<string, string | string[] | undefined>,
method: string,
): Record<string, string> {
const out: Record<string, string> = {};
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];