@squiz/dx-common-lib 1.72.0 → 1.72.3
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/CHANGELOG.md +18 -0
- package/lib/events/EventBusService.d.ts +107 -0
- package/lib/events/EventBusService.js +196 -0
- package/lib/events/EventBusService.js.map +1 -0
- package/lib/events/EventBusService.spec.d.ts +1 -0
- package/lib/events/EventBusService.spec.js +527 -0
- package/lib/events/EventBusService.spec.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/index.js.map +1 -1
- package/lib/secret-api-key-service/DevSecretApiKeyService.d.ts +25 -0
- package/lib/secret-api-key-service/DevSecretApiKeyService.js +35 -0
- package/lib/secret-api-key-service/DevSecretApiKeyService.js.map +1 -0
- package/lib/secret-api-key-service/DevSecretApiKeyService.spec.d.ts +1 -0
- package/lib/secret-api-key-service/DevSecretApiKeyService.spec.js +157 -0
- package/lib/secret-api-key-service/DevSecretApiKeyService.spec.js.map +1 -0
- package/lib/secret-api-key-service/SecretApiKeyService.d.ts +0 -9
- package/lib/secret-api-key-service/SecretApiKeyService.js +0 -7
- package/lib/secret-api-key-service/SecretApiKeyService.js.map +1 -1
- package/lib/secret-api-key-service/SecretApiKeyService.spec.js +0 -37
- package/lib/secret-api-key-service/SecretApiKeyService.spec.js.map +1 -1
- package/lib/secret-api-key-service/getSecretApiKeyService.d.ts +20 -0
- package/lib/secret-api-key-service/getSecretApiKeyService.js +41 -0
- package/lib/secret-api-key-service/getSecretApiKeyService.js.map +1 -0
- package/lib/secret-api-key-service/getSecretApiKeyService.spec.d.ts +1 -0
- package/lib/secret-api-key-service/getSecretApiKeyService.spec.js +313 -0
- package/lib/secret-api-key-service/getSecretApiKeyService.spec.js.map +1 -0
- package/lib/stream/EventStreamHandler.d.ts +38 -0
- package/lib/stream/EventStreamHandler.js +128 -0
- package/lib/stream/EventStreamHandler.js.map +1 -0
- package/lib/stream/EventStreamHandler.spec.d.ts +1 -0
- package/lib/stream/EventStreamHandler.spec.js +364 -0
- package/lib/stream/EventStreamHandler.spec.js.map +1 -0
- package/lib/stream/StreamUtils.d.ts +38 -0
- package/lib/stream/StreamUtils.js +59 -0
- package/lib/stream/StreamUtils.js.map +1 -0
- package/lib/stream/StreamUtils.spec.d.ts +1 -0
- package/lib/stream/StreamUtils.spec.js +92 -0
- package/lib/stream/StreamUtils.spec.js.map +1 -0
- package/package.json +3 -2
- package/src/events/EventBusService.spec.ts +707 -0
- package/src/events/EventBusService.ts +316 -0
- package/src/index.ts +5 -0
- package/src/secret-api-key-service/DevSecretApiKeyService.spec.ts +211 -0
- package/src/secret-api-key-service/DevSecretApiKeyService.ts +36 -0
- package/src/secret-api-key-service/SecretApiKeyService.spec.ts +0 -46
- package/src/secret-api-key-service/SecretApiKeyService.ts +0 -13
- package/src/secret-api-key-service/getSecretApiKeyService.spec.ts +405 -0
- package/src/secret-api-key-service/getSecretApiKeyService.ts +45 -0
- package/src/stream/EventStreamHandler.spec.ts +440 -0
- package/src/stream/EventStreamHandler.ts +192 -0
- package/src/stream/StreamUtils.spec.ts +113 -0
- package/src/stream/StreamUtils.ts +58 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright Squiz Australia Pty Ltd. All Rights Reserved.
|
|
4
|
+
*/
|
|
5
|
+
import { Logger } from '@squiz/dx-logger-lib';
|
|
6
|
+
|
|
7
|
+
export interface EventBusConfig {
|
|
8
|
+
eventBusUrl: string;
|
|
9
|
+
eventBusKey: string;
|
|
10
|
+
tenantId?: string;
|
|
11
|
+
deploymentEnvironment: string;
|
|
12
|
+
squizRegion: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Matches DXP Event Bus POST /events envelope (flat `metadata` + `event`, not nested under `detail`). */
|
|
16
|
+
export interface EventBusEvent {
|
|
17
|
+
source: string;
|
|
18
|
+
'detail-type': string;
|
|
19
|
+
action: string;
|
|
20
|
+
eventTime: string;
|
|
21
|
+
metadata: {
|
|
22
|
+
tenantID: string;
|
|
23
|
+
/** Deployment label, e.g. `dev` / `uat` / `prod` (from `EventBusConfig.deploymentEnvironment`). */
|
|
24
|
+
environment: string;
|
|
25
|
+
region: string;
|
|
26
|
+
};
|
|
27
|
+
event: object;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EventBusResponse {
|
|
31
|
+
statusCode: number;
|
|
32
|
+
event: {
|
|
33
|
+
id: string;
|
|
34
|
+
source: string;
|
|
35
|
+
detailType: string;
|
|
36
|
+
time: string;
|
|
37
|
+
detail: {
|
|
38
|
+
metadata: {
|
|
39
|
+
action: string;
|
|
40
|
+
tenantID: string;
|
|
41
|
+
eventTime: string;
|
|
42
|
+
};
|
|
43
|
+
event: object;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type EventBusApiResponse = Array<EventBusResponse>;
|
|
49
|
+
|
|
50
|
+
/** Response bodies can be huge; keep previews bounded. */
|
|
51
|
+
const MAX_RESPONSE_BODY_LOG_CHARS = 16_384;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Request body is the exact JSON string sent to `POST …/events` — log it in full for debugging unless enormous.
|
|
55
|
+
* (CloudWatch/Lambda still have per-line limits; this cap avoids pathological cases.)
|
|
56
|
+
*/
|
|
57
|
+
const MAX_REQUEST_BODY_LOG_CHARS = 512 * 1024;
|
|
58
|
+
|
|
59
|
+
function isMissing(value: string | undefined): boolean {
|
|
60
|
+
return value === undefined || value.trim() === '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function truncateForLog(value: string, max: number): string {
|
|
64
|
+
if (value.length <= max) {
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
return `${value.slice(0, max)}… [truncated, ${value.length - max} more chars]`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Full outbound payload for logs (same bytes as fetch `body`), with a rare size cap. */
|
|
71
|
+
function requestBodyForLog(body: string): string {
|
|
72
|
+
if (body.length <= MAX_REQUEST_BODY_LOG_CHARS) {
|
|
73
|
+
return body;
|
|
74
|
+
}
|
|
75
|
+
return truncateForLog(body, MAX_REQUEST_BODY_LOG_CHARS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface EventBusServiceInterface {
|
|
79
|
+
/**
|
|
80
|
+
* Publishes a single event to the Event Bus via HTTP POST.
|
|
81
|
+
* @param detailType The type of the event (e.g., 'component.change.create')
|
|
82
|
+
* @param detail The event payload
|
|
83
|
+
* @returns The API response containing event IDs and status codes
|
|
84
|
+
* @throws Error if configuration is missing or API call fails
|
|
85
|
+
*/
|
|
86
|
+
publishEvent(detailType: string, detail: object): Promise<EventBusApiResponse>;
|
|
87
|
+
/**
|
|
88
|
+
* Publishes a single event with safe error handling and response validation.
|
|
89
|
+
* Catches errors and logs them without throwing, so it doesn't break the calling flow.
|
|
90
|
+
* Validates that the API returned valid status codes for all events.
|
|
91
|
+
* @param detailType The type of the event (e.g., 'component.change.create')
|
|
92
|
+
* @param detail The event payload
|
|
93
|
+
*/
|
|
94
|
+
publishEventSafely(detailType: string, detail: object): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Publishes multiple events to the Event Bus via HTTP POST in a single request.
|
|
97
|
+
* @param events Array of events with detailType and detail payload
|
|
98
|
+
* @returns The API response containing event IDs and status codes
|
|
99
|
+
* @throws Error if configuration is missing or API call fails
|
|
100
|
+
*/
|
|
101
|
+
publishEvents(events: Array<{ detailType: string; detail: object }>): Promise<EventBusApiResponse>;
|
|
102
|
+
/**
|
|
103
|
+
* Publishes multiple events with safe error handling and response validation.
|
|
104
|
+
* Catches errors and logs them without throwing, so it doesn't break the calling flow.
|
|
105
|
+
* @param events Array of events with detailType and detail payload
|
|
106
|
+
*/
|
|
107
|
+
publishEventsSafely(events: Array<{ detailType: string; detail: object }>): Promise<void>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class EventBusService implements EventBusServiceInterface {
|
|
111
|
+
private readonly validStatusCodes = [200, 201, 202];
|
|
112
|
+
|
|
113
|
+
constructor(private logger: Logger, private config: EventBusConfig) {}
|
|
114
|
+
|
|
115
|
+
public setTenantId(tenantId: string): void {
|
|
116
|
+
this.config.tenantId = tenantId;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Throws with a specific message so operators know whether URL, API key, or tenant context is missing.
|
|
121
|
+
*/
|
|
122
|
+
private ensureEventBusCredentials(): void {
|
|
123
|
+
const missing: string[] = [];
|
|
124
|
+
if (isMissing(this.config.eventBusUrl)) {
|
|
125
|
+
missing.push('EVENT_BUS_URL (Event Bus base URL; Lambda receives this at deploy time)');
|
|
126
|
+
}
|
|
127
|
+
if (isMissing(this.config.eventBusKey)) {
|
|
128
|
+
missing.push('EVENT_BUS_KEY (API key sent as x-api-key; set via CDK/CI or Lambda env)');
|
|
129
|
+
}
|
|
130
|
+
if (missing.length > 0) {
|
|
131
|
+
throw new Error(`Event Bus is not configured: missing ${missing.join(' and ')}.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private ensureTenantId(): void {
|
|
136
|
+
if (isMissing(this.config.tenantId)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
'Tenant ID is not set. The stream processor must call setTenantId before publishing (x-dxp-tenant header).',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private eventBusPostUrl(): string {
|
|
144
|
+
const base = this.config.eventBusUrl.replace(/\/+$/, '');
|
|
145
|
+
return `${base}/events`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Safe when URL is unset (e.g. logging after a config error). */
|
|
149
|
+
private safeEventBusEndpointForLog(): string | undefined {
|
|
150
|
+
if (isMissing(this.config.eventBusUrl)) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
return this.eventBusPostUrl();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async postToEventBus(eventsPayload: EventBusEvent[]): Promise<EventBusApiResponse> {
|
|
157
|
+
const url = this.eventBusPostUrl();
|
|
158
|
+
const body = JSON.stringify(eventsPayload);
|
|
159
|
+
const headers: Record<string, string> = {
|
|
160
|
+
'x-api-key': this.config.eventBusKey,
|
|
161
|
+
'x-dxp-tenant': this.config.tenantId as string,
|
|
162
|
+
'Content-Type': 'application/json',
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const headersForLog = { ...headers, 'x-api-key': '[REDACTED]' };
|
|
166
|
+
|
|
167
|
+
let response: Response;
|
|
168
|
+
try {
|
|
169
|
+
response = await fetch(url, { method: 'POST', headers, body });
|
|
170
|
+
} catch (err) {
|
|
171
|
+
this.logger.error('Event Bus request failed before HTTP response (network/DNS/TLS)', {
|
|
172
|
+
eventBusEndpoint: url,
|
|
173
|
+
method: 'POST',
|
|
174
|
+
tenantId: this.config.tenantId,
|
|
175
|
+
/** Exact JSON sent as the fetch body (see `requestBodyForLog` for rare truncation). */
|
|
176
|
+
requestBody: requestBodyForLog(body),
|
|
177
|
+
headers: headersForLog,
|
|
178
|
+
error: err instanceof Error ? err.message : String(err),
|
|
179
|
+
});
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
const responseText = await response.text();
|
|
185
|
+
const responsePreview = truncateForLog(responseText, MAX_RESPONSE_BODY_LOG_CHARS);
|
|
186
|
+
this.logger.error('Event Bus API returned a non-success HTTP status', {
|
|
187
|
+
eventBusEndpoint: url,
|
|
188
|
+
httpStatus: response.status,
|
|
189
|
+
httpStatusText: response.statusText,
|
|
190
|
+
tenantId: this.config.tenantId,
|
|
191
|
+
/** Full outbound JSON (same as HTTP body); large payloads may be truncated — see `requestBodyForLog`. */
|
|
192
|
+
requestBody: requestBodyForLog(body),
|
|
193
|
+
responseBodyPreview: responsePreview,
|
|
194
|
+
headers: headersForLog,
|
|
195
|
+
});
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Event Bus API error: HTTP ${response.status} ${response.statusText} for POST ${url}. Response body (preview): ${responsePreview}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return (await response.json()) as EventBusApiResponse;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
public async publishEvent(detailType: string, detail: object): Promise<EventBusApiResponse> {
|
|
205
|
+
this.ensureEventBusCredentials();
|
|
206
|
+
this.ensureTenantId();
|
|
207
|
+
|
|
208
|
+
const eventTime = new Date().toISOString();
|
|
209
|
+
const event: EventBusEvent = {
|
|
210
|
+
source: 'component-service',
|
|
211
|
+
'detail-type': detailType,
|
|
212
|
+
action: detailType,
|
|
213
|
+
eventTime,
|
|
214
|
+
metadata: {
|
|
215
|
+
tenantID: this.config.tenantId as string,
|
|
216
|
+
environment: this.config.deploymentEnvironment,
|
|
217
|
+
region: this.config.squizRegion,
|
|
218
|
+
},
|
|
219
|
+
event: detail,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const eventsPayload = [event];
|
|
223
|
+
return this.postToEventBus(eventsPayload);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public async publishEvents(events: Array<{ detailType: string; detail: object }>): Promise<EventBusApiResponse> {
|
|
227
|
+
this.ensureEventBusCredentials();
|
|
228
|
+
|
|
229
|
+
if (events.length === 0) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.ensureTenantId();
|
|
234
|
+
|
|
235
|
+
const eventTime = new Date().toISOString();
|
|
236
|
+
const eventsPayload: EventBusEvent[] = events.map(({ detailType, detail }) => ({
|
|
237
|
+
source: 'component-service',
|
|
238
|
+
'detail-type': detailType,
|
|
239
|
+
action: detailType,
|
|
240
|
+
eventTime,
|
|
241
|
+
metadata: {
|
|
242
|
+
tenantID: this.config.tenantId as string,
|
|
243
|
+
environment: this.config.deploymentEnvironment,
|
|
244
|
+
region: this.config.squizRegion,
|
|
245
|
+
},
|
|
246
|
+
event: detail,
|
|
247
|
+
}));
|
|
248
|
+
|
|
249
|
+
return this.postToEventBus(eventsPayload);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
public async publishEventSafely(detailType: string, detail: object): Promise<void> {
|
|
253
|
+
try {
|
|
254
|
+
const response = await this.publishEvent(detailType, detail);
|
|
255
|
+
|
|
256
|
+
const failedEvents = response.filter(
|
|
257
|
+
(eventResponse) => !this.validStatusCodes.includes(eventResponse.statusCode),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (failedEvents.length > 0) {
|
|
261
|
+
const failedStatuses = failedEvents.map((e) => e.statusCode).join(', ');
|
|
262
|
+
this.logger.error(
|
|
263
|
+
`Event publishing failed: received non-valid status codes [${failedStatuses}] for event ${detailType}`,
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const eventIds = response.map((eventResponse) => eventResponse.event.id).join(', ');
|
|
269
|
+
this.logger.info(
|
|
270
|
+
`Successfully published event: ${detailType} with IDs [${eventIds}] to ${this.config.eventBusUrl}/events`,
|
|
271
|
+
);
|
|
272
|
+
} catch (e) {
|
|
273
|
+
const errorMessage = `Failed to publish event ${detailType} to event bus: ${
|
|
274
|
+
e instanceof Error ? e.message : typeof e === 'string' ? e : JSON.stringify(e)
|
|
275
|
+
}`;
|
|
276
|
+
this.logger.error(errorMessage, {
|
|
277
|
+
detailType,
|
|
278
|
+
tenantId: this.config.tenantId,
|
|
279
|
+
eventBusEndpoint: this.safeEventBusEndpointForLog(),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
public async publishEventsSafely(events: Array<{ detailType: string; detail: object }>): Promise<void> {
|
|
285
|
+
try {
|
|
286
|
+
const response = await this.publishEvents(events);
|
|
287
|
+
|
|
288
|
+
const failedEvents = response.filter(
|
|
289
|
+
(eventResponse) => !this.validStatusCodes.includes(eventResponse.statusCode),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (failedEvents.length > 0) {
|
|
293
|
+
const failedStatuses = failedEvents.map((e) => e.statusCode).join(', ');
|
|
294
|
+
const errorMessage = `Event publishing failed: received non-valid status codes [${failedStatuses}] for batch of ${events.length} events`;
|
|
295
|
+
this.logger.error(errorMessage);
|
|
296
|
+
throw new Error(errorMessage);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const eventIds = response.map((eventResponse) => eventResponse.event.id).join(', ');
|
|
300
|
+
this.logger.info(
|
|
301
|
+
`Successfully published batch of ${events.length} events with IDs [${eventIds}] to ${this.config.eventBusUrl}/events`,
|
|
302
|
+
);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
const errorMessage = `Failed to publish batch of ${events.length} events to event bus: ${
|
|
305
|
+
e instanceof Error ? e.message : typeof e === 'string' ? e : JSON.stringify(e)
|
|
306
|
+
}`;
|
|
307
|
+
this.logger.error(errorMessage, {
|
|
308
|
+
tenantId: this.config.tenantId,
|
|
309
|
+
eventCount: events.length,
|
|
310
|
+
detailTypes: events.map((ev) => ev.detailType),
|
|
311
|
+
eventBusEndpoint: this.safeEventBusEndpointForLog(),
|
|
312
|
+
});
|
|
313
|
+
throw new Error(errorMessage);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,3 +16,8 @@ export * from './edge-components-secret-key-service/EdgeComponentsSecretKeyServi
|
|
|
16
16
|
export * from './edge-components-secret-key-service/getEdgeComponentsSecretService';
|
|
17
17
|
export * from './esi-mac-token-generator/EsiMacTokenGenerator';
|
|
18
18
|
export * from './secret-api-key-service/SecretApiKeyService';
|
|
19
|
+
export * from './secret-api-key-service/DevSecretApiKeyService';
|
|
20
|
+
export * from './secret-api-key-service/getSecretApiKeyService';
|
|
21
|
+
export * from './events/EventBusService';
|
|
22
|
+
export * from './stream/EventStreamHandler';
|
|
23
|
+
export * from './stream/StreamUtils';
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { DevSecretApiKeyService } from './DevSecretApiKeyService';
|
|
2
|
+
|
|
3
|
+
describe('DevSecretApiKeyService', () => {
|
|
4
|
+
describe('Constructor', () => {
|
|
5
|
+
it('should successfully create service with valid API key', () => {
|
|
6
|
+
const apiKey = 'test-api-key-12345';
|
|
7
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
8
|
+
|
|
9
|
+
expect(service).toBeInstanceOf(DevSecretApiKeyService);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should throw error when API key is empty string', () => {
|
|
13
|
+
expect(() => new DevSecretApiKeyService('')).toThrow('API key must be a non-empty string');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should throw error when API key is whitespace only', () => {
|
|
17
|
+
expect(() => new DevSecretApiKeyService(' ')).toThrow('API key must be a non-empty string');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should throw error when API key is only tabs', () => {
|
|
21
|
+
expect(() => new DevSecretApiKeyService('\t\t')).toThrow('API key must be a non-empty string');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should throw error when API key is only newlines', () => {
|
|
25
|
+
expect(() => new DevSecretApiKeyService('\n\n')).toThrow('API key must be a non-empty string');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should accept API key with surrounding whitespace', () => {
|
|
29
|
+
// Note: The constructor checks trimmed length, but stores the original value
|
|
30
|
+
const apiKey = ' valid-key ';
|
|
31
|
+
expect(() => new DevSecretApiKeyService(apiKey)).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('getApiKey', () => {
|
|
36
|
+
it('should return the API key provided in constructor', async () => {
|
|
37
|
+
const apiKey = 'test-api-key-xyz';
|
|
38
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
39
|
+
|
|
40
|
+
const result = await service.getApiKey();
|
|
41
|
+
|
|
42
|
+
expect(result).toBe(apiKey);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should return same API key on multiple calls', async () => {
|
|
46
|
+
const apiKey = 'consistent-api-key';
|
|
47
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
48
|
+
|
|
49
|
+
const result1 = await service.getApiKey();
|
|
50
|
+
const result2 = await service.getApiKey();
|
|
51
|
+
const result3 = await service.getApiKey();
|
|
52
|
+
|
|
53
|
+
expect(result1).toBe(apiKey);
|
|
54
|
+
expect(result2).toBe(apiKey);
|
|
55
|
+
expect(result3).toBe(apiKey);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should handle concurrent calls efficiently', async () => {
|
|
59
|
+
const apiKey = 'concurrent-test-key';
|
|
60
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
61
|
+
|
|
62
|
+
// Make multiple concurrent calls
|
|
63
|
+
const promises = Array(10)
|
|
64
|
+
.fill(null)
|
|
65
|
+
.map(() => service.getApiKey());
|
|
66
|
+
|
|
67
|
+
const results = await Promise.all(promises);
|
|
68
|
+
|
|
69
|
+
// All should return the same key
|
|
70
|
+
expect(results).toEqual(Array(10).fill(apiKey));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('Edge Cases', () => {
|
|
75
|
+
it('should handle very long API keys', async () => {
|
|
76
|
+
const apiKey = 'x'.repeat(1000); // 1000 character API key
|
|
77
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
78
|
+
|
|
79
|
+
const result = await service.getApiKey();
|
|
80
|
+
|
|
81
|
+
expect(result).toBe(apiKey);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should handle special characters in API key', async () => {
|
|
85
|
+
const apiKey = 'test-key-!@#$%^&*()_+-=[]{}|;:"<>,.?/~`';
|
|
86
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
87
|
+
|
|
88
|
+
const result = await service.getApiKey();
|
|
89
|
+
|
|
90
|
+
expect(result).toBe(apiKey);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should handle unicode characters in API key', async () => {
|
|
94
|
+
const apiKey = 'test-key-你好-مرحبا-🔑';
|
|
95
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
96
|
+
|
|
97
|
+
const result = await service.getApiKey();
|
|
98
|
+
|
|
99
|
+
expect(result).toBe(apiKey);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle API key with line breaks', async () => {
|
|
103
|
+
const apiKey = 'key-with\nline\nbreaks';
|
|
104
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
105
|
+
|
|
106
|
+
const result = await service.getApiKey();
|
|
107
|
+
|
|
108
|
+
expect(result).toBe(apiKey);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should handle base64-like API keys', async () => {
|
|
112
|
+
const apiKey =
|
|
113
|
+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
|
114
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
115
|
+
|
|
116
|
+
const result = await service.getApiKey();
|
|
117
|
+
|
|
118
|
+
expect(result).toBe(apiKey);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should handle UUID-format API keys', async () => {
|
|
122
|
+
const apiKey = '550e8400-e29b-41d4-a716-446655440000';
|
|
123
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
124
|
+
|
|
125
|
+
const result = await service.getApiKey();
|
|
126
|
+
|
|
127
|
+
expect(result).toBe(apiKey);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should handle hexadecimal API keys', async () => {
|
|
131
|
+
const apiKey = 'a1b2c3d4e5f6789012345678901234567890abcdef';
|
|
132
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
133
|
+
|
|
134
|
+
const result = await service.getApiKey();
|
|
135
|
+
|
|
136
|
+
expect(result).toBe(apiKey);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('Interface Compliance', () => {
|
|
141
|
+
it('should implement SecretApiKeyServiceInterface', async () => {
|
|
142
|
+
const service = new DevSecretApiKeyService('test-key');
|
|
143
|
+
|
|
144
|
+
// Check that the required methods exist
|
|
145
|
+
expect(typeof service.getApiKey).toBe('function');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should return a Promise from getApiKey', () => {
|
|
149
|
+
const service = new DevSecretApiKeyService('test-key');
|
|
150
|
+
const result = service.getApiKey();
|
|
151
|
+
|
|
152
|
+
expect(result).toBeInstanceOf(Promise);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('Memory and Performance', () => {
|
|
157
|
+
it('should not modify the API key after construction', async () => {
|
|
158
|
+
const originalKey = 'immutable-key-123';
|
|
159
|
+
const service = new DevSecretApiKeyService(originalKey);
|
|
160
|
+
|
|
161
|
+
// Get the key multiple times
|
|
162
|
+
await service.getApiKey();
|
|
163
|
+
await service.getApiKey();
|
|
164
|
+
await service.getApiKey();
|
|
165
|
+
|
|
166
|
+
// Key should still be the same
|
|
167
|
+
const result = await service.getApiKey();
|
|
168
|
+
expect(result).toBe(originalKey);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should handle rapid sequential calls', async () => {
|
|
172
|
+
const apiKey = 'rapid-call-key';
|
|
173
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
174
|
+
|
|
175
|
+
// Make rapid sequential calls
|
|
176
|
+
for (let i = 0; i < 100; i++) {
|
|
177
|
+
const result = await service.getApiKey();
|
|
178
|
+
expect(result).toBe(apiKey);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('Real-world Scenarios', () => {
|
|
184
|
+
it('should work with typical metrics service API key format', async () => {
|
|
185
|
+
const apiKey = 'metrics-api-key-prod-abc123def456';
|
|
186
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
187
|
+
|
|
188
|
+
const result = await service.getApiKey();
|
|
189
|
+
|
|
190
|
+
expect(result).toBe(apiKey);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should work with AWS-style secret format', async () => {
|
|
194
|
+
const apiKey = 'aws-secret-key-AKIAIOSFODNN7EXAMPLE';
|
|
195
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
196
|
+
|
|
197
|
+
const result = await service.getApiKey();
|
|
198
|
+
|
|
199
|
+
expect(result).toBe(apiKey);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should work with Bearer token format', async () => {
|
|
203
|
+
const apiKey = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
|
|
204
|
+
const service = new DevSecretApiKeyService(apiKey);
|
|
205
|
+
|
|
206
|
+
const result = await service.getApiKey();
|
|
207
|
+
|
|
208
|
+
expect(result).toBe(apiKey);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* @file EnvApiKeyService.ts
|
|
3
|
+
* @description Retrieves API key from environment variable (for local development).
|
|
4
|
+
* @author Squiz
|
|
5
|
+
* @copyright 2025 Squiz
|
|
6
|
+
* @license MIT
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SecretApiKeyServiceInterface } from './SecretApiKeyService';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Service for retrieving API keys from environment variables.
|
|
13
|
+
* Used for local development as an alternative to AWS Secrets Manager.
|
|
14
|
+
*/
|
|
15
|
+
export class DevSecretApiKeyService implements SecretApiKeyServiceInterface {
|
|
16
|
+
private readonly apiKey: string;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Constructor for EnvApiKeyService
|
|
20
|
+
* @param apiKey The API key value from environment variable
|
|
21
|
+
*/
|
|
22
|
+
constructor(apiKey: string) {
|
|
23
|
+
if (!apiKey || apiKey.trim().length === 0) {
|
|
24
|
+
throw new Error('API key must be a non-empty string');
|
|
25
|
+
}
|
|
26
|
+
this.apiKey = apiKey;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the API key.
|
|
31
|
+
* @returns The API key string
|
|
32
|
+
*/
|
|
33
|
+
public async getApiKey(): Promise<string> {
|
|
34
|
+
return this.apiKey;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -112,52 +112,6 @@ describe('SecretApiKeyService', () => {
|
|
|
112
112
|
expect(result2).toBe(mockApiKey);
|
|
113
113
|
expect(mockSend).toHaveBeenCalledTimes(1); // Still only 1 call
|
|
114
114
|
});
|
|
115
|
-
|
|
116
|
-
it('should clear cache when clearMetricsApiKeyCache is called', async () => {
|
|
117
|
-
const mockApiKey1 = 'first-api-key';
|
|
118
|
-
const mockApiKey2 = 'second-api-key';
|
|
119
|
-
|
|
120
|
-
mockSend
|
|
121
|
-
.mockResolvedValueOnce({
|
|
122
|
-
SecretString: JSON.stringify({ apikey: mockApiKey1 }),
|
|
123
|
-
})
|
|
124
|
-
.mockResolvedValueOnce({
|
|
125
|
-
SecretString: JSON.stringify({ apikey: mockApiKey2 }),
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
// First retrieval
|
|
129
|
-
const result1 = await service.getApiKey();
|
|
130
|
-
expect(result1).toBe(mockApiKey1);
|
|
131
|
-
|
|
132
|
-
// Clear cache
|
|
133
|
-
service.clearCache();
|
|
134
|
-
|
|
135
|
-
// Second retrieval after cache clear - should fetch again
|
|
136
|
-
const result2 = await service.getApiKey();
|
|
137
|
-
expect(result2).toBe(mockApiKey2);
|
|
138
|
-
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('should create new SecretsManagerClient for each non-cached call', async () => {
|
|
142
|
-
// This test verifies that a new client is created when cache is cleared
|
|
143
|
-
|
|
144
|
-
mockSend
|
|
145
|
-
.mockResolvedValueOnce({
|
|
146
|
-
SecretString: JSON.stringify({ apikey: 'key1' }),
|
|
147
|
-
})
|
|
148
|
-
.mockResolvedValueOnce({
|
|
149
|
-
SecretString: JSON.stringify({ apikey: 'key2' }),
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
await service.getApiKey();
|
|
153
|
-
service.clearCache(); // Clears API key cache
|
|
154
|
-
await service.getApiKey();
|
|
155
|
-
|
|
156
|
-
// The mock send should be called twice (once for each call after clearing cache)
|
|
157
|
-
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
158
|
-
// SecretsManagerClient should be created twice (once per non-cached call)
|
|
159
|
-
expect(mockSecretsManagerClient).toHaveBeenCalledTimes(2);
|
|
160
|
-
});
|
|
161
115
|
});
|
|
162
116
|
|
|
163
117
|
describe('Error Handling', () => {
|
|
@@ -20,11 +20,6 @@ export interface SecretApiKeyServiceInterface {
|
|
|
20
20
|
* @returns The API key string
|
|
21
21
|
*/
|
|
22
22
|
getApiKey(): Promise<string>;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Clears the cached API key (useful for testing)
|
|
26
|
-
*/
|
|
27
|
-
clearCache(): void;
|
|
28
23
|
}
|
|
29
24
|
|
|
30
25
|
/**
|
|
@@ -110,12 +105,4 @@ export class SecretApiKeyService implements SecretApiKeyServiceInterface {
|
|
|
110
105
|
throw wrappedError;
|
|
111
106
|
}
|
|
112
107
|
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Clears the cached API key and client (useful for testing)
|
|
116
|
-
* @returns void
|
|
117
|
-
*/
|
|
118
|
-
public clearCache(): void {
|
|
119
|
-
this.cachedApiKey = undefined;
|
|
120
|
-
}
|
|
121
108
|
}
|