diff --git a/packages/controllers/jest.config.js b/packages/controllers/jest.config.js index 136310f785..d5e4214501 100644 --- a/packages/controllers/jest.config.js +++ b/packages/controllers/jest.config.js @@ -8,10 +8,10 @@ module.exports = { coveragePathIgnorePatterns: ['/node_modules/', '/mocks/', '/test/'], coverageThreshold: { global: { - branches: 86, + branches: 85.97, functions: 95.33, lines: 94.88, - statements: 94.98, + statements: 94.97, }, }, projects: [ diff --git a/packages/controllers/src/snaps/endowments/keyring.test.ts b/packages/controllers/src/snaps/endowments/keyring.test.ts index 98346e2f50..fe23929f4f 100644 --- a/packages/controllers/src/snaps/endowments/keyring.test.ts +++ b/packages/controllers/src/snaps/endowments/keyring.test.ts @@ -181,7 +181,9 @@ describe('keyringCaveatSpecifications', () => { namespaces: undefined, }, }), - ).toThrow('Expected a valid namespaces object.'); + ).toThrow( + 'Invalid namespaces object: Expected an object, but received: undefined.', + ); }); }); }); diff --git a/packages/controllers/src/snaps/endowments/keyring.ts b/packages/controllers/src/snaps/endowments/keyring.ts index c04811369a..30247e2af1 100644 --- a/packages/controllers/src/snaps/endowments/keyring.ts +++ b/packages/controllers/src/snaps/endowments/keyring.ts @@ -1,5 +1,5 @@ import { - isNamespacesObject, + assertIsNamespacesObject, Namespaces, SnapCaveatType, } from '@metamask/snap-utils'; @@ -92,11 +92,7 @@ function validateCaveatNamespace(caveat: Caveat): void { }); } - if (!isNamespacesObject(value.namespaces)) { - throw ethErrors.rpc.invalidParams({ - message: 'Expected a valid namespaces object.', - }); - } + assertIsNamespacesObject(value.namespaces, ethErrors.rpc.invalidParams); } /** diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js index 4a42050639..7885fd5e88 100644 --- a/packages/utils/jest.config.js +++ b/packages/utils/jest.config.js @@ -15,10 +15,10 @@ module.exports = { coverageReporters: ['clover', 'json', 'lcov', 'text', 'json-summary'], coverageThreshold: { global: { - branches: 86.05, - functions: 97.14, - lines: 96.79, - statements: 96.86, + branches: 86.54, + functions: 97.19, + lines: 96.82, + statements: 96.89, }, }, moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'], diff --git a/packages/utils/src/assert.test.ts b/packages/utils/src/assert.test.ts index c3077d467f..5d06ce01c3 100644 --- a/packages/utils/src/assert.test.ts +++ b/packages/utils/src/assert.test.ts @@ -10,11 +10,11 @@ describe('assertStruct', () => { it('throws meaningful error messages for an invalid value', () => { expect(() => assertStruct({ data: 'foo' }, EventStruct)).toThrow( - 'Assertion failed: At path: name -- Expected a string, but received: undefined', + 'Assertion failed: At path: name -- Expected a string, but received: undefined.', ); expect(() => assertStruct({ name: 1, data: 'foo' }, EventStruct)).toThrow( - 'Assertion failed: At path: name -- Expected a string, but received: 1', + 'Assertion failed: At path: name -- Expected a string, but received: 1.', ); }); @@ -22,7 +22,39 @@ describe('assertStruct', () => { expect(() => assertStruct({ data: 'foo' }, EventStruct, 'Invalid event'), ).toThrow( - 'Invalid event: At path: name -- Expected a string, but received: undefined', + 'Invalid event: At path: name -- Expected a string, but received: undefined.', + ); + }); + + it('throws with a custom error class', () => { + class CustomError extends Error { + constructor({ message }: { message: string }) { + super(message); + this.name = 'CustomError'; + } + } + + expect(() => + assertStruct({ data: 'foo' }, EventStruct, 'Invalid event', CustomError), + ).toThrow( + new CustomError({ + message: + 'Invalid event: At path: name -- Expected a string, but received: undefined.', + }), + ); + }); + + it('throws with a custom error function', () => { + const CustomError = ({ message }: { message: string }) => + new Error(message); + + expect(() => + assertStruct({ data: 'foo' }, EventStruct, 'Invalid event', CustomError), + ).toThrow( + CustomError({ + message: + 'Invalid event: At path: name -- Expected a string, but received: undefined.', + }), ); }); }); diff --git a/packages/utils/src/assert.ts b/packages/utils/src/assert.ts index 243ee6bb1b..842658dd5e 100644 --- a/packages/utils/src/assert.ts +++ b/packages/utils/src/assert.ts @@ -1,6 +1,22 @@ import { AssertionError } from '@metamask/utils'; import { Struct, assert as assertSuperstruct } from 'superstruct'; +export type AssertionErrorConstructor = + | (new (args: { message: string }) => Error) + | ((args: { message: string }) => Error); + +/** + * Check if a value is a constructor. + * + * @param fn - The value to check. + * @returns `true` if the value is a constructor, or `false` otherwise. + */ +function isConstructable( + fn: AssertionErrorConstructor, +): fn is new (args: { message: string }) => Error { + return Boolean(typeof fn?.prototype?.constructor?.name === 'string'); +} + /** * Assert a value against a Superstruct struct. * @@ -8,18 +24,27 @@ import { Struct, assert as assertSuperstruct } from 'superstruct'; * @param struct - The struct to validate against. * @param errorPrefix - A prefix to add to the error message. Defaults to * "Assertion failed". + * @param ErrorWrapper - The error class to throw if the assertion fails. + * Defaults to {@link AssertionError}. * @throws If the value is not valid. */ export function assertStruct( value: unknown, struct: Struct, errorPrefix = 'Assertion failed', + ErrorWrapper: AssertionErrorConstructor = AssertionError, ): asserts value is T { try { assertSuperstruct(value, struct); } catch (error) { - throw new AssertionError({ - message: `${errorPrefix}: ${error.message}`, - }); + if (isConstructable(ErrorWrapper)) { + throw new ErrorWrapper({ + message: `${errorPrefix}: ${error.message}.`, + }); + } else { + throw ErrorWrapper({ + message: `${errorPrefix}: ${error.message}.`, + }); + } } } diff --git a/packages/utils/src/namespace.test.ts b/packages/utils/src/namespace.test.ts index 95ea12f89c..6242d11e81 100644 --- a/packages/utils/src/namespace.test.ts +++ b/packages/utils/src/namespace.test.ts @@ -7,6 +7,7 @@ import { import { assertIsConnectArguments, assertIsMultiChainRequest, + assertIsNamespacesObject, assertIsSession, isAccountId, isAccountIdArray, @@ -645,3 +646,40 @@ describe('isNamespacesObject', () => { expect(isNamespacesObject(object)).toBe(false); }); }); + +describe('assertIsNamespacesObject', () => { + it.each([ + {}, + { eip155: getNamespace() }, + { bip122: getNamespace() }, + { eip155: getNamespace(), bip122: getNamespace() }, + ])('does not throw for a valid namespaces object', (object) => { + expect(() => assertIsNamespacesObject(object)).not.toThrow(); + }); + + it.each([ + true, + false, + null, + undefined, + 1, + 'foo', + { eip155: {} }, + { eip155: [], bip122: [] }, + { eip155: true, bip122: true }, + { eip155: false, bip122: false }, + { eip155: null, bip122: null }, + { eip155: undefined, bip122: undefined }, + { eip155: 1, bip122: 1 }, + { eip155: 'foo', bip122: 'foo' }, + { eip155: { methods: [] }, bip122: { methods: [] } }, + { eip155: { chains: ['foo'] }, bip122: { chains: ['foo'] } }, + { a: getNamespace() }, + { eip155: getNamespace(), a: getNamespace() }, + { foobarbaz: getNamespace() }, + ])('throws for an invalid namespaces object', (object) => { + expect(() => assertIsNamespacesObject(object)).toThrow( + 'Invalid namespaces object:', + ); + }); +}); diff --git a/packages/utils/src/namespace.ts b/packages/utils/src/namespace.ts index 7cb27a843b..f191fee160 100644 --- a/packages/utils/src/namespace.ts +++ b/packages/utils/src/namespace.ts @@ -14,7 +14,7 @@ import { pick, } from 'superstruct'; import { JsonRpcRequestStruct } from '@metamask/utils'; -import { assertStruct } from './assert'; +import { AssertionErrorConstructor, assertStruct } from './assert'; export const CHAIN_ID_REGEX = /^(?[-a-z0-9]{3,8}):(?[-a-zA-Z0-9]{1,32})$/u; @@ -276,3 +276,23 @@ export function isNamespace(value: unknown): value is Namespace { export function isNamespacesObject(value: unknown): value is Namespaces { return is(value, NamespacesStruct); } + +/** + * Assert that the given value is a {@link Namespaces} object. + * + * @param value - The value to check. + * @param ErrorWrapper - The error wrapper to use. Defaults to + * {@link AssertionError}. + * @throws If the value is not a valid {@link Namespaces} object. + */ +export function assertIsNamespacesObject( + value: unknown, + ErrorWrapper?: AssertionErrorConstructor, +): asserts value is Namespaces { + assertStruct( + value, + NamespacesStruct, + 'Invalid namespaces object', + ErrorWrapper, + ); +}