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
1 change: 1 addition & 0 deletions jest.config.base.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ module.exports = {
'!./src/**/*.test.tsx',
'!./src/**/*.test.browser.ts',
'!./src/test-utils/**/*.ts',
'!./src/test-utils/**/*.tsx',
'!./src/**/*.d.ts',
'!./src/**/__test__/**',
'!./src/**/__mocks__/**',
Expand Down
2 changes: 1 addition & 1 deletion packages/examples/packages/images/snap.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SnapConfig } from '@metamask/snaps-cli';

const config: SnapConfig = {
input: './src/index.ts',
input: './src/index.tsx',
server: {
port: 8026,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/examples/packages/images/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
"shasum": "1naltBL/H7TBXHn+Zxgrq7wz6HUKiUUxFQTYTbp5KkQ=",
"shasum": "A2pzU53b9kEBkckTMFDxcPr9/lbaEsOvNUJK+rNx3/8=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { expect } from '@jest/globals';
import { installSnap } from '@metamask/snaps-jest';
import { DialogType, image, panel, text } from '@metamask/snaps-sdk';
import { installSnap, assertIsAlertDialog } from '@metamask/snaps-jest';
import { Box, Text, Image } from '@metamask/snaps-sdk/jsx';
import { renderSVG } from 'uqr';

describe('onRpcRequest', () => {
it('throws an error if the requested method does not exist', async () => {
const { request, close } = await installSnap();
const { request } = await installSnap();

const response = await request({
method: 'foo',
Expand All @@ -20,8 +20,6 @@ describe('onRpcRequest', () => {
cause: null,
},
});

await close();
});

describe('getQrCode', () => {
Expand All @@ -36,52 +34,42 @@ describe('onRpcRequest', () => {
});

const ui = await response.getInterface();
assertIsAlertDialog(ui);

expect(ui).toRender(
panel([
text(`The following is a QR code for the data "Hello, world!":`),
image(renderSVG('Hello, world!')),
]),
<Box>
<Text>
The following is a QR code for the data "{'Hello, world!'}":
</Text>
<Image src={renderSVG('Hello, world!')} />
</Box>,
);

// TODO(ritave): Fix types in SnapInterface
await (ui as any).ok();
await ui.ok();

expect(await response).toRespondWith(null);
});
});

describe('getCat', () => {
// This test is flaky, so we disable it for now.
// eslint-disable-next-line jest/no-disabled-tests
it.skip('shows a cat', async () => {
it('shows a cat using an external URL', async () => {
const { request } = await installSnap();

const response = request({
method: 'getCat',
});

const ui = await response.getInterface();
expect(ui).toStrictEqual(
expect.objectContaining({
type: DialogType.Alert,
content: {
type: 'panel',
children: [
{
type: 'text',
value: 'Enjoy your cat!',
},
{
type: 'image',
value: expect.any(String),
},
],
},
}),
assertIsAlertDialog(ui);

expect(ui).toRender(
<Box>
<Text>Enjoy your cat!</Text>
<Image src="https://cataas.com/cat" height={400} width={400} />
</Box>,
);

// TODO(ritave): Fix types in SnapInterface
await (ui as any).ok();
await ui.ok();

expect(await response).toRespondWith(null);
});
Expand All @@ -96,6 +84,8 @@ describe('onRpcRequest', () => {
});

const ui = await response.getInterface();
assertIsAlertDialog(ui);

// eslint-disable-next-line jest/prefer-strict-equal
expect(ui.content).toEqual({
type: 'Box',
Expand All @@ -120,8 +110,7 @@ describe('onRpcRequest', () => {
key: null,
});

// TODO(ritave): Fix types in SnapInterface
await (ui as any).ok();
await ui.ok();

expect(await response).toRespondWith(null);
});
Expand All @@ -136,6 +125,8 @@ describe('onRpcRequest', () => {
});

const ui = await response.getInterface();
assertIsAlertDialog(ui);

// eslint-disable-next-line jest/prefer-strict-equal
expect(ui.content).toEqual({
type: 'Box',
Expand All @@ -160,8 +151,7 @@ describe('onRpcRequest', () => {
key: null,
});

// TODO(ritave): Fix types in SnapInterface
await (ui as any).ok();
await ui.ok();

expect(await response).toRespondWith(null);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import type { OnRpcRequestHandler } from '@metamask/snaps-sdk';
import {
DialogType,
getImageComponent,
image,
panel,
text,
MethodNotFoundError,
} from '@metamask/snaps-sdk';
import { DialogType, MethodNotFoundError } from '@metamask/snaps-sdk';
import { Box, Text, Image } from '@metamask/snaps-sdk/jsx';
import { renderSVG } from 'uqr';

import pngIcon from './images/icon.png';
Expand All @@ -26,10 +20,8 @@ type GetQrCodeParams = {
* `wallet_invokeSnap` method. This handler handles two methods:
*
* - `getQrCode`: Show a QR code to the user. The QR code is generated using
* the `uqr` library, and rendered using the `image` component.
* - `getCat`: Show a cat to the user. The cat image is fetched using the
* `getImageComponent` helper. The helper returns an `image` component, which
* can be rendered in a Snap dialog, for example.
* the `uqr` library, and rendered using the `Image` component.
* - `getCat`: Show a cat to the user using an external image.
*
* @param params - The request parameters.
* @param params.request - The JSON-RPC request object.
Expand All @@ -44,17 +36,19 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
const { data } = request.params as GetQrCodeParams;

// `renderSVG` returns a `<svg>` element as a string, which can be
// rendered using the `image` component.
// rendered using the `Image` component.
const qr = renderSVG(data);

return await snap.request({
method: 'snap_dialog',
params: {
type: DialogType.Alert,
content: panel([
text(`The following is a QR code for the data "${data}":`),
image(qr),
]),
content: (
<Box>
<Text>The following is a QR code for the data "{data}":</Text>
<Image src={qr} />
</Box>
),
},
});
}
Expand All @@ -64,15 +58,12 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
method: 'snap_dialog',
params: {
type: DialogType.Alert,
content: panel([
text('Enjoy your cat!'),

// The `getImageComponent` helper can also be used to fetch an image
// from a URL and render it using the `image` component.
await getImageComponent('https://cataas.com/cat', {
width: 400,
}),
]),
content: (
<Box>
<Text>Enjoy your cat!</Text>
<Image src="https://cataas.com/cat" width={400} height={400} />
</Box>
),
},
});
}
Expand All @@ -84,8 +75,13 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
type: DialogType.Alert,

// `.svg` files are imported as strings, so they can be used directly
// with the `image` component.
content: panel([text('Here is an SVG icon:'), image(svgIcon)]),
// with the `Image` component.
content: (
<Box>
<Text>Here is an SVG icon:</Text>
<Image src={svgIcon} />
</Box>
),
},
});
}
Expand All @@ -97,8 +93,13 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
type: DialogType.Alert,

// `.png` files are imported as SVGs containing an `<image>` tag,
// so they can be used directly with the `image` component.
content: panel([text('Here is a PNG icon:'), image(pngIcon)]),
// so they can be used directly with the `Image` component.
content: (
<Box>
<Text>Here is a PNG icon:</Text>
<Image src={pngIcon} />
</Box>
),
},
});
}
Expand Down
4 changes: 2 additions & 2 deletions packages/snaps-controllers/coverage.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"branches": 94.77,
"functions": 98.03,
"branches": 95.16,
"functions": 99,
"lines": 98.63,
"statements": 98.43
}
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,39 @@ describe('SnapInterfaceController', () => {
).toThrow('A Snap interface context may not be larger than 5 MB');
});

it('throws if the Snap attempts to use external images without permission', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger =
getRestrictedSnapInterfaceControllerMessenger(rootMessenger);

rootMessenger.registerActionHandler(
'PermissionController:hasPermission',
() => false,
);

// eslint-disable-next-line no-new
new SnapInterfaceController({
messenger: controllerMessenger,
});

const element = (
<Box>
<Image src="https://metamask.io/foo.png" />
</Box>
);

expect(() =>
rootMessenger.call(
'SnapInterfaceController:createInterface',
MOCK_SNAP_ID,
element,
{},
),
).toThrow(
'Using external images is only permitted with the `endowment:network-access` permission.',
);
});

it('throws if a link is on the phishing list', async () => {
const rootMessenger = getRootSnapInterfaceControllerMessenger();
const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
} from '@metamask/base-controller';
import { BaseController } from '@metamask/base-controller';
import type { Messenger } from '@metamask/messenger';
import type { HasPermission } from '@metamask/permission-controller';
import type { TestOrigin } from '@metamask/phishing-controller';
import type {
InterfaceState,
Expand Down Expand Up @@ -114,7 +115,8 @@ export type SnapInterfaceControllerAllowedActions =
| MultichainAssetsControllerGetStateAction
| AccountsControllerGetSelectedMultichainAccountAction
| AccountsControllerGetAccountByAddressAction
| AccountsControllerListMultichainAccountsAction;
| AccountsControllerListMultichainAccountsAction
| HasPermission;

export type SnapInterfaceControllerActions =
| CreateInterface
Expand Down Expand Up @@ -282,7 +284,7 @@ export class SnapInterfaceController extends BaseController<
contentType?: ContentType,
) {
const element = getJsxInterface(content);
this.#validateContent(element);
this.#validateContent(snapId, element);
validateInterfaceContext(context);

const id = nanoid();
Expand Down Expand Up @@ -339,7 +341,7 @@ export class SnapInterfaceController extends BaseController<
) {
this.#validateArgs(snapId, id);
const element = getJsxInterface(content);
this.#validateContent(element);
this.#validateContent(snapId, element);
validateInterfaceContext(context);

const oldState = this.state.interfaces[id].state;
Expand Down Expand Up @@ -530,13 +532,22 @@ export class SnapInterfaceController extends BaseController<
return this.messenger.call('SnapController:get', id);
}

#hasPermission(snapId: SnapId, permission: string) {
return this.messenger.call(
'PermissionController:hasPermission',
snapId,
permission,
);
}

/**
* Utility function to validate the components of an interface.
* Throws if something is invalid.
*
* @param snapId - The Snap ID.
* @param element - The JSX element to verify.
*/
#validateContent(element: JSXElement) {
#validateContent(snapId: SnapId, element: JSXElement) {
// We assume the validity of this JSON to be validated by the caller.
// E.g., in the RPC method implementation.
const size = getJsonSizeUnsafe(element);
Expand All @@ -549,6 +560,7 @@ export class SnapInterfaceController extends BaseController<
isOnPhishingList: this.#checkPhishingList.bind(this),
getSnap: this.#getSnap.bind(this),
getAccountByAddress: this.#getAccountByAddress.bind(this),
hasPermission: this.#hasPermission.bind(this, snapId),
});
}

Expand Down
Loading
Loading