UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

463 lines (383 loc) 18 kB
import { mockUnderlyingWallet, MockedBSV_SDK } from './WalletPermissionsManager.fixtures' import { WalletPermissionsManager, PermissionToken } from '../WalletPermissionsManager' import { jest } from '@jest/globals' // We mock the underlying @bsv/sdk references with our test fixtures: jest.mock('@bsv/sdk', () => MockedBSV_SDK) /** * A lightweight helper that forces the manager to never find any on-chain token. * We do this so we can reliably test the request flow (i.e., that it truly initiates * a new permission request if no token is found). */ function mockNoTokensFound(manager: WalletPermissionsManager) { jest.spyOn(manager as any, 'findProtocolToken').mockResolvedValue(undefined) jest.spyOn(manager as any, 'findBasketToken').mockResolvedValue(undefined) jest.spyOn(manager as any, 'findCertificateToken').mockResolvedValue(undefined) jest.spyOn(manager as any, 'findSpendingToken').mockResolvedValue(undefined) } describe('WalletPermissionsManager - Permission Request Flow & Active Requests', () => { let underlying: ReturnType<typeof mockUnderlyingWallet> let manager: WalletPermissionsManager beforeEach(() => { underlying = mockUnderlyingWallet() manager = new WalletPermissionsManager(underlying, 'admin.test.com') }) afterEach(() => { jest.clearAllMocks() }) /** * UNIT TESTS */ describe('Unit Tests: requestPermissionFlow & activeRequests map', () => { it('should coalesce parallel requests for the same resource into a single user prompt', async () => { // We want to test the underlying private method "requestPermissionFlow" indirectly // or we can test it via a public method that calls it. We'll do so via ensureProtocolPermission. // Force no token found => triggers a request flow mockNoTokensFound(manager) // Spy on the manager's "onProtocolPermissionRequested" callbacks const requestCallback = jest.fn(() => {}) manager.bindCallback('onProtocolPermissionRequested', requestCallback) // Make two parallel calls for the same resource const callA = manager.ensureProtocolPermission({ originator: 'example.com', privileged: false, protocolID: [1, 'someproto'], counterparty: 'self', reason: 'UnitTest - same resource A', seekPermission: true, usageType: 'signing' }) const callB = manager.ensureProtocolPermission({ originator: 'example.com', privileged: false, protocolID: [1, 'someproto'], counterparty: 'self', reason: 'UnitTest - same resource B', seekPermission: true, usageType: 'signing' }) // Wait a short moment for the async request flow to trigger await new Promise(res => setTimeout(res, 5)) // We expect only one "onProtocolPermissionRequested" event for both calls expect(requestCallback).toHaveBeenCalledTimes(1) // Now let's deny the request: // Grab the requestID that the manager gave us from the callback param const callbackArg = (requestCallback.mock as any).calls[0][0] const requestID = callbackArg.requestID expect(typeof requestID).toBe('string') // manager-generated // Deny the request await manager.denyPermission(requestID) // Both calls should reject await expect(callA).rejects.toThrow(/Permission denied/) await expect(callB).rejects.toThrow(/Permission denied/) // Confirm activeRequests map is empty after denial const activeRequests = (manager as any).activeRequests as Map<string, any[]> expect(activeRequests.size).toBe(0) }) it('should generate two distinct user prompts for two different permission requests', async () => { // Force no tokens mockNoTokensFound(manager) // Spy on basket & protocol request callbacks const protocolRequestCb = jest.fn(() => {}) const basketRequestCb = jest.fn(() => {}) manager.bindCallback('onProtocolPermissionRequested', protocolRequestCb) manager.bindCallback('onBasketAccessRequested', basketRequestCb) // Make one call for protocol usage const pCall = manager.ensureProtocolPermission({ originator: 'example.com', privileged: false, protocolID: [1, 'proto-A'], counterparty: 'self', reason: 'Different request A', seekPermission: true, usageType: 'signing' }) // Make a second call for basket usage const bCall = manager.ensureBasketAccess({ originator: 'example.com', basket: 'some-basket', reason: 'Different request B', seekPermission: true, usageType: 'insertion' }) // Wait a moment for them to trigger await new Promise(res => setTimeout(res, 5)) // We expect one protocol request AND one basket request expect(protocolRequestCb).toHaveBeenCalledTimes(1) expect(basketRequestCb).toHaveBeenCalledTimes(1) // Deny protocol request const pReqID = (protocolRequestCb.mock as any).calls[0][0].requestID await manager.denyPermission(pReqID) // Deny basket request const bReqID = (basketRequestCb.mock as any).calls[0][0].requestID await manager.denyPermission(bReqID) // Both calls should have rejected await expect(pCall).rejects.toThrow(/Permission denied/) await expect(bCall).rejects.toThrow(/Permission denied/) // activeRequests is empty const activeRequests = (manager as any).activeRequests as Map<string, any[]> expect(activeRequests.size).toBe(0) }) it('should resolve all parallel requests when permission is granted, referencing the same requestID', async () => { // No tokens => triggers request flow mockNoTokensFound(manager) const requestCb = jest.fn(() => {}) manager.bindCallback('onProtocolPermissionRequested', requestCb) // Parallel calls const promiseA = manager.ensureProtocolPermission({ originator: 'example.com', privileged: false, protocolID: [1, 'proto-X'], counterparty: 'anyone', reason: 'Test parallel grant A', seekPermission: true, usageType: 'encrypting' }) const promiseB = manager.ensureProtocolPermission({ originator: 'example.com', privileged: false, protocolID: [1, 'proto-X'], counterparty: 'anyone', reason: 'Test parallel grant B', seekPermission: true, usageType: 'encrypting' }) // Let the request event fire await new Promise(res => setTimeout(res, 5)) expect(requestCb).toHaveBeenCalledTimes(1) // Extract the requestID from the callback const { requestID } = (requestCb.mock as any).calls[0][0] // Now we grant permission for that same requestID // Because ephemeral is false by default, the manager will attempt to create on-chain tokens // We'll mock the internal createPermissionOnChain so it doesn't blow up const createOnChainSpy = jest.spyOn(manager as any, 'createPermissionOnChain').mockResolvedValue(undefined) await manager.grantPermission({ requestID }) // Both calls should resolve with `true` (the manager returns a boolean) await expect(promiseA).resolves.toBe(true) await expect(promiseB).resolves.toBe(true) // activeRequests map is empty const activeRequests = (manager as any).activeRequests as Map<string, any[]> expect(activeRequests.size).toBe(0) // The manager tried to create an on-chain permission token once expect(createOnChainSpy).toHaveBeenCalledTimes(1) }) it('should reject only the matching request queue on deny if requestID is specified', async () => { // This scenario tests the manager's partial denial logic where we pass { requestID } // to only reject the queued requests with that ID, leaving others (with a different requestID) // in the queue. mockNoTokensFound(manager) // We do two separate calls for the same resource but at different times, resulting in separate queues. // Actually, the manager normally merges them into one queue if the resource is the same. // So let's do two different resources to ensure we get two separate keys. const protoCb = jest.fn(() => {}) manager.bindCallback('onProtocolPermissionRequested', protoCb) // Resource 1 const p1Promise = manager.ensureProtocolPermission({ originator: 'siteA.com', privileged: false, protocolID: [1, 'proto-siteA'], counterparty: 'self', usageType: 'encrypting' }) await new Promise(res => setTimeout(res, 5)) const p1ReqID = (protoCb.mock as any).calls[0][0].requestID // At this point, resource 1 is pending in activeRequests. We'll not resolve it yet. // Resource 2 const p2Promise = manager.ensureProtocolPermission({ originator: 'siteB.com', privileged: false, protocolID: [1, 'proto-siteB'], counterparty: 'self', usageType: 'encrypting' }) await new Promise(res => setTimeout(res, 5)) // the second call triggers a second onProtocolPermissionRequested callback expect(protoCb).toHaveBeenCalledTimes(2) const p2ReqID = (protoCb.mock as any).calls[1][0].requestID // Deny the second request only await manager.denyPermission(p2ReqID) await expect(p2Promise).rejects.toThrow(/Permission denied/) // But the first request is still waiting const activeRequests = (manager as any).activeRequests as Map<string, any[]> expect(activeRequests.size).toBe(1) // Now let's deny the first request too await manager.denyPermission(p1ReqID) await expect(p1Promise).rejects.toThrow(/Permission denied/) // The queue is empty now expect(activeRequests.size).toBe(0) }) }) /** * INTEGRATION TESTS */ describe('Integration Tests: ephemeral vs. persistent tokens', () => { it('should not create a token if ephemeral=true, so subsequent calls re-trigger the request', async () => { // We'll do a "protocol" permission scenario: mockNoTokensFound(manager) // Bind the request callback const requestCb = jest.fn(() => {}) manager.bindCallback('onProtocolPermissionRequested', requestCb) // Force any on-chain creation attempt to be spied on const createTokenSpy = jest.spyOn(manager as any, 'createPermissionOnChain') // 1) Call ensureProtocolPermission => triggers request const pCall1 = manager.ensureProtocolPermission({ originator: 'appdomain.com', privileged: false, protocolID: [1, 'ephemeral-proto'], counterparty: 'self', reason: 'test ephemeral #1', usageType: 'signing' }) // Wait for request callback await new Promise(res => setTimeout(res, 5)) expect(requestCb).toHaveBeenCalledTimes(1) const reqID1 = (requestCb.mock as any).calls[0][0].requestID // Grant ephemeral await manager.grantPermission({ requestID: reqID1, ephemeral: true }) // pCall1 is resolved await expect(pCall1).resolves.toBe(true) // Because ephemeral=true, we do NOT create an on-chain token expect(createTokenSpy).not.toHaveBeenCalled() // Clear cache to actually run again ;(manager as any).permissionCache = new Map() // 2) Immediately call ensureProtocolPermission again for the same resource // Because ephemeral usage didn't store a token, it should re-prompt. const pCall2 = manager.ensureProtocolPermission({ originator: 'appdomain.com', privileged: false, protocolID: [1, 'ephemeral-proto'], counterparty: 'self', reason: 'test ephemeral #2', usageType: 'signing' }) await new Promise(res => setTimeout(res, 5)) // We expect a new request callback expect(requestCb).toHaveBeenCalledTimes(2) // We'll deny the second request const reqID2 = (requestCb.mock as any).calls[1][0].requestID await manager.denyPermission(reqID2) await expect(pCall2).rejects.toThrow(/Permission denied/) }) it('should create a token if ephemeral=false, so subsequent calls do not re-trigger if unexpired', async () => { // We want the manager to truly create a token. We'll confirm that // subsequent calls for the same resource skip user prompt. mockNoTokensFound(manager) // We'll also ensure no token is found "the first time." // But on subsequent calls, we can mock that the manager sees the newly created token. // Let's spy on "createPermissionOnChain" so we can intercept the new token const createTokenSpy = jest.spyOn(manager as any, 'createPermissionOnChain').mockResolvedValue(undefined) // no real on-chain creation // Spy on "findProtocolToken" so we can simulate that the second time it's called, // there's a valid token. We'll do this by setting the mock to return undefined the first time, // and a valid token the second time (or we can just rely on the manager's logic). let firstFindCall = true jest.spyOn(manager as any, 'findProtocolToken').mockImplementation(async () => { if (firstFindCall) { firstFindCall = false return undefined // first time triggers request } // second time => pretend we found a valid token const mockToken: PermissionToken = { tx: [], txid: 'abcdef', outputIndex: 0, outputScript: '00', satoshis: 1, originator: 'persistentdomain.com', expiry: Math.floor(Date.now() / 1000) + 3600, // unexpired privileged: false, protocol: 'persist-proto', securityLevel: 1, counterparty: 'self' } return mockToken }) // We'll observe the request callback const requestCb = jest.fn(() => {}) manager.bindCallback('onProtocolPermissionRequested', requestCb) // 1) First call => no token => triggers request const call1 = manager.ensureProtocolPermission({ originator: 'persistentdomain.com', privileged: false, protocolID: [1, 'persist-proto'], counterparty: 'self', reason: 'test persistent #1', usageType: 'signing' }) await new Promise(res => setTimeout(res, 5)) expect(requestCb).toHaveBeenCalledTimes(1) // Grant ephemeral=false => triggers createPermissionOnChain const reqID = (requestCb.mock as any).calls[0][0].requestID await manager.grantPermission({ requestID: reqID, ephemeral: false }) await expect(call1).resolves.toBe(true) expect(createTokenSpy).toHaveBeenCalledTimes(1) // 2) Second call => the manager should find the token we just "created" => no request prompt const call2 = manager.ensureProtocolPermission({ originator: 'persistentdomain.com', privileged: false, protocolID: [1, 'persist-proto'], counterparty: 'self', reason: 'test persistent #2', usageType: 'signing' }) // We do not expect a new user prompt => requestCb remains at 1 await new Promise(res => setTimeout(res, 5)) expect(requestCb).toHaveBeenCalledTimes(1) // The second call should resolve immediately, no prompt await expect(call2).resolves.toBe(true) }) it('should handle renewal if the found token is expired, passing previousToken in the request', async () => { // We'll test the "renewal" flow: // If the manager finds a token but it's expired, it sets { renewal: true, previousToken } in the request. // We'll mock findProtocolToken to return an expired token const expiredToken: PermissionToken = { tx: [], txid: 'expiredTxid123', outputIndex: 0, outputScript: '76a914xxxx...88ac', satoshis: 1, originator: 'renewme.com', expiry: Math.floor(Date.now() / 1000) - 100, // in the past privileged: false, protocol: 'renew-proto', securityLevel: 1, counterparty: 'self' } jest.spyOn(manager as any, 'findProtocolToken').mockResolvedValue(expiredToken) // Spy on request callback const requestCb = jest.fn(() => {}) manager.bindCallback('onProtocolPermissionRequested', requestCb) // We'll also spy on "renewPermissionOnChain" to see if it's called const renewSpy = jest.spyOn(manager as any, 'renewPermissionOnChain').mockResolvedValue(undefined) // Call ensureProtocolPermission => sees expired token => triggers request with renewal const promise = manager.ensureProtocolPermission({ originator: 'renewme.com', privileged: false, protocolID: [1, 'renew-proto'], counterparty: 'self', reason: 'test renewal', usageType: 'encrypting' }) // Wait for request callback await new Promise(res => setTimeout(res, 10)) expect(requestCb).toHaveBeenCalledTimes(1) // Confirm the callback param includes `renewal=true` and `previousToken=expiredToken` const { renewal, previousToken } = (requestCb.mock as any).calls[0][0] expect(renewal).toBe(true) expect(previousToken.txid).toBe('expiredTxid123') // Grant ephemeral=false => manager calls renewPermissionOnChain const { requestID } = (requestCb.mock as any).calls[0][0] await manager.grantPermission({ requestID, ephemeral: false }) await expect(promise).resolves.toBe(true) expect(renewSpy).toHaveBeenCalledTimes(1) // The first arg is the old token, second is request, etc. expect(renewSpy).toHaveBeenCalledWith( expiredToken, expect.objectContaining({ originator: 'renewme.com' }), expect.any(Number), undefined ) }) }) })