Skip to content

fix(scan): handling conflict on errors #256

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 45 additions & 55 deletions packages/scan/src/DefaultDiscoveries.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DefaultDiscoveries } from './DefaultDiscoveries';
import { HttpMethod } from './models';
import { Target } from './target';
import { anyOfClass, deepEqual, instance, mock, reset, when } from 'ts-mockito';
import { ApiClient, Configuration } from '@sectester/core';
import { ApiClient, Configuration, ApiError } from '@sectester/core';
import { randomUUID } from 'crypto';

describe('DefaultDiscoveries', () => {
Expand Down Expand Up @@ -58,7 +58,8 @@ describe('DefaultDiscoveries', () => {
headers: testTarget.headers,
body: await testTarget.text()
}
})
}),
handle409Redirects: false
})
)
).thenResolve(response);
Expand Down Expand Up @@ -95,10 +96,11 @@ describe('DefaultDiscoveries', () => {
headers: testTarget.headers,
body: await testTarget.text()
}
})
}),
handle409Redirects: false
})
)
).thenResolve(conflictResponse);
).thenThrow(new ApiError(conflictResponse));

when(
mockedApiClient.request(
Expand Down Expand Up @@ -160,7 +162,8 @@ describe('DefaultDiscoveries', () => {
headers: getTarget.headers,
body: undefined
}
})
}),
handle409Redirects: false
})
)
).thenResolve(response);
Expand All @@ -170,14 +173,13 @@ describe('DefaultDiscoveries', () => {
expect(result).toEqual({ id: entryPointId });
});

it('should get existing entry point even if PUT fails', async () => {
const redirectLocation =
'https://example.com/api/v2/projects/123/entry-points/456';
it('should throw error when PUT fails during 409 conflict handling', async () => {
const redirectLocation = `/api/v2/projects/${projectId}/entry-points/${entryPointId}`;
const conflictResponse = new Response(null, {
status: 409,
headers: new Headers({ location: redirectLocation })
});
const putFailResponse = new Response('Internal Server Error', {
const putErrorResponse = new Response('Internal Server Error', {
status: 500,
statusText: 'Internal Server Error'
});
Expand All @@ -200,10 +202,11 @@ describe('DefaultDiscoveries', () => {
headers: testTarget.headers,
body: await testTarget.text()
}
})
}),
handle409Redirects: false
})
)
).thenResolve(conflictResponse);
).thenThrow(new ApiError(conflictResponse));

when(
mockedApiClient.request(
Expand All @@ -226,12 +229,14 @@ describe('DefaultDiscoveries', () => {
})
})
)
).thenResolve(putFailResponse);
).thenThrow(
new ApiError(putErrorResponse, 'API request failed with status 500')
);

const result = discoveries.createEntrypoint(testTarget, repeaterId);

await expect(result).rejects.toThrow(
`Failed to update existing entrypoint at ${redirectLocation}: Internal Server Error`
`Failed to update existing entrypoint at ${redirectLocation}: API request failed with status 500`
);
});

Expand Down Expand Up @@ -259,15 +264,18 @@ describe('DefaultDiscoveries', () => {
headers: testTarget.headers,
body: await testTarget.text()
}
})
}),
handle409Redirects: false
})
)
).thenResolve(errorResponse);
).thenThrow(
new ApiError(errorResponse, 'API request failed with status 400')
);

const result = discoveries.createEntrypoint(testTarget, repeaterId);

await expect(result).rejects.toThrow(
'Failed to create entrypoint: Bad Request'
'API request failed with status 400'
);
});

Expand Down Expand Up @@ -295,15 +303,18 @@ describe('DefaultDiscoveries', () => {
headers: testTarget.headers,
body: await testTarget.text()
}
})
}),
handle409Redirects: false
})
)
).thenResolve(conflictResponse);
).thenThrow(
new ApiError(conflictResponse, 'API request failed with status 409')
);

const result = discoveries.createEntrypoint(testTarget, repeaterId);

await expect(result).rejects.toThrow(
'Failed to create entrypoint: Conflict'
'API request failed with status 409'
);
});

Expand Down Expand Up @@ -337,10 +348,11 @@ describe('DefaultDiscoveries', () => {
headers: testTarget.headers,
body: await testTarget.text()
}
})
}),
handle409Redirects: false
})
)
).thenResolve(conflictResponse);
).thenThrow(new ApiError(conflictResponse));

when(
mockedApiClient.request(
Expand All @@ -365,14 +377,14 @@ describe('DefaultDiscoveries', () => {
)
).thenResolve(putResponse);

when(mockedApiClient.request(redirectLocation)).thenResolve(
getFailResponse
when(mockedApiClient.request(redirectLocation)).thenThrow(
new ApiError(getFailResponse, 'API request failed with status 404')
);

const result = discoveries.createEntrypoint(testTarget, repeaterId);

await expect(result).rejects.toThrow(
'Failed to create entrypoint: Not Found'
'API request failed with status 404'
);
});

Expand Down Expand Up @@ -403,41 +415,19 @@ describe('DefaultDiscoveries', () => {
headers: testTarget.headers,
body: await testTarget.text()
}
})
})
)
).thenResolve(conflictResponse);

const putResponse = new Response(null, { status: 204 });
when(
mockedApiClient.request(
'',
deepEqual({
signal: anyOfClass(AbortSignal),
method: 'PUT',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
repeaterId,
authObjectId: testTarget.auth,
request: {
method: testTarget.method,
url: testTarget.url,
headers: testTarget.headers,
body: await testTarget.text()
}
})
}),
handle409Redirects: false
})
)
).thenResolve(putResponse);

const finalResponse = new Response(JSON.stringify({ id: entryPointId }));
when(mockedApiClient.request('')).thenResolve(finalResponse);
).thenThrow(
new ApiError(conflictResponse, 'API request failed with status 409')
);

const result = await discoveries.createEntrypoint(testTarget, repeaterId);
const result = discoveries.createEntrypoint(testTarget, repeaterId);

expect(result).toEqual({ id: entryPointId });
await expect(result).rejects.toThrow(
'API request failed with status 409'
);
});
});
});
71 changes: 49 additions & 22 deletions packages/scan/src/DefaultDiscoveries.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Target } from './target';
import { Discoveries } from './Discoveries';
import { inject, injectable } from 'tsyringe';
import { ApiClient, Configuration } from '@sectester/core';
import {
ApiClient,
Configuration,
ApiError,
ApiRequestInit
} from '@sectester/core';

@injectable()
export class DefaultDiscoveries implements Discoveries {
Expand Down Expand Up @@ -34,35 +39,57 @@ export class DefaultDiscoveries implements Discoveries {
headers: { 'content-type': 'application/json' }
};

let response = await this.client.request(
`/api/v2/projects/${this.configuration.projectId}/entry-points`,
{ ...requestOptions, method: 'POST' }
);
try {
const response = await this.client.request(
`/api/v2/projects/${this.configuration.projectId}/entry-points`,
{ ...requestOptions, handle409Redirects: false, method: 'POST' }
);

if (response.status === 409 && response.headers.has('location')) {
const location = response.headers.get('location') as string;
const putResponse = await this.client.request(location, {
...requestOptions,
method: 'PUT'
});
const data = (await response.json()) as { id: string };

if (!putResponse.ok) {
const errorText = await putResponse.text();
throw new Error(
`Failed to update existing entrypoint at ${location}: ${errorText}`
);
return data;
} catch (error) {
if (this.isConflictError(error)) {
return this.handleConflictError(error, requestOptions);
}

response = await this.client.request(location);
throw error;
}
}

if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to create entrypoint: ${errorText}`);
private isConflictError(error: unknown): error is ApiError {
if (!(error instanceof ApiError) || error.response.status !== 409) {
return false;
}

const data = (await response.json()) as { id: string };
const location = error.response.headers.get('location');

return !!location && location.trim() !== '';
}

private async handleConflictError(
error: ApiError,
requestOptions?: ApiRequestInit
): Promise<{ id: string }> {
const location = error.response.headers.get('location') as string;

return data;
try {
await this.client.request(location, {
...requestOptions,
method: 'PUT'
});

const response = await this.client.request(location);
const data = (await response.json()) as { id: string };

return data;
} catch (putError) {
if (putError instanceof ApiError) {
throw new Error(
`Failed to update existing entrypoint at ${location}: ${putError.message}`
);
}
throw putError;
}
}
}