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:
1 parent
d49b87a2ce
commit
c66ad96e4a
3 files changed
+19
-2
No files matched your search
+1
-1
@@ -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?$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. |
|
| `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`). |
|
| `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:
|
Reglas:
|
||||||
- **Solo recursos registrados** en `src/sl/sl-resources.ts`; el resto es 404. Nunca registrar `EmployeesInfo` (contiene `U_WEB_PASS`).
|
- **Solo recursos registrados** en `src/sl/sl-resources.ts`; el resto es 404. Nunca registrar `EmployeesInfo` (contiene `U_WEB_PASS`).
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ describe('entityPath', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('pickSlHeaders', () => {
|
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', () => {
|
it('deja pasar solo las B1S-* permitidas para el método', () => {
|
||||||
expect(pickSlHeaders({ 'b1s-replacecollectionsonpatch': 'true', host: 'x', cookie: 'y' }, 'PATCH')).toEqual({
|
expect(pickSlHeaders({ 'b1s-replacecollectionsonpatch': 'true', host: 'x', cookie: 'y' }, 'PATCH')).toEqual({
|
||||||
'B1S-ReplaceCollectionsOnPatch': 'true',
|
'B1S-ReplaceCollectionsOnPatch': 'true',
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export type GetQuery = z.infer<typeof getQuerySchema>;
|
|||||||
|
|
||||||
export const bodySchema = z.record(z.string(), z.unknown());
|
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 }> = {
|
const B1S_HEADERS: Record<string, { name: string; methods: string[]; valid: (v: string) => boolean }> = {
|
||||||
'b1s-replacecollectionsonpatch': {
|
'b1s-replacecollectionsonpatch': {
|
||||||
name: '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(
|
export function pickSlHeaders(
|
||||||
incoming: Record<string, string | string[] | undefined>,
|
incoming: Record<string, string | string[] | undefined>,
|
||||||
method: string,
|
method: string,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const out: 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)) {
|
for (const [rawName, value] of Object.entries(incoming)) {
|
||||||
if (!rawName.startsWith('b1s-')) continue;
|
if (!rawName.startsWith('b1s-')) continue;
|
||||||
const rule = B1S_HEADERS[rawName];
|
const rule = B1S_HEADERS[rawName];
|
||||||
|
|||||||
Reference in new issue
Block a user