Skip to content
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
91 changes: 91 additions & 0 deletions __test__/errorCode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Fast unit test (no Docker/testcontainers): asserts that a GraphQL error's
// extensions.code survives the SDK's response parsing, so consumers like
// authorizer-react can switch on a stable code (e.g. TOO_MANY_REQUESTS for
// the TOTP lockout) instead of matching message text.
jest.mock('cross-fetch', () => jest.fn());

import crossFetch from 'cross-fetch';
import { Authorizer } from '../lib';

const mockFetch = crossFetch as unknown as jest.Mock;

const jsonResponse = (body: unknown, ok = true) =>
Promise.resolve({
ok,
status: ok ? 200 : 500,
text: () => Promise.resolve(JSON.stringify(body)),
});

describe('GraphQL error extensions.code propagation', () => {
const authorizerRef = new Authorizer({
authorizerURL: 'http://localhost:8080',
redirectURL: 'http://localhost:8080/app',
});

afterEach(() => mockFetch.mockReset());

it('surfaces extensions.code on the returned error', async () => {
mockFetch.mockReturnValueOnce(
jsonResponse({
errors: [
{
message: 'too many failed attempts, please try again later',
path: ['verify_otp'],
extensions: { code: 'TOO_MANY_REQUESTS' },
},
],
data: null,
}),
);

const res = await authorizerRef.graphqlQuery({
query: 'mutation { verify_otp(params: {}) { message } }',
});

expect(res.errors).toHaveLength(1);
expect(res.errors[0].message).toBe(
'too many failed attempts, please try again later',
);
expect(res.errors[0].code).toBe('TOO_MANY_REQUESTS');
});

it('leaves code undefined when the server sends no extensions', async () => {
mockFetch.mockReturnValueOnce(
jsonResponse({
errors: [{ message: 'invalid otp', path: ['verify_otp'] }],
data: null,
}),
);

const res = await authorizerRef.graphqlQuery({
query: 'mutation { verify_otp(params: {}) { message } }',
});

expect(res.errors).toHaveLength(1);
expect(res.errors[0].message).toBe('invalid otp');
expect(res.errors[0].code).toBeUndefined();
});

it('attaches code as non-enumerable, so it does not change Object.keys()/JSON.stringify() output', async () => {
mockFetch.mockReturnValueOnce(
jsonResponse({
errors: [
{
message: 'too many failed attempts, please try again later',
extensions: { code: 'TOO_MANY_REQUESTS' },
},
],
data: null,
}),
);

const res = await authorizerRef.graphqlQuery({
query: 'mutation { verify_otp(params: {}) { message } }',
});

const err = res.errors[0];
expect(err.code).toBe('TOO_MANY_REQUESTS');
expect(Object.keys(err)).toEqual([]);
expect(JSON.stringify(err)).toBe('{}');
});
});
10 changes: 3 additions & 7 deletions src/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import crossFetch from 'cross-fetch';
import * as Types from './types';
import { coerceInt64Fields, hasWindow, trimURL } from './utils';
import { toSDKError } from './errors';

// set fetch based on window object. Cross fetch have issues with umd build
const getFetcher = () => (hasWindow() ? window.fetch : crossFetch);
Expand Down Expand Up @@ -31,14 +32,9 @@ const orgSAMLConnectionFragment =
'id org_id name idp_entity_id idp_sso_url sp_entity_id acs_url attribute_mapping allow_idp_initiated is_active created_at updated_at';
const scimEndpointFragment = 'id org_id enabled created_at updated_at';

function toErrorList(errors: unknown): Error[] {
function toErrorList(errors: unknown): Types.AuthorizerSDKError[] {
if (Array.isArray(errors)) {
return errors.map((item) => {
if (item instanceof Error) return item;
if (item && typeof item === 'object' && 'message' in item)
return new Error(String((item as { message: unknown }).message));
return new Error(String(item));
});
return errors.map(toSDKError);
}
if (errors instanceof Error) return [errors];
if (errors !== null && typeof errors === 'object') {
Expand Down
32 changes: 32 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as Types from './types';

// Shared by index.ts and admin.ts's toErrorList array branch, so a future
// extensions.* field is only wired in one place instead of drifting between
// two copies (this is itself a fix for exactly that: admin.ts's copy had no
// test coverage of its own).
//
// `code` is attached as a non-enumerable property: an additive field that
// changes Object.keys()/JSON.stringify() output on every error with a code
// isn't truly invisible to existing consumers, and this way it actually is.
export function toSDKError(item: unknown): Types.AuthorizerSDKError {
if (item instanceof Error) return item;
if (item && typeof item === 'object' && 'message' in item) {
const err: Types.AuthorizerSDKError = new Error(
String((item as { message: unknown }).message),
);
const extensions = (item as { extensions?: unknown }).extensions;
if (extensions && typeof extensions === 'object') {
const code = (extensions as { code?: unknown }).code;
if (typeof code === 'string') {
Object.defineProperty(err, 'code', {
value: code,
enumerable: false,
writable: true,
configurable: true,
});
}
}
return err;
}
return new Error(String(item));
}
10 changes: 3 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
sha256,
trimURL,
} from './utils';
import { toSDKError } from './errors';

// re-usable gql response fragment
const userFragment =
Expand All @@ -25,14 +26,9 @@ const authTokenFragment = `message access_token expires_in refresh_token id_toke
// set fetch based on window object. Cross fetch have issues with umd build
const getFetcher = () => (hasWindow() ? window.fetch : crossFetch);

function toErrorList(errors: unknown): Error[] {
function toErrorList(errors: unknown): Types.AuthorizerSDKError[] {
if (Array.isArray(errors)) {
return errors.map((item) => {
if (item instanceof Error) return item;
if (item && typeof item === 'object' && 'message' in item)
return new Error(String((item as { message: unknown }).message));
return new Error(String(item));
});
return errors.map(toSDKError);
}
if (errors instanceof Error) return [errors];
if (errors !== null && typeof errors === 'object') {
Expand Down
13 changes: 11 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
// AuthorizerSDKError is a native Error additionally carrying the GraphQL
// extensions.code from the server (e.g. "TOO_MANY_REQUESTS"), when present,
// so callers can switch on a stable code instead of matching message text.
// Optional and additive - existing code that only reads `.message` is
// unaffected.
export interface AuthorizerSDKError extends Error {
code?: string;
}

export interface GrapQlResponseType {
data: any | undefined;
errors: Error[];
errors: AuthorizerSDKError[];
}
export interface ApiResponse<T> {
errors: Error[];
errors: AuthorizerSDKError[];
data: T | undefined;
}
/**
Expand Down
Loading