feat(security): harden the API, trust Hiboutik for deletions, lock syncs (#3)

This commit is contained in:
2026-11-08 09:36:50 +00:00
parent 6121b9c9bf
commit 04960379e8
31 changed files with 923 additions and 499 deletions

View File

@@ -0,0 +1,66 @@
import { of, throwError } from 'rxjs';
import { HiboutikClient } from './hiboutik.client';
describe('HiboutikClient', () => {
const httpService = { get: jest.fn() };
const configService = { get: jest.fn((key: string) => (key === 'HIBOUTIK_BASE_URL' ? 'https://shop.hiboutik.test/api' : 'x')) };
let client: HiboutikClient;
const answer = (data: unknown) => of({ data });
const page = (size: number) => Array.from({ length: size }, (_, index) => ({ id: index }));
beforeEach(() => {
jest.clearAllMocks();
client = new HiboutikClient(httpService as never, configService as never);
});
describe('getList', () => {
it('returns the list of a 2xx answer, empty or not', async () => {
httpService.get.mockReturnValueOnce(answer([{ id: 1 }])).mockReturnValueOnce(answer([]));
await expect(client.getList('/x')).resolves.toEqual([{ id: 1 }]);
await expect(client.getList('/x')).resolves.toEqual([]);
});
it('treats a 2xx answer without body as an empty list', async () => {
httpService.get.mockReturnValueOnce(answer('')).mockReturnValueOnce(answer(null));
await expect(client.getList('/x')).resolves.toEqual([]);
await expect(client.getList('/x')).resolves.toEqual([]);
});
it('rejects a 2xx answer that is not a list, without leaking its content', async () => {
httpService.get.mockReturnValueOnce(answer({ error: 'secret details' }));
const result = client.getList('/x');
await expect(result).rejects.toThrow('a list was expected');
await expect(result).rejects.not.toThrow('secret details');
});
it('lets HTTP errors through', async () => {
httpService.get.mockReturnValueOnce(throwError(() => new Error('Request failed with status code 500')));
await expect(client.getList('/x')).rejects.toThrow('500');
});
});
describe('getAllPages', () => {
it('walks the pages until a short one', async () => {
httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(answer(page(10)));
const results = await client.getAllPages('/x');
expect(results).toHaveLength(260);
expect(httpService.get).toHaveBeenCalledTimes(2);
});
it('fails as a whole when a later page fails, instead of returning a truncated list', async () => {
httpService.get
.mockReturnValueOnce(answer(page(250)))
.mockReturnValueOnce(throwError(() => new Error('Request failed with status code 502')));
await expect(client.getAllPages('/x')).rejects.toThrow('502');
});
});
});

View File

@@ -33,6 +33,17 @@ export class HiboutikClient {
return response.data;
}
/**
* GET on a list endpoint. HTTP errors (4xx/5xx) throw, a 2xx answer without body is an empty list, and any other
* non-list body throws: an empty list triggers deletions in the sync, so it must never be inferred from a malformed answer.
*/
async getList<T>(path: string, params?: Record<string, unknown>): Promise<T[]> {
const data = await this.get<T[] | '' | null | undefined>(path, params);
if (data === '' || data == null) return [];
if (!Array.isArray(data)) throw new Error(`Unexpected answer from Hiboutik ${path}: a list was expected`);
return data;
}
/**
* Fetches every page of a paginated endpoint (`p` param, starting at 1) and concatenates the results.
* Only for paginated endpoints: on a non-paginated one, a full first page would loop forever on the same results.
@@ -42,8 +53,8 @@ export class HiboutikClient {
let page = 1;
while (true) {
const pageData = await this.get<T[]>(path, { ...baseParams, p: page });
if (!pageData || pageData.length === 0) break;
const pageData = await this.getList<T>(path, { ...baseParams, p: page });
if (pageData.length === 0) break;
results.push(...pageData);
// A page smaller than the page size is the last one: no need to request an empty page
if (pageData.length < HIBOUTIK_PAGE_SIZE) break;

View File

@@ -9,7 +9,7 @@ export class StockHiboutikRepository {
/** Stock of every product and size, all warehouses combined. Not paginated. */
async getStocks(): Promise<StockHiboutikDto[]> {
const stocks = await this.hiboutikClient.get<StockHiboutikDto[]>('/stock_available/all_wh', {});
const stocks = await this.hiboutikClient.getList<StockHiboutikDto>('/stock_available/all_wh', {});
// Hiboutik may return ids as strings: normalize them so strict comparisons work downstream
return stocks.map((stock) => ({
...stock,

View File

@@ -9,7 +9,7 @@ export class SupplierHiboutikRepository {
/** `/suppliers` is not paginated: everything comes in one call. */
async getSuppliers(): Promise<SupplierHiboutikDto[]> {
return this.hiboutikClient.get<SupplierHiboutikDto[]>('/suppliers', {
return this.hiboutikClient.getList<SupplierHiboutikDto>('/suppliers', {
order_by: 'supplier_id',
sort: 'ASC',
});