@squiz/job-runner-lib 2.5.0 → 2.5.1

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/job-runner-lib",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "",
5
5
  "main": "lib/index.js",
6
6
  "private": false,
@@ -38,7 +38,7 @@
38
38
  "@squiz/dx-json-schema-lib": "1.78.1",
39
39
  "@squiz/dx-logger-lib": "^1.64.0",
40
40
  "@squiz/optimization-logger": "^1.9.0",
41
- "axios": "^1.13.6",
41
+ "axios": "^1.15.0",
42
42
  "cli-color": "^2.0.4",
43
43
  "fs-extra": "11.1.0",
44
44
  "immer": "^10.1.3",
@@ -31,6 +31,20 @@ export class S3RepositoryBase {
31
31
  this.bucketName = bucketName;
32
32
  }
33
33
 
34
+ /**
35
+ * Validates S3 key to prevent malicious input
36
+ * @param {string} key The S3 object key to validate
37
+ * @private
38
+ */
39
+ private validateS3Key(key: string): void {
40
+ if (!key || typeof key !== 'string') {
41
+ throw new Error('Invalid S3 key: must be a non-empty string');
42
+ }
43
+ if (key.includes('..') || key.startsWith('/')) {
44
+ throw new Error('Invalid S3 key: contains suspicious patterns');
45
+ }
46
+ }
47
+
34
48
  /**
35
49
  * Save object into S3.
36
50
  * @param {string} key The S3 object key.
@@ -39,6 +53,7 @@ export class S3RepositoryBase {
39
53
  * @memberof S3RepositoryBase
40
54
  */
41
55
  async put(key: string, body: PutObjectCommandInput['Body']): Promise<void> {
56
+ this.validateS3Key(key);
42
57
  await this.s3Client.send(
43
58
  new PutObjectCommand({
44
59
  Bucket: this.bucketName,
@@ -55,6 +70,7 @@ export class S3RepositoryBase {
55
70
  * @memberof S3RepositoryBase
56
71
  */
57
72
  public async delete(key: string): Promise<void> {
73
+ this.validateS3Key(key);
58
74
  await this.s3Client.send(
59
75
  new DeleteObjectCommand({
60
76
  Bucket: this.bucketName,
@@ -70,6 +86,7 @@ export class S3RepositoryBase {
70
86
  * @memberof S3RepositoryBase
71
87
  */
72
88
  async get(key: string): Promise<string | null> {
89
+ this.validateS3Key(key);
73
90
  const s3Object = await this.s3Client.send(
74
91
  new GetObjectCommand({
75
92
  Bucket: this.bucketName,
@@ -87,6 +104,7 @@ export class S3RepositoryBase {
87
104
  * @memberof S3RepositoryBase
88
105
  */
89
106
  async check(key: string): Promise<HeadObjectCommandOutput> {
107
+ this.validateS3Key(key);
90
108
  return this.s3Client.send(
91
109
  new HeadObjectCommand({
92
110
  Bucket: this.bucketName,
@@ -96,6 +114,7 @@ export class S3RepositoryBase {
96
114
  }
97
115
 
98
116
  async getS3SignedUrl(key: string): Promise<string> {
117
+ this.validateS3Key(key);
99
118
  const command = new GetObjectCommand({
100
119
  Bucket: this.bucketName,
101
120
  Key: key,
@@ -265,6 +265,59 @@ describe('JobExecutionTaskService', () => {
265
265
  expect(response).toBe(undefined);
266
266
  });
267
267
 
268
+ it('should handle when ECS returns no task ARNs or failures', async () => {
269
+ const jobContext = createMockJobContext();
270
+ const jobManifest = createMockJobManifest();
271
+ const jobExecution = createMockJobExecution({
272
+ id: `20250917T005949395Z-9602417782`,
273
+ jobName: 'jobName',
274
+ version: 'jobVersion',
275
+ });
276
+
277
+ mockJobContextService.findJobContext.mockResolvedValue(jobContext);
278
+ mockJobManifestService.findJobManifest.mockResolvedValue(jobManifest);
279
+ mockEcsClient.on(RunTaskCommand).resolves({ tasks: [], failures: [] });
280
+
281
+ const response = await jobExecutionTaskService.runJobExecutionTask({
282
+ ...defaultRunJobTaskParams,
283
+ jobExecution: jobExecution,
284
+ });
285
+
286
+ // Similar to a ConflictException being thrown. ECS appears to sometimes return an empty tasks and failures
287
+ // array when the idempotency key is used.
288
+ expect(response).toBe(undefined);
289
+ });
290
+
291
+ it('should throw when ECS returns no task ARNs and has failures', async () => {
292
+ const jobContext = createMockJobContext();
293
+ const jobManifest = createMockJobManifest();
294
+ const jobExecution = createMockJobExecution({
295
+ id: `20250917T005949395Z-9602417782`,
296
+ jobName: 'jobName',
297
+ version: 'jobVersion',
298
+ });
299
+ const failures = [
300
+ {
301
+ arn: faker.string.uuid(),
302
+ detail: 'Insufficient capacity to start job',
303
+ reason: faker.lorem.words(),
304
+ },
305
+ ];
306
+
307
+ mockJobContextService.findJobContext.mockResolvedValue(jobContext);
308
+ mockJobManifestService.findJobManifest.mockResolvedValue(jobManifest);
309
+ mockEcsClient.on(RunTaskCommand).resolves({ tasks: [], failures });
310
+
311
+ await expect(
312
+ jobExecutionTaskService.runJobExecutionTask({
313
+ ...defaultRunJobTaskParams,
314
+ jobExecution: jobExecution,
315
+ }),
316
+ ).rejects.toThrow(
317
+ `Execution start failed: ${JSON.stringify(failures)} (id: "20250917T005949395Z-9602417782", job: jobName, tenant: some_tenant)`,
318
+ );
319
+ });
320
+
268
321
  it('should re-throw unhandled errors when starting a task', async () => {
269
322
  const expectedError = new ResourceNotFoundException({ message: faker.lorem.words(), $metadata: {} });
270
323
  const jobContext = createMockJobContext();
@@ -121,6 +121,30 @@ export class JobExecutionTaskService {
121
121
  }),
122
122
  );
123
123
 
124
+ if (!result.tasks?.length) {
125
+ if (result.failures?.length) {
126
+ // Task has probably already started.
127
+ // ECS can sometimes return no tasks/failures if attempting to start
128
+ // the same task multiple times in quick succession:
129
+ // * {"tasks":[],"failures":[],"$metadata":{"httpStatusCode":200,...}}
130
+ throw new Error(
131
+ `Execution start failed: ${JSON.stringify(result.failures)} ` +
132
+ `(id: "${params.jobExecution.id}", job: ${params.jobExecution.jobName}, tenant: ${params.tenantId})`,
133
+ );
134
+ } else {
135
+ // Task has probably already started.
136
+ // ECS can sometimes return no tasks/failures if attempting to start
137
+ // the same task multiple times in quick succession:
138
+ // * {"tasks":[],"failures":[],"$metadata":{"httpStatusCode":200,...}}
139
+ console.warn(
140
+ `Execution start returned no ECS task ID` +
141
+ ` (id: "${params.jobExecution.id}", job: ${params.jobExecution.jobName}, tenant: ${params.tenantId})`,
142
+ JSON.stringify(result),
143
+ );
144
+ return undefined;
145
+ }
146
+ }
147
+
124
148
  // This task ARN can be used to terminate the job task.
125
149
  // If no error is returned, assume task has started.
126
150
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion