diff --git a/__test__/errorCode.test.ts b/__test__/errorCode.test.ts new file mode 100644 index 0000000..9032852 --- /dev/null +++ b/__test__/errorCode.test.ts @@ -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('{}'); + }); +}); diff --git a/src/admin.ts b/src/admin.ts index 9075e46..c0acaef 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -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); @@ -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') { diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..7f02e61 --- /dev/null +++ b/src/errors.ts @@ -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)); +} diff --git a/src/index.ts b/src/index.ts index c677f37..1b338a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { sha256, trimURL, } from './utils'; +import { toSDKError } from './errors'; // re-usable gql response fragment const userFragment = @@ -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') { diff --git a/src/types.ts b/src/types.ts index 029d1c6..ca18e6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { - errors: Error[]; + errors: AuthorizerSDKError[]; data: T | undefined; } /**