Skip to content

Commit

Permalink
Fix handling of UnprocessableEntityException responses (#615)
Browse files Browse the repository at this point in the history
* Fix handling of UnprocessableEntityException responses

* Remove unreachable MFA response type and improve tests

* Lint

* Collapse requestID into object
  • Loading branch information
robframpton authored May 23, 2022
1 parent ba9b962 commit e706a2d
Show file tree
Hide file tree
Showing 4 changed files with 214 additions and 22 deletions.
37 changes: 31 additions & 6 deletions src/common/exceptions/unprocessable-entity.exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,41 @@ import { UnprocessableEntityError } from '../interfaces';
export class UnprocessableEntityException extends Error {
readonly status: number = 422;
readonly name: string = 'UnprocessableEntityException';
readonly message: string;
readonly message: string = 'Unprocessable entity';
readonly code?: string;
readonly requestID: string;

constructor(errors: UnprocessableEntityError[], readonly requestID: string) {
constructor({
code,
errors,
message,
requestID,
}: {
code?: string;
errors?: UnprocessableEntityError[];
message?: string;
requestID: string;
}) {
super();
const requirement: string = pluralize('requirement', errors.length);

this.message = `The following ${requirement} must be met:\n`;
this.requestID = requestID;

for (const { code } of errors) {
this.message = this.message.concat(`\t${code}\n`);
if (message) {
this.message = message;
}

if (code) {
this.code = code;
}

if (errors) {
const requirement: string = pluralize('requirement', errors.length);

this.message = `The following ${requirement} must be met:\n`;

for (const { code } of errors) {
this.message = this.message.concat(`\t${code}\n`);
}
}
}
}
10 changes: 2 additions & 8 deletions src/mfa/interfaces/verify-factor-response.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import { Challenge } from './challenge.interface';
export interface VerifyResponseSuccess {

export interface VerifyResponse {
challenge: Challenge;
valid: boolean;
}

export interface VerifyResponseError {
code: string;
message: string;
}

export type VerifyResponse = VerifyResponseSuccess | VerifyResponseError;
133 changes: 133 additions & 0 deletions src/mfa/mfa.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { UnprocessableEntityException } from '../common/exceptions';

import { WorkOS } from '../workos';

Expand All @@ -14,6 +15,7 @@ describe('MFA', () => {
expect(factor).toMatchInlineSnapshot(`Object {}`);
});
});

describe('deleteFactor', () => {
it('sends request to delete a Factor', async () => {
const mock = new MockAdapter(axios);
Expand All @@ -25,6 +27,7 @@ describe('MFA', () => {
expect(mock.history.delete[0].url).toEqual('/auth/factors/conn_123');
});
});

describe('enrollFactor', () => {
describe('with generic', () => {
it('enrolls a factor with generic type', async () => {
Expand Down Expand Up @@ -55,6 +58,7 @@ describe('MFA', () => {
`);
});
});

describe('with totp', () => {
it('enrolls a factor with totp type', async () => {
const mock = new MockAdapter(axios);
Expand Down Expand Up @@ -94,6 +98,7 @@ describe('MFA', () => {
`);
});
});

describe('with sms', () => {
it('enrolls a factor with sms type', async () => {
const mock = new MockAdapter(axios);
Expand Down Expand Up @@ -129,6 +134,34 @@ describe('MFA', () => {
}
`);
});

describe('when phone number is invalid', () => {
it('throws an exception', async () => {
const mock = new MockAdapter(axios);

mock.onPost('/auth/factors/enroll').reply(
422,
{
message: `Phone number is invalid: 'foo'`,
code: 'invalid_phone_number',
},
{
'X-Request-ID': 'req_123',
},
);

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', {
apiHostname: 'api.workos.dev',
});

await expect(
workos.mfa.enrollFactor({
type: 'sms',
phoneNumber: 'foo',
}),
).rejects.toThrow(UnprocessableEntityException);
});
});
});
});

Expand Down Expand Up @@ -234,6 +267,7 @@ describe('MFA', () => {
},
valid: true,
});

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', {
apiHostname: 'api.workos.dev',
});
Expand All @@ -242,6 +276,7 @@ describe('MFA', () => {
authenticationChallengeId: 'auth_challenge_1234',
code: '12345',
});

expect(verifyResponse).toMatchInlineSnapshot(`
Object {
"challenge": Object {
Expand All @@ -258,5 +293,103 @@ describe('MFA', () => {
`);
});
});

describe('when the challenge has been previously verified', () => {
it('throws an exception', async () => {
const mock = new MockAdapter(axios);
mock
.onPost('/auth/factors/verify', {
authentication_challenge_id: 'auth_challenge_1234',
code: '12345',
})
.reply(
422,
{
message: `The authentication challenge '12345' has already been verified.`,
code: 'authentication_challenge_previously_verified',
},
{
'X-Request-ID': 'req_123',
},
);

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', {
apiHostname: 'api.workos.dev',
});

await expect(
workos.mfa.verifyFactor({
authenticationChallengeId: 'auth_challenge_1234',
code: '12345',
}),
).rejects.toThrow(UnprocessableEntityException);
});
});

describe('when the challenge has expired', () => {
it('throws an exception', async () => {
const mock = new MockAdapter(axios);
mock
.onPost('/auth/factors/verify', {
authentication_challenge_id: 'auth_challenge_1234',
code: '12345',
})
.reply(
422,
{
message: `The authentication challenge '12345' has expired.`,
code: 'authentication_challenge_expired',
},
{
'X-Request-ID': 'req_123',
},
);

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', {
apiHostname: 'api.workos.dev',
});

await expect(
workos.mfa.verifyFactor({
authenticationChallengeId: 'auth_challenge_1234',
code: '12345',
}),
).rejects.toThrow(UnprocessableEntityException);
});

it('exception has code', async () => {
const mock = new MockAdapter(axios);
mock
.onPost('/auth/factors/verify', {
authentication_challenge_id: 'auth_challenge_1234',
code: '12345',
})
.reply(
422,
{
message: `The authentication challenge '12345' has expired.`,
code: 'authentication_challenge_expired',
},
{
'X-Request-ID': 'req_123',
},
);

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', {
apiHostname: 'api.workos.dev',
});

try {
await workos.mfa.verifyFactor({
authenticationChallengeId: 'auth_challenge_1234',
code: '12345',
});
} catch (error) {
expect(error).toMatchObject({
code: 'authentication_challenge_expired',
});
}
});
});
});
});
56 changes: 48 additions & 8 deletions src/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ export class WorkOS {
if (response) {
const { status, data, headers } = response;
const requestID = headers['X-Request-ID'];
const { error, error_description: errorDescription } = data;
const {
code,
error_description: errorDescription,
error,
message,
} = data;

switch (status) {
case 401: {
Expand All @@ -101,7 +106,12 @@ export class WorkOS {
case 422: {
const { errors } = data;

throw new UnprocessableEntityException(errors, requestID);
throw new UnprocessableEntityException({
code,
errors,
message,
requestID,
});
}
case 404: {
throw new NotFoundException(path, requestID);
Expand Down Expand Up @@ -143,7 +153,12 @@ export class WorkOS {
if (response) {
const { status, data, headers } = response;
const requestID = headers['X-Request-ID'];
const { error, error_description: errorDescription } = data;
const {
code,
error_description: errorDescription,
error,
message,
} = data;

switch (status) {
case 401: {
Expand All @@ -152,7 +167,12 @@ export class WorkOS {
case 422: {
const { errors } = data;

throw new UnprocessableEntityException(errors, requestID);
throw new UnprocessableEntityException({
code,
errors,
message,
requestID,
});
}
case 404: {
throw new NotFoundException(path, requestID);
Expand Down Expand Up @@ -198,7 +218,12 @@ export class WorkOS {
if (response) {
const { status, data, headers } = response;
const requestID = headers['X-Request-ID'];
const { error, error_description: errorDescription } = data;
const {
code,
error_description: errorDescription,
error,
message,
} = data;

switch (status) {
case 401: {
Expand All @@ -207,7 +232,12 @@ export class WorkOS {
case 422: {
const { errors } = data;

throw new UnprocessableEntityException(errors, requestID);
throw new UnprocessableEntityException({
code,
errors,
message,
requestID,
});
}
case 404: {
throw new NotFoundException(path, requestID);
Expand Down Expand Up @@ -242,7 +272,12 @@ export class WorkOS {
if (response) {
const { status, data, headers } = response;
const requestID = headers['X-Request-ID'];
const { error, error_description: errorDescription } = data;
const {
code,
error_description: errorDescription,
error,
message,
} = data;

switch (status) {
case 401: {
Expand All @@ -251,7 +286,12 @@ export class WorkOS {
case 422: {
const { errors } = data;

throw new UnprocessableEntityException(errors, requestID);
throw new UnprocessableEntityException({
code,
errors,
message,
requestID,
});
}
case 404: {
throw new NotFoundException(path, requestID);
Expand Down

0 comments on commit e706a2d

Please sign in to comment.