Skip to content

Commit b8fb8e8

Browse files
authored
Merge pull request #632 from sommy92/somzilla_issues
Implement somzilla issues #492, #493, #495
2 parents 0c05372 + 145e589 commit b8fb8e8

18 files changed

Lines changed: 502 additions & 19 deletions

src/common/http/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { createRequestIdAwareAxios } from './request-id-axios';
2+
export { requestIdAwareFetch } from './request-id-fetch';
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Request-id-aware Axios — unit tests
3+
*
4+
* Covers:
5+
* - Adds x-request-id header when a request ID is active in context
6+
* - Omits x-request-id header when no request ID is active
7+
* - Preserves existing headers on the outgoing request
8+
*/
9+
import { createRequestIdAwareAxios } from './request-id-axios';
10+
import { RequestContextService } from '../request-context/request-context.service';
11+
12+
describe('createRequestIdAwareAxios', () => {
13+
/** Create a client with a custom adapter that captures the final headers. */
14+
async function captureHeaders(
15+
requestId: string | null,
16+
extraHeaders?: Record<string, string>,
17+
): Promise<Record<string, string> | undefined> {
18+
const client = createRequestIdAwareAxios({ baseURL: 'https://example.com' });
19+
let captured: Record<string, string> | undefined;
20+
21+
// Override the adapter to capture the config before the real HTTP call
22+
(client as any).defaults.adapter = (config: any) => {
23+
const h: Record<string, string> = {};
24+
// AxiosHeaders is iterable via forEach
25+
if (typeof config.headers?.forEach === 'function') {
26+
config.headers.forEach((v: string, k: string) => { h[k] = v; });
27+
} else if (config.headers) {
28+
Object.assign(h, config.headers);
29+
}
30+
captured = h;
31+
return Promise.resolve({ data: null, status: 200, statusText: 'OK', headers: {}, config });
32+
};
33+
34+
const action = async () => {
35+
await client.get('/test', extraHeaders ? { headers: extraHeaders } : undefined);
36+
};
37+
38+
if (requestId) {
39+
await RequestContextService.run({ requestId }, action);
40+
} else {
41+
await action();
42+
}
43+
44+
return captured;
45+
}
46+
47+
it('adds x-request-id header when a request ID is active in context', async () => {
48+
const headers = await captureHeaders('ctx-req-001');
49+
expect(headers).toBeDefined();
50+
expect(headers!['x-request-id']).toBe('ctx-req-001');
51+
});
52+
53+
it('omits x-request-id header when no request ID is active', async () => {
54+
const headers = await captureHeaders(null);
55+
expect(headers).toBeDefined();
56+
expect(headers!['x-request-id']).toBeUndefined();
57+
});
58+
59+
it('preserves existing custom headers on the outgoing request', async () => {
60+
const headers = await captureHeaders('req-with-custom', { 'x-custom': 'my-value' });
61+
expect(headers).toBeDefined();
62+
expect(headers!['x-request-id']).toBe('req-with-custom');
63+
expect(headers!['x-custom']).toBe('my-value');
64+
});
65+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import axios, { AxiosInstance, CreateAxiosDefaults } from 'axios';
2+
import { RequestContextService } from '../request-context/request-context.service';
3+
4+
/**
5+
* Creates a pre-configured Axios instance that automatically propagates the
6+
* `x-request-id` header on every outbound HTTP request.
7+
*
8+
* The request ID is read from the current AsyncLocalStorage context
9+
* (populated by the request-logging middleware). When no request ID is
10+
* active the header is omitted so downstream services behave normally.
11+
*
12+
* Usage:
13+
* ```typescript
14+
* import { createRequestIdAwareAxios } from '../common/http/request-id-axios';
15+
*
16+
* const http = createRequestIdAwareAxios({ baseURL: 'https://horizon.example.com' });
17+
* const res = await http.get('/transactions/abc');
18+
* // ^ automatically includes `x-request-id: <current-id>` in the request headers
19+
* ```
20+
*
21+
* @param config Optional Axios configuration (baseURL, timeout, headers, …)
22+
* @returns A configured Axios instance
23+
*/
24+
export function createRequestIdAwareAxios(
25+
config?: CreateAxiosDefaults,
26+
): AxiosInstance {
27+
const instance = axios.create(config);
28+
29+
instance.interceptors.request.use(
30+
(reqConfig) => {
31+
const requestId = RequestContextService.getCurrentRequestId();
32+
if (requestId && reqConfig.headers) {
33+
// Axios 1.x uses AxiosHeaders — set header via direct property assignment
34+
(reqConfig.headers as Record<string, unknown>)['x-request-id'] = requestId;
35+
} else if (requestId) {
36+
// Fallback for when headers object is absent
37+
(reqConfig as any).headers = { 'x-request-id': requestId };
38+
}
39+
return reqConfig;
40+
},
41+
(error) => Promise.reject(error),
42+
);
43+
44+
return instance;
45+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Request-id-aware fetch — unit tests
3+
*
4+
* Covers:
5+
* - Adds x-request-id header when a request ID is active in context
6+
* - Omits x-request-id header when no request ID is active
7+
* - Preserves existing custom headers
8+
*/
9+
import { requestIdAwareFetch } from './request-id-fetch';
10+
import { RequestContextService } from '../request-context/request-context.service';
11+
12+
// Mock the global fetch
13+
const mockFetch = jest.fn();
14+
global.fetch = mockFetch as any;
15+
16+
describe('requestIdAwareFetch', () => {
17+
beforeEach(() => {
18+
jest.clearAllMocks();
19+
mockFetch.mockResolvedValue(new Response('ok', { status: 200 }));
20+
});
21+
22+
it('adds x-request-id header when a request ID is active in context', async () => {
23+
await RequestContextService.run({ requestId: 'fetch-req-001' }, async () => {
24+
await requestIdAwareFetch('https://example.com/api');
25+
});
26+
27+
const callHeaders = mockFetch.mock.calls[0][1]?.headers;
28+
expect(callHeaders).toBeDefined();
29+
expect(callHeaders.get('x-request-id')).toBe('fetch-req-001');
30+
});
31+
32+
it('omits x-request-id header when no request ID is active', async () => {
33+
await requestIdAwareFetch('https://example.com/api');
34+
35+
const callHeaders = mockFetch.mock.calls[0][1]?.headers;
36+
expect(callHeaders).toBeDefined();
37+
expect(callHeaders.has('x-request-id')).toBe(false);
38+
});
39+
40+
it('preserves existing custom headers on the outgoing request', async () => {
41+
await RequestContextService.run({ requestId: 'req-ctx' }, async () => {
42+
await requestIdAwareFetch('https://example.com/api', {
43+
headers: { 'x-custom': 'my-value' },
44+
});
45+
});
46+
47+
const callHeaders = mockFetch.mock.calls[0][1]?.headers;
48+
expect(callHeaders.get('x-request-id')).toBe('req-ctx');
49+
expect(callHeaders.get('x-custom')).toBe('my-value');
50+
});
51+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { RequestContextService } from '../request-context/request-context.service';
2+
3+
/**
4+
* Wraps the built-in `fetch` so that every outbound request automatically
5+
* includes the current `x-request-id` header (when one is active in
6+
* AsyncLocalStorage).
7+
*
8+
* Usage — replace global fetch:
9+
* ```typescript
10+
* import { requestIdAwareFetch } from '../common/http/request-id-fetch';
11+
*
12+
* const response = await requestIdAwareFetch('https://friendbot.example.com', {
13+
* method: 'GET',
14+
* });
15+
* ```
16+
*
17+
* @param input URL or Request object
18+
* @param init Optional init overrides (headers, method, etc.)
19+
* @returns Same as the native `fetch` — a Promise<Response>
20+
*/
21+
export async function requestIdAwareFetch(
22+
input: RequestInfo | URL,
23+
init?: RequestInit,
24+
): Promise<Response> {
25+
const requestId = RequestContextService.getCurrentRequestId();
26+
const headers = new Headers(init?.headers);
27+
28+
if (requestId && !headers.has('x-request-id')) {
29+
headers.set('x-request-id', requestId);
30+
}
31+
32+
return fetch(input, { ...init, headers });
33+
}

src/common/interceptors/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ResponseSanitizerInterceptor } from './response-sanitizer.interceptor';
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* ResponseSanitizerInterceptor — unit tests
3+
*
4+
* Covers:
5+
* - Strips privateKey from response body
6+
* - Strips encryptedSecret from response body
7+
* - Strips nested sensitive fields
8+
* - Passes through non-sensitive fields unchanged
9+
* - Handles null/undefined responses
10+
* - Handles array responses
11+
*/
12+
import { ResponseSanitizerInterceptor } from './response-sanitizer.interceptor';
13+
import { ExecutionContext, CallHandler } from '@nestjs/common';
14+
import { of } from 'rxjs';
15+
import { firstValueFrom } from 'rxjs';
16+
17+
describe('ResponseSanitizerInterceptor', () => {
18+
let interceptor: ResponseSanitizerInterceptor;
19+
20+
beforeEach(() => {
21+
interceptor = new ResponseSanitizerInterceptor();
22+
});
23+
24+
function mockContext(): ExecutionContext {
25+
return {
26+
switchToHttp: () => ({
27+
getRequest: () => ({}),
28+
getResponse: () => ({}),
29+
}),
30+
getHandler: () => ({}),
31+
getClass: () => ({}),
32+
} as ExecutionContext;
33+
}
34+
35+
function callHandler(responseBody: unknown): CallHandler {
36+
return { handle: () => of(responseBody) };
37+
}
38+
39+
it('strips privateKey from the response body', async () => {
40+
const body = {
41+
wallet: { id: 'wallet-1', publicKey: 'GABC' },
42+
privateKey: 'S-secret-key',
43+
isNewWallet: true,
44+
};
45+
46+
const result = await firstValueFrom(
47+
interceptor.intercept(mockContext(), callHandler(body)),
48+
);
49+
50+
expect(result.privateKey).toBe('[REDACTED]');
51+
expect(result.wallet.id).toBe('wallet-1');
52+
expect(result.isNewWallet).toBe(true);
53+
});
54+
55+
it('strips encryptedSecret from the response body', async () => {
56+
const body = {
57+
wallet: {
58+
id: 'wallet-1',
59+
encryptedSecret: 'enc-very-secret',
60+
publicKey: 'GABC',
61+
},
62+
};
63+
64+
const result = await firstValueFrom(
65+
interceptor.intercept(mockContext(), callHandler(body)),
66+
);
67+
68+
expect(result.wallet.encryptedSecret).toBe('[REDACTED]');
69+
expect(result.wallet.publicKey).toBe('GABC');
70+
});
71+
72+
it('strips nested sensitive fields deep in the response', async () => {
73+
const body = {
74+
data: {
75+
items: [
76+
{ privateKey: 'S-key-1', name: 'item1' },
77+
{ encryptedSecret: 'enc-2', name: 'item2' },
78+
],
79+
},
80+
};
81+
82+
const result = await firstValueFrom(
83+
interceptor.intercept(mockContext(), callHandler(body)),
84+
);
85+
86+
expect(result.data.items[0].privateKey).toBe('[REDACTED]');
87+
expect(result.data.items[0].name).toBe('item1');
88+
expect(result.data.items[1].encryptedSecret).toBe('[REDACTED]');
89+
expect(result.data.items[1].name).toBe('item2');
90+
});
91+
92+
it('passes through non-sensitive fields unchanged', async () => {
93+
const body = {
94+
wallet: { id: 'w-1', publicKey: 'GABC', status: 'ACTIVE' },
95+
isNewWallet: false,
96+
idempotencyKey: 'key-123',
97+
};
98+
99+
const result = await firstValueFrom(
100+
interceptor.intercept(mockContext(), callHandler(body)),
101+
);
102+
103+
expect(result.wallet.id).toBe('w-1');
104+
expect(result.wallet.publicKey).toBe('GABC');
105+
expect(result.wallet.status).toBe('ACTIVE');
106+
expect(result.isNewWallet).toBe(false);
107+
expect(result.idempotencyKey).toBe('key-123');
108+
});
109+
110+
it('handles null and undefined responses', async () => {
111+
const nullResult = await firstValueFrom(
112+
interceptor.intercept(mockContext(), callHandler(null)),
113+
);
114+
expect(nullResult).toBeNull();
115+
116+
const undefinedResult = await firstValueFrom(
117+
interceptor.intercept(mockContext(), callHandler(undefined)),
118+
);
119+
expect(undefinedResult).toBeUndefined();
120+
});
121+
122+
it('handles array responses', async () => {
123+
const body = [
124+
{ privateKey: 'S-key-1', publicKey: 'GABC' },
125+
{ privateKey: 'S-key-2', publicKey: 'GDEF' },
126+
];
127+
128+
const result = await firstValueFrom(
129+
interceptor.intercept(mockContext(), callHandler(body)),
130+
);
131+
132+
expect(result[0].privateKey).toBe('[REDACTED]');
133+
expect(result[0].publicKey).toBe('GABC');
134+
expect(result[1].privateKey).toBe('[REDACTED]');
135+
expect(result[1].publicKey).toBe('GDEF');
136+
});
137+
});

0 commit comments

Comments
 (0)