@bsv/sdk
Version:
BSV Blockchain Software Development Kit
770 lines (668 loc) • 26.9 kB
text/typescript
import { WalletCertificate, WalletInterface } from '../../wallet/index'
import { IdentityClient } from '../IdentityClient'
import { Certificate } from '../../auth/certificates/index.js'
import { KNOWN_IDENTITY_TYPES, defaultIdentity } from '../types/index.js'
// ----- Mocks for external dependencies -----
jest.mock('../../script', () => {
return {
PushDrop: jest.fn().mockImplementation(() => ({
lock: jest.fn().mockResolvedValue({
toHex: () => 'lockingScriptHex'
}),
unlock: jest.fn()
}))
}
})
jest.mock('../../overlay-tools/index.js', () => {
return {
TopicBroadcaster: jest.fn().mockImplementation(() => ({
broadcast: jest.fn().mockResolvedValue('broadcastResult')
}))
}
})
jest.mock('../../transaction/index.js', () => {
return {
Transaction: {
fromAtomicBEEF: jest.fn().mockImplementation((tx) => ({
toHexBEEF: () => 'transactionHex'
})),
fromBEEF: jest.fn().mockReturnValue({
outputs: [{ lockingScript: { toHex: () => 'mockLockingScript' } }]
})
}
}
})
jest.mock('../../script', () => {
const mockPushDropInstance = {
lock: jest.fn().mockResolvedValue({
toHex: () => 'lockingScriptHex'
}),
unlock: jest.fn().mockReturnValue({
sign: jest.fn().mockResolvedValue({
toHex: () => 'unlockingScriptHex'
})
})
}
const mockPushDrop: any = jest.fn().mockImplementation(() => mockPushDropInstance)
mockPushDrop.decode = jest.fn().mockReturnValue({
fields: [new Uint8Array([1, 2, 3, 4])]
})
return {
PushDrop: mockPushDrop,
// Provide LockingScript.fromHex to satisfy ContactsManager.getContacts decode path
LockingScript: {
fromHex: jest.fn().mockImplementation((hex: string) => ({ toHex: () => hex }))
}
}
})
jest.mock('../../primitives/index.js', () => {
return {
Utils: {
toBase64: jest.fn().mockReturnValue('mockKeyID'),
toArray: jest.fn().mockReturnValue(new Uint8Array()),
toUTF8: jest.fn().mockImplementation((data) => {
return new TextDecoder().decode(data)
}),
toHex: jest.fn().mockReturnValue('0102030405060708')
},
Random: jest.fn().mockReturnValue(new Uint8Array(32)),
PrivateKey: jest.fn().mockImplementation(() => ({
toPublicKey: jest.fn().mockReturnValue({
toString: jest.fn().mockReturnValue('mockPublicKeyString')
})
}))
}
})
// ----- Begin Test Suite -----
describe('IdentityClient', () => {
let walletMock: Partial<WalletInterface>
let identityClient: IdentityClient
beforeEach(() => {
// Mock localStorage for Node.js environment
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn()
}
Object.defineProperty(global, 'localStorage', {
value: localStorageMock,
writable: true
})
// Create a fake wallet implementing the methods used by IdentityClient.
walletMock = {
proveCertificate: jest.fn().mockResolvedValue({ keyringForVerifier: 'fakeKeyring' }),
createAction: jest.fn().mockResolvedValue({
tx: [1, 2, 3],
signableTransaction: { tx: [1, 2, 3], reference: 'ref' }
}),
listCertificates: jest.fn().mockResolvedValue({ certificates: [] }),
acquireCertificate: jest.fn().mockResolvedValue({
fields: { name: 'Alice' },
verify: jest.fn().mockResolvedValue(true)
}),
signAction: jest.fn().mockResolvedValue({ tx: [4, 5, 6] }),
getNetwork: jest.fn().mockResolvedValue({ network: 'testnet' }),
discoverByIdentityKey: jest.fn(),
discoverByAttributes: jest.fn(),
// ContactsManager specific methods
listOutputs: jest.fn().mockResolvedValue({ outputs: [], BEEF: [] }),
createHmac: jest.fn().mockResolvedValue({ hmac: new Uint8Array([1, 2, 3, 4]) }),
decrypt: jest.fn().mockResolvedValue({ plaintext: new Uint8Array() }),
encrypt: jest.fn().mockResolvedValue({ ciphertext: new Uint8Array([5, 6, 7, 8]) })
}
identityClient = new IdentityClient(walletMock as WalletInterface)
// Clear any previous calls/spies.
jest.clearAllMocks()
})
describe('publiclyRevealAttributes', () => {
it('should throw an error if certificate has no fields', async () => {
const certificate = {
fields: {},
verify: jest.fn().mockResolvedValue(true)
} as any as WalletCertificate
const fieldsToReveal = ['name']
await expect(
identityClient.publiclyRevealAttributes(certificate, fieldsToReveal)
).rejects.toThrow('Certificate has no fields to reveal!')
})
it('should throw an error if fieldsToReveal is empty', async () => {
const certificate = {
fields: { name: 'Alice' },
verify: jest.fn().mockResolvedValue(true)
} as any as WalletCertificate
const fieldsToReveal: string[] = []
await expect(
identityClient.publiclyRevealAttributes(certificate, fieldsToReveal)
).rejects.toThrow('You must reveal at least one field!')
})
it('should throw an error if certificate verification fails', async () => {
const certificate = {
fields: { name: 'Alice' },
verify: jest.fn().mockRejectedValue(new Error('Verification error')),
type: 'dummyType',
serialNumber: 'dummySerial',
subject: 'dummySubject',
certifier: 'dummyCertifier',
revocationOutpoint: 'dummyRevocation',
signature: 'dummySignature'
} as any as WalletCertificate
const fieldsToReveal = ['name']
await expect(
identityClient.publiclyRevealAttributes(certificate, fieldsToReveal)
).rejects.toThrow('Certificate verification failed!')
})
it('should publicly reveal attributes successfully', async () => {
// Prepare a dummy certificate with all required properties.
const certificate = {
fields: { name: 'Alice' },
verify: jest.fn().mockResolvedValue(true), // this property is not used since the Certificate is re-instantiated
type: 'xCert',
serialNumber: '12345',
subject: 'abcdef1234567890',
certifier: 'CertifierX',
revocationOutpoint: 'outpoint1',
signature: 'signature1'
} as any as WalletCertificate
// Ensure that Certificate.verify (called on the re-instantiated Certificate)
// resolves successfully.
jest.spyOn(Certificate.prototype, 'verify').mockResolvedValue(false)
const fieldsToReveal = ['name']
const result = await identityClient.publiclyRevealAttributes(certificate, fieldsToReveal)
expect(result).toEqual('broadcastResult')
// Validate that proveCertificate was called with the proper arguments.
expect(walletMock.proveCertificate).toHaveBeenCalledWith({
certificate,
fieldsToReveal,
verifier: expect.any(String)
})
// Validate that createAction was called.
expect(walletMock.createAction).toHaveBeenCalled()
})
})
describe('resolveByIdentityKey', () => {
it('should return parsed identities from discovered certificates', async () => {
const dummyCertificate = {
type: KNOWN_IDENTITY_TYPES.xCert,
subject: 'abcdef1234567890',
decryptedFields: {
userName: 'Alice',
profilePhoto: 'alicePhotoUrl'
},
certifierInfo: {
name: 'CertifierX',
iconUrl: 'certifierIconUrl'
}
}
// Mock discoverByIdentityKey to return a certificate list.
walletMock.discoverByIdentityKey = jest.fn().mockResolvedValue({ certificates: [dummyCertificate] })
const identities = await identityClient.resolveByIdentityKey({ identityKey: 'dummyKey' })
expect(walletMock.discoverByIdentityKey).toHaveBeenCalledWith({ identityKey: 'dummyKey' }, undefined)
expect(identities).toHaveLength(1)
expect(identities[0]).toEqual({
name: 'Alice',
avatarURL: 'alicePhotoUrl',
abbreviatedKey: 'abcdef1234...',
identityKey: 'abcdef1234567890',
badgeLabel: 'X account certified by CertifierX',
badgeIconURL: 'certifierIconUrl',
badgeClickURL: 'https://socialcert.net'
})
})
it('should prioritize contacts over discovered identities for same identity key', async () => {
const contact = {
name: 'Alice Smith (Personal Contact)',
identityKey: 'alice-identity-key',
avatarURL: 'alice-avatar.png',
abbreviatedKey: 'alice-i...',
badgeIconURL: '',
badgeLabel: '',
badgeClickURL: ''
}
const discoveredCertificate = {
type: KNOWN_IDENTITY_TYPES.xCert,
subject: 'alice-identity-key',
decryptedFields: {
userName: 'Alice Public',
profilePhoto: 'public-photo.png'
},
certifierInfo: {
name: 'CertifierX',
iconUrl: 'certifier-icon.png'
}
}
// Mock ContactsManager to return contact for the specific identity key
const mockContactsManager = identityClient['contactsManager']
mockContactsManager.getContacts = jest.fn().mockResolvedValue([contact])
walletMock.discoverByIdentityKey = jest.fn().mockResolvedValue({ certificates: [discoveredCertificate] })
const identities = await identityClient.resolveByIdentityKey({ identityKey: 'alice-identity-key' })
expect(identities).toHaveLength(1)
expect(identities[0].name).toBe('Alice Smith (Personal Contact)') // Contact should be returned, not discovered identity
// Wallet method should not be called when contact is found
expect(walletMock.discoverByIdentityKey).not.toHaveBeenCalled()
})
})
it('should throw if createAction returns no tx', async () => {
const certificate = {
fields: { name: 'Alice' },
verify: jest.fn().mockResolvedValue(true),
type: 'xCert',
serialNumber: '12345',
subject: 'abcdef1234567890',
certifier: 'CertifierX',
revocationOutpoint: 'outpoint1',
signature: 'signature1'
} as any as WalletCertificate
jest.spyOn(Certificate.prototype, 'verify').mockResolvedValue(false)
// Simulate createAction returning an object with tx = undefined
walletMock.createAction = jest.fn().mockResolvedValue({
tx: undefined,
signableTransaction: { tx: undefined, reference: 'ref' }
})
const fieldsToReveal = ['name']
await expect(
identityClient.publiclyRevealAttributes(certificate, fieldsToReveal)
).rejects.toThrow('Public reveal failed: failed to create action!')
})
describe('resolveByAttributes', () => {
beforeEach(() => {
// Mock both getContacts and discoverByAttributes
walletMock.discoverByAttributes = jest.fn().mockResolvedValue({ certificates: [] })
identityClient.getContacts = jest.fn().mockResolvedValue([])
})
it('should return parsed identities from discovered certificates only', async () => {
const dummyCertificate = {
type: KNOWN_IDENTITY_TYPES.emailCert,
subject: 'alice-identity-key',
decryptedFields: {
identityKey: 'alice-identity-key',
email: 'alice@example.com'
},
certifierInfo: {
name: 'Email Certifier',
iconUrl: 'certifier-icon.png',
publicKey: 'certifier-public-key',
website: 'https://certifier.example.com'
}
}
walletMock.discoverByAttributes = jest.fn().mockResolvedValue({ certificates: [dummyCertificate] })
const identities = await identityClient.resolveByAttributes({ attributes: { email: 'alice@example.com' } })
expect(identities).toHaveLength(1)
expect(identities[0].name).toBe('alice@example.com')
})
it('should prioritize contacts over discovered identities for same identity key', async () => {
const contact = {
name: 'Alice Smith (Personal)',
identityKey: 'alice-identity-key',
avatarURL: 'alice-avatar.png',
abbreviatedKey: 'alice-i...',
badgeIconURL: '',
badgeLabel: '',
badgeClickURL: ''
}
const discoveredCertificate = {
type: KNOWN_IDENTITY_TYPES.emailCert,
subject: 'alice-identity-key',
decryptedFields: {
email: 'alice@example.com'
},
certifierInfo: {
name: 'Email Certifier',
iconUrl: 'certifier-icon.png',
publicKey: 'certifier-public-key',
website: 'https://certifier.example.com'
}
}
// Mock the ContactsManager's getContacts method instead of the IdentityClient method
const mockContactsManager = identityClient['contactsManager']
mockContactsManager.getContacts = jest.fn().mockResolvedValue([contact])
walletMock.discoverByAttributes = jest.fn().mockResolvedValue({ certificates: [discoveredCertificate] })
const identities = await identityClient.resolveByAttributes({ attributes: { name: 'Alice' } })
expect(identities).toHaveLength(1)
expect(identities[0].name).toBe('Alice Smith (Personal)') // Contact should be returned, not discovered identity
})
it('should return empty array for empty search terms', async () => {
const contacts = [
{
name: 'Alice Smith',
identityKey: 'alice-key',
avatarURL: '', abbreviatedKey: 'alice-i...', badgeIconURL: '', badgeLabel: '', badgeClickURL: ''
}
]
const mockContactsManager = identityClient['contactsManager']
mockContactsManager.getContacts = jest.fn().mockResolvedValue(contacts)
const identities = await identityClient.resolveByAttributes({ attributes: { name: '', email: ' ' } })
expect(identities).toHaveLength(0)
})
it('should return only discovered identities when overrideWithContacts is false', async () => {
const contacts = [
{
name: 'Alice Smith (Personal)',
identityKey: 'alice-key',
avatarURL: '', abbreviatedKey: 'alice-i...', badgeIconURL: '', badgeLabel: '', badgeClickURL: ''
}
]
const discoveredCertificate = {
type: KNOWN_IDENTITY_TYPES.emailCert,
subject: 'alice-key', // Same key as contact but should not be filtered out
decryptedFields: {
email: 'alice@example.com'
},
certifierInfo: {
name: 'Email Certifier',
iconUrl: 'certifier-icon.png',
publicKey: 'certifier-public-key',
website: 'https://certifier.example.com'
}
}
const mockContactsManager = identityClient['contactsManager']
mockContactsManager.getContacts = jest.fn().mockResolvedValue(contacts)
walletMock.discoverByAttributes = jest.fn().mockResolvedValue({ certificates: [discoveredCertificate] })
// With overrideWithContacts = false, should ignore contacts entirely
const identities = await identityClient.resolveByAttributes(
{ attributes: { name: 'Alice' } },
false
)
expect(identities).toHaveLength(1)
expect(identities[0].name).toBe('alice@example.com') // Should be discovered identity, not contact
expect(mockContactsManager.getContacts).not.toHaveBeenCalled() // Should not fetch contacts
})
})
describe('parseIdentity', () => {
it('should correctly parse an xCert identity', () => {
const dummyCertificate = {
type: KNOWN_IDENTITY_TYPES.xCert,
subject: 'abcdef1234567890',
decryptedFields: {
userName: 'Alice',
profilePhoto: 'alicePhotoUrl'
},
certifierInfo: {
name: 'CertifierX',
iconUrl: 'certifierIconUrl'
}
}
const identity = IdentityClient.parseIdentity(dummyCertificate as unknown as any)
expect(identity).toEqual({
name: 'Alice',
avatarURL: 'alicePhotoUrl',
abbreviatedKey: 'abcdef1234...',
identityKey: 'abcdef1234567890',
badgeLabel: 'X account certified by CertifierX',
badgeIconURL: 'certifierIconUrl',
badgeClickURL: 'https://socialcert.net'
})
})
it('should return default identity for unknown type', () => {
const dummyCertificate = {
type: 'unknownType',
subject: '',
decryptedFields: {
profilePhoto: 'defaultPhoto'
},
certifierInfo: {}
}
const identity = IdentityClient.parseIdentity(dummyCertificate as any)
expect(identity).toEqual({
name: defaultIdentity.name,
avatarURL: 'defaultPhoto',
abbreviatedKey: '',
identityKey: '',
badgeLabel: defaultIdentity.badgeLabel,
badgeIconURL: defaultIdentity.badgeIconURL,
badgeClickURL: defaultIdentity.badgeClickURL
})
})
})
describe('ContactsManager Integration', () => {
const mockContact = {
name: 'Alice Smith',
identityKey: 'abcdef1234567890abcdef1234567890',
avatarURL: 'https://example.com/avatar.jpg',
abbreviatedKey: 'abcdef1234...',
badgeLabel: 'Verified User',
badgeIconURL: 'https://example.com/badge.png',
badgeClickURL: 'https://example.com/verify'
}
const mockContactWithMetadata = {
...mockContact,
metadata: { notes: 'Met at conference' }
}
beforeEach(() => {
// Reset wallet mocks for each test
jest.clearAllMocks()
})
describe('saveContact', () => {
it('should save a contact without metadata', async () => {
// Mock empty contacts list (new contact)
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [],
BEEF: []
})
await identityClient.saveContact(mockContact)
// Verify HMAC was created for tagging
expect(walletMock.createHmac).toHaveBeenCalledWith({
protocolID: [2, 'contact'],
keyID: mockContact.identityKey,
counterparty: 'self',
data: expect.any(Uint8Array)
})
// Verify contact data was encrypted
expect(walletMock.encrypt).toHaveBeenCalledWith({
plaintext: expect.any(Uint8Array),
protocolID: [2, 'contact'],
keyID: expect.any(String),
counterparty: 'self'
})
// Verify new contact transaction was created
expect(walletMock.createAction).toHaveBeenCalledWith(
expect.objectContaining({
description: 'Add Contact',
outputs: expect.arrayContaining([
expect.objectContaining({
basket: 'contacts',
outputDescription: `Contact: ${mockContact.name}`
})
])
})
)
// Verify contact is now available from cache
const contacts = await identityClient.getContacts()
expect(contacts).toContainEqual(expect.objectContaining({
name: mockContact.name,
identityKey: mockContact.identityKey
}))
})
it('should save a contact with metadata', async () => {
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [],
BEEF: []
})
await identityClient.saveContact(mockContact, { notes: 'Met at conference' })
// Verify contact with metadata is available from cache
const contacts = await identityClient.getContacts()
expect(contacts).toContainEqual(expect.objectContaining({
name: mockContact.name,
identityKey: mockContact.identityKey,
metadata: { notes: 'Met at conference' }
}))
})
it('should update existing contact', async () => {
// First save a contact to establish it exists
; (walletMock.listOutputs as jest.Mock).mockResolvedValueOnce({
outputs: [],
BEEF: []
})
await identityClient.saveContact(mockContact)
// Now mock finding the existing contact for update
const existingOutput = {
outpoint: 'txid.0',
customInstructions: JSON.stringify({ keyID: 'existingKeyID' })
}
; (walletMock.listOutputs as jest.Mock).mockResolvedValueOnce({
outputs: [existingOutput],
BEEF: [1, 2, 3]
})
; (walletMock.decrypt as jest.Mock).mockResolvedValue({
plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
})
const updatedContact = { ...mockContact, name: 'Alice Updated' }
await identityClient.saveContact(updatedContact)
// Should create update action since contact exists
expect(walletMock.createAction).toHaveBeenLastCalledWith(
expect.objectContaining({
description: 'Update Contact',
inputBEEF: [1, 2, 3],
inputs: expect.arrayContaining([
expect.objectContaining({
outpoint: 'txid.0'
})
])
})
)
})
})
describe('getContacts', () => {
it('should return cached contacts when available', async () => {
// First save a contact to populate cache
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [],
BEEF: []
})
await identityClient.saveContact(mockContact)
// Clear mocks to verify cache is used
jest.clearAllMocks()
// Get contacts should use cache and not call wallet
const result = await identityClient.getContacts()
expect(result).toContainEqual(expect.objectContaining({
name: mockContact.name,
identityKey: mockContact.identityKey
}))
expect(walletMock.listOutputs).not.toHaveBeenCalled()
})
it('should load contacts from wallet basket when cache is empty', async () => {
const mockOutput = {
outpoint: 'txid.0',
customInstructions: JSON.stringify({ keyID: 'mockKeyID' }),
lockingScript: 'lockingScriptHex'
}
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [mockOutput],
BEEF: [1, 2, 3]
})
; (walletMock.decrypt as jest.Mock).mockResolvedValue({
plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
})
const result = await identityClient.getContacts()
expect(result).toEqual([mockContact])
expect(walletMock.listOutputs).toHaveBeenCalledWith({
basket: 'contacts',
include: 'locking scripts',
includeCustomInstructions: true,
tags: [],
limit: 1000
})
// Verify subsequent call uses cache
jest.clearAllMocks()
const cachedResult = await identityClient.getContacts()
expect(cachedResult).toEqual([mockContact])
expect(walletMock.listOutputs).not.toHaveBeenCalled()
})
it('should force refresh when requested', async () => {
// First populate cache
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [],
BEEF: []
})
await identityClient.saveContact(mockContact)
// Mock empty result for force refresh
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [],
BEEF: []
})
const result = await identityClient.getContacts(undefined, true)
expect(result).toEqual([])
expect(walletMock.listOutputs).toHaveBeenCalled()
})
it('should filter by identity key when provided', async () => {
// Save two different contacts
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [],
BEEF: []
})
await identityClient.saveContact(mockContact)
const otherContact = { ...mockContact, identityKey: 'different-key', name: 'Bob' }
await identityClient.saveContact(otherContact)
// Filter by specific identity key
const result = await identityClient.getContacts(mockContact.identityKey)
expect(result).toEqual([expect.objectContaining({
name: mockContact.name,
identityKey: mockContact.identityKey
})])
expect(result).toHaveLength(1)
})
it('should throw error on listOutputs failure', async () => {
; (walletMock.listOutputs as jest.Mock).mockRejectedValue(
new Error('List outputs error')
)
await expect(
identityClient.getContacts(undefined, true)
).rejects.toThrow('List outputs error')
})
})
describe('removeContact', () => {
it('should remove contact from cache and spend UTXO', async () => {
// First save two contacts
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [],
BEEF: []
})
await identityClient.saveContact(mockContact)
const otherContact = { ...mockContact, identityKey: 'other-key', name: 'Bob' }
await identityClient.saveContact(otherContact)
// Mock finding the contact to remove
const mockOutput = {
outpoint: 'txid.0',
customInstructions: JSON.stringify({ keyID: 'mockKeyID' })
}
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [mockOutput],
BEEF: [1, 2, 3]
})
; (walletMock.decrypt as jest.Mock).mockResolvedValue({
plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
})
await identityClient.removeContact(mockContact.identityKey)
// Verify delete action was created
expect(walletMock.createAction).toHaveBeenLastCalledWith(
expect.objectContaining({
description: 'Delete Contact',
inputBEEF: [1, 2, 3],
inputs: expect.arrayContaining([
expect.objectContaining({
outpoint: 'txid.0'
})
]),
outputs: [] // No outputs for deletion
})
)
// Verify contact is removed from cache
const remainingContacts = await identityClient.getContacts()
expect(remainingContacts).not.toContainEqual(
expect.objectContaining({ identityKey: mockContact.identityKey })
)
})
it('should handle contact not found gracefully', async () => {
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
outputs: [],
BEEF: []
})
// Should not throw when contact doesn't exist
await expect(
identityClient.removeContact('non-existent-key')
).resolves.toBeUndefined()
// Should not call createAction since no contact found
expect(walletMock.createAction).not.toHaveBeenCalled()
})
})
})
})