create-tigra 2.1.4 → 2.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
| Redis | Caching (frequently accessed data, lookups), rate limiting, background task signaling |
|
|
15
15
|
| Zod | Runtime validation and type inference |
|
|
16
16
|
| JWT (HS256/RS256) | Auth — minimal token payload (id, role), role-based access control |
|
|
17
|
+
| axios | Outbound HTTP client (external API calls) |
|
|
17
18
|
| PM2 + Nginx | Production deployment (cluster mode) |
|
|
18
19
|
| Jest / Vitest | Testing — deterministic, mock external APIs |
|
|
19
20
|
|
|
@@ -148,3 +149,66 @@ Collection Root (Bearer Token: {{accessToken}})
|
|
|
148
149
|
- Never interpolate raw values into SQL — always use Prisma query builder
|
|
149
150
|
- Rate limit public endpoints
|
|
150
151
|
- Avoid N+1 queries — prefer joins or batched queries
|
|
152
|
+
|
|
153
|
+
## Outbound HTTP
|
|
154
|
+
|
|
155
|
+
Use `httpClient` from `src/libs/http.ts` for ALL outbound HTTP calls. Never use native `fetch`, `node:http`, or a new `axios.create()` at the call site — the singleton provides consistent logging, 30s timeout, and automatic error conversion.
|
|
156
|
+
|
|
157
|
+
### Import
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { httpClient } from '@libs/http.js';
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Wrapping pattern (service layer)
|
|
164
|
+
|
|
165
|
+
Outbound HTTP calls belong in the **service layer**. The interceptor converts `AxiosError` to `InternalError` automatically — services only deal with `AppError` subclasses:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
class WeatherService {
|
|
169
|
+
async getCurrentWeather(city: string): Promise<WeatherData> {
|
|
170
|
+
const response = await httpClient.get<WeatherApiResponse>(
|
|
171
|
+
`https://api.weather.example.com/v1/current`,
|
|
172
|
+
{ params: { q: city } },
|
|
173
|
+
);
|
|
174
|
+
return response.data.result;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export const weatherService = new WeatherService();
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Auth headers
|
|
181
|
+
|
|
182
|
+
Do NOT add auth headers to the `httpClient` singleton — it is shared. Pass per-request headers at the call site:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
await httpClient.post(url, body, {
|
|
186
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Fine-grained error mapping
|
|
191
|
+
|
|
192
|
+
The interceptor always throws `InternalError`. If you need a more specific error (e.g., a 404 from an external API should surface as `NotFoundError`), catch and rethrow:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
try {
|
|
196
|
+
return await httpClient.get(url);
|
|
197
|
+
} catch {
|
|
198
|
+
throw new NotFoundError('External resource not found');
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Fixed-base-URL services
|
|
203
|
+
|
|
204
|
+
If a service always calls the same external API, create a private derived instance **inside the service file only** (never exported):
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
const apiClient = axios.create({ ...httpClient.defaults, baseURL: 'https://api.example.com/v2' });
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Rules
|
|
211
|
+
|
|
212
|
+
- Always use `httpClient` — never `fetch`, `node:http`, or inline `axios.create()`.
|
|
213
|
+
- Never add auth headers, cookies, or credentials to the singleton itself.
|
|
214
|
+
- Never log response bodies (may contain PII or secrets).
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import type { InternalAxiosRequestConfig, AxiosResponse } from 'axios';
|
|
3
|
+
import { InternalError } from '@shared/errors/errors.js';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Captured interceptor handlers (populated when http.ts is first imported)
|
|
7
|
+
// vi.hoisted() ensures these are available inside the vi.mock factory
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const captured = vi.hoisted(() => ({
|
|
10
|
+
requestFulfilled: null as null | ((c: InternalAxiosRequestConfig) => InternalAxiosRequestConfig),
|
|
11
|
+
requestRejected: null as null | ((e: unknown) => never),
|
|
12
|
+
responseFulfilled: null as null | ((r: AxiosResponse) => AxiosResponse),
|
|
13
|
+
responseRejected: null as null | ((e: unknown) => never),
|
|
14
|
+
mockIsAxiosError: vi.fn<(e: unknown) => boolean>(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Mock axios — captures interceptor handlers at registration time
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
vi.mock('axios', () => ({
|
|
21
|
+
default: {
|
|
22
|
+
create: vi.fn(() => ({
|
|
23
|
+
interceptors: {
|
|
24
|
+
request: {
|
|
25
|
+
use: vi.fn((onFulfilled: any, onRejected: any) => {
|
|
26
|
+
captured.requestFulfilled = onFulfilled;
|
|
27
|
+
captured.requestRejected = onRejected;
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
response: {
|
|
31
|
+
use: vi.fn((onFulfilled: any, onRejected: any) => {
|
|
32
|
+
captured.responseFulfilled = onFulfilled;
|
|
33
|
+
captured.responseRejected = onRejected;
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
})),
|
|
38
|
+
},
|
|
39
|
+
isAxiosError: captured.mockIsAxiosError,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Mock logger — prevents real Pino I/O in tests
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
vi.mock('@libs/logger.js', () => ({
|
|
46
|
+
logger: {
|
|
47
|
+
debug: vi.fn(),
|
|
48
|
+
error: vi.fn(),
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// Import AFTER mocks are registered
|
|
53
|
+
import { httpClient } from '../http.js';
|
|
54
|
+
import { logger } from '@libs/logger.js';
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
function makeConfig(overrides: Partial<InternalAxiosRequestConfig> = {}): InternalAxiosRequestConfig {
|
|
60
|
+
return { headers: {} as InternalAxiosRequestConfig['headers'], ...overrides };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeResponse(overrides: Partial<AxiosResponse> = {}): AxiosResponse {
|
|
64
|
+
return {
|
|
65
|
+
data: {},
|
|
66
|
+
status: 200,
|
|
67
|
+
statusText: 'OK',
|
|
68
|
+
headers: {},
|
|
69
|
+
config: makeConfig({ method: 'get', url: '/test' }),
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Tests
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
describe('httpClient', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
vi.clearAllMocks();
|
|
80
|
+
captured.mockIsAxiosError.mockReturnValue(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// -------------------------------------------------------------------------
|
|
84
|
+
// Module contract
|
|
85
|
+
// -------------------------------------------------------------------------
|
|
86
|
+
describe('module contract', () => {
|
|
87
|
+
it('should be exported as a named export', () => {
|
|
88
|
+
expect(httpClient).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should return the same singleton instance on re-import', async () => {
|
|
92
|
+
const { httpClient: httpClient2 } = await import('../http.js');
|
|
93
|
+
expect(httpClient2).toBe(httpClient);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should have registered exactly one request interceptor', () => {
|
|
97
|
+
expect(captured.requestFulfilled).not.toBeNull();
|
|
98
|
+
expect(captured.requestRejected).not.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should have registered exactly one response interceptor', () => {
|
|
102
|
+
expect(captured.responseFulfilled).not.toBeNull();
|
|
103
|
+
expect(captured.responseRejected).not.toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// -------------------------------------------------------------------------
|
|
108
|
+
// Request interceptor — success path
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
describe('request interceptor (success)', () => {
|
|
111
|
+
it('should return the config unchanged', () => {
|
|
112
|
+
const config = makeConfig({ method: 'get', url: '/users' });
|
|
113
|
+
const result = captured.requestFulfilled!(config);
|
|
114
|
+
expect(result).toBe(config);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should log outbound request at debug level', () => {
|
|
118
|
+
const config = makeConfig({ method: 'get', url: '/users', baseURL: 'https://api.example.com' });
|
|
119
|
+
captured.requestFulfilled!(config);
|
|
120
|
+
expect(logger.debug).toHaveBeenCalledOnce();
|
|
121
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
122
|
+
{ method: 'GET', url: '/users', baseURL: 'https://api.example.com' },
|
|
123
|
+
'[HTTP] Outbound request',
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should uppercase the HTTP method in the log', () => {
|
|
128
|
+
captured.requestFulfilled!(makeConfig({ method: 'post', url: '/items' }));
|
|
129
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({ method: 'POST' }),
|
|
131
|
+
'[HTTP] Outbound request',
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle undefined method without throwing', () => {
|
|
136
|
+
const config = makeConfig({ url: '/noop' });
|
|
137
|
+
expect(() => captured.requestFulfilled!(config)).not.toThrow();
|
|
138
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
139
|
+
expect.objectContaining({ method: undefined }),
|
|
140
|
+
'[HTTP] Outbound request',
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should not call logger.error on the success path', () => {
|
|
145
|
+
captured.requestFulfilled!(makeConfig({ method: 'get', url: '/ok' }));
|
|
146
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
// Request interceptor — error path
|
|
152
|
+
// -------------------------------------------------------------------------
|
|
153
|
+
describe('request interceptor (error)', () => {
|
|
154
|
+
it('should throw an InternalError', () => {
|
|
155
|
+
expect(() => captured.requestRejected!(new Error('setup fail'))).toThrow(InternalError);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should throw with the correct message', () => {
|
|
159
|
+
expect(() => captured.requestRejected!(new Error('x'))).toThrow(
|
|
160
|
+
'Outbound HTTP request could not be constructed',
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should throw InternalError with statusCode 500', () => {
|
|
165
|
+
let thrown: unknown;
|
|
166
|
+
try {
|
|
167
|
+
captured.requestRejected!(new Error('x'));
|
|
168
|
+
} catch (e) {
|
|
169
|
+
thrown = e;
|
|
170
|
+
}
|
|
171
|
+
expect((thrown as InternalError).statusCode).toBe(500);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should throw InternalError with code INTERNAL_ERROR', () => {
|
|
175
|
+
let thrown: unknown;
|
|
176
|
+
try {
|
|
177
|
+
captured.requestRejected!(new Error('x'));
|
|
178
|
+
} catch (e) {
|
|
179
|
+
thrown = e;
|
|
180
|
+
}
|
|
181
|
+
expect((thrown as InternalError).code).toBe('INTERNAL_ERROR');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should log the error before throwing', () => {
|
|
185
|
+
const err = new Error('setup fail');
|
|
186
|
+
try {
|
|
187
|
+
captured.requestRejected!(err);
|
|
188
|
+
} catch {
|
|
189
|
+
// expected
|
|
190
|
+
}
|
|
191
|
+
expect(logger.error).toHaveBeenCalledWith({ err }, '[HTTP] Request setup failed');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should not call logger.debug on the error path', () => {
|
|
195
|
+
try {
|
|
196
|
+
captured.requestRejected!(new Error('x'));
|
|
197
|
+
} catch {
|
|
198
|
+
// expected
|
|
199
|
+
}
|
|
200
|
+
expect(logger.debug).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// -------------------------------------------------------------------------
|
|
205
|
+
// Response interceptor — success path
|
|
206
|
+
// -------------------------------------------------------------------------
|
|
207
|
+
describe('response interceptor (success)', () => {
|
|
208
|
+
it('should return the response unchanged', () => {
|
|
209
|
+
const response = makeResponse({ status: 200, data: { id: 42 } });
|
|
210
|
+
const result = captured.responseFulfilled!(response);
|
|
211
|
+
expect(result).toBe(response);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should log the response at debug level', () => {
|
|
215
|
+
const response = makeResponse({
|
|
216
|
+
status: 201,
|
|
217
|
+
config: makeConfig({ method: 'post', url: '/items' }),
|
|
218
|
+
});
|
|
219
|
+
captured.responseFulfilled!(response);
|
|
220
|
+
expect(logger.debug).toHaveBeenCalledOnce();
|
|
221
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
222
|
+
{ method: 'POST', url: '/items', status: 201 },
|
|
223
|
+
'[HTTP] Outbound response',
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should uppercase the HTTP method in the response log', () => {
|
|
228
|
+
const response = makeResponse({
|
|
229
|
+
config: makeConfig({ method: 'delete', url: '/items/1' }),
|
|
230
|
+
});
|
|
231
|
+
captured.responseFulfilled!(response);
|
|
232
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
233
|
+
expect.objectContaining({ method: 'DELETE' }),
|
|
234
|
+
'[HTTP] Outbound response',
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should not call logger.error on the success path', () => {
|
|
239
|
+
captured.responseFulfilled!(makeResponse());
|
|
240
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// -------------------------------------------------------------------------
|
|
245
|
+
// Response interceptor — AxiosError path
|
|
246
|
+
// -------------------------------------------------------------------------
|
|
247
|
+
describe('response interceptor (AxiosError)', () => {
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
captured.mockIsAxiosError.mockReturnValue(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const buildAxiosError = (overrides: Record<string, unknown> = {}): object => ({
|
|
253
|
+
isAxiosError: true,
|
|
254
|
+
config: { method: 'get', url: '/failing', headers: {} },
|
|
255
|
+
response: { status: 503 },
|
|
256
|
+
message: 'Service Unavailable',
|
|
257
|
+
...overrides,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should throw an InternalError', () => {
|
|
261
|
+
expect(() => captured.responseRejected!(buildAxiosError())).toThrow(InternalError);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should throw with the correct message', () => {
|
|
265
|
+
expect(() => captured.responseRejected!(buildAxiosError())).toThrow(
|
|
266
|
+
'Outbound HTTP request failed',
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should throw InternalError with statusCode 500', () => {
|
|
271
|
+
let thrown: unknown;
|
|
272
|
+
try {
|
|
273
|
+
captured.responseRejected!(buildAxiosError());
|
|
274
|
+
} catch (e) {
|
|
275
|
+
thrown = e;
|
|
276
|
+
}
|
|
277
|
+
expect((thrown as InternalError).statusCode).toBe(500);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should throw InternalError with code INTERNAL_ERROR', () => {
|
|
281
|
+
let thrown: unknown;
|
|
282
|
+
try {
|
|
283
|
+
captured.responseRejected!(buildAxiosError());
|
|
284
|
+
} catch (e) {
|
|
285
|
+
thrown = e;
|
|
286
|
+
}
|
|
287
|
+
expect((thrown as InternalError).code).toBe('INTERNAL_ERROR');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should log method, url, status, and message', () => {
|
|
291
|
+
try {
|
|
292
|
+
captured.responseRejected!(
|
|
293
|
+
buildAxiosError({
|
|
294
|
+
config: { method: 'post', url: '/upload', headers: {} },
|
|
295
|
+
response: { status: 413 },
|
|
296
|
+
message: 'Payload Too Large',
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
} catch {
|
|
300
|
+
// expected
|
|
301
|
+
}
|
|
302
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
303
|
+
{ method: 'POST', url: '/upload', status: 413, message: 'Payload Too Large' },
|
|
304
|
+
'[HTTP] Outbound request failed',
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should uppercase method in error log', () => {
|
|
309
|
+
try {
|
|
310
|
+
captured.responseRejected!(
|
|
311
|
+
buildAxiosError({ config: { method: 'delete', url: '/x', headers: {} } }),
|
|
312
|
+
);
|
|
313
|
+
} catch {
|
|
314
|
+
// expected
|
|
315
|
+
}
|
|
316
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
317
|
+
expect.objectContaining({ method: 'DELETE' }),
|
|
318
|
+
'[HTTP] Outbound request failed',
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should handle missing config/response gracefully', () => {
|
|
323
|
+
const minimalError = { isAxiosError: true, message: 'timeout' };
|
|
324
|
+
expect(() => captured.responseRejected!(minimalError)).toThrow(InternalError);
|
|
325
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
326
|
+
{ method: undefined, url: undefined, status: undefined, message: 'timeout' },
|
|
327
|
+
'[HTTP] Outbound request failed',
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should not call logger.debug on the error path', () => {
|
|
332
|
+
try {
|
|
333
|
+
captured.responseRejected!(buildAxiosError());
|
|
334
|
+
} catch {
|
|
335
|
+
// expected
|
|
336
|
+
}
|
|
337
|
+
expect(logger.debug).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// -------------------------------------------------------------------------
|
|
342
|
+
// Response interceptor — non-AxiosError path
|
|
343
|
+
// -------------------------------------------------------------------------
|
|
344
|
+
describe('response interceptor (non-AxiosError)', () => {
|
|
345
|
+
beforeEach(() => {
|
|
346
|
+
captured.mockIsAxiosError.mockReturnValue(false);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should throw an InternalError for a plain Error', () => {
|
|
350
|
+
expect(() => captured.responseRejected!(new Error('network failure'))).toThrow(InternalError);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should throw with the correct message', () => {
|
|
354
|
+
expect(() => captured.responseRejected!(new Error('x'))).toThrow(
|
|
355
|
+
'Outbound HTTP request failed',
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should log with the generic message and err field', () => {
|
|
360
|
+
const err = new Error('network failure');
|
|
361
|
+
try {
|
|
362
|
+
captured.responseRejected!(err);
|
|
363
|
+
} catch {
|
|
364
|
+
// expected
|
|
365
|
+
}
|
|
366
|
+
expect(logger.error).toHaveBeenCalledWith({ err }, '[HTTP] Unexpected outbound error');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should throw InternalError for thrown string', () => {
|
|
370
|
+
expect(() => captured.responseRejected!('something broke')).toThrow(InternalError);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should throw InternalError for thrown null', () => {
|
|
374
|
+
expect(() => captured.responseRejected!(null)).toThrow(InternalError);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should not call logger.debug on the error path', () => {
|
|
378
|
+
try {
|
|
379
|
+
captured.responseRejected!(new Error('x'));
|
|
380
|
+
} catch {
|
|
381
|
+
// expected
|
|
382
|
+
}
|
|
383
|
+
expect(logger.debug).not.toHaveBeenCalled();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// -------------------------------------------------------------------------
|
|
388
|
+
// Security: sensitive data must NOT be logged
|
|
389
|
+
// -------------------------------------------------------------------------
|
|
390
|
+
describe('security: no sensitive data in logs', () => {
|
|
391
|
+
it('should not log request body in request interceptor', () => {
|
|
392
|
+
captured.requestFulfilled!(makeConfig({ method: 'post', url: '/login', data: { password: 'secret' } }));
|
|
393
|
+
const logCall = vi.mocked(logger.debug).mock.calls[0][0] as Record<string, unknown>;
|
|
394
|
+
expect(logCall).not.toHaveProperty('data');
|
|
395
|
+
expect(JSON.stringify(logCall)).not.toContain('secret');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('should not log response body in response interceptor', () => {
|
|
399
|
+
captured.responseFulfilled!(makeResponse({ data: { token: 'bearer-xyz', password: 'hidden' } }));
|
|
400
|
+
const logCall = vi.mocked(logger.debug).mock.calls[0][0] as Record<string, unknown>;
|
|
401
|
+
expect(logCall).not.toHaveProperty('data');
|
|
402
|
+
expect(JSON.stringify(logCall)).not.toContain('bearer-xyz');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should not log request headers (may contain Authorization)', () => {
|
|
406
|
+
captured.requestFulfilled!(
|
|
407
|
+
makeConfig({ method: 'get', url: '/me', headers: { Authorization: 'Bearer token123' } as any }),
|
|
408
|
+
);
|
|
409
|
+
const logCall = vi.mocked(logger.debug).mock.calls[0][0] as Record<string, unknown>;
|
|
410
|
+
expect(logCall).not.toHaveProperty('headers');
|
|
411
|
+
expect(JSON.stringify(logCall)).not.toContain('token123');
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import axios, {
|
|
2
|
+
type AxiosInstance,
|
|
3
|
+
type InternalAxiosRequestConfig,
|
|
4
|
+
type AxiosResponse,
|
|
5
|
+
isAxiosError,
|
|
6
|
+
} from 'axios';
|
|
7
|
+
import { logger } from '@libs/logger.js';
|
|
8
|
+
import { InternalError } from '@shared/errors/errors.js';
|
|
9
|
+
|
|
10
|
+
const TIMEOUT_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
const httpClient: AxiosInstance = axios.create({
|
|
13
|
+
timeout: TIMEOUT_MS,
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
Accept: 'application/json',
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Request interceptor — log every outbound call
|
|
21
|
+
httpClient.interceptors.request.use(
|
|
22
|
+
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
|
|
23
|
+
logger.debug(
|
|
24
|
+
{ method: config.method?.toUpperCase(), url: config.url, baseURL: config.baseURL },
|
|
25
|
+
'[HTTP] Outbound request',
|
|
26
|
+
);
|
|
27
|
+
return config;
|
|
28
|
+
},
|
|
29
|
+
(error: unknown): never => {
|
|
30
|
+
logger.error({ err: error }, '[HTTP] Request setup failed');
|
|
31
|
+
throw new InternalError('Outbound HTTP request could not be constructed');
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Response interceptor — log responses and convert AxiosError → InternalError
|
|
36
|
+
httpClient.interceptors.response.use(
|
|
37
|
+
(response: AxiosResponse): AxiosResponse => {
|
|
38
|
+
logger.debug(
|
|
39
|
+
{
|
|
40
|
+
method: response.config.method?.toUpperCase(),
|
|
41
|
+
url: response.config.url,
|
|
42
|
+
status: response.status,
|
|
43
|
+
},
|
|
44
|
+
'[HTTP] Outbound response',
|
|
45
|
+
);
|
|
46
|
+
return response;
|
|
47
|
+
},
|
|
48
|
+
(error: unknown): never => {
|
|
49
|
+
if (isAxiosError(error)) {
|
|
50
|
+
logger.error(
|
|
51
|
+
{
|
|
52
|
+
method: error.config?.method?.toUpperCase(),
|
|
53
|
+
url: error.config?.url,
|
|
54
|
+
status: error.response?.status,
|
|
55
|
+
message: error.message,
|
|
56
|
+
},
|
|
57
|
+
'[HTTP] Outbound request failed',
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
logger.error({ err: error }, '[HTTP] Unexpected outbound error');
|
|
61
|
+
}
|
|
62
|
+
throw new InternalError('Outbound HTTP request failed');
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
export { httpClient };
|