@bsv/sdk
Version: 
BSV Blockchain Software Development Kit
658 lines (585 loc) • 16.2 kB
text/typescript
/**
 * @jest-environment jsdom
 */
import XDMSubstrate from '../../../wallet/substrates/XDM'
import { WalletError } from '../../../wallet/WalletError'
import { Utils } from '../../../primitives/index'
describe('XDMSubstrate', () => {
  let xdmSubstrate
  let originalWindow
  let eventHandlers: Record<string, (event: any) => void> = {}
  beforeEach(() => {
    // Save the original window object
    originalWindow = global.window
    // Reset event handlers
    eventHandlers = {}
    // Mock window object
    global.window = {
      postMessage: jest.fn(),
      parent: {
        postMessage: jest.fn()
      } as unknown as Window,
      addEventListener: jest.fn((event, handler) => {
        eventHandlers[event] = handler
      })
    } as unknown as Window & typeof globalThis
    jest.spyOn(window.parent, 'postMessage')
  })
  afterEach(() => {
    // Restore the original window object
    global.window = originalWindow
    jest.restoreAllMocks()
  })
  describe('constructor', () => {
    it('should throw if window is not an object', () => {
      delete (global as any).window
      expect(() => {
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const _ = new XDMSubstrate()
      }).toThrow('The XDM substrate requires a global window object.')
    })
    it('should throw if window.postMessage is not an object', () => {
      delete (global as any).window.postMessage
      expect(() => {
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const _ = new XDMSubstrate()
      }).toThrow(
        'The window object does not seem to support postMessage calls.'
      )
    })
    it('should construct successfully if window and window.postMessage are defined', () => {
      expect(() => {
        xdmSubstrate = new XDMSubstrate()
      }).not.toThrow()
    })
  })
  describe('invoke', () => {
    beforeEach(() => {
      xdmSubstrate = new XDMSubstrate()
    })
    it('should send a message to window.parent.postMessage with correct parameters', async () => {
      const call = 'testCall'
      const args = { foo: 'bar' }
      const mockId = 'mockedId'
      jest.spyOn(Utils, 'toBase64').mockReturnValue(mockId)
      xdmSubstrate.invoke(call, args)
      expect(window.parent.postMessage).toHaveBeenCalledWith(
        {
          type: 'CWI',
          isInvocation: true,
          id: mockId,
          call,
          args
        },
        '*'
      )
    })
    it('should resolve when receiving a valid message', async () => {
      const call = 'testCall'
      const args = { foo: 'bar' }
      const result = { data: 'some data' }
      const mockId = 'mockedId'
      jest.spyOn(Utils, 'toBase64').mockReturnValue(mockId)
      const invokePromise = xdmSubstrate.invoke(call, args)
      // Simulate receiving the message
      const event = {
        data: {
          type: 'CWI',
          isInvocation: false,
          id: mockId,
          status: 'success',
          result
        },
        isTrusted: true
      }
      eventHandlers.message(event)
      const res = await invokePromise
      expect(res).toEqual(result)
    })
    it('should reject when receiving an error message', async () => {
      const call = 'testCall'
      const args = { foo: 'bar' }
      const errorDescription = 'An error occurred'
      const errorCode = 123
      const mockId = 'mockedId'
      jest.spyOn(Utils, 'toBase64').mockReturnValue(mockId)
      const invokePromise = xdmSubstrate.invoke(call, args)
      // Simulate receiving the message
      const event = {
        data: {
          type: 'CWI',
          isInvocation: false,
          id: mockId,
          status: 'error',
          description: errorDescription,
          code: errorCode
        },
        isTrusted: true
      }
      eventHandlers.message(event)
      await expect(invokePromise).rejects.toThrow(WalletError)
      await expect(invokePromise).rejects.toThrow(errorDescription)
      try {
        await invokePromise
      } catch (err) {
        expect(err.code).toBe(errorCode)
      }
    })
    it('should ignore messages with incorrect type', async () => {
      const call = 'testCall'
      const args = { foo: 'bar' }
      const result = { data: 'some data' }
      const mockId = 'mockedId'
      jest.spyOn(Utils, 'toBase64').mockReturnValue(mockId)
      const invokePromise = xdmSubstrate.invoke(call, args)
      // Simulate receiving an unrelated message
      const event = {
        data: {
          type: 'WrongType',
          isInvocation: false,
          id: mockId,
          status: 'success',
          result
        },
        isTrusted: true
      }
      eventHandlers.message(event)
      // The promise should still be pending
      let isResolved = false
      invokePromise.then(() => {
        isResolved = true
      })
      // Wait a bit to ensure no unintended resolution
      await new Promise((resolve) => setTimeout(resolve, 1))
      expect(isResolved).toBe(false)
    })
    it('should ignore messages with incorrect id', async () => {
      const call = 'testCall'
      const args = { foo: 'bar' }
      const result = { data: 'some data' }
      const mockId = 'mockedId'
      jest.spyOn(Utils, 'toBase64').mockReturnValue(mockId)
      const invokePromise = xdmSubstrate.invoke(call, args)
      // Simulate receiving a message with wrong id
      const event = {
        data: {
          type: 'CWI',
          isInvocation: false,
          id: 'wrongId',
          status: 'success',
          result
        },
        isTrusted: true
      }
      eventHandlers.message(event)
      // The promise should still be pending
      let isResolved = false
      invokePromise.then(() => {
        isResolved = true
      })
      // Wait a bit to ensure no unintended resolution
      await new Promise((resolve) => setTimeout(resolve, 1))
      expect(isResolved).toBe(false)
    })
    it('should ignore messages where e.isTrusted is false', async () => {
      const call = 'testCall'
      const args = { foo: 'bar' }
      const result = { data: 'some data' }
      const mockId = 'mockedId'
      jest.spyOn(Utils, 'toBase64').mockReturnValue(mockId)
      const invokePromise = xdmSubstrate.invoke(call, args)
      // Simulate receiving a message with isTrusted false
      const event = {
        data: {
          type: 'CWI',
          isInvocation: false,
          id: mockId,
          status: 'success',
          result
        },
        isTrusted: false
      }
      eventHandlers.message(event)
      // The promise should still be pending
      let isResolved = false
      invokePromise.then(() => {
        isResolved = true
      })
      // Wait a bit to ensure no unintended resolution
      await new Promise((resolve) => setTimeout(resolve, 1))
      expect(isResolved).toBe(false)
    })
    it('should ignore messages where e.data.isInvocation is true', async () => {
      const call = 'testCall'
      const args = { foo: 'bar' }
      const result = { data: 'some data' }
      const mockId = 'mockedId'
      jest.spyOn(Utils, 'toBase64').mockReturnValue(mockId)
      const invokePromise = xdmSubstrate.invoke(call, args)
      // Simulate receiving a message with isInvocation true
      const event = {
        data: {
          type: 'CWI',
          isInvocation: true,
          id: mockId,
          status: 'success',
          result
        },
        isTrusted: true
      }
      eventHandlers.message(event)
      // The promise should still be pending
      let isResolved = false
      invokePromise.then(() => {
        isResolved = true
      })
      // Wait a bit to ensure no unintended resolution
      await new Promise((resolve) => setTimeout(resolve, 1))
      expect(isResolved).toBe(false)
    })
  })
  // Helper function to test methods
  const testMethod = (methodName: string, args: any, result: any): void => {
    describe(methodName, () => {
      beforeEach(() => {
        xdmSubstrate = new XDMSubstrate()
      })
      it('should call invoke with correct arguments and return the result', async () => {
        const call = methodName
        const mockId = 'mockedId'
        jest.spyOn(Utils, 'toBase64').mockReturnValue(mockId)
        const invokePromise = xdmSubstrate[methodName](args)
        expect(window.parent.postMessage).toHaveBeenCalledWith(
          {
            type: 'CWI',
            isInvocation: true,
            id: mockId,
            call,
            args
          },
          '*'
        )
        const event = {
          data: {
            type: 'CWI',
            isInvocation: false,
            id: mockId,
            status: 'success',
            result
          },
          isTrusted: true
        }
        eventHandlers.message(event)
        const res = await invokePromise
        expect(res).toEqual(result)
      })
      it('should throw error when invoke rejects', async () => {
        const call = methodName
        const errorDescription = 'An error occurred'
        const errorCode = 123
        const mockId = 'mockedId'
        jest.spyOn(Utils, 'toBase64').mockReturnValue(mockId)
        const invokePromise = xdmSubstrate[methodName](args)
        expect(window.parent.postMessage).toHaveBeenCalledWith(
          {
            type: 'CWI',
            isInvocation: true,
            id: mockId,
            call,
            args
          },
          '*'
        )
        // Simulate receiving an error message
        const event = {
          data: {
            type: 'CWI',
            isInvocation: false,
            id: mockId,
            status: 'error',
            description: errorDescription,
            code: errorCode
          },
          isTrusted: true
        }
        eventHandlers.message(event)
        await expect(invokePromise).rejects.toThrow(WalletError)
        await expect(invokePromise).rejects.toThrow(errorDescription)
        await invokePromise.catch((err) => {
          expect(err.code).toBe(errorCode)
        })
      })
    })
  }
  // List of methods to test
  const methodsToTest = [
    {
      methodName: 'createAction',
      args: {
        description: 'Test description',
        inputs: [],
        outputs: []
      },
      result: { txid: 'abc123' }
    },
    {
      methodName: 'signAction',
      args: {
        spends: {},
        reference: 'someReference'
      },
      result: { txid: 'abc123' }
    },
    {
      methodName: 'abortAction',
      args: {
        reference: 'someReference'
      },
      result: { aborted: true }
    },
    {
      methodName: 'listActions',
      args: {
        labels: []
      },
      result: { totalActions: 0, actions: [] }
    },
    {
      methodName: 'internalizeAction',
      args: {
        tx: 'someTx',
        outputs: [],
        description: 'Test description'
      },
      result: { accepted: true }
    },
    {
      methodName: 'listOutputs',
      args: {
        basket: 'someBasket'
      },
      result: { totalOutputs: 0, outputs: [] }
    },
    {
      methodName: 'relinquishOutput',
      args: {
        basket: 'someBasket',
        output: 'someOutput'
      },
      result: { relinquished: true }
    },
    {
      methodName: 'getPublicKey',
      args: {
        identityKey: true
      },
      result: { publicKey: 'somePubKey' }
    },
    {
      methodName: 'revealCounterpartyKeyLinkage',
      args: {
        counterparty: 'someCounterparty',
        verifier: 'someVerifier'
      },
      result: {
        prover: 'someProver',
        verifier: 'someVerifier',
        counterparty: 'someCounterparty',
        revelationTime: 'someTime',
        encryptedLinkage: [],
        encryptedLinkageProof: []
      }
    },
    {
      methodName: 'revealSpecificKeyLinkage',
      args: {
        counterparty: 'someCounterparty',
        verifier: 'someVerifier',
        protocolID: [0, 'someProtocol'],
        keyID: 'someKeyID'
      },
      result: {
        prover: 'someProver',
        verifier: 'someVerifier',
        counterparty: 'someCounterparty',
        protocolID: [0, 'someProtocol'],
        keyID: 'someKeyID',
        encryptedLinkage: [],
        encryptedLinkageProof: [],
        proofType: []
      }
    },
    {
      methodName: 'encrypt',
      args: {
        plaintext: [],
        protocolID: [0, 'someProtocol'],
        keyID: 'someKeyID'
      },
      result: { ciphertext: [] }
    },
    {
      methodName: 'decrypt',
      args: {
        ciphertext: [],
        protocolID: [0, 'someProtocol'],
        keyID: 'someKeyID'
      },
      result: { plaintext: [] }
    },
    {
      methodName: 'createHmac',
      args: {
        data: [],
        protocolID: [0, 'someProtocol'],
        keyID: 'someKeyID'
      },
      result: { hmac: [] }
    },
    {
      methodName: 'verifyHmac',
      args: {
        data: [],
        hmac: [],
        protocolID: [0, 'someProtocol'],
        keyID: 'someKeyID'
      },
      result: { valid: true }
    },
    {
      methodName: 'createSignature',
      args: {
        data: [],
        protocolID: [0, 'someProtocol'],
        keyID: 'someKeyID'
      },
      result: { signature: [] }
    },
    {
      methodName: 'verifySignature',
      args: {
        data: [],
        signature: [],
        protocolID: [0, 'someProtocol'],
        keyID: 'someKeyID'
      },
      result: { valid: true }
    },
    {
      methodName: 'acquireCertificate',
      args: {
        type: 'someType',
        subject: 'someSubject',
        serialNumber: 'someSerialNumber',
        revocationOutpoint: 'someOutpoint',
        signature: 'someSignature',
        fields: {},
        certifier: 'someCertifier',
        keyringRevealer: 'certifier',
        keyringForSubject: {},
        acquisitionProtocol: 'direct'
      },
      result: {
        type: 'someType',
        subject: 'someSubject',
        serialNumber: 'someSerialNumber',
        certifier: 'someCertifier',
        revocationOutpoint: 'someOutpoint',
        signature: 'someSignature',
        fields: {}
      }
    },
    {
      methodName: 'listCertificates',
      args: {
        certifiers: [],
        types: []
      },
      result: {
        totalCertificates: 0,
        certificates: []
      }
    },
    {
      methodName: 'proveCertificate',
      args: {
        certificate: {
          type: 'someType',
          subject: 'someSubject',
          serialNumber: 'someSerialNumber',
          certifier: 'someCertifier',
          revocationOutpoint: 'someOutpoint',
          signature: 'someSignature',
          fields: {}
        },
        fieldsToReveal: [],
        verifier: 'someVerifier'
      },
      result: {
        keyringForVerifier: {}
      }
    },
    {
      methodName: 'relinquishCertificate',
      args: {
        type: 'someType',
        serialNumber: 'someSerialNumber',
        certifier: 'someCertifier'
      },
      result: { relinquished: true }
    },
    {
      methodName: 'discoverByIdentityKey',
      args: {
        identityKey: 'someIdentityKey'
      },
      result: {
        totalCertificates: 0,
        certificates: []
      }
    },
    {
      methodName: 'discoverByAttributes',
      args: {
        attributes: {}
      },
      result: {
        totalCertificates: 0,
        certificates: []
      }
    },
    {
      methodName: 'isAuthenticated',
      args: {},
      result: { authenticated: true }
    },
    {
      methodName: 'waitForAuthentication',
      args: {},
      result: { authenticated: true }
    },
    {
      methodName: 'getHeight',
      args: {},
      result: { height: 1000 }
    },
    {
      methodName: 'getHeaderForHeight',
      args: { height: 1000 },
      result: { header: 'someHeader' }
    },
    {
      methodName: 'getNetwork',
      args: {},
      result: { network: 'mainnet' }
    },
    {
      methodName: 'getVersion',
      args: {},
      result: { version: '1.0.0' }
    }
  ]
  methodsToTest.forEach(({ methodName, args, result }) => {
    testMethod(methodName, args, result)
  })
})