feat(security): harden the API, trust Hiboutik for deletions, lock syncs (#3)
This commit is contained in:
66
src/hiboutik/hiboutik.client.spec.ts
Normal file
66
src/hiboutik/hiboutik.client.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user