import { strict as assert } from 'assert'; import { ObservableStore } from '@metamask/obs-store'; import nanoid from 'nanoid'; import { useFakeTimers } from 'sinon'; import { constants, getters, noop, } from '../../../../test/mocks/permission-controller'; import { validateActivityEntry } from '../../../../test/helpers/permission-controller-helpers'; import PermissionsLogController from './permissionsLog'; import { LOG_LIMIT, LOG_METHOD_TYPES } from './enums'; const { PERMS, RPC_REQUESTS } = getters; const { ACCOUNTS, EXPECTED_HISTORIES, DOMAINS, PERM_NAMES, REQUEST_IDS, RESTRICTED_METHODS, } = constants; let clock; const initPermLog = () => { return new PermissionsLogController({ store: new ObservableStore(), restrictedMethods: RESTRICTED_METHODS, }); }; const mockNext = (handler) => { if (handler) { handler(noop); } }; const initMiddleware = (permLog) => { const middleware = permLog.createMiddleware(); return (req, res, next = mockNext) => { middleware(req, res, next); }; }; const initClock = () => { // useFakeTimers, is in fact, not a react-hook // eslint-disable-next-line clock = useFakeTimers(1); }; const tearDownClock = () => { clock.restore(); }; const getSavedMockNext = (arr) => (handler) => { arr.push(handler); }; describe('permissions log', function () { describe('activity log', function () { let permLog, logMiddleware; beforeEach(function () { permLog = initPermLog(); logMiddleware = initMiddleware(permLog); }); it('records activity for restricted methods', function () { let log, req, res; // test_method, success req = RPC_REQUESTS.test_method(DOMAINS.a.origin); req.id = REQUEST_IDS.a; res = { foo: 'bar' }; logMiddleware({ ...req }, res); log = permLog.getActivityLog(); const entry1 = log[0]; assert.equal(log.length, 1, 'log should have single entry'); validateActivityEntry( entry1, { ...req }, { ...res }, LOG_METHOD_TYPES.restricted, true, ); // eth_accounts, failure req = RPC_REQUESTS.eth_accounts(DOMAINS.b.origin); req.id = REQUEST_IDS.b; res = { error: new Error('Unauthorized.') }; logMiddleware({ ...req }, res); log = permLog.getActivityLog(); const entry2 = log[1]; assert.equal(log.length, 2, 'log should have 2 entries'); validateActivityEntry( entry2, { ...req }, { ...res }, LOG_METHOD_TYPES.restricted, false, ); // eth_requestAccounts, success req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.c.origin); req.id = REQUEST_IDS.c; res = { result: ACCOUNTS.c.permitted }; logMiddleware({ ...req }, res); log = permLog.getActivityLog(); const entry3 = log[2]; assert.equal(log.length, 3, 'log should have 3 entries'); validateActivityEntry( entry3, { ...req }, { ...res }, LOG_METHOD_TYPES.restricted, true, ); // test_method, no response req = RPC_REQUESTS.test_method(DOMAINS.a.origin); req.id = REQUEST_IDS.a; res = null; logMiddleware({ ...req }, res); log = permLog.getActivityLog(); const entry4 = log[3]; assert.equal(log.length, 4, 'log should have 4 entries'); validateActivityEntry( entry4, { ...req }, null, LOG_METHOD_TYPES.restricted, false, ); // validate final state assert.equal(entry1, log[0], 'first log entry should remain'); assert.equal(entry2, log[1], 'second log entry should remain'); assert.equal(entry3, log[2], 'third log entry should remain'); assert.equal(entry4, log[3], 'fourth log entry should remain'); }); it('handles responses added out of order', function () { let log; const handlerArray = []; const id1 = nanoid(); const id2 = nanoid(); const id3 = nanoid(); const req = RPC_REQUESTS.test_method(DOMAINS.a.origin); // get make requests req.id = id1; const res1 = { foo: id1 }; logMiddleware({ ...req }, { ...res1 }, getSavedMockNext(handlerArray)); req.id = id2; const res2 = { foo: id2 }; logMiddleware({ ...req }, { ...res2 }, getSavedMockNext(handlerArray)); req.id = id3; const res3 = { foo: id3 }; logMiddleware({ ...req }, { ...res3 }, getSavedMockNext(handlerArray)); // verify log state log = permLog.getActivityLog(); assert.equal(log.length, 3, 'log should have 3 entries'); const entry1 = log[0]; const entry2 = log[1]; const entry3 = log[2]; assert.ok( entry1.id === id1 && entry1.response === null && entry2.id === id2 && entry2.response === null && entry3.id === id3 && entry3.response === null, 'all entries should be in correct order and without responses', ); // call response handlers for (const i of [1, 2, 0]) { handlerArray[i](noop); } // verify log state again log = permLog.getActivityLog(); assert.equal(log.length, 3, 'log should have 3 entries'); // verify all entries log = permLog.getActivityLog(); validateActivityEntry( log[0], { ...req, id: id1 }, { ...res1 }, LOG_METHOD_TYPES.restricted, true, ); validateActivityEntry( log[1], { ...req, id: id2 }, { ...res2 }, LOG_METHOD_TYPES.restricted, true, ); validateActivityEntry( log[2], { ...req, id: id3 }, { ...res3 }, LOG_METHOD_TYPES.restricted, true, ); }); it('handles a lack of response', function () { let req = RPC_REQUESTS.test_method(DOMAINS.a.origin); req.id = REQUEST_IDS.a; let res = { foo: 'bar' }; // noop for next handler prevents recording of response logMiddleware({ ...req }, res, noop); let log = permLog.getActivityLog(); const entry1 = log[0]; assert.equal(log.length, 1, 'log should have single entry'); validateActivityEntry( entry1, { ...req }, null, LOG_METHOD_TYPES.restricted, true, ); // next request should be handled as normal req = RPC_REQUESTS.eth_accounts(DOMAINS.b.origin); req.id = REQUEST_IDS.b; res = { result: ACCOUNTS.b.permitted }; logMiddleware({ ...req }, res); log = permLog.getActivityLog(); const entry2 = log[1]; assert.equal(log.length, 2, 'log should have 2 entries'); validateActivityEntry( entry2, { ...req }, { ...res }, LOG_METHOD_TYPES.restricted, true, ); // validate final state assert.equal(entry1, log[0], 'first log entry remains'); assert.equal(entry2, log[1], 'second log entry remains'); }); it('ignores expected methods', function () { let log = permLog.getActivityLog(); assert.equal(log.length, 0, 'log should be empty'); const res = { foo: 'bar' }; const req1 = RPC_REQUESTS.metamask_sendDomainMetadata( DOMAINS.c.origin, 'foobar', ); const req2 = RPC_REQUESTS.custom(DOMAINS.b.origin, 'eth_getBlockNumber'); const req3 = RPC_REQUESTS.custom(DOMAINS.b.origin, 'net_version'); logMiddleware(req1, res); logMiddleware(req2, res); logMiddleware(req3, res); log = permLog.getActivityLog(); assert.equal(log.length, 0, 'log should still be empty'); }); it('enforces log limit', function () { const req = RPC_REQUESTS.test_method(DOMAINS.a.origin); const res = { foo: 'bar' }; // max out log let lastId; for (let i = 0; i < LOG_LIMIT; i++) { lastId = nanoid(); logMiddleware({ ...req, id: lastId }, { ...res }); } // check last entry valid let log = permLog.getActivityLog(); assert.equal( log.length, LOG_LIMIT, 'log should have LOG_LIMIT num entries', ); validateActivityEntry( log[LOG_LIMIT - 1], { ...req, id: lastId }, res, LOG_METHOD_TYPES.restricted, true, ); // store the id of the current second entry const nextFirstId = log[1].id; // add one more entry to log, putting it over the limit lastId = nanoid(); logMiddleware({ ...req, id: lastId }, { ...res }); // check log length log = permLog.getActivityLog(); assert.equal( log.length, LOG_LIMIT, 'log should have LOG_LIMIT num entries', ); // check first and last entries validateActivityEntry( log[0], { ...req, id: nextFirstId }, res, LOG_METHOD_TYPES.restricted, true, ); validateActivityEntry( log[LOG_LIMIT - 1], { ...req, id: lastId }, res, LOG_METHOD_TYPES.restricted, true, ); }); }); describe('permissions history', function () { let permLog, logMiddleware; beforeEach(function () { permLog = initPermLog(); logMiddleware = initMiddleware(permLog); initClock(); }); afterEach(function () { tearDownClock(); }); it('only updates history on responses', function () { let permHistory; const req = RPC_REQUESTS.requestPermission( DOMAINS.a.origin, PERM_NAMES.test_method, ); const res = { result: [PERMS.granted.test_method()] }; // noop => no response logMiddleware({ ...req }, { ...res }, noop); permHistory = permLog.getHistory(); assert.deepEqual(permHistory, {}, 'history should not have been updated'); // response => records granted permissions logMiddleware({ ...req }, { ...res }); permHistory = permLog.getHistory(); assert.equal( Object.keys(permHistory).length, 1, 'history should have single origin', ); assert.ok( Boolean(permHistory[DOMAINS.a.origin]), 'history should have expected origin', ); }); it('ignores malformed permissions requests', function () { const req = RPC_REQUESTS.requestPermission( DOMAINS.a.origin, PERM_NAMES.test_method, ); delete req.params; const res = { result: [PERMS.granted.test_method()] }; // no params => no response logMiddleware({ ...req }, { ...res }); assert.deepEqual( permLog.getHistory(), {}, 'history should not have been updated', ); }); it('records and updates account history as expected', async function () { let permHistory; const req = RPC_REQUESTS.requestPermission( DOMAINS.a.origin, PERM_NAMES.eth_accounts, ); const res = { result: [PERMS.granted.eth_accounts(ACCOUNTS.a.permitted)], }; logMiddleware({ ...req }, { ...res }); // validate history permHistory = permLog.getHistory(); assert.deepEqual( permHistory, EXPECTED_HISTORIES.case1[0], 'should have correct history', ); // mock permission requested again, with another approved account clock.tick(1); res.result = [PERMS.granted.eth_accounts([ACCOUNTS.a.permitted[0]])]; logMiddleware({ ...req }, { ...res }); permHistory = permLog.getHistory(); assert.deepEqual( permHistory, EXPECTED_HISTORIES.case1[1], 'should have correct history', ); }); it('handles eth_accounts response without caveats', async function () { const req = RPC_REQUESTS.requestPermission( DOMAINS.a.origin, PERM_NAMES.eth_accounts, ); const res = { result: [PERMS.granted.eth_accounts(ACCOUNTS.a.permitted)], }; delete res.result[0].caveats; logMiddleware({ ...req }, { ...res }); // validate history assert.deepEqual( permLog.getHistory(), EXPECTED_HISTORIES.case2[0], 'should have expected history', ); }); it('handles extra caveats for eth_accounts', async function () { const req = RPC_REQUESTS.requestPermission( DOMAINS.a.origin, PERM_NAMES.eth_accounts, ); const res = { result: [PERMS.granted.eth_accounts(ACCOUNTS.a.permitted)], }; res.result[0].caveats.push({ foo: 'bar' }); logMiddleware({ ...req }, { ...res }); // validate history assert.deepEqual( permLog.getHistory(), EXPECTED_HISTORIES.case1[0], 'should have correct history', ); }); // wallet_requestPermissions returns all permissions approved for the // requesting origin, including old ones it('handles unrequested permissions on the response', async function () { const req = RPC_REQUESTS.requestPermission( DOMAINS.a.origin, PERM_NAMES.eth_accounts, ); const res = { result: [ PERMS.granted.eth_accounts(ACCOUNTS.a.permitted), PERMS.granted.test_method(), ], }; logMiddleware({ ...req }, { ...res }); // validate history assert.deepEqual( permLog.getHistory(), EXPECTED_HISTORIES.case1[0], 'should have correct history', ); }); it('does not update history if no new permissions are approved', async function () { let req = RPC_REQUESTS.requestPermission( DOMAINS.a.origin, PERM_NAMES.test_method, ); let res = { result: [PERMS.granted.test_method()], }; logMiddleware({ ...req }, { ...res }); // validate history assert.deepEqual( permLog.getHistory(), EXPECTED_HISTORIES.case4[0], 'should have correct history', ); // new permission requested, but not approved clock.tick(1); req = RPC_REQUESTS.requestPermission( DOMAINS.a.origin, PERM_NAMES.eth_accounts, ); res = { result: [PERMS.granted.test_method()], }; logMiddleware({ ...req }, { ...res }); // validate history assert.deepEqual( permLog.getHistory(), EXPECTED_HISTORIES.case4[0], 'should have same history as before', ); }); it('records and updates history for multiple origins, regardless of response order', async function () { let permHistory; // make first round of requests const round1 = []; const handlers1 = []; // first origin round1.push({ req: RPC_REQUESTS.requestPermission( DOMAINS.a.origin, PERM_NAMES.test_method, ), res: { result: [PERMS.granted.test_method()], }, }); // second origin round1.push({ req: RPC_REQUESTS.requestPermission( DOMAINS.b.origin, PERM_NAMES.eth_accounts, ), res: { result: [PERMS.granted.eth_accounts(ACCOUNTS.b.permitted)], }, }); // third origin round1.push({ req: RPC_REQUESTS.requestPermissions(DOMAINS.c.origin, { [PERM_NAMES.test_method]: {}, [PERM_NAMES.eth_accounts]: {}, }), res: { result: [ PERMS.granted.test_method(), PERMS.granted.eth_accounts(ACCOUNTS.c.permitted), ], }, }); // make requests and process responses out of order round1.forEach((x) => { logMiddleware({ ...x.req }, { ...x.res }, getSavedMockNext(handlers1)); }); for (const i of [1, 2, 0]) { handlers1[i](noop); } // validate history permHistory = permLog.getHistory(); assert.deepEqual( permHistory, EXPECTED_HISTORIES.case3[0], 'should have expected history', ); // make next round of requests clock.tick(1); const round2 = []; // we're just gonna process these in order // first origin round2.push({ req: RPC_REQUESTS.requestPermission( DOMAINS.a.origin, PERM_NAMES.test_method, ), res: { result: [PERMS.granted.test_method()], }, }); // nothing for second origin // third origin round2.push({ req: RPC_REQUESTS.requestPermissions(DOMAINS.c.origin, { [PERM_NAMES.eth_accounts]: {}, }), res: { result: [PERMS.granted.eth_accounts(ACCOUNTS.b.permitted)], }, }); // make requests round2.forEach((x) => { logMiddleware({ ...x.req }, { ...x.res }); }); // validate history permHistory = permLog.getHistory(); assert.deepEqual( permHistory, EXPECTED_HISTORIES.case3[1], 'should have expected history', ); }); }); });