diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index c02c90e6d6..077ed1c65d 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 92.88, + branches: 92.94, functions: 97.26, - lines: 97.84, - statements: 97.36, + lines: 97.87, + statements: 97.39, }, }, }); diff --git a/packages/snaps-rpc-methods/src/restricted/notify.test.ts b/packages/snaps-rpc-methods/src/restricted/notify.test.tsx similarity index 55% rename from packages/snaps-rpc-methods/src/restricted/notify.test.ts rename to packages/snaps-rpc-methods/src/restricted/notify.test.tsx index fc62b2854a..c78a28ad1a 100644 --- a/packages/snaps-rpc-methods/src/restricted/notify.test.ts +++ b/packages/snaps-rpc-methods/src/restricted/notify.test.tsx @@ -1,5 +1,6 @@ import { PermissionType, SubjectType } from '@metamask/permission-controller'; -import { NotificationType } from '@metamask/snaps-sdk'; +import { ContentType, NotificationType } from '@metamask/snaps-sdk'; +import { Box, Text } from '@metamask/snaps-sdk/jsx'; import { getImplementation, @@ -20,6 +21,7 @@ describe('snap_notify', () => { showInAppNotification: jest.fn(), isOnPhishingList: jest.fn(), maybeUpdatePhishingList: jest.fn(), + createInterface: jest.fn(), getSnap: jest.fn(), }; @@ -44,12 +46,14 @@ describe('snap_notify', () => { const isOnPhishingList = jest.fn().mockResolvedValueOnce(false); const getSnap = jest.fn(); const maybeUpdatePhishingList = jest.fn(); + const createInterface = jest.fn(); const notificationImplementation = getImplementation({ showNativeNotification, showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + createInterface, getSnap, }); @@ -70,11 +74,57 @@ describe('snap_notify', () => { }); }); + it('shows inApp notifications with a detailed view', async () => { + const showNativeNotification = jest.fn().mockResolvedValueOnce(true); + const showInAppNotification = jest.fn().mockResolvedValueOnce(true); + const isOnPhishingList = jest.fn().mockResolvedValueOnce(false); + const maybeUpdatePhishingList = jest.fn(); + const createInterface = jest.fn().mockResolvedValueOnce(1); + const getSnap = jest.fn(); + + const notificationImplementation = getImplementation({ + showNativeNotification, + showInAppNotification, + isOnPhishingList, + maybeUpdatePhishingList, + createInterface, + getSnap, + }); + + await notificationImplementation({ + context: { + origin: 'extension', + }, + method: 'snap_notify', + params: { + type: NotificationType.InApp, + message: 'Some message', + title: 'Detailed view title', + content: Hello, + }, + }); + + expect(showInAppNotification).toHaveBeenCalledWith('extension', { + type: NotificationType.InApp, + message: 'Some message', + title: 'Detailed view title', + content: 1, + }); + + expect(createInterface).toHaveBeenCalledWith( + 'extension', + Hello, + undefined, + ContentType.Notification, + ); + }); + it('shows native notification', async () => { const showNativeNotification = jest.fn().mockResolvedValueOnce(true); const showInAppNotification = jest.fn().mockResolvedValueOnce(true); const isOnPhishingList = jest.fn().mockResolvedValueOnce(false); const maybeUpdatePhishingList = jest.fn(); + const createInterface = jest.fn(); const getSnap = jest.fn(); const notificationImplementation = getImplementation({ @@ -82,6 +132,7 @@ describe('snap_notify', () => { showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + createInterface, getSnap, }); @@ -107,6 +158,7 @@ describe('snap_notify', () => { const showInAppNotification = jest.fn().mockResolvedValueOnce(true); const isOnPhishingList = jest.fn().mockResolvedValueOnce(false); const maybeUpdatePhishingList = jest.fn(); + const createInterface = jest.fn(); const getSnap = jest.fn(); const notificationImplementation = getImplementation({ @@ -114,6 +166,7 @@ describe('snap_notify', () => { showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + createInterface, getSnap, }); @@ -139,6 +192,7 @@ describe('snap_notify', () => { const showInAppNotification = jest.fn().mockResolvedValueOnce(true); const isOnPhishingList = jest.fn().mockResolvedValueOnce(false); const maybeUpdatePhishingList = jest.fn(); + const createInterface = jest.fn(); const getSnap = jest.fn(); const notificationImplementation = getImplementation({ @@ -146,6 +200,7 @@ describe('snap_notify', () => { showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + createInterface, getSnap, }); @@ -163,12 +218,91 @@ describe('snap_notify', () => { }), ).rejects.toThrow('Must specify a valid notification "type".'); }); + }); + + describe('getValidatedParams', () => { + it('throws an error if the params is not an object', () => { + const isOnPhishingList = jest.fn().mockResolvedValue(true); + expect(() => getValidatedParams([], isOnPhishingList, jest.fn())).toThrow( + 'Expected params to be a single object.', + ); + }); + + it('throws an error if the type is missing from params object', () => { + const isOnPhishingList = jest.fn().mockResolvedValue(true); + expect(() => + getValidatedParams( + { type: undefined, message: 'Something happened.' }, + isOnPhishingList, + jest.fn(), + ), + ).toThrow('Must specify a valid notification "type".'); + }); + + it('throws an error if the message is empty', () => { + const isOnPhishingList = jest.fn().mockResolvedValue(true); + expect(() => + getValidatedParams( + { type: NotificationType.InApp, message: '' }, + isOnPhishingList, + jest.fn(), + ), + ).toThrow( + 'Must specify a non-empty string "message" less than 500 characters long.', + ); + }); + + it('throws an error if the message is not a string', () => { + const isOnPhishingList = jest.fn().mockResolvedValue(true); + expect(() => + getValidatedParams( + { type: NotificationType.InApp, message: 123 }, + isOnPhishingList, + jest.fn(), + ), + ).toThrow( + 'Must specify a non-empty string "message" less than 500 characters long.', + ); + }); + + it('throws an error if the message is larger than or equal to 50 characters for native notifications', () => { + const isOnPhishingList = jest.fn().mockResolvedValue(true); + expect(() => + getValidatedParams( + { + type: NotificationType.Native, + message: 'test'.repeat(20), + }, + isOnPhishingList, + jest.fn(), + ), + ).toThrow( + 'Must specify a non-empty string "message" less than 50 characters long.', + ); + }); - it('throws an error if a link is on the phishing list', async () => { + it('throws an error if the message is larger than or equal to 500 characters for in app notifications', () => { + const isOnPhishingList = jest.fn().mockResolvedValue(true); + expect(() => + getValidatedParams( + { + type: NotificationType.InApp, + message: 'test'.repeat(150), + }, + isOnPhishingList, + jest.fn(), + ), + ).toThrow( + 'Must specify a non-empty string "message" less than 500 characters long.', + ); + }); + + it('throws an error if a link in the `message` property is on the phishing list', async () => { const showNativeNotification = jest.fn().mockResolvedValueOnce(true); const showInAppNotification = jest.fn().mockResolvedValueOnce(true); - const isOnPhishingList = jest.fn().mockResolvedValueOnce(true); + const isOnPhishingList = jest.fn().mockResolvedValue(true); const maybeUpdatePhishingList = jest.fn(); + const createInterface = jest.fn(); const getSnap = jest.fn(); const notificationImplementation = getImplementation({ @@ -176,6 +310,7 @@ describe('snap_notify', () => { showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + createInterface, getSnap, }); @@ -186,18 +321,19 @@ describe('snap_notify', () => { }, method: 'snap_notify', params: { - type: 'native', + type: 'inApp', message: '[test link](https://foo.bar)', }, }), ).rejects.toThrow('Invalid URL: The specified URL is not allowed.'); }); - it('throws an error if a link is invalid', async () => { + it('throws an error if a link in the `message` property is invalid', async () => { const showNativeNotification = jest.fn().mockResolvedValueOnce(true); const showInAppNotification = jest.fn().mockResolvedValueOnce(true); - const isOnPhishingList = jest.fn().mockResolvedValueOnce(true); + const isOnPhishingList = jest.fn().mockResolvedValue(true); const maybeUpdatePhishingList = jest.fn(); + const createInterface = jest.fn(); const getSnap = jest.fn(); const notificationImplementation = getImplementation({ @@ -205,6 +341,7 @@ describe('snap_notify', () => { showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + createInterface, getSnap, }); @@ -215,7 +352,7 @@ describe('snap_notify', () => { }, method: 'snap_notify', params: { - type: 'native', + type: 'inApp', message: '[test](http://foo.bar)', }, }), @@ -223,63 +360,94 @@ describe('snap_notify', () => { 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', ); }); - }); - describe('getValidatedParams', () => { - it('throws an error if the params is not an object', () => { - expect(() => getValidatedParams([])).toThrow( - 'Expected params to be a single object.', - ); - }); + it('throws an error if a link in the `footerLink` property is on the phishing list', async () => { + const showNativeNotification = jest.fn().mockResolvedValueOnce(true); + const showInAppNotification = jest.fn().mockResolvedValueOnce(true); + const isOnPhishingList = jest.fn().mockResolvedValue(true); + const maybeUpdatePhishingList = jest.fn(); + const createInterface = jest.fn(); + const getSnap = jest.fn(); - it('throws an error if the type is missing from params object', () => { - expect(() => - getValidatedParams({ type: undefined, message: 'Something happened.' }), - ).toThrow('Must specify a valid notification "type".'); - }); + const notificationImplementation = getImplementation({ + showNativeNotification, + showInAppNotification, + isOnPhishingList, + maybeUpdatePhishingList, + createInterface, + getSnap, + }); - it('throws an error if the message is empty', () => { - expect(() => - getValidatedParams({ type: NotificationType.InApp, message: '' }), - ).toThrow( - 'Must specify a non-empty string "message" less than 500 characters long.', + const content = ( + + Hello, world! + ); - }); - it('throws an error if the message is not a string', () => { - expect(() => - getValidatedParams({ type: NotificationType.InApp, message: 123 }), - ).toThrow( - 'Must specify a non-empty string "message" less than 500 characters long.', - ); + await expect( + notificationImplementation({ + context: { + origin: 'extension', + }, + method: 'snap_notify', + params: { + type: 'inApp', + message: 'message', + footerLink: { href: 'https://www.metamask.io', text: 'test' }, + title: 'A title', + content, + }, + }), + ).rejects.toThrow('Invalid URL: The specified URL is not allowed.'); }); - it('throws an error if the message is larger than or equal to 50 characters for native notifications', () => { - expect(() => - getValidatedParams({ - type: NotificationType.Native, - message: - 'test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg', - }), - ).toThrow( - 'Must specify a non-empty string "message" less than 50 characters long.', + it('throws an error if a link in the `footerLink` property is invalid', async () => { + const showNativeNotification = jest.fn().mockResolvedValueOnce(true); + const showInAppNotification = jest.fn().mockResolvedValueOnce(true); + const isOnPhishingList = jest.fn().mockResolvedValue(true); + const maybeUpdatePhishingList = jest.fn(); + const createInterface = jest.fn(); + const getSnap = jest.fn(); + + const notificationImplementation = getImplementation({ + showNativeNotification, + showInAppNotification, + isOnPhishingList, + maybeUpdatePhishingList, + createInterface, + getSnap, + }); + + const content = ( + + Hello, world! + ); - }); - it('throws an error if the message is larger than or equal to 500 characters for in app notifications', () => { - expect(() => - getValidatedParams({ - type: NotificationType.InApp, - message: - 'test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg_test_msg', + await expect( + notificationImplementation({ + context: { + origin: 'extension', + }, + method: 'snap_notify', + params: { + type: 'inApp', + message: 'message', + footerLink: { href: 'http://foo.bar', text: 'test' }, + title: 'A title', + content, + }, }), - ).toThrow( - 'Must specify a non-empty string "message" less than 500 characters long.', + ).rejects.toThrow( + 'Invalid params: Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', ); }); it('returns valid parameters', () => { - expect(getValidatedParams(validParams)).toStrictEqual(validParams); + const isNotOnPhishingList = jest.fn().mockResolvedValueOnce(false); + expect( + getValidatedParams(validParams, isNotOnPhishingList, jest.fn()), + ).toStrictEqual(validParams); }); }); }); diff --git a/packages/snaps-rpc-methods/src/restricted/notify.ts b/packages/snaps-rpc-methods/src/restricted/notify.ts index 86d551951a..82e46a3033 100644 --- a/packages/snaps-rpc-methods/src/restricted/notify.ts +++ b/packages/snaps-rpc-methods/src/restricted/notify.ts @@ -5,31 +5,68 @@ import type { } from '@metamask/permission-controller'; import { PermissionType, SubjectType } from '@metamask/permission-controller'; import { rpcErrors } from '@metamask/rpc-errors'; -import { NotificationType } from '@metamask/snaps-sdk'; import type { NotifyParams, NotifyResult, - EnumToUnion, + InterfaceContext, + ComponentOrElement, } from '@metamask/snaps-sdk'; -import { type Snap, validateTextLinks } from '@metamask/snaps-utils'; +import { + enumValue, + NotificationType, + union, + ContentType, + getErrorMessage, + ComponentOrElementStruct, +} from '@metamask/snaps-sdk'; +import { + createUnion, + validateLink, + validateTextLinks, + type Snap, +} from '@metamask/snaps-utils'; +import type { InferMatching } from '@metamask/snaps-utils'; +import { object, string, optional } from '@metamask/superstruct'; import type { NonEmptyArray } from '@metamask/utils'; -import { isObject } from '@metamask/utils'; +import { hasProperty, isObject } from '@metamask/utils'; import { type MethodHooksObject } from '../utils'; const methodName = 'snap_notify'; -export type NotificationArgs = { - /** - * Enum type to determine notification type. - */ - type: EnumToUnion; +const NativeNotificationStruct = object({ + type: enumValue(NotificationType.Native), + message: string(), +}); - /** - * A message to show on the notification. - */ - message: string; -}; +const InAppNotificationStruct = object({ + type: enumValue(NotificationType.InApp), + message: string(), +}); + +const InAppNotificationWithDetailsStruct = object({ + type: enumValue(NotificationType.InApp), + message: string(), + content: ComponentOrElementStruct, + title: string(), + footerLink: optional( + object({ + href: string(), + text: string(), + }), + ), +}); + +const NotificationParametersStruct = union([ + InAppNotificationStruct, + InAppNotificationWithDetailsStruct, + NativeNotificationStruct, +]); + +export type NotificationArgs = InferMatching< + typeof NotificationParametersStruct, + NotifyParams +>; export type NotifyMethodHooks = { /** @@ -54,6 +91,12 @@ export type NotifyMethodHooks = { maybeUpdatePhishingList: () => Promise; + createInterface: ( + origin: string, + content: ComponentOrElement, + context?: InterfaceContext, + contentType?: ContentType, + ) => Promise; getSnap: (snapId: string) => Snap | undefined; }; @@ -97,6 +140,7 @@ const methodHooks: MethodHooksObject = { showInAppNotification: true, isOnPhishingList: true, maybeUpdatePhishingList: true, + createInterface: true, getSnap: true, }; @@ -114,6 +158,7 @@ export const notifyBuilder = Object.freeze({ * @param hooks.showInAppNotification - A function that shows a notification in the MetaMask UI. * @param hooks.isOnPhishingList - A function that checks for links against the phishing list. * @param hooks.maybeUpdatePhishingList - A function that updates the phishing list if needed. + * @param hooks.createInterface - A function that creates the interface in SnapInterfaceController. * @param hooks.getSnap - A function that checks if a snap is installed. * @returns The method implementation which returns `null` on success. * @throws If the params are invalid. @@ -123,6 +168,7 @@ export function getImplementation({ showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + createInterface, getSnap, }: NotifyMethodHooks) { return async function implementation( @@ -133,11 +179,23 @@ export function getImplementation({ context: { origin }, } = args; - const validatedParams = getValidatedParams(params); - await maybeUpdatePhishingList(); - validateTextLinks(validatedParams.message, isOnPhishingList, getSnap); + const validatedParams = getValidatedParams( + params, + isOnPhishingList, + getSnap, + ); + + if (hasProperty(validatedParams, 'content')) { + const id = await createInterface( + origin, + validatedParams.content as ComponentOrElement, + undefined, + ContentType.Notification, + ); + validatedParams.content = id; + } switch (validatedParams.type) { case NotificationType.Native: @@ -157,9 +215,16 @@ export function getImplementation({ * type. Throws if validation fails. * * @param params - The unvalidated params object from the method request. + * @param isOnPhishingList - The function that checks for links against the phishing list. + * @param getSnap - A function that checks if a snap is installed. * @returns The validated method parameter object. + * @throws If the params are invalid. */ -export function getValidatedParams(params: unknown): NotifyParams { +export function getValidatedParams( + params: unknown, + isOnPhishingList: NotifyMethodHooks['isOnPhishingList'], + getSnap: NotifyMethodHooks['getSnap'], +): NotifyParams { if (!isObject(params)) { throw rpcErrors.invalidParams({ message: 'Expected params to be a single object.', @@ -200,5 +265,23 @@ export function getValidatedParams(params: unknown): NotifyParams { }); } - return params as NotificationArgs; + try { + const validatedParams = createUnion( + params, + NotificationParametersStruct, + 'type', + ); + + validateTextLinks(validatedParams.message, isOnPhishingList, getSnap); + + if (hasProperty(validatedParams, 'footerLink')) { + validateLink(validatedParams.footerLink.href, isOnPhishingList, getSnap); + } + + return validatedParams; + } catch (error) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${getErrorMessage(error)}`, + }); + } } diff --git a/packages/snaps-sdk/src/types/methods/notify.ts b/packages/snaps-sdk/src/types/methods/notify.ts index d9a5f7ca9b..baf48da8d1 100644 --- a/packages/snaps-sdk/src/types/methods/notify.ts +++ b/packages/snaps-sdk/src/types/methods/notify.ts @@ -1,4 +1,5 @@ -import type { EnumToUnion } from '../../internals'; +import { type EnumToUnion } from '../../internals'; +import type { ComponentOrElement } from '../interface'; /** * The types of notifications that can be displayed. @@ -17,10 +18,22 @@ export enum NotificationType { * @property type - The type of notification to display. * @property message - The message to display in the notification. */ -export type NotifyParams = { - type: EnumToUnion; - message: string; -}; +export type NotifyParams = + | { + type: EnumToUnion; + message: string; + } + | { + type: EnumToUnion; + message: string; + } + | { + type: EnumToUnion; + message: string; + content: ComponentOrElement; + title: string; + footerLink?: { href: string; text: string }; + }; /** * The result returned by the `snap_notify` method.