From a18ec231843caaa6465ae7924fcae21cf6dbd0d9 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 3 Nov 2020 10:00:34 -0800 Subject: [PATCH] Add approval controller (#289) * Add approval controller * Add approval controller tests * Add JavaScript tests for approval controller, update CI config --- .circleci/config.yml | 23 +- package.json | 5 +- src/approval/ApprovalController.ts | 380 +++++++++++++++++++++++++++++ tests/ApprovalController.test.js | 151 ++++++++++++ tests/ApprovalController.test.ts | 363 +++++++++++++++++++++++++++ yarn.lock | 5 + 6 files changed, 916 insertions(+), 11 deletions(-) create mode 100644 src/approval/ApprovalController.ts create mode 100644 tests/ApprovalController.test.js create mode 100644 tests/ApprovalController.test.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 99c14412b4f..db75e30c1b6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,7 +4,7 @@ workflows: build-test: jobs: - prep-deps - - test-build: + - prep-build: requires: - prep-deps - test-lint: @@ -12,13 +12,12 @@ workflows: - prep-deps - test-unit: requires: - - prep-deps + - prep-build - test-format: requires: - - prep-deps + - prep-build - all-tests-pass: requires: - - test-build - test-lint - test-unit - test-format @@ -43,7 +42,7 @@ jobs: - node_modules - build-artifacts - test-format: + prep-build: docker: - image: circleci/node:10 steps: @@ -51,10 +50,14 @@ jobs: - attach_workspace: at: . - run: - name: Format - command: yarn format + name: Build + command: yarn build + - persist_to_workspace: + root: . + paths: + - dist - test-build: + test-format: docker: - image: circleci/node:10 steps: @@ -62,8 +65,8 @@ jobs: - attach_workspace: at: . - run: - name: Build project - command: yarn build + name: Format + command: yarn format test-lint: docker: diff --git a/package.json b/package.json index 52bd8b4eeba..448be145b08 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "human-standard-token-abi": "^2.0.0", "isomorphic-fetch": "^3.0.0", "jsonschema": "^1.2.4", + "nanoid": "^3.1.12", "percentile": "^1.2.1", "single-call-balance-checker-abi": "^1.0.0", "uuid": "^3.3.2", @@ -94,10 +95,12 @@ "^.+\\.tsx?$": "ts-jest" }, "testMatch": [ - "**/*.test.ts" + "**/*.test.ts", + "**/*.test.js" ], "coveragePathIgnorePatterns": [ "/node_modules/", + "dist/BaseController*", "tests/" ], "coverageThreshold": { diff --git a/src/approval/ApprovalController.ts b/src/approval/ApprovalController.ts new file mode 100644 index 00000000000..3b2fb492322 --- /dev/null +++ b/src/approval/ApprovalController.ts @@ -0,0 +1,380 @@ +import { nanoid } from 'nanoid'; +import { ethErrors } from 'eth-rpc-errors'; +import BaseController, { BaseConfig, BaseState } from '../BaseController'; + +const NO_TYPE = Symbol('NO_APPROVAL_TYPE'); +const STORE_KEY = 'pendingApprovals'; + +type ApprovalType = string | typeof NO_TYPE; + +type ApprovalPromiseResolve = (value?: unknown) => void; +type ApprovalPromiseReject = (error?: Error) => void; + +type RequestData = Record; + +interface ApprovalCallbacks { + resolve: ApprovalPromiseResolve; + reject: ApprovalPromiseReject; +} + +/** + * Data associated with a pending approval. + */ +export interface ApprovalInfo { + id: string; + origin: string; + type?: string; + requestData?: RequestData; +} + +export interface ApprovalConfig extends BaseConfig { + showApprovalRequest: () => void; +} + +export interface ApprovalState extends BaseState { + [STORE_KEY]: { [approvalId: string]: ApprovalInfo }; +} + +const getAlreadyPendingMessage = (origin: string, type: ApprovalType) => ( + `Request ${type === NO_TYPE ? '' : `of type '${type}' `}already pending for origin ${origin}. Please wait.` +); + +const defaultState = { [STORE_KEY]: {} }; + +/** + * Controller for keeping track of pending approvals by id and/or origin and + * type pair. + * + * Useful for managing requests that require user approval, and restricting + * the number of approvals a particular origin can have pending at any one time. + */ +export default class ApprovalController extends BaseController { + + private _approvals: Map; + + private _origins: Map>; + + private _showApprovalRequest: () => void; + + /** + * @param opts - Options bag + * @param opts.showApprovalRequest - Function for opening the MetaMask user + * confirmation UI. + */ + constructor(config: ApprovalConfig, state?: ApprovalState) { + super(config, state || defaultState); + + this._approvals = new Map(); + + this._origins = new Map(); + + this._showApprovalRequest = config.showApprovalRequest; + } + + /** + * Adds a pending approval per the given arguments, opens the MetaMask user + * confirmation UI, and returns the associated id and approval promise. + * An internal, default type will be used if none is specified. + * + * There can only be one approval per origin and type. An error is thrown if + * attempting + * + * @param opts - Options bag. + * @param opts.id - The id of the approval request. A random id will be + * generated if none is provided. + * @param opts.origin - The origin of the approval request. + * @param opts.type - The type associated with the approval request, if + * applicable. + * @param opts.requestData - The request data associated with the approval + * request. + * @returns The approval promise. + */ + addAndShowApprovalRequest(opts: { + id?: string; + origin: string; + type?: string; + requestData?: RequestData; + }): Promise { + const promise = this._add(opts.origin, opts.requestData, opts.id, opts.type); + this._showApprovalRequest(); + return promise; + } + + /** + * Adds a pending approval per the given arguments, and returns the associated + * id and approval promise. An internal, default type will be used if none is + * specified. + * + * There can only be one approval per origin and type. An error is thrown if + * attempting + * + * @param opts - Options bag. + * @param opts.id - The id of the approval request. A random id will be + * generated if none is provided. + * @param opts.origin - The origin of the approval request. + * @param opts.type - The type associated with the approval request, if + * applicable. + * @param opts.requestData - The request data associated with the approval + * request. + * @returns The approval promise. + */ + add(opts: { + id?: string; + origin: string; + type?: string; + requestData?: RequestData; + }): Promise { + return this._add(opts.origin, opts.requestData, opts.id, opts.type); + } + + /** + * Gets the pending approval info for the given id. + * + * @param id - The id of the approval request. + * @returns The pending approval data associated with the id. + */ + get(id: string): ApprovalInfo | undefined { + const info = this.state[STORE_KEY][id]; + return info + ? { ...info } + : undefined; + } + + /** + * Checks if there's a pending approval request for the given id, or origin + * and type pair if no id is specified. + * If no type is specified, the default type will be used. + * + * @param opts - Options bag. + * @param opts.id - The id of the approval request. + * @param opts.origin - The origin of the approval request. + * @param opts.type - The type of the approval request. + * @returns True if an approval is found, false otherwise. + */ + has(opts: { id?: string; origin?: string; type?: string }): boolean { + const _type = opts.type === undefined ? NO_TYPE : opts.type; + if (!_type) { + throw new Error('May not specify falsy type.'); + } + + if (opts.id) { + return this._approvals.has(opts.id); + } else if (opts.origin) { + return Boolean(this._origins.get(opts.origin)?.has(_type)); + } + throw new Error('Must specify id or origin.'); + } + + /** + * Resolves the promise of the approval with the given id, and deletes the + * approval. Throws an error if no such approval exists. + * + * @param id - The id of the approval request. + * @param value - The value to resolve the approval promise with. + */ + resolve(id: string, value?: unknown): void { + this._deleteApprovalAndGetCallbacks(id).resolve(value); + } + + /** + * Rejects the promise of the approval with the given id, and deletes the + * approval. Throws an error if no such approval exists. + * + * @param id - The id of the approval request. + * @param error - The error to reject the approval promise with. + */ + reject(id: string, error: Error): void { + this._deleteApprovalAndGetCallbacks(id).reject(error); + } + + /** + * Rejects and deletes all pending approval requests. + */ + clear(): void { + const rejectionError = ethErrors.rpc.resourceUnavailable( + 'The request was rejected; please try again.' + ); + + for (const id of this._approvals.keys()) { + this.reject(id, rejectionError); + } + this._origins.clear(); + this.update(defaultState, true); + } + + /** + * Implementation of add operation. + * + * @param id - The id of the approval request. + * @param origin - The origin of the approval request. + * @param type - The type associated with the approval request, if applicable. + * @param requestData - The request data associated with the approval request. + * @returns The approval promise. + */ + private _add( + origin: string, + requestData?: RequestData, + id: string = nanoid(), + type: ApprovalType = NO_TYPE, + ): Promise { + this._validateAddParams(id, origin, type, requestData); + + if (this._origins.get(origin)?.has(type)) { + throw ethErrors.rpc.resourceUnavailable( + getAlreadyPendingMessage(origin, type), + ); + } + + // add pending approval + return new Promise((resolve, reject) => { + this._approvals.set(id, { resolve, reject }); + this._addPendingApprovalOrigin(origin, type); + this._addToStore(id, origin, type, requestData); + }); + } + + /** + * Validates parameters to the add method. + * + * @param id - The id of the approval request. + * @param origin - The origin of the approval request. + * @param type - The type associated with the approval request. + * @param requestData - The request data associated with the approval request. + */ + private _validateAddParams( + id: string, + origin: string, + type: ApprovalType, + requestData?: RequestData + ): void { + let errorMessage = null; + if (!id) { + errorMessage = 'Must specify non-empty string id.'; + } else if (!origin) { + errorMessage = 'Must specify origin.'; + } else if (this._approvals.has(id)) { + errorMessage = `Approval with id '${id}' already exists.`; + } else if (typeof type !== 'string' && type !== NO_TYPE) { + errorMessage = 'Must specify string type.'; + } else if (!type) { + errorMessage = 'May not specify empty string type.'; + } else if (requestData && ( + typeof requestData !== 'object' || Array.isArray(requestData) + )) { + errorMessage = 'Request data must be a plain object if specified.'; + } + + if (errorMessage) { + throw ethErrors.rpc.internal(errorMessage); + } + } + + /** + * Adds an entry to _origins. + * Performs no validation. + * + * @param origin - The origin of the approval request. + * @param type - The type associated with the approval request. + */ + private _addPendingApprovalOrigin(origin: string, type: ApprovalType): void { + const originSet = this._origins.get(origin) || new Set(); + originSet.add(type); + + if (!this._origins.has(origin)) { + this._origins.set(origin, originSet); + } + } + + /** + * Adds an entry to the store. + * Performs no validation. + * + * @param id - The id of the approval request. + * @param origin - The origin of the approval request. + * @param type - The type associated with the approval request. + * @param requestData - The request data associated with the approval request. + */ + private _addToStore( + id: string, + origin: string, + type: ApprovalType, + requestData?: RequestData + ): void { + const info: ApprovalInfo = { id, origin }; + // default type is for internal bookkeeping only + if (type !== NO_TYPE) { + info.type = type; + } + if (requestData) { + info.requestData = requestData; + } + + this.update({ + [STORE_KEY]: { + ...this.state[STORE_KEY], + [id]: info, + }, + }, true); + } + + /** + * Deletes the approval with the given id. The approval promise must be + * resolved or reject before this method is called. + * Deletion is an internal operation because approval state is solely + * managed by this controller. + * + * @param id - The id of the approval request to be deleted. + */ + private _delete(id: string): void { + if (this._approvals.has(id)) { + this._approvals.delete(id); + + const state = this.state[STORE_KEY]; + const { + origin, + type = NO_TYPE, + } = state[id]; + + /* istanbul ignore next */ + this._origins.get(origin)?.delete(type); + if (this._isEmptyOrigin(origin)) { + this._origins.delete(origin); + } + + const newState = { ...state }; + delete newState[id]; + this.update({ + [STORE_KEY]: newState, + }, true); + } + } + + /** + * Gets the approval callbacks for the given id, deletes the entry, and then + * returns the callbacks for promise resolution. + * Throws an error if no approval is found for the given id. + * + * @param id - The id of the approval request. + * @returns The pending approval callbacks associated with the id. + */ + private _deleteApprovalAndGetCallbacks(id: string): ApprovalCallbacks { + const callbacks = this._approvals.get(id); + if (!callbacks) { + throw new Error(`Approval with id '${id}' not found.`); + } + + this._delete(id); + return callbacks; + } + + /** + * Checks whether there are any pending approvals associated with the given + * origin. + * + * @param origin - The origin to check. + * @returns True if the origin has no pending approvals, false otherwise. + */ + private _isEmptyOrigin(origin: string): boolean { + return !this._origins.get(origin)?.size; + } +} diff --git a/tests/ApprovalController.test.js b/tests/ApprovalController.test.js new file mode 100644 index 00000000000..bfcadcad2a1 --- /dev/null +++ b/tests/ApprovalController.test.js @@ -0,0 +1,151 @@ +const { errorCodes } = require('eth-rpc-errors'); +const ApprovalController = require('../dist/approval/ApprovalController.js').default; + +const defaultConfig = { showApprovalRequest: () => undefined }; + +const getApprovalController = () => new ApprovalController({ ...defaultConfig }); + +const STORE_KEY = 'pendingApprovals'; + +describe('ApprovalController: Input Validation', () => { + describe('add', () => { + it('validates input', () => { + const approvalController = getApprovalController(); + + expect(() => approvalController.add({ id: null, origin: 'bar.baz' })).toThrow(getInvalidIdError()); + + expect(() => approvalController.add({ id: 'foo' })).toThrow(getMissingOriginError()); + + expect(() => approvalController.add({ id: 'foo', origin: 'bar.baz', type: {} })).toThrow( + getNonStringTypeError(errorCodes.rpc.internal), + ); + + expect(() => approvalController.add({ id: 'foo', origin: 'bar.baz', type: '' })).toThrow( + getEmptyStringTypeError(errorCodes.rpc.internal), + ); + + expect(() => + approvalController.add({ + id: 'foo', + origin: 'bar.baz', + requestData: 'foo', + }), + ).toThrow(getInvalidRequestDataError()); + }); + }); + + describe('get', () => { + it('returns undefined for non-existing entry', () => { + const approvalController = getApprovalController(); + + approvalController.add({ id: 'foo', origin: 'bar.baz' }); + + expect(approvalController.get('fizz')).toBeUndefined(); + + expect(approvalController.get()).toBeUndefined(); + + expect(approvalController.get({})).toBeUndefined(); + }); + }); + + describe('has', () => { + it('validates input', () => { + const approvalController = getApprovalController(); + + expect(() => approvalController.has({})).toThrow(getMissingIdOrOriginError()); + + expect(() => approvalController.has({ type: false })).toThrow(getNoFalsyTypeError()); + }); + }); + + // We test this internal function before resolve, reject, and clear because + // they are heavily dependent upon it. + describe('_delete', () => { + let approvalController; + + beforeEach(() => { + approvalController = new ApprovalController({ ...defaultConfig }); + }); + + it('deletes entry', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz' }); + + approvalController._delete('foo'); + + expect( + !approvalController.has({ id: 'foo' }) && + !approvalController.has({ origin: 'bar.baz' }) && + !approvalController.state[STORE_KEY].foo, + ).toEqual(true); + }); + + it('deletes one entry out of many without side-effects', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz' }); + approvalController.add({ id: 'fizz', origin: 'bar.baz', type: 'myType' }); + + approvalController._delete('fizz'); + + expect( + !approvalController.has({ id: 'fizz' }) && !approvalController.has({ origin: 'bar.baz', type: 'myType' }), + ).toEqual(true); + + expect(approvalController.has({ id: 'foo' }) && approvalController.has({ origin: 'bar.baz' })).toEqual(true); + }); + + it('does nothing when deleting non-existing entry', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz' }); + + expect(() => approvalController._delete('fizz')).not.toThrow(); + + expect(approvalController.has({ id: 'foo' }) && approvalController.has({ origin: 'bar.baz' })).toEqual(true); + }); + }); + + describe('miscellaneous', () => { + it('isEmptyOrigin: handles non-existing origin', () => { + const approvalController = getApprovalController(); + expect(() => approvalController._isEmptyOrigin('kaplar')).not.toThrow(); + }); + }); +}); + +// helpers + +function getInvalidIdError() { + return getError('Must specify non-empty string id.', errorCodes.rpc.internal); +} + +function getMissingOriginError() { + return getError('Must specify origin.', errorCodes.rpc.internal); +} + +function getInvalidRequestDataError() { + return getError('Request data must be a plain object if specified.', errorCodes.rpc.internal); +} + +function getNoFalsyTypeError() { + return getError('May not specify falsy type.'); +} + +function getNonStringTypeError(code) { + return getError('Must specify string type.', code); +} + +function getEmptyStringTypeError(code) { + return getError('May not specify empty string type.', code); +} + +function getMissingIdOrOriginError() { + return getError('Must specify id or origin.'); +} + +function getError(message, code) { + const err = { + name: 'Error', + message, + }; + if (code !== undefined) { + err.code = code; + } + return err; +} diff --git a/tests/ApprovalController.test.ts b/tests/ApprovalController.test.ts new file mode 100644 index 00000000000..89678317adb --- /dev/null +++ b/tests/ApprovalController.test.ts @@ -0,0 +1,363 @@ +import { errorCodes } from 'eth-rpc-errors'; +import ApprovalController from '../dist/approval/ApprovalController'; + +const sinon = require('sinon'); + +const STORE_KEY = 'pendingApprovals'; + +const defaultConfig = { showApprovalRequest: () => undefined }; + +describe('approval controller', () => { + describe('add', () => { + let approvalController: ApprovalController; + + beforeEach(() => { + approvalController = new ApprovalController({ ...defaultConfig }); + }); + + it('adds correctly specified entry', () => { + expect(() => approvalController.add({ id: 'foo', origin: 'bar.baz' })).not.toThrow(); + + expect(approvalController.has({ id: 'foo' })).toEqual(true); + expect(approvalController.state[STORE_KEY]).toEqual({ foo: { id: 'foo', origin: 'bar.baz' } }); + }); + + it('adds id if non provided', () => { + expect(() => approvalController.add({ id: undefined, origin: 'bar.baz' })).not.toThrow(); + + const id = Object.keys(approvalController.state[STORE_KEY])[0]; + expect(id && typeof id === 'string').toBeTruthy(); + }); + + it('adds correctly specified entry with custom type', () => { + expect(() => approvalController.add({ id: 'foo', origin: 'bar.baz', type: 'myType' })).not.toThrow(); + + expect(approvalController.has({ id: 'foo' })).toEqual(true); + expect(approvalController.has({ origin: 'bar.baz', type: 'myType' })).toEqual(true); + expect(approvalController.state[STORE_KEY]).toEqual({ foo: { id: 'foo', origin: 'bar.baz', type: 'myType' } }); + }); + + it('adds correctly specified entry with request data', () => { + expect(() => + approvalController.add({ + id: 'foo', + origin: 'bar.baz', + type: undefined, + requestData: { foo: 'bar' }, + }), + ).not.toThrow(); + + expect(approvalController.has({ id: 'foo' })).toEqual(true); + expect(approvalController.has({ origin: 'bar.baz' })).toEqual(true); + expect(approvalController.state[STORE_KEY].foo.requestData).toEqual({ foo: 'bar' }); + }); + + it('adds multiple entries for same origin with different types and ids', () => { + const ORIGIN = 'bar.baz'; + + expect(() => approvalController.add({ id: 'foo1', origin: ORIGIN })).not.toThrow(); + expect(() => approvalController.add({ id: 'foo2', origin: ORIGIN, type: 'myType1' })).not.toThrow(); + expect(() => approvalController.add({ id: 'foo3', origin: ORIGIN, type: 'myType2' })).not.toThrow(); + + expect( + approvalController.has({ id: 'foo1' }) && + approvalController.has({ id: 'foo3' }) && + approvalController.has({ id: 'foo3' }), + ).toEqual(true); + expect( + approvalController.has({ origin: ORIGIN }) && + approvalController.has({ origin: ORIGIN, type: 'myType1' }) && + approvalController.has({ origin: ORIGIN, type: 'myType2' }), + ).toEqual(true); + }); + + it('throws on id collision', () => { + expect(() => approvalController.add({ id: 'foo', origin: 'bar.baz' })).not.toThrow(); + + expect(() => approvalController.add({ id: 'foo', origin: 'fizz.buzz' })).toThrow(getIdCollisionError('foo')); + }); + + it('throws on origin and default type collision', () => { + expect(() => approvalController.add({ id: 'foo', origin: 'bar.baz' })).not.toThrow(); + + expect(() => approvalController.add({ id: 'foo1', origin: 'bar.baz' })).toThrow( + getOriginTypeCollisionError('bar.baz'), + ); + }); + + it('throws on origin and custom type collision', () => { + expect(() => approvalController.add({ id: 'foo', origin: 'bar.baz', type: 'myType' })).not.toThrow(); + + expect(() => approvalController.add({ id: 'foo1', origin: 'bar.baz', type: 'myType' })).toThrow( + getOriginTypeCollisionError('bar.baz', 'myType'), + ); + }); + }); + + // otherwise tested by 'add' above + describe('addAndShowApprovalRequest', () => { + it('addAndShowApprovalRequest', () => { + const showApprovalSpy = sinon.spy(); + const approvalController = new ApprovalController({ + showApprovalRequest: showApprovalSpy, + }); + + const result = approvalController.addAndShowApprovalRequest({ + id: 'foo', + origin: 'bar.baz', + type: 'myType', + requestData: { foo: 'bar' }, + }); + expect(result instanceof Promise).toEqual(true); + expect(showApprovalSpy.calledOnce).toEqual(true); + }); + }); + + describe('get', () => { + let approvalController: ApprovalController; + + beforeEach(() => { + approvalController = new ApprovalController({ ...defaultConfig }); + }); + + it('gets entry with default type', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz' }); + + expect(approvalController.get('foo')).toEqual({ id: 'foo', origin: 'bar.baz' }); + }); + + it('gets entry with custom type', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz', type: 'myType' }); + + expect(approvalController.get('foo')).toEqual({ id: 'foo', origin: 'bar.baz', type: 'myType' }); + }); + }); + + describe('has', () => { + let approvalController: ApprovalController; + + beforeEach(() => { + approvalController = new ApprovalController({ ...defaultConfig }); + }); + + it('returns true for existing entry by id', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz' }); + + expect(approvalController.has({ id: 'foo' })).toEqual(true); + }); + + it('returns true for existing entry by origin', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz' }); + + expect(approvalController.has({ origin: 'bar.baz' })).toEqual(true); + }); + + it('returns true for existing entry by origin and custom type', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz', type: 'myType' }); + + expect(approvalController.has({ origin: 'bar.baz', type: 'myType' })).toEqual(true); + }); + + it('returns false for non-existing entry by id', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz' }); + + expect(approvalController.has({ id: 'fizz' })).toEqual(false); + }); + + it('returns false for non-existing entry by origin', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz' }); + + expect(approvalController.has({ origin: 'fizz.buzz' })).toEqual(false); + }); + + it('returns false for non-existing entry by origin and type', () => { + approvalController.add({ id: 'foo', origin: 'bar.baz' }); + + expect(approvalController.has({ origin: 'bar.baz', type: 'myType' })).toEqual(false); + }); + }); + + describe('resolve', () => { + let approvalController: ApprovalController; + let numDeletions: number; + let deleteSpy: typeof sinon.spy; + + beforeEach(() => { + approvalController = new ApprovalController({ ...defaultConfig }); + deleteSpy = sinon.spy(approvalController, '_delete'); + numDeletions = 0; + }); + + it('resolves approval promise', async () => { + numDeletions = 1; + + const approvalPromise = approvalController.add({ id: 'foo', origin: 'bar.baz' }); + approvalController.resolve('foo', 'success'); + + const result = await approvalPromise; + expect(result).toEqual('success'); + expect(deleteSpy.callCount).toEqual(numDeletions); + }); + + it('resolves multiple approval promises out of order', async () => { + numDeletions = 2; + + const approvalPromise1 = approvalController.add({ id: 'foo1', origin: 'bar.baz' }); + const approvalPromise2 = approvalController.add({ id: 'foo2', origin: 'bar.baz', type: 'myType2' }); + + approvalController.resolve('foo2', 'success2'); + + let result = await approvalPromise2; + expect(result).toEqual('success2'); + + approvalController.resolve('foo1', 'success1'); + + result = await approvalPromise1; + expect(result).toEqual('success1'); + expect(deleteSpy.callCount).toEqual(numDeletions); + }); + + it('throws on unknown id', () => { + expect(() => approvalController.resolve('foo')).toThrow(getIdNotFoundError('foo')); + expect(deleteSpy.callCount).toEqual(numDeletions); + }); + }); + + describe('reject', () => { + let approvalController: ApprovalController; + let numDeletions: number; + let deleteSpy: typeof sinon.spy; + + beforeEach(() => { + approvalController = new ApprovalController({ ...defaultConfig }); + deleteSpy = sinon.spy(approvalController, '_delete'); + numDeletions = 0; + }); + + it('rejects approval promise', async () => { + numDeletions = 1; + + const approvalPromise = approvalController.add({ id: 'foo', origin: 'bar.baz' }).catch((error) => { + expect(error).toMatchObject(getError('failure')); + }); + + approvalController.reject('foo', new Error('failure')); + await approvalPromise; + expect(deleteSpy.callCount).toEqual(numDeletions); + }); + + it('rejects multiple approval promises out of order', async () => { + numDeletions = 2; + + const rejectionPromise1 = approvalController.add({ id: 'foo1', origin: 'bar.baz' }).catch((error) => { + expect(error).toMatchObject(getError('failure1')); + }); + const rejectionPromise2 = approvalController + .add({ id: 'foo2', origin: 'bar.baz', type: 'myType2' }) + .catch((error) => { + expect(error).toMatchObject(getError('failure2')); + }); + + approvalController.reject('foo2', new Error('failure2')); + await rejectionPromise2; + + approvalController.reject('foo1', new Error('failure1')); + await rejectionPromise1; + expect(deleteSpy.callCount).toEqual(numDeletions); + }); + + it('throws on unknown id', () => { + expect(() => approvalController.reject('foo', new Error('bar'))).toThrow(getIdNotFoundError('foo')); + expect(deleteSpy.callCount).toEqual(numDeletions); + }); + }); + + describe('resolve and reject', () => { + it('resolves and rejects multiple approval promises out of order', async () => { + const approvalController = new ApprovalController({ ...defaultConfig }); + + const promise1 = approvalController.add({ id: 'foo1', origin: 'bar.baz' }); + const promise2 = approvalController.add({ id: 'foo2', origin: 'bar.baz', type: 'myType2' }); + const promise3 = approvalController.add({ id: 'foo3', origin: 'fizz.buzz' }).catch((error) => { + expect(error).toMatchObject(getError('failure3')); + }); + const promise4 = approvalController.add({ id: 'foo4', origin: 'bar.baz', type: 'myType4' }).catch((error) => { + expect(error).toMatchObject(getError('failure4')); + }); + + approvalController.resolve('foo2', 'success2'); + + let result = await promise2; + expect(result).toEqual('success2'); + + approvalController.reject('foo4', new Error('failure4')); + await promise4; + + approvalController.reject('foo3', new Error('failure3')); + await promise3; + + expect(approvalController.has({ origin: 'fizz.buzz' })).toEqual(false); + expect(approvalController.has({ origin: 'bar.baz' })).toEqual(true); + + approvalController.resolve('foo1', 'success1'); + + result = await promise1; + expect(result).toEqual('success1'); + + expect(approvalController.has({ origin: 'bar.baz' })).toEqual(false); + }); + }); + + describe('clear', () => { + let approvalController: ApprovalController; + + beforeEach(() => { + approvalController = new ApprovalController({ ...defaultConfig }); + }); + + it('does nothing if state is already empty', () => { + expect(() => approvalController.clear()).not.toThrow(); + }); + + it('deletes existing entries', async () => { + const rejectSpy = sinon.spy(approvalController, 'reject'); + + approvalController.add({ id: 'foo1', origin: 'bar.baz' }).catch((_error) => undefined); + approvalController.add({ id: 'foo2', origin: 'bar.baz', type: 'myType' }).catch((_error) => undefined); + approvalController.add({ id: 'foo3', origin: 'fizz.buzz', type: 'myType' }).catch((_error) => undefined); + + approvalController.clear(); + + expect(approvalController.state[STORE_KEY]).toEqual({}); + expect(rejectSpy.callCount).toEqual(3); + }); + }); +}); + +// helpers + +function getIdCollisionError(id: string) { + return getError(`Approval with id '${id}' already exists.`, errorCodes.rpc.internal); +} + +function getOriginTypeCollisionError(origin: string, type = '_default') { + const message = `Request${ + type === '_default' ? '' : ` of type '${type}'` + } already pending for origin ${origin}. Please wait.`; + return getError(message, errorCodes.rpc.resourceUnavailable); +} + +function getIdNotFoundError(id: string) { + return getError(`Approval with id '${id}' not found.`); +} + +function getError(message: string, code?: number) { + const err: any = { + name: 'Error', + message, + }; + if (code !== undefined) { + err.code = code; + } + return err; +} diff --git a/yarn.lock b/yarn.lock index 990f8c86c15..dcf10a166b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5090,6 +5090,11 @@ nan@^2.0.8, nan@^2.14.0, nan@^2.2.1: resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== +nanoid@^3.1.12: + version "3.1.12" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.12.tgz#6f7736c62e8d39421601e4a0c77623a97ea69654" + integrity sha512-1qstj9z5+x491jfiC4Nelk+f8XBad7LN20PmyWINJEMRSf3wcAjAWysw1qaA8z6NSKe2sjq1hRSDpBH5paCb6A== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"