diff --git a/.prettierignore b/.prettierignore index abe0f82..5c1b4c8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,9 +3,4 @@ demos/** dist/** coverage/** lib/wasm_exec.js -package-lock.json -package.json README.md -tsconfig.json -tslint.json -webpack.config.js diff --git a/.prettierrc b/.prettierrc index 8130bc5..9929f7a 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,8 @@ { "singleQuote": true, - "tabWidth": 4, + "tabWidth": 2, "semi": true, "trailingComma": "none", - "bracketSpacing": true -} + "bracketSpacing": true, + "printWidth": 80 +} \ No newline at end of file diff --git a/lib/api/createRpc.test.ts b/lib/api/createRpc.test.ts index 537f1d6..1949afc 100644 --- a/lib/api/createRpc.test.ts +++ b/lib/api/createRpc.test.ts @@ -6,173 +6,170 @@ import { createRpc } from './createRpc'; // Mock the external dependency vi.mock('@lightninglabs/lnc-core', () => ({ - subscriptionMethods: [ - 'lnrpc.Lightning.SubscribeInvoices', - 'lnrpc.Lightning.SubscribeChannelEvents', - 'lnrpc.Lightning.ChannelAcceptor' - ] + subscriptionMethods: [ + 'lnrpc.Lightning.SubscribeInvoices', + 'lnrpc.Lightning.SubscribeChannelEvents', + 'lnrpc.Lightning.ChannelAcceptor' + ] })); // Create the mocked LNC instance const mockLnc = { - request: vi.fn(), - subscribe: vi.fn() + request: vi.fn(), + subscribe: vi.fn() } as unknown as Mocked; describe('RPC Creation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createRpc function', () => { + it('should create a proxy object', () => { + const packageName = 'lnrpc.Lightning'; + const rpc = createRpc(packageName, mockLnc); + + expect(typeof rpc).toBe('object'); + expect(rpc).toBeInstanceOf(Object); + }); + }); + + describe('Proxy behavior', () => { + const packageName = 'lnrpc.Lightning'; + let rpc: Lightning; + beforeEach(() => { - vi.clearAllMocks(); + rpc = createRpc(packageName, mockLnc); + }); + + describe('Method name capitalization', () => { + it('should capitalize method names correctly', () => { + // Access a property to trigger the proxy get handler + const method = rpc.getInfo; + + expect(typeof method).toBe('function'); + + // Call the method to verify capitalization + const request = { includeChannels: true }; + method(request); + + expect(mockLnc.request).toHaveBeenCalledWith( + 'lnrpc.Lightning.GetInfo', + request + ); + }); + + it('should handle method names with numbers', () => { + const method = (rpc as any).method123; + + const request = {}; + method(request); + + expect(mockLnc.request).toHaveBeenCalledWith( + 'lnrpc.Lightning.Method123', + request + ); + }); + }); + + describe('Unary RPC methods', () => { + it('should create async functions for non-subscription methods', async () => { + const method = rpc.getInfo; + expect(typeof method).toBe('function'); + + const mockResponse = { identityPubkey: 'test' }; + mockLnc.request.mockResolvedValue(mockResponse); + + const request = {}; + const result = await method(request); + + expect(result).toBe(mockResponse); + expect(mockLnc.request).toHaveBeenCalledWith( + 'lnrpc.Lightning.GetInfo', + request + ); + }); + + it('should handle empty request objects', async () => { + const method = rpc.getInfo; + const request = {}; + + mockLnc.request.mockResolvedValue({}); + + await method(request); + + expect(mockLnc.request).toHaveBeenCalledWith( + 'lnrpc.Lightning.GetInfo', + request + ); + }); }); - describe('createRpc function', () => { - it('should create a proxy object', () => { - const packageName = 'lnrpc.Lightning'; - const rpc = createRpc(packageName, mockLnc); + describe('Streaming RPC methods (subscriptions)', () => { + it('should create subscription functions for streaming methods', () => { + // Test with SubscribeInvoices which is in subscriptionMethods + const method = rpc.subscribeInvoices; + + expect(typeof method).toBe('function'); + + const request = { addIndex: '1' }; + const callback = vi.fn(); + const errCallback = vi.fn(); - expect(typeof rpc).toBe('object'); - expect(rpc).toBeInstanceOf(Object); - }); + method(request, callback, errCallback); + + expect(mockLnc.subscribe).toHaveBeenCalledWith( + 'lnrpc.Lightning.SubscribeInvoices', + request, + callback, + errCallback + ); + }); + + it('should create subscription functions for ChannelAcceptor', () => { + const method = rpc.channelAcceptor; + + expect(typeof method).toBe('function'); + + const request = {}; + const callback = vi.fn(); + const errCallback = vi.fn(); + + method(request, callback, errCallback); + + expect(mockLnc.subscribe).toHaveBeenCalledWith( + 'lnrpc.Lightning.ChannelAcceptor', + request, + callback, + errCallback + ); + }); }); - describe('Proxy behavior', () => { - const packageName = 'lnrpc.Lightning'; - let rpc: Lightning; + describe('Method classification', () => { + it('should handle different package names correctly', () => { + const walletRpc = createRpc('lnrpc.WalletKit', mockLnc); + const method = walletRpc.listUnspent; - beforeEach(() => { - rpc = createRpc(packageName, mockLnc); - }); + const request = { minConfs: 1 }; + method(request); + + expect(mockLnc.request).toHaveBeenCalledWith( + 'lnrpc.WalletKit.ListUnspent', + request + ); + }); + }); - describe('Method name capitalization', () => { - it('should capitalize method names correctly', () => { - // Access a property to trigger the proxy get handler - const method = rpc.getInfo; + describe('Error handling', () => { + it('should handle LNC request errors', async () => { + const method = rpc.getInfo; + const error = new Error('RPC Error'); + mockLnc.request.mockRejectedValueOnce(error); - expect(typeof method).toBe('function'); - - // Call the method to verify capitalization - const request = { includeChannels: true }; - method(request); - - expect(mockLnc.request).toHaveBeenCalledWith( - 'lnrpc.Lightning.GetInfo', - request - ); - }); - - it('should handle method names with numbers', () => { - const method = (rpc as any).method123; - - const request = {}; - method(request); - - expect(mockLnc.request).toHaveBeenCalledWith( - 'lnrpc.Lightning.Method123', - request - ); - }); - }); - - describe('Unary RPC methods', () => { - it('should create async functions for non-subscription methods', async () => { - const method = rpc.getInfo; - expect(typeof method).toBe('function'); - - const mockResponse = { identityPubkey: 'test' }; - mockLnc.request.mockResolvedValue(mockResponse); - - const request = {}; - const result = await method(request); - - expect(result).toBe(mockResponse); - expect(mockLnc.request).toHaveBeenCalledWith( - 'lnrpc.Lightning.GetInfo', - request - ); - }); - - it('should handle empty request objects', async () => { - const method = rpc.getInfo; - const request = {}; - - mockLnc.request.mockResolvedValue({}); - - await method(request); - - expect(mockLnc.request).toHaveBeenCalledWith( - 'lnrpc.Lightning.GetInfo', - request - ); - }); - }); - - describe('Streaming RPC methods (subscriptions)', () => { - it('should create subscription functions for streaming methods', () => { - // Test with SubscribeInvoices which is in subscriptionMethods - const method = rpc.subscribeInvoices; - - expect(typeof method).toBe('function'); - - const request = { addIndex: '1' }; - const callback = vi.fn(); - const errCallback = vi.fn(); - - method(request, callback, errCallback); - - expect(mockLnc.subscribe).toHaveBeenCalledWith( - 'lnrpc.Lightning.SubscribeInvoices', - request, - callback, - errCallback - ); - }); - - it('should create subscription functions for ChannelAcceptor', () => { - const method = rpc.channelAcceptor; - - expect(typeof method).toBe('function'); - - const request = {}; - const callback = vi.fn(); - const errCallback = vi.fn(); - - method(request, callback, errCallback); - - expect(mockLnc.subscribe).toHaveBeenCalledWith( - 'lnrpc.Lightning.ChannelAcceptor', - request, - callback, - errCallback - ); - }); - }); - - describe('Method classification', () => { - it('should handle different package names correctly', () => { - const walletRpc = createRpc( - 'lnrpc.WalletKit', - mockLnc - ); - const method = walletRpc.listUnspent; - - const request = { minConfs: 1 }; - method(request); - - expect(mockLnc.request).toHaveBeenCalledWith( - 'lnrpc.WalletKit.ListUnspent', - request - ); - }); - }); - - describe('Error handling', () => { - it('should handle LNC request errors', async () => { - const method = rpc.getInfo; - const error = new Error('RPC Error'); - mockLnc.request.mockRejectedValueOnce(error); - - const request = {}; - await expect(method(request)).rejects.toThrow('RPC Error'); - }); - }); + const request = {}; + await expect(method(request)).rejects.toThrow('RPC Error'); + }); }); + }); }); diff --git a/lib/api/createRpc.ts b/lib/api/createRpc.ts index 3e5e77e..f55a7a9 100644 --- a/lib/api/createRpc.ts +++ b/lib/api/createRpc.ts @@ -9,30 +9,30 @@ const capitalize = (s: string) => s && s[0].toUpperCase() + s.slice(1); * subscribe methods depending on which function is called on the object */ export function createRpc(packageName: string, lnc: LNC): T { - const rpc = {}; - return new Proxy(rpc, { - get(target, key, c) { - const methodName = capitalize(key.toString()); - // the full name of the method (ex: lnrpc.Lightning.OpenChannel) - const method = `${packageName}.${methodName}`; + const rpc = {}; + return new Proxy(rpc, { + get(target, key, c) { + const methodName = capitalize(key.toString()); + // the full name of the method (ex: lnrpc.Lightning.OpenChannel) + const method = `${packageName}.${methodName}`; - if (subscriptionMethods.includes(method)) { - // call subscribe for streaming methods - return ( - request: object, - callback: (msg: object) => void, - errCallback?: (err: Error) => void - ): void => { - lnc.subscribe(method, request, callback, errCallback); - }; - } else { - // call request for unary methods - return async (request: object): Promise => { - return await lnc.request(method, request); - }; - } - } - }) as T; + if (subscriptionMethods.includes(method)) { + // call subscribe for streaming methods + return ( + request: object, + callback: (msg: object) => void, + errCallback?: (err: Error) => void + ): void => { + lnc.subscribe(method, request, callback, errCallback); + }; + } else { + // call request for unary methods + return async (request: object): Promise => { + return await lnc.request(method, request); + }; + } + } + }) as T; } export default createRpc; diff --git a/lib/index.test.ts b/lib/index.test.ts index b093007..16b0487 100644 --- a/lib/index.test.ts +++ b/lib/index.test.ts @@ -7,63 +7,62 @@ vi.mock('../../lib/wasm_exec', () => ({})); vi.mock('@lightninglabs/lnc-core'); describe('Index Module', () => { - let originalInstantiateStreaming: any; + let originalInstantiateStreaming: any; - beforeEach(() => { - // Store original values - originalInstantiateStreaming = - globalThis.WebAssembly?.instantiateStreaming; + beforeEach(() => { + // Store original values + originalInstantiateStreaming = globalThis.WebAssembly?.instantiateStreaming; - // Mock WebAssembly for testing - globalThis.WebAssembly = { - instantiateStreaming: vi.fn(), - instantiate: vi.fn(), - compile: vi.fn() - } as any; - }); + // Mock WebAssembly for testing + globalThis.WebAssembly = { + instantiateStreaming: vi.fn(), + instantiate: vi.fn(), + compile: vi.fn() + } as any; + }); - afterEach(() => { - // Restore original values - if (originalInstantiateStreaming) { - globalThis.WebAssembly.instantiateStreaming = - originalInstantiateStreaming; - } - vi.restoreAllMocks(); - }); + afterEach(() => { + // Restore original values + if (originalInstantiateStreaming) { + globalThis.WebAssembly.instantiateStreaming = + originalInstantiateStreaming; + } + vi.restoreAllMocks(); + }); - describe('WebAssembly Polyfill', () => { - it('should polyfill WebAssembly.instantiateStreaming when not available', async () => { - // Remove instantiateStreaming to test polyfill - delete (globalThis.WebAssembly as any).instantiateStreaming; + describe('WebAssembly Polyfill', () => { + it('should polyfill WebAssembly.instantiateStreaming when not available', async () => { + // Remove instantiateStreaming to test polyfill + delete (globalThis.WebAssembly as any).instantiateStreaming; - // Import the index module to trigger the polyfill - await import('./index'); + // Import the index module to trigger the polyfill + await import('./index'); - // Now WebAssembly.instantiateStreaming should exist - expect(typeof globalThis.WebAssembly?.instantiateStreaming).toBe( - 'function' - ); - }); + // Now WebAssembly.instantiateStreaming should exist + expect(typeof globalThis.WebAssembly?.instantiateStreaming).toBe( + 'function' + ); + }); - it('should use existing WebAssembly.instantiateStreaming when available', async () => { - // Set up existing WebAssembly.instantiateStreaming - const existingInstantiateStreaming = vi.fn().mockResolvedValue({ - module: {}, - instance: {} - }); + it('should use existing WebAssembly.instantiateStreaming when available', async () => { + // Set up existing WebAssembly.instantiateStreaming + const existingInstantiateStreaming = vi.fn().mockResolvedValue({ + module: {}, + instance: {} + }); - globalThis.WebAssembly = { - ...globalThis.WebAssembly, - instantiateStreaming: existingInstantiateStreaming - }; + globalThis.WebAssembly = { + ...globalThis.WebAssembly, + instantiateStreaming: existingInstantiateStreaming + }; - // Import the index module - await import('./index'); + // Import the index module + await import('./index'); - // The existing function should still be there - expect(globalThis.WebAssembly.instantiateStreaming).toBe( - existingInstantiateStreaming - ); - }); + // The existing function should still be there + expect(globalThis.WebAssembly.instantiateStreaming).toBe( + existingInstantiateStreaming + ); }); + }); }); diff --git a/lib/index.ts b/lib/index.ts index 166447c..b6de45a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -5,10 +5,10 @@ import LNC from './lnc'; // polyfill if (!WebAssembly.instantiateStreaming) { - WebAssembly.instantiateStreaming = async (resp, importObject) => { - const source = await (await resp).arrayBuffer(); - return await WebAssembly.instantiate(source, importObject); - }; + WebAssembly.instantiateStreaming = async (resp, importObject) => { + const source = await (await resp).arrayBuffer(); + return await WebAssembly.instantiate(source, importObject); + }; } export type { LncConfig, CredentialStore } from './types/lnc'; diff --git a/lib/lnc.test.ts b/lib/lnc.test.ts index d6f923d..bb86631 100644 --- a/lib/lnc.test.ts +++ b/lib/lnc.test.ts @@ -1,11 +1,11 @@ import { - afterEach, - beforeEach, - describe, - expect, - it, - Mocked, - vi + afterEach, + beforeEach, + describe, + expect, + it, + Mocked, + vi } from 'vitest'; import { createMockSetup, MockSetup } from '../test/utils/mock-factory'; import { globalAccess, testData } from '../test/utils/test-helpers'; @@ -13,940 +13,935 @@ import LNC, { DEFAULT_CONFIG } from './lnc'; import { WasmGlobal } from './types/lnc'; describe('LNC Core Class', () => { - let mockSetup: MockSetup; - let wasmGlobal: Mocked; + let mockSetup: MockSetup; + let wasmGlobal: Mocked; + + beforeEach(() => { + // Create fresh mocks for each test (without WASM global by default) + mockSetup = createMockSetup('default', false); + wasmGlobal = globalAccess.setupWasmGlobal(); + wasmGlobal.wasmClientIsReady.mockReturnValue(true); + wasmGlobal.wasmClientIsConnected.mockReturnValue(false); + }); + + afterEach(() => { + mockSetup.cleanup(); + vi.clearAllMocks(); + }); + + describe('Constructor', () => { + it('should create instance with default configuration', () => { + const lnc = new LNC(); + + expect(lnc).toBeInstanceOf(LNC); + expect(lnc._wasmClientCode).toBe(DEFAULT_CONFIG.wasmClientCode); + expect(lnc._namespace).toBe('default'); + expect(lnc.credentials).toBeDefined(); + expect(lnc.go).toBeDefined(); + expect(lnc.lnd).toBeDefined(); + expect(lnc.loop).toBeDefined(); + expect(lnc.pool).toBeDefined(); + expect(lnc.faraday).toBeDefined(); + expect(lnc.tapd).toBeDefined(); + expect(lnc.lit).toBeDefined(); + }); - beforeEach(() => { - // Create fresh mocks for each test (without WASM global by default) - mockSetup = createMockSetup('default', false); - wasmGlobal = globalAccess.setupWasmGlobal(); - wasmGlobal.wasmClientIsReady.mockReturnValue(true); - wasmGlobal.wasmClientIsConnected.mockReturnValue(false); + it('should merge custom config with defaults', () => { + const partialConfig = { + namespace: 'custom_namespace' + }; + + const lnc = new LNC(partialConfig); + + // Custom value should be used + expect(lnc._namespace).toBe('custom_namespace'); + // Default values should be preserved + expect(lnc._wasmClientCode).toBe(DEFAULT_CONFIG.wasmClientCode); }); - afterEach(() => { - mockSetup.cleanup(); - vi.clearAllMocks(); - }); - - describe('Constructor', () => { - it('should create instance with default configuration', () => { - const lnc = new LNC(); - - expect(lnc).toBeInstanceOf(LNC); - expect(lnc._wasmClientCode).toBe(DEFAULT_CONFIG.wasmClientCode); - expect(lnc._namespace).toBe('default'); - expect(lnc.credentials).toBeDefined(); - expect(lnc.go).toBeDefined(); - expect(lnc.lnd).toBeDefined(); - expect(lnc.loop).toBeDefined(); - expect(lnc.pool).toBeDefined(); - expect(lnc.faraday).toBeDefined(); - expect(lnc.tapd).toBeDefined(); - expect(lnc.lit).toBeDefined(); - }); - - it('should merge custom config with defaults', () => { - const partialConfig = { - namespace: 'custom_namespace' - }; - - const lnc = new LNC(partialConfig); - - // Custom value should be used - expect(lnc._namespace).toBe('custom_namespace'); - // Default values should be preserved - expect(lnc._wasmClientCode).toBe(DEFAULT_CONFIG.wasmClientCode); - }); - - it('should create credential store with correct namespace and password', () => { - const config = { - namespace: 'test_namespace', - password: testData.password - }; - - const lnc = new LNC(config); - - expect(lnc.credentials).toBeDefined(); - // The credential store should have been created with the namespace and password - expect(globalThis.localStorage.setItem).toHaveBeenCalledWith( - 'lnc-web:test_namespace', - expect.any(String) - ); - }); - - it('should use custom credential store if provided', () => { - const customCredentialStore = { - password: testData.password, - pairingPhrase: testData.pairingPhrase, - serverHost: testData.serverHost, - localKey: testData.localKey, - remoteKey: testData.remoteKey, - isPaired: true, - clear: vi.fn() - }; - - const config = { - credentialStore: customCredentialStore - }; - - const lnc = new LNC(config); - - expect(lnc.credentials).toBe(customCredentialStore); - }); - - it('should set serverHost from config if not already paired', () => { - const config = { - serverHost: 'custom.server:9000', - namespace: 'test' - }; - - // Pre-populate with non-paired data - globalThis.localStorage.setItem( - 'lnc-web:test', - JSON.stringify({ - salt: 'salt', - cipher: 'cipher', - serverHost: 'existing.server:443', - remoteKey: '', // No remote key = not paired - pairingPhrase: '', - localKey: '' - }) - ); - - const lnc = new LNC(config); - - // Server host should be set from config since not paired - expect(lnc.credentials.serverHost).toBe('custom.server:9000'); - }); - - it('should set pairingPhrase on credential store if provided', () => { - const config = { - pairingPhrase: 'test_pairing_phrase', - namespace: 'test' - }; - - const lnc = new LNC(config); - - expect(lnc.credentials.pairingPhrase).toBe('test_pairing_phrase'); - }); - - it('should create Go instance correctly', () => { - const lnc = new LNC(); - - expect(lnc.go).toBeDefined(); - expect(typeof lnc.go).toBe('object'); - expect(lnc.go.importObject).toBeDefined(); - }); - - it('should initialize all API instances', () => { - const lnc = new LNC(); - - // All API instances should be created - expect(lnc.lnd).toBeDefined(); - expect(lnc.loop).toBeDefined(); - expect(lnc.pool).toBeDefined(); - expect(lnc.faraday).toBeDefined(); - expect(lnc.tapd).toBeDefined(); - expect(lnc.lit).toBeDefined(); - }); - - it('should handle undefined config gracefully', () => { - const lnc = new LNC(undefined); - - expect(lnc._wasmClientCode).toBe(DEFAULT_CONFIG.wasmClientCode); - expect(lnc._namespace).toBe(DEFAULT_CONFIG.namespace); - }); - - it('should handle empty config object gracefully', () => { - const lnc = new LNC({}); - - expect(lnc._wasmClientCode).toBe(DEFAULT_CONFIG.wasmClientCode); - expect(lnc._namespace).toBe(DEFAULT_CONFIG.namespace); - }); - }); - - describe('Configuration Properties', () => { - it('should set correct default wasmClientCode', () => { - const lnc = new LNC(); - - expect(lnc._wasmClientCode).toBe(DEFAULT_CONFIG.wasmClientCode); - }); - - it('should set correct default namespace', () => { - const lnc = new LNC(); - - expect(lnc._namespace).toBe('default'); - }); - }); - - describe('WebAssembly Integration', () => { - it('should preload WASM client successfully', async () => { - const lnc = new LNC(); + it('should create credential store with correct namespace and password', () => { + const config = { + namespace: 'test_namespace', + password: testData.password + }; - // Mock WebAssembly.instantiateStreaming - const mockSource = { - module: { exports: {} }, - instance: { exports: {} } - }; - vi.spyOn( - globalThis.WebAssembly, - 'instantiateStreaming' - ).mockResolvedValue(mockSource); - - // Mock fetch - globalThis.fetch = vi.fn().mockResolvedValue(new Response()); - - await lnc.preload(); - - expect( - globalThis.WebAssembly.instantiateStreaming - ).toHaveBeenCalledWith(expect.any(Promise), lnc.go.importObject); - expect(lnc.result).toEqual(mockSource); - }); + const lnc = new LNC(config); - it('should run WASM client successfully', async () => { - const lnc = new LNC(); + expect(lnc.credentials).toBeDefined(); + // The credential store should have been created with the namespace and password + expect(globalThis.localStorage.setItem).toHaveBeenCalledWith( + 'lnc-web:test_namespace', + expect.any(String) + ); + }); + + it('should use custom credential store if provided', () => { + const customCredentialStore = { + password: testData.password, + pairingPhrase: testData.pairingPhrase, + serverHost: testData.serverHost, + localKey: testData.localKey, + remoteKey: testData.remoteKey, + isPaired: true, + clear: vi.fn() + }; - // Set up WASM result first - lnc.result = { - module: { exports: {} }, - instance: { exports: {} } - }; + const config = { + credentialStore: customCredentialStore + }; - // Mock WebAssembly.instantiate before calling run - const instantiateMock = vi.fn().mockResolvedValue({ - exports: {} - }); - globalThis.WebAssembly.instantiate = instantiateMock; + const lnc = new LNC(config); - await lnc.run(); - - expect(lnc.go.run).toHaveBeenCalledWith(lnc.result.instance); - expect(instantiateMock).toHaveBeenCalledWith( - lnc.result.module, - lnc.go.importObject - ); - }); - - it('should preload automatically if not ready during run', async () => { - const lnc = new LNC(); - - wasmGlobal.wasmClientIsReady.mockReturnValue(false); + expect(lnc.credentials).toBe(customCredentialStore); + }); - // Mock preload to set result - const preloadSpy = vi.spyOn(lnc, 'preload').mockResolvedValue(); - lnc.result = { - module: { exports: {} }, - instance: { exports: {} } - }; + it('should set serverHost from config if not already paired', () => { + const config = { + serverHost: 'custom.server:9000', + namespace: 'test' + }; + + // Pre-populate with non-paired data + globalThis.localStorage.setItem( + 'lnc-web:test', + JSON.stringify({ + salt: 'salt', + cipher: 'cipher', + serverHost: 'existing.server:443', + remoteKey: '', // No remote key = not paired + pairingPhrase: '', + localKey: '' + }) + ); + + const lnc = new LNC(config); + + // Server host should be set from config since not paired + expect(lnc.credentials.serverHost).toBe('custom.server:9000'); + }); - // Mock WebAssembly.instantiate - vi.spyOn(globalThis.WebAssembly, 'instantiate').mockResolvedValue({ - exports: {} - }); + it('should set pairingPhrase on credential store if provided', () => { + const config = { + pairingPhrase: 'test_pairing_phrase', + namespace: 'test' + }; - await lnc.run(); + const lnc = new LNC(config); - expect(preloadSpy).toHaveBeenCalled(); - }); + expect(lnc.credentials.pairingPhrase).toBe('test_pairing_phrase'); + }); - it('should throw error if WASM instance not found during run', async () => { - const lnc = new LNC(); + it('should create Go instance correctly', () => { + const lnc = new LNC(); - // Mock the preload to avoid network errors and ensure no result - const preloadSpy = vi.spyOn(lnc, 'preload').mockResolvedValue(); + expect(lnc.go).toBeDefined(); + expect(typeof lnc.go).toBe('object'); + expect(lnc.go.importObject).toBeDefined(); + }); - await expect(lnc.run()).rejects.toThrow( - "Can't find WASM instance." - ); + it('should initialize all API instances', () => { + const lnc = new LNC(); - preloadSpy.mockRestore(); - }); + // All API instances should be created + expect(lnc.lnd).toBeDefined(); + expect(lnc.loop).toBeDefined(); + expect(lnc.pool).toBeDefined(); + expect(lnc.faraday).toBeDefined(); + expect(lnc.tapd).toBeDefined(); + expect(lnc.lit).toBeDefined(); + }); - it('should set up WASM callbacks correctly', async () => { - const lnc = new LNC(); + it('should handle undefined config gracefully', () => { + const lnc = new LNC(undefined); - globalAccess.clearWasmGlobal(lnc._namespace); + expect(lnc._wasmClientCode).toBe(DEFAULT_CONFIG.wasmClientCode); + expect(lnc._namespace).toBe(DEFAULT_CONFIG.namespace); + }); - lnc.result = { - module: { exports: {} }, - instance: { exports: {} } - }; + it('should handle empty config object gracefully', () => { + const lnc = new LNC({}); - // Mock WebAssembly.instantiate for this test - const instantiateMock = vi.fn().mockResolvedValue({ - exports: {} - }); - globalThis.WebAssembly.instantiate = instantiateMock; - - await lnc.run(); - - // Check that callbacks are set up in global namespace - const namespace = globalAccess.getWasmGlobal(lnc._namespace); - expect(namespace.onLocalPrivCreate).toBeDefined(); - expect(namespace.onRemoteKeyReceive).toBeDefined(); - expect(namespace.onAuthData).toBeDefined(); - }); + expect(lnc._wasmClientCode).toBe(DEFAULT_CONFIG.wasmClientCode); + expect(lnc._namespace).toBe(DEFAULT_CONFIG.namespace); + }); + }); - it('should set correct Go argv during run', async () => { - const lnc = new LNC(); + describe('Configuration Properties', () => { + it('should set correct default wasmClientCode', () => { + const lnc = new LNC(); - lnc.result = { - module: { exports: {} }, - instance: { exports: {} } - }; + expect(lnc._wasmClientCode).toBe(DEFAULT_CONFIG.wasmClientCode); + }); - // Mock WebAssembly.instantiate for this test - const instantiateMock = vi.fn().mockResolvedValue({ - exports: {} - }); - globalThis.WebAssembly.instantiate = instantiateMock; + it('should set correct default namespace', () => { + const lnc = new LNC(); - await lnc.run(); + expect(lnc._namespace).toBe('default'); + }); + }); + + describe('WebAssembly Integration', () => { + it('should preload WASM client successfully', async () => { + const lnc = new LNC(); + + // Mock WebAssembly.instantiateStreaming + const mockSource = { + module: { exports: {} }, + instance: { exports: {} } + }; + vi.spyOn( + globalThis.WebAssembly, + 'instantiateStreaming' + ).mockResolvedValue(mockSource); + + // Mock fetch + globalThis.fetch = vi.fn().mockResolvedValue(new Response()); + + await lnc.preload(); + + expect(globalThis.WebAssembly.instantiateStreaming).toHaveBeenCalledWith( + expect.any(Promise), + lnc.go.importObject + ); + expect(lnc.result).toEqual(mockSource); + }); - expect(lnc.go.argv).toEqual([ - 'wasm-client', - '--debuglevel=debug,GOBN=info,GRPC=info', - '--namespace=default', - '--onlocalprivcreate=default.onLocalPrivCreate', - '--onremotekeyreceive=default.onRemoteKeyReceive', - '--onauthdata=default.onAuthData' - ]); - }); + it('should run WASM client successfully', async () => { + const lnc = new LNC(); - it('should wait until WASM client is ready successfully', async () => { - vi.useFakeTimers(); - const lnc = new LNC(); + // Set up WASM result first + lnc.result = { + module: { exports: {} }, + instance: { exports: {} } + }; - // Set up WASM global - // WASM global already set up in beforeEach - wasmGlobal.wasmClientIsReady.mockReturnValue(false); + // Mock WebAssembly.instantiate before calling run + const instantiateMock = vi.fn().mockResolvedValue({ + exports: {} + }); + globalThis.WebAssembly.instantiate = instantiateMock; - // Mock successful ready state after delay - setTimeout(() => { - wasmGlobal.wasmClientIsReady.mockReturnValue(true); - }, 10); + await lnc.run(); - const waitPromise = lnc.waitTilReady(); - vi.runAllTimers(); + expect(lnc.go.run).toHaveBeenCalledWith(lnc.result.instance); + expect(instantiateMock).toHaveBeenCalledWith( + lnc.result.module, + lnc.go.importObject + ); + }); - await waitPromise; + it('should preload automatically if not ready during run', async () => { + const lnc = new LNC(); - expect(wasmGlobal.wasmClientIsReady).toHaveBeenCalled(); - vi.useRealTimers(); - }); + wasmGlobal.wasmClientIsReady.mockReturnValue(false); - it('should timeout if WASM client never becomes ready', async () => { - vi.useFakeTimers(); - const lnc = new LNC(); + // Mock preload to set result + const preloadSpy = vi.spyOn(lnc, 'preload').mockResolvedValue(); + lnc.result = { + module: { exports: {} }, + instance: { exports: {} } + }; - // Set up WASM global that never becomes ready - // WASM global already set up in beforeEach - wasmGlobal.wasmClientIsReady.mockReturnValue(false); + // Mock WebAssembly.instantiate + vi.spyOn(globalThis.WebAssembly, 'instantiate').mockResolvedValue({ + exports: {} + }); - const waitPromise = lnc.waitTilReady(); + await lnc.run(); - // Fast-forward past the timeout (20 * 500ms = 10 seconds) - vi.advanceTimersByTime(11 * 1000); + expect(preloadSpy).toHaveBeenCalled(); + }); - await expect(waitPromise).rejects.toThrow( - 'Failed to load the WASM client' - ); - vi.useRealTimers(); - }); + it('should throw error if WASM instance not found during run', async () => { + const lnc = new LNC(); - it('should handle undefined WASM global gracefully', async () => { - vi.useFakeTimers(); - const lnc = new LNC(); + // Mock the preload to avoid network errors and ensure no result + const preloadSpy = vi.spyOn(lnc, 'preload').mockResolvedValue(); - // Don't set up WASM global - should timeout - globalAccess.clearWasmGlobal('default'); + await expect(lnc.run()).rejects.toThrow("Can't find WASM instance."); - const waitPromise = lnc.waitTilReady(); + preloadSpy.mockRestore(); + }); - // Fast-forward past the timeout - vi.advanceTimersByTime(11 * 1000); + it('should set up WASM callbacks correctly', async () => { + const lnc = new LNC(); - await expect(waitPromise).rejects.toThrow( - 'Failed to load the WASM client' - ); - vi.useRealTimers(); - }); + globalAccess.clearWasmGlobal(lnc._namespace); - it('should handle WASM global without wasmClientIsReady method', async () => { - vi.useFakeTimers(); - const lnc = new LNC(); + lnc.result = { + module: { exports: {} }, + instance: { exports: {} } + }; - // Set up incomplete WASM global - globalAccess.setWasmGlobal('default', {} as any); + // Mock WebAssembly.instantiate for this test + const instantiateMock = vi.fn().mockResolvedValue({ + exports: {} + }); + globalThis.WebAssembly.instantiate = instantiateMock; - const waitPromise = lnc.waitTilReady(); + await lnc.run(); - // Fast-forward past the timeout - vi.advanceTimersByTime(11 * 1000); + // Check that callbacks are set up in global namespace + const namespace = globalAccess.getWasmGlobal(lnc._namespace); + expect(namespace.onLocalPrivCreate).toBeDefined(); + expect(namespace.onRemoteKeyReceive).toBeDefined(); + expect(namespace.onAuthData).toBeDefined(); + }); - await expect(waitPromise).rejects.toThrow( - 'Failed to load the WASM client' - ); - vi.useRealTimers(); - }); + it('should set correct Go argv during run', async () => { + const lnc = new LNC(); + + lnc.result = { + module: { exports: {} }, + instance: { exports: {} } + }; + + // Mock WebAssembly.instantiate for this test + const instantiateMock = vi.fn().mockResolvedValue({ + exports: {} + }); + globalThis.WebAssembly.instantiate = instantiateMock; + + await lnc.run(); + + expect(lnc.go.argv).toEqual([ + 'wasm-client', + '--debuglevel=debug,GOBN=info,GRPC=info', + '--namespace=default', + '--onlocalprivcreate=default.onLocalPrivCreate', + '--onremotekeyreceive=default.onRemoteKeyReceive', + '--onauthdata=default.onAuthData' + ]); + }); - it('should check ready status multiple times before succeeding', async () => { - vi.useFakeTimers(); - const lnc = new LNC(); + it('should wait until WASM client is ready successfully', async () => { + vi.useFakeTimers(); + const lnc = new LNC(); - // Mock the ready state to become true after several checks - let callCount = 0; - wasmGlobal.wasmClientIsReady.mockImplementation(() => { - callCount++; - return callCount > 3; // Become ready after 4 calls - }); + // Set up WASM global + // WASM global already set up in beforeEach + wasmGlobal.wasmClientIsReady.mockReturnValue(false); - const waitPromise = lnc.waitTilReady(); + // Mock successful ready state after delay + setTimeout(() => { + wasmGlobal.wasmClientIsReady.mockReturnValue(true); + }, 10); - // Advance time to allow multiple checks - vi.advanceTimersByTime(2000); // 4 * 500ms intervals + const waitPromise = lnc.waitTilReady(); + vi.runAllTimers(); - await waitPromise; + await waitPromise; - expect(callCount).toBeGreaterThan(3); - vi.useRealTimers(); - }); + expect(wasmGlobal.wasmClientIsReady).toHaveBeenCalled(); + vi.useRealTimers(); }); - describe('Status and Permission Getters', () => { - it('should return true for isReady when WASM is ready', () => { - const lnc = new LNC(); + it('should timeout if WASM client never becomes ready', async () => { + vi.useFakeTimers(); + const lnc = new LNC(); - wasmGlobal.wasmClientIsReady.mockReturnValue(true); + // Set up WASM global that never becomes ready + // WASM global already set up in beforeEach + wasmGlobal.wasmClientIsReady.mockReturnValue(false); - expect(lnc.isReady).toBe(true); - }); + const waitPromise = lnc.waitTilReady(); - it('should return true for isConnected when connected', () => { - const lnc = new LNC(); + // Fast-forward past the timeout (20 * 500ms = 10 seconds) + vi.advanceTimersByTime(11 * 1000); - wasmGlobal.wasmClientIsConnected.mockReturnValue(true); + await expect(waitPromise).rejects.toThrow( + 'Failed to load the WASM client' + ); + vi.useRealTimers(); + }); + + it('should handle undefined WASM global gracefully', async () => { + vi.useFakeTimers(); + const lnc = new LNC(); - expect(lnc.isConnected).toBe(true); - }); + // Don't set up WASM global - should timeout + globalAccess.clearWasmGlobal('default'); - it('should return undefined for status when WASM not available', () => { - const lnc = new LNC(); + const waitPromise = lnc.waitTilReady(); - // Clean up the WASM global for this test - globalAccess.clearWasmGlobal('default'); + // Fast-forward past the timeout + vi.advanceTimersByTime(11 * 1000); - expect(lnc.status).toBeUndefined(); + await expect(waitPromise).rejects.toThrow( + 'Failed to load the WASM client' + ); + vi.useRealTimers(); + }); - // Restore for other tests - wasmGlobal = globalAccess.setupWasmGlobal(); - }); + it('should handle WASM global without wasmClientIsReady method', async () => { + vi.useFakeTimers(); + const lnc = new LNC(); - it('should return undefined for expiry when WASM not available', () => { - const lnc = new LNC(); + // Set up incomplete WASM global + globalAccess.setWasmGlobal('default', {} as any); - // Clean up the WASM global for this test - globalAccess.clearWasmGlobal('default'); + const waitPromise = lnc.waitTilReady(); - expect(lnc.expiry).toBeUndefined(); + // Fast-forward past the timeout + vi.advanceTimersByTime(11 * 1000); - // Restore for other tests - wasmGlobal = globalAccess.setupWasmGlobal(); - }); + await expect(waitPromise).rejects.toThrow( + 'Failed to load the WASM client' + ); + vi.useRealTimers(); + }); - it('should return correct expiry date from WASM', () => { - const lnc = new LNC(); - const timestamp = Date.now() / 1000; + it('should check ready status multiple times before succeeding', async () => { + vi.useFakeTimers(); + const lnc = new LNC(); - wasmGlobal.wasmClientGetExpiry.mockReturnValue(timestamp); + // Mock the ready state to become true after several checks + let callCount = 0; + wasmGlobal.wasmClientIsReady.mockImplementation(() => { + callCount++; + return callCount > 3; // Become ready after 4 calls + }); - const expectedDate = new Date(timestamp * 1000); - expect(lnc.expiry).toEqual(expectedDate); - }); + const waitPromise = lnc.waitTilReady(); - it('should return undefined for hasPerms when WASM not available', () => { - const lnc = new LNC(); + // Advance time to allow multiple checks + vi.advanceTimersByTime(2000); // 4 * 500ms intervals - // Clean up the WASM global for this test - globalAccess.clearWasmGlobal('default'); + await waitPromise; - expect(lnc.hasPerms('test.permission')).toBeUndefined(); + expect(callCount).toBeGreaterThan(3); + vi.useRealTimers(); + }); + }); - // Restore for other tests - wasmGlobal = globalAccess.setupWasmGlobal(); - }); + describe('Status and Permission Getters', () => { + it('should return true for isReady when WASM is ready', () => { + const lnc = new LNC(); - it('should return correct status from WASM', () => { - const lnc = new LNC(); + wasmGlobal.wasmClientIsReady.mockReturnValue(true); - wasmGlobal.wasmClientStatus.mockReturnValue('connected'); + expect(lnc.isReady).toBe(true); + }); - expect(lnc.status).toBe('connected'); - }); + it('should return true for isConnected when connected', () => { + const lnc = new LNC(); - it('should return correct readOnly status from WASM', () => { - const lnc = new LNC(); + wasmGlobal.wasmClientIsConnected.mockReturnValue(true); - wasmGlobal.wasmClientIsReadOnly.mockReturnValue(true); + expect(lnc.isConnected).toBe(true); + }); - expect(lnc.isReadOnly).toBe(true); - }); + it('should return undefined for status when WASM not available', () => { + const lnc = new LNC(); - it('should return correct permission status from WASM', () => { - const lnc = new LNC(); + // Clean up the WASM global for this test + globalAccess.clearWasmGlobal('default'); - wasmGlobal.wasmClientHasPerms.mockReturnValue(true); + expect(lnc.status).toBeUndefined(); - expect(lnc.hasPerms('test.permission')).toBe(true); - expect(wasmGlobal.wasmClientHasPerms).toHaveBeenCalledWith( - 'test.permission' - ); - }); + // Restore for other tests + wasmGlobal = globalAccess.setupWasmGlobal(); }); - describe('Connection Management', () => { - const originalWindow = globalAccess.window; - const mockWindow = { addEventListener: vi.fn() } as any; + it('should return undefined for expiry when WASM not available', () => { + const lnc = new LNC(); - beforeEach(() => { - vi.useFakeTimers(); - globalAccess.window = mockWindow; - }); + // Clean up the WASM global for this test + globalAccess.clearWasmGlobal('default'); - afterEach(() => { - vi.useRealTimers(); - globalAccess.window = originalWindow; - }); + expect(lnc.expiry).toBeUndefined(); - it('should connect successfully when not already connected', async () => { - const lnc = new LNC(); + // Restore for other tests + wasmGlobal = globalAccess.setupWasmGlobal(); + }); - // Mock successful connection after delay - setTimeout(() => { - wasmGlobal.wasmClientIsConnected.mockReturnValue(true); - }, 10); + it('should return correct expiry date from WASM', () => { + const lnc = new LNC(); + const timestamp = Date.now() / 1000; - const connectPromise = lnc.connect(); + wasmGlobal.wasmClientGetExpiry.mockReturnValue(timestamp); - // Advance timers to make the connection succeed immediately - vi.runAllTimers(); + const expectedDate = new Date(timestamp * 1000); + expect(lnc.expiry).toEqual(expectedDate); + }); - await connectPromise; + it('should return undefined for hasPerms when WASM not available', () => { + const lnc = new LNC(); - expect(wasmGlobal.wasmClientConnectServer).toHaveBeenCalled(); - }); + // Clean up the WASM global for this test + globalAccess.clearWasmGlobal('default'); - it('should run WASM if not ready during connect', async () => { - const lnc = new LNC(); + expect(lnc.hasPerms('test.permission')).toBeUndefined(); - // Override default setup for this test - WASM not ready - wasmGlobal.wasmClientIsReady.mockReturnValue(false); + // Restore for other tests + wasmGlobal = globalAccess.setupWasmGlobal(); + }); - // Mock run and waitTilReady - const runSpy = vi - .spyOn(lnc, 'run') - .mockImplementation(() => Promise.resolve()); + it('should return correct status from WASM', () => { + const lnc = new LNC(); - let waitRan = false; - const waitSpy = vi - .spyOn(lnc, 'waitTilReady') - .mockImplementation(() => { - waitRan = true; - return Promise.resolve(); - }); + wasmGlobal.wasmClientStatus.mockReturnValue('connected'); - const connectPromise = lnc.connect(); + expect(lnc.status).toBe('connected'); + }); - // Wait for the waitTilReady promise to resolve to ensure the `runAllTimers` - // call below will exhaust all setInterval callbacks. - await vi.waitFor(() => { - expect(waitRan).toBe(true); - }); + it('should return correct readOnly status from WASM', () => { + const lnc = new LNC(); - // Mock successful connection after delay - setTimeout(() => { - wasmGlobal.wasmClientIsConnected.mockReturnValue(true); - }, 100); + wasmGlobal.wasmClientIsReadOnly.mockReturnValue(true); - vi.runAllTimers(); + expect(lnc.isReadOnly).toBe(true); + }); - await connectPromise; + it('should return correct permission status from WASM', () => { + const lnc = new LNC(); - expect(runSpy).toHaveBeenCalled(); - expect(waitSpy).toHaveBeenCalled(); - }); + wasmGlobal.wasmClientHasPerms.mockReturnValue(true); - it('should pass correct parameters to connectServer', async () => { - const lnc = new LNC(); + expect(lnc.hasPerms('test.permission')).toBe(true); + expect(wasmGlobal.wasmClientHasPerms).toHaveBeenCalledWith( + 'test.permission' + ); + }); + }); - // Set up credentials - lnc.credentials.serverHost = 'test.host:443'; - lnc.credentials.pairingPhrase = 'test_phrase'; - lnc.credentials.localKey = 'test_local_key'; - lnc.credentials.remoteKey = 'test_remote_key'; + describe('Connection Management', () => { + const originalWindow = globalAccess.window; + const mockWindow = { addEventListener: vi.fn() } as any; - setTimeout(() => { - wasmGlobal.wasmClientIsConnected.mockReturnValue(true); - }, 10); + beforeEach(() => { + vi.useFakeTimers(); + globalAccess.window = mockWindow; + }); - const connectPromise = lnc.connect(); - vi.runAllTimers(); - await connectPromise; + afterEach(() => { + vi.useRealTimers(); + globalAccess.window = originalWindow; + }); - expect(wasmGlobal.wasmClientConnectServer).toHaveBeenCalledWith( - 'test.host:443', - false, - 'test_phrase', - 'test_local_key', - 'test_remote_key' - ); - }); + it('should connect successfully when not already connected', async () => { + const lnc = new LNC(); - it('should add unload event listener in browser environment', async () => { - const lnc = new LNC(); + // Mock successful connection after delay + setTimeout(() => { + wasmGlobal.wasmClientIsConnected.mockReturnValue(true); + }, 10); - setTimeout(() => { - wasmGlobal.wasmClientIsConnected.mockReturnValue(true); - }, 10); + const connectPromise = lnc.connect(); - const connectPromise = lnc.connect(); - vi.runAllTimers(); - await connectPromise; + // Advance timers to make the connection succeed immediately + vi.runAllTimers(); - expect(mockWindow.addEventListener).toHaveBeenCalledWith( - 'unload', - wasmGlobal.wasmClientDisconnect - ); - }); + await connectPromise; - it('should timeout connection after 20 attempts', async () => { - const lnc = new LNC(); + expect(wasmGlobal.wasmClientConnectServer).toHaveBeenCalled(); + }); - const connectPromise = lnc.connect(); + it('should run WASM if not ready during connect', async () => { + const lnc = new LNC(); - // Fast-forward past the timeout (20 * 500ms = 10 seconds) - vi.advanceTimersByTime(11 * 1000); + // Override default setup for this test - WASM not ready + wasmGlobal.wasmClientIsReady.mockReturnValue(false); - await expect(connectPromise).rejects.toThrow( - 'Failed to connect the WASM client to the proxy server' - ); - }); + // Mock run and waitTilReady + const runSpy = vi + .spyOn(lnc, 'run') + .mockImplementation(() => Promise.resolve()); - it('should handle connection attempts that exceed counter limit', async () => { - const lnc = new LNC(); + let waitRan = false; + const waitSpy = vi.spyOn(lnc, 'waitTilReady').mockImplementation(() => { + waitRan = true; + return Promise.resolve(); + }); - let connectionCheckCount = 0; - wasmGlobal.wasmClientIsConnected.mockImplementation(() => { - connectionCheckCount++; - return false; // Always return false - }); - - const connectPromise = lnc.connect(); - - // Advance timers to trigger all 20 attempts - vi.advanceTimersByTime(11 * 1000); + const connectPromise = lnc.connect(); - await expect(connectPromise).rejects.toThrow(); + // Wait for the waitTilReady promise to resolve to ensure the `runAllTimers` + // call below will exhaust all setInterval callbacks. + await vi.waitFor(() => { + expect(waitRan).toBe(true); + }); - // Verify we made exactly 22 connection checks - expect(connectionCheckCount).toBe(22); - }); + // Mock successful connection after delay + setTimeout(() => { + wasmGlobal.wasmClientIsConnected.mockReturnValue(true); + }, 100); - it('should handle connection when window object is undefined', async () => { - const lnc = new LNC(); + vi.runAllTimers(); - // Mock window as undefined to simulate non-browser environment - globalAccess.window = undefined as any; + await connectPromise; - // Mock successful connection - setTimeout(() => { - wasmGlobal.wasmClientIsConnected.mockReturnValue(true); - }, 10); + expect(runSpy).toHaveBeenCalled(); + expect(waitSpy).toHaveBeenCalled(); + }); - const connectPromise = lnc.connect(); - vi.runAllTimers(); - await connectPromise; + it('should pass correct parameters to connectServer', async () => { + const lnc = new LNC(); + + // Set up credentials + lnc.credentials.serverHost = 'test.host:443'; + lnc.credentials.pairingPhrase = 'test_phrase'; + lnc.credentials.localKey = 'test_local_key'; + lnc.credentials.remoteKey = 'test_remote_key'; + + setTimeout(() => { + wasmGlobal.wasmClientIsConnected.mockReturnValue(true); + }, 10); + + const connectPromise = lnc.connect(); + vi.runAllTimers(); + await connectPromise; + + expect(wasmGlobal.wasmClientConnectServer).toHaveBeenCalledWith( + 'test.host:443', + false, + 'test_phrase', + 'test_local_key', + 'test_remote_key' + ); + }); - // Verify connection still works without window - expect(wasmGlobal.wasmClientConnectServer).toHaveBeenCalled(); + it('should add unload event listener in browser environment', async () => { + const lnc = new LNC(); - // Cleanup - globalAccess.window = originalWindow; - }); + setTimeout(() => { + wasmGlobal.wasmClientIsConnected.mockReturnValue(true); + }, 10); - it('should clear in-memory credentials after successful connection when password is set', async () => { - const lnc = new LNC(); + const connectPromise = lnc.connect(); + vi.runAllTimers(); + await connectPromise; - // Set up credentials with password to encrypt the data in localStorage - lnc.credentials.localKey = 'test_local_key'; - lnc.credentials.remoteKey = 'test_remote_key'; - lnc.credentials.serverHost = 'test.host:443'; - lnc.credentials.pairingPhrase = 'test_phrase'; - lnc.credentials.password = 'test_password'; + expect(mockWindow.addEventListener).toHaveBeenCalledWith( + 'unload', + wasmGlobal.wasmClientDisconnect + ); + }); - // Set the password again to the same value to decrypt the data from storage - // and set the password in memory - lnc.credentials.password = 'test_password'; + it('should timeout connection after 20 attempts', async () => { + const lnc = new LNC(); - // Mock clear method - const clearSpy = vi.spyOn(lnc.credentials, 'clear'); - - // Mock successful connection after delay - setTimeout(() => { - wasmGlobal.wasmClientIsConnected.mockReturnValue(true); - }, 10); + const connectPromise = lnc.connect(); - const connectPromise = lnc.connect(); - vi.runAllTimers(); - await connectPromise; + // Fast-forward past the timeout (20 * 500ms = 10 seconds) + vi.advanceTimersByTime(11 * 1000); - // Verify clear was called with memoryOnly=true - expect(clearSpy).toHaveBeenCalledWith(true); + await expect(connectPromise).rejects.toThrow( + 'Failed to connect the WASM client to the proxy server' + ); + }); - // Cleanup - globalAccess.window = originalWindow; - clearSpy.mockRestore(); - }); + it('should handle connection attempts that exceed counter limit', async () => { + const lnc = new LNC(); - it('should clear credentials when password is truthy', async () => { - const lnc = new LNC(); + let connectionCheckCount = 0; + wasmGlobal.wasmClientIsConnected.mockImplementation(() => { + connectionCheckCount++; + return false; // Always return false + }); - // Set up credentials with password to encrypt the data in localStorage - lnc.credentials.localKey = 'test_local_key'; - lnc.credentials.remoteKey = 'test_remote_key'; - lnc.credentials.serverHost = 'test.host:443'; - lnc.credentials.pairingPhrase = 'test_phrase'; - lnc.credentials.password = 'test_password'; + const connectPromise = lnc.connect(); - // Set the password again to the same value to decrypt the data from storage - // and set the password in memory - lnc.credentials.password = 'test_password'; + // Advance timers to trigger all 20 attempts + vi.advanceTimersByTime(11 * 1000); - // Mock the clear method on the credential store instance - const clearSpy = vi.spyOn(lnc.credentials, 'clear'); + await expect(connectPromise).rejects.toThrow(); - // Mock successful connection - setTimeout(() => { - wasmGlobal.wasmClientIsConnected.mockReturnValue(true); - }, 10); + // Verify we made exactly 22 connection checks + expect(connectionCheckCount).toBe(22); + }); - const connectPromise = lnc.connect(); - vi.runAllTimers(); - await connectPromise; + it('should handle connection when window object is undefined', async () => { + const lnc = new LNC(); - // Verify clear was called - expect(clearSpy).toHaveBeenCalledWith(true); + // Mock window as undefined to simulate non-browser environment + globalAccess.window = undefined as any; - // Cleanup - globalAccess.window = originalWindow; - clearSpy.mockRestore(); - }); + // Mock successful connection + setTimeout(() => { + wasmGlobal.wasmClientIsConnected.mockReturnValue(true); + }, 10); - it('should disconnect successfully', () => { - const lnc = new LNC(); + const connectPromise = lnc.connect(); + vi.runAllTimers(); + await connectPromise; - lnc.disconnect(); + // Verify connection still works without window + expect(wasmGlobal.wasmClientConnectServer).toHaveBeenCalled(); - expect(wasmGlobal.wasmClientDisconnect).toHaveBeenCalled(); - }); - }); - - describe('RPC Communication', () => { - it('should make RPC request successfully', async () => { - const lnc = new LNC(); - - const testRequest = { field: 'value' }; - const testResponse = { result: 'success' }; - - wasmGlobal.wasmClientInvokeRPC.mockImplementation( - (method, request, callback) => { - callback(JSON.stringify(testResponse)); - } - ); - - const result = await lnc.request('TestMethod', testRequest); - - expect(result).toEqual(testResponse); - expect(wasmGlobal.wasmClientInvokeRPC).toHaveBeenCalledWith( - 'TestMethod', - JSON.stringify(testRequest), - expect.any(Function) - ); - }); + // Cleanup + globalAccess.window = originalWindow; + }); - it('should convert snake_case to camelCase in RPC response', async () => { - const lnc = new LNC(); + it('should clear in-memory credentials after successful connection when password is set', async () => { + const lnc = new LNC(); - const snakeResponse = { - snake_field: 'value', - nested: { another_field: 'nested' } - }; - const camelResponse = { - snakeField: 'value', - nested: { anotherField: 'nested' } - }; - - wasmGlobal.wasmClientInvokeRPC.mockImplementation( - (method, request, callback) => { - callback(JSON.stringify(snakeResponse)); - } - ); - - const result = await lnc.request('TestMethod'); + // Set up credentials with password to encrypt the data in localStorage + lnc.credentials.localKey = 'test_local_key'; + lnc.credentials.remoteKey = 'test_remote_key'; + lnc.credentials.serverHost = 'test.host:443'; + lnc.credentials.pairingPhrase = 'test_phrase'; + lnc.credentials.password = 'test_password'; - expect(result).toEqual(camelResponse); - }); - - it('should handle RPC request error', async () => { - const lnc = new LNC(); + // Set the password again to the same value to decrypt the data from storage + // and set the password in memory + lnc.credentials.password = 'test_password'; - const errorMessage = 'RPC Error'; - - wasmGlobal.wasmClientInvokeRPC.mockImplementation( - (method, request, callback) => { - callback(errorMessage); - } - ); + // Mock clear method + const clearSpy = vi.spyOn(lnc.credentials, 'clear'); - await expect(lnc.request('TestMethod')).rejects.toThrow( - errorMessage - ); - }); - - it('should handle malformed JSON in RPC response', async () => { - const lnc = new LNC(); + // Mock successful connection after delay + setTimeout(() => { + wasmGlobal.wasmClientIsConnected.mockReturnValue(true); + }, 10); - const malformedResponse = '{ invalid json }'; + const connectPromise = lnc.connect(); + vi.runAllTimers(); + await connectPromise; - wasmGlobal.wasmClientInvokeRPC.mockImplementation( - (method, request, callback) => { - callback(malformedResponse); - } - ); + // Verify clear was called with memoryOnly=true + expect(clearSpy).toHaveBeenCalledWith(true); - await expect(lnc.request('TestMethod')).rejects.toThrow( - malformedResponse - ); - }); + // Cleanup + globalAccess.window = originalWindow; + clearSpy.mockRestore(); + }); + + it('should clear credentials when password is truthy', async () => { + const lnc = new LNC(); + + // Set up credentials with password to encrypt the data in localStorage + lnc.credentials.localKey = 'test_local_key'; + lnc.credentials.remoteKey = 'test_remote_key'; + lnc.credentials.serverHost = 'test.host:443'; + lnc.credentials.pairingPhrase = 'test_phrase'; + lnc.credentials.password = 'test_password'; + + // Set the password again to the same value to decrypt the data from storage + // and set the password in memory + lnc.credentials.password = 'test_password'; + + // Mock the clear method on the credential store instance + const clearSpy = vi.spyOn(lnc.credentials, 'clear'); + + // Mock successful connection + setTimeout(() => { + wasmGlobal.wasmClientIsConnected.mockReturnValue(true); + }, 10); + + const connectPromise = lnc.connect(); + vi.runAllTimers(); + await connectPromise; + + // Verify clear was called + expect(clearSpy).toHaveBeenCalledWith(true); + + // Cleanup + globalAccess.window = originalWindow; + clearSpy.mockRestore(); + }); + + it('should disconnect successfully', () => { + const lnc = new LNC(); + + lnc.disconnect(); - it('should subscribe to RPC stream successfully', () => { - const lnc = new LNC(); + expect(wasmGlobal.wasmClientDisconnect).toHaveBeenCalled(); + }); + }); + + describe('RPC Communication', () => { + it('should make RPC request successfully', async () => { + const lnc = new LNC(); + + const testRequest = { field: 'value' }; + const testResponse = { result: 'success' }; + + wasmGlobal.wasmClientInvokeRPC.mockImplementation( + (method, request, callback) => { + callback(JSON.stringify(testResponse)); + } + ); + + const result = await lnc.request('TestMethod', testRequest); + + expect(result).toEqual(testResponse); + expect(wasmGlobal.wasmClientInvokeRPC).toHaveBeenCalledWith( + 'TestMethod', + JSON.stringify(testRequest), + expect.any(Function) + ); + }); + + it('should convert snake_case to camelCase in RPC response', async () => { + const lnc = new LNC(); + + const snakeResponse = { + snake_field: 'value', + nested: { another_field: 'nested' } + }; + const camelResponse = { + snakeField: 'value', + nested: { anotherField: 'nested' } + }; - const testRequest = { field: 'value' }; - const testResponse = { result: 'success' }; - const onMessage = vi.fn(); - const onError = vi.fn(); + wasmGlobal.wasmClientInvokeRPC.mockImplementation( + (method, request, callback) => { + callback(JSON.stringify(snakeResponse)); + } + ); - wasmGlobal.wasmClientInvokeRPC.mockImplementation( - (method, request, callback) => { - callback(JSON.stringify(testResponse)); - } - ); + const result = await lnc.request('TestMethod'); - lnc.subscribe('TestMethod', testRequest, onMessage, onError); + expect(result).toEqual(camelResponse); + }); + + it('should handle RPC request error', async () => { + const lnc = new LNC(); - expect(wasmGlobal.wasmClientInvokeRPC).toHaveBeenCalledWith( - 'TestMethod', - JSON.stringify(testRequest), - expect.any(Function) - ); - expect(onMessage).toHaveBeenCalledWith(testResponse); - }); - - it('should handle subscribe error', () => { - const lnc = new LNC(); - - const errorMessage = 'Subscribe Error'; - const onError = vi.fn(); - - wasmGlobal.wasmClientInvokeRPC.mockImplementation( - (method, request, callback) => { - callback(errorMessage); - } - ); - - lnc.subscribe('TestMethod', {}, undefined, onError); - - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - expect(onError.mock.calls[0][0].message).toBe(errorMessage); - }); - - it('should handle subscribe without callbacks', () => { - const lnc = new LNC(); + const errorMessage = 'RPC Error'; - const testResponse = { result: 'success' }; - - wasmGlobal.wasmClientInvokeRPC.mockImplementation( - (method, request, callback) => { - callback(JSON.stringify(testResponse)); - } - ); + wasmGlobal.wasmClientInvokeRPC.mockImplementation( + (method, request, callback) => { + callback(errorMessage); + } + ); - // Should not throw when no callbacks provided - expect(() => { - lnc.subscribe('TestMethod'); - }).not.toThrow(); - }); + await expect(lnc.request('TestMethod')).rejects.toThrow(errorMessage); }); - describe('WASM Callback Functions', () => { - it('should call onLocalPrivCreate callback and update credentials', async () => { - const lnc = new LNC(); + it('should handle malformed JSON in RPC response', async () => { + const lnc = new LNC(); - lnc.result = { - module: { exports: {} }, - instance: { exports: {} } - }; + const malformedResponse = '{ invalid json }'; - // Mock WebAssembly.instantiate for this test - const instantiateMock = vi.fn().mockResolvedValue({ - exports: {} - }); - globalThis.WebAssembly.instantiate = instantiateMock; - - await lnc.run(); + wasmGlobal.wasmClientInvokeRPC.mockImplementation( + (method, request, callback) => { + callback(malformedResponse); + } + ); - // Get the callback function that was assigned - const wasm = globalAccess.getWasmGlobal(lnc._namespace); - const callback = wasm.onLocalPrivCreate!; + await expect(lnc.request('TestMethod')).rejects.toThrow( + malformedResponse + ); + }); - // Call the callback - const testKey = 'test_local_key_hex'; - callback(testKey); + it('should subscribe to RPC stream successfully', () => { + const lnc = new LNC(); - // Verify the credential was updated - expect(lnc.credentials.localKey).toBe(testKey); - }); + const testRequest = { field: 'value' }; + const testResponse = { result: 'success' }; + const onMessage = vi.fn(); + const onError = vi.fn(); + + wasmGlobal.wasmClientInvokeRPC.mockImplementation( + (method, request, callback) => { + callback(JSON.stringify(testResponse)); + } + ); + + lnc.subscribe('TestMethod', testRequest, onMessage, onError); + + expect(wasmGlobal.wasmClientInvokeRPC).toHaveBeenCalledWith( + 'TestMethod', + JSON.stringify(testRequest), + expect.any(Function) + ); + expect(onMessage).toHaveBeenCalledWith(testResponse); + }); + + it('should handle subscribe error', () => { + const lnc = new LNC(); + + const errorMessage = 'Subscribe Error'; + const onError = vi.fn(); + + wasmGlobal.wasmClientInvokeRPC.mockImplementation( + (method, request, callback) => { + callback(errorMessage); + } + ); + + lnc.subscribe('TestMethod', {}, undefined, onError); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(onError.mock.calls[0][0].message).toBe(errorMessage); + }); + + it('should handle subscribe without callbacks', () => { + const lnc = new LNC(); + + const testResponse = { result: 'success' }; + + wasmGlobal.wasmClientInvokeRPC.mockImplementation( + (method, request, callback) => { + callback(JSON.stringify(testResponse)); + } + ); + + // Should not throw when no callbacks provided + expect(() => { + lnc.subscribe('TestMethod'); + }).not.toThrow(); + }); + }); + + describe('WASM Callback Functions', () => { + it('should call onLocalPrivCreate callback and update credentials', async () => { + const lnc = new LNC(); + + lnc.result = { + module: { exports: {} }, + instance: { exports: {} } + }; + + // Mock WebAssembly.instantiate for this test + const instantiateMock = vi.fn().mockResolvedValue({ + exports: {} + }); + globalThis.WebAssembly.instantiate = instantiateMock; + + await lnc.run(); + + // Get the callback function that was assigned + const wasm = globalAccess.getWasmGlobal(lnc._namespace); + const callback = wasm.onLocalPrivCreate!; + + // Call the callback + const testKey = 'test_local_key_hex'; + callback(testKey); + + // Verify the credential was updated + expect(lnc.credentials.localKey).toBe(testKey); + }); - it('should handle callback functions with logging', async () => { - const lnc = new LNC(); + it('should handle callback functions with logging', async () => { + const lnc = new LNC(); - lnc.result = { - module: { exports: {} }, - instance: { exports: {} } - }; + lnc.result = { + module: { exports: {} }, + instance: { exports: {} } + }; - // Mock WebAssembly.instantiate for this test - const instantiateMock = vi.fn().mockResolvedValue({ - exports: {} - }); - globalThis.WebAssembly.instantiate = instantiateMock; + // Mock WebAssembly.instantiate for this test + const instantiateMock = vi.fn().mockResolvedValue({ + exports: {} + }); + globalThis.WebAssembly.instantiate = instantiateMock; - await lnc.run(); + await lnc.run(); - // Get the callback functions - const namespace = globalAccess.getWasmGlobal(lnc._namespace); + // Get the callback functions + const namespace = globalAccess.getWasmGlobal(lnc._namespace); - // Call callbacks - this should trigger the debug logs - namespace.onLocalPrivCreate!('test_key'); - namespace.onRemoteKeyReceive!('test_remote_key'); - namespace.onAuthData!('test_macaroon'); + // Call callbacks - this should trigger the debug logs + namespace.onLocalPrivCreate!('test_key'); + namespace.onRemoteKeyReceive!('test_remote_key'); + namespace.onAuthData!('test_macaroon'); - // Verify credentials were updated (this indirectly tests the callbacks ran) - expect(lnc.credentials.localKey).toBe('test_key'); - expect(lnc.credentials.remoteKey).toBe('test_remote_key'); - }); + // Verify credentials were updated (this indirectly tests the callbacks ran) + expect(lnc.credentials.localKey).toBe('test_key'); + expect(lnc.credentials.remoteKey).toBe('test_remote_key'); }); + }); }); diff --git a/lib/lnc.ts b/lib/lnc.ts index 04c124d..ba6e1f3 100644 --- a/lib/lnc.ts +++ b/lib/lnc.ts @@ -1,11 +1,11 @@ import { - FaradayApi, - LitApi, - LndApi, - LoopApi, - PoolApi, - snakeKeysToCamel, - TaprootAssetsApi + FaradayApi, + LitApi, + LndApi, + LoopApi, + PoolApi, + snakeKeysToCamel, + TaprootAssetsApi } from '@lightninglabs/lnc-core'; import { createRpc } from './api/createRpc'; import { CredentialStore, LncConfig, WasmGlobal } from './types/lnc'; @@ -18,331 +18,317 @@ import { wasmLog as log } from './util/log'; * need for casting `globalThis` to `any`. */ export const lncGlobal = globalThis as typeof globalThis & { - Go: new () => GoInstance; + Go: new () => GoInstance; } & { - [key: string]: unknown; + [key: string]: unknown; }; /** The default values for the LncConfig options */ export const DEFAULT_CONFIG = { - wasmClientCode: 'https://lightning.engineering/lnc-v0.3.4-alpha.wasm', - namespace: 'default', - serverHost: 'mailbox.terminal.lightning.today:443' + wasmClientCode: 'https://lightning.engineering/lnc-v0.3.4-alpha.wasm', + namespace: 'default', + serverHost: 'mailbox.terminal.lightning.today:443' } as Required; export default class LNC { - go: GoInstance; - result?: { - module: WebAssembly.Module; - instance: WebAssembly.Instance; - }; - - _wasmClientCode: any; - _namespace: string; - credentials: CredentialStore; - - lnd: LndApi; - loop: LoopApi; - pool: PoolApi; - faraday: FaradayApi; - tapd: TaprootAssetsApi; - lit: LitApi; - - constructor(lncConfig?: LncConfig) { - // merge the passed in config with the defaults - const config = Object.assign({}, DEFAULT_CONFIG, lncConfig); - - this._wasmClientCode = config.wasmClientCode; - this._namespace = config.namespace; - - if (config.credentialStore) { - this.credentials = config.credentialStore; - } else { - this.credentials = new LncCredentialStore( - config.namespace, - config.password - ); - // don't overwrite an existing serverHost if we're already paired - if (!this.credentials.isPaired) - this.credentials.serverHost = config.serverHost; - if (config.pairingPhrase) - this.credentials.pairingPhrase = config.pairingPhrase; - } - - // Pull Go off of the global object. This is injected by the wasm_exec.js file. - this.go = new lncGlobal.Go(); - - this.lnd = new LndApi(createRpc, this); - this.loop = new LoopApi(createRpc, this); - this.pool = new PoolApi(createRpc, this); - this.faraday = new FaradayApi(createRpc, this); - this.tapd = new TaprootAssetsApi(createRpc, this); - this.lit = new LitApi(createRpc, this); - } - - private get wasm() { - return lncGlobal[this._namespace] as WasmGlobal; + go: GoInstance; + result?: { + module: WebAssembly.Module; + instance: WebAssembly.Instance; + }; + + _wasmClientCode: any; + _namespace: string; + credentials: CredentialStore; + + lnd: LndApi; + loop: LoopApi; + pool: PoolApi; + faraday: FaradayApi; + tapd: TaprootAssetsApi; + lit: LitApi; + + constructor(lncConfig?: LncConfig) { + // merge the passed in config with the defaults + const config = Object.assign({}, DEFAULT_CONFIG, lncConfig); + + this._wasmClientCode = config.wasmClientCode; + this._namespace = config.namespace; + + if (config.credentialStore) { + this.credentials = config.credentialStore; + } else { + this.credentials = new LncCredentialStore( + config.namespace, + config.password + ); + // don't overwrite an existing serverHost if we're already paired + if (!this.credentials.isPaired) + this.credentials.serverHost = config.serverHost; + if (config.pairingPhrase) + this.credentials.pairingPhrase = config.pairingPhrase; } - private set wasm(value: any) { - lncGlobal[this._namespace] = value; + // Pull Go off of the global object. This is injected by the wasm_exec.js file. + this.go = new lncGlobal.Go(); + + this.lnd = new LndApi(createRpc, this); + this.loop = new LoopApi(createRpc, this); + this.pool = new PoolApi(createRpc, this); + this.faraday = new FaradayApi(createRpc, this); + this.tapd = new TaprootAssetsApi(createRpc, this); + this.lit = new LitApi(createRpc, this); + } + + private get wasm() { + return lncGlobal[this._namespace] as WasmGlobal; + } + + private set wasm(value: any) { + lncGlobal[this._namespace] = value; + } + + get isReady() { + return ( + this.wasm && this.wasm.wasmClientIsReady && this.wasm.wasmClientIsReady() + ); + } + + get isConnected() { + return ( + this.wasm && + this.wasm.wasmClientIsConnected && + this.wasm.wasmClientIsConnected() + ); + } + + get status() { + return ( + this.wasm && this.wasm.wasmClientStatus && this.wasm.wasmClientStatus() + ); + } + + get expiry(): Date { + return ( + this.wasm && + this.wasm.wasmClientGetExpiry && + new Date(this.wasm.wasmClientGetExpiry() * 1000) + ); + } + + get isReadOnly() { + return ( + this.wasm && + this.wasm.wasmClientIsReadOnly && + this.wasm.wasmClientIsReadOnly() + ); + } + + hasPerms(permission: string) { + return ( + this.wasm && + this.wasm.wasmClientHasPerms && + this.wasm.wasmClientHasPerms(permission) + ); + } + + /** + * Downloads the WASM client binary + */ + async preload() { + this.result = await WebAssembly.instantiateStreaming( + fetch(this._wasmClientCode), + this.go.importObject + ); + log.info('downloaded WASM file'); + } + + /** + * Loads keys from storage and runs the Wasm client binary + */ + async run() { + // make sure the WASM client binary is downloaded first + if (!this.isReady) await this.preload(); + + // create the namespace object in the global scope if it doesn't exist + // so that we can assign the WASM callbacks to it + if (typeof this.wasm !== 'object') { + this.wasm = {}; } - get isReady() { - return ( - this.wasm && - this.wasm.wasmClientIsReady && - this.wasm.wasmClientIsReady() - ); + // assign the WASM callbacks to the namespace object if they haven't + // already been assigned by the consuming app + if (!this.wasm.onLocalPrivCreate) { + this.wasm.onLocalPrivCreate = (keyHex: string) => { + log.debug('local private key created: ' + keyHex); + this.credentials.localKey = keyHex; + }; } - - get isConnected() { - return ( - this.wasm && - this.wasm.wasmClientIsConnected && - this.wasm.wasmClientIsConnected() - ); + if (!this.wasm.onRemoteKeyReceive) { + this.wasm.onRemoteKeyReceive = (keyHex: string) => { + log.debug('remote key received: ' + keyHex); + this.credentials.remoteKey = keyHex; + }; } - - get status() { - return ( - this.wasm && - this.wasm.wasmClientStatus && - this.wasm.wasmClientStatus() - ); + if (!this.wasm.onAuthData) { + this.wasm.onAuthData = (keyHex: string) => { + log.debug('auth data received: ' + keyHex); + }; } - get expiry(): Date { - return ( - this.wasm && - this.wasm.wasmClientGetExpiry && - new Date(this.wasm.wasmClientGetExpiry() * 1000) - ); + this.go.argv = [ + 'wasm-client', + '--debuglevel=debug,GOBN=info,GRPC=info', + '--namespace=' + this._namespace, + `--onlocalprivcreate=${this._namespace}.onLocalPrivCreate`, + `--onremotekeyreceive=${this._namespace}.onRemoteKeyReceive`, + `--onauthdata=${this._namespace}.onAuthData` + ]; + + if (this.result) { + this.go.run(this.result.instance); + await WebAssembly.instantiate(this.result.module, this.go.importObject); + } else { + throw new Error("Can't find WASM instance."); } - - get isReadOnly() { - return ( - this.wasm && - this.wasm.wasmClientIsReadOnly && - this.wasm.wasmClientIsReadOnly() - ); + } + + /** + * Connects to the LNC proxy server + * @returns a promise that resolves when the connection is established + */ + async connect() { + // do not attempt to connect multiple times + if (this.isConnected) return; + + // ensure the WASM binary is loaded + if (!this.isReady) { + await this.run(); + await this.waitTilReady(); } - hasPerms(permission: string) { - return ( - this.wasm && - this.wasm.wasmClientHasPerms && - this.wasm.wasmClientHasPerms(permission) - ); + const { pairingPhrase, localKey, remoteKey, serverHost } = this.credentials; + + // connect to the server + this.wasm.wasmClientConnectServer( + serverHost, + false, + pairingPhrase, + localKey, + remoteKey + ); + + // add an event listener to disconnect if the page is unloaded + if (typeof window !== 'undefined') { + window.addEventListener('unload', this.wasm.wasmClientDisconnect); + } else { + log.info('No unload event listener added. window is not available'); } - /** - * Downloads the WASM client binary - */ - async preload() { - this.result = await WebAssembly.instantiateStreaming( - fetch(this._wasmClientCode), - this.go.importObject - ); - log.info('downloaded WASM file'); - } - - /** - * Loads keys from storage and runs the Wasm client binary - */ - async run() { - // make sure the WASM client binary is downloaded first - if (!this.isReady) await this.preload(); - - // create the namespace object in the global scope if it doesn't exist - // so that we can assign the WASM callbacks to it - if (typeof this.wasm !== 'object') { - this.wasm = {}; - } - - // assign the WASM callbacks to the namespace object if they haven't - // already been assigned by the consuming app - if (!this.wasm.onLocalPrivCreate) { - this.wasm.onLocalPrivCreate = (keyHex: string) => { - log.debug('local private key created: ' + keyHex); - this.credentials.localKey = keyHex; - }; + // repeatedly check if the connection was successful + return new Promise((resolve, reject) => { + let counter = 0; + const interval = setInterval(() => { + counter++; + if (this.isConnected) { + clearInterval(interval); + resolve(); + log.info('The WASM client is connected to the server'); + + // clear the in-memory credentials after connecting if the + // credentials are persisted in local storage + if (this.credentials.password) { + this.credentials.clear(true); + } + } else if (counter > 20) { + clearInterval(interval); + reject( + new Error('Failed to connect the WASM client to the proxy server') + ); } - if (!this.wasm.onRemoteKeyReceive) { - this.wasm.onRemoteKeyReceive = (keyHex: string) => { - log.debug('remote key received: ' + keyHex); - this.credentials.remoteKey = keyHex; - }; + }, 500); + }); + } + + /** + * Disconnects from the proxy server + */ + disconnect() { + this.wasm.wasmClientDisconnect(); + } + + /** + * Waits until the WASM client is executed and ready to accept connection info + */ + async waitTilReady() { + return new Promise((resolve, reject) => { + let counter = 0; + const interval = setInterval(() => { + counter++; + if (this.isReady) { + clearInterval(interval); + resolve(); + log.info('The WASM client is ready'); + } else if (counter > 20) { + clearInterval(interval); + reject(new Error('Failed to load the WASM client')); } - if (!this.wasm.onAuthData) { - this.wasm.onAuthData = (keyHex: string) => { - log.debug('auth data received: ' + keyHex); - }; + }, 500); + }); + } + + /** + * Emulates a GRPC request but uses the WASM client instead to communicate with the LND node + * @param method the GRPC method to call on the service + * @param request The GRPC request message to send + */ + request(method: string, request?: object): Promise { + return new Promise((resolve, reject) => { + log.debug(`${method} request`, request); + const reqJSON = JSON.stringify(request || {}); + this.wasm.wasmClientInvokeRPC(method, reqJSON, (response: string) => { + try { + const rawRes = JSON.parse(response); + // log.debug(`${method} raw response`, rawRes); + const res = snakeKeysToCamel(rawRes); + log.debug(`${method} response`, res); + resolve(res as TRes); + } catch (error) { + log.debug(`${method} raw response`, response); + reject(new Error(response)); + return; } - - this.go.argv = [ - 'wasm-client', - '--debuglevel=debug,GOBN=info,GRPC=info', - '--namespace=' + this._namespace, - `--onlocalprivcreate=${this._namespace}.onLocalPrivCreate`, - `--onremotekeyreceive=${this._namespace}.onRemoteKeyReceive`, - `--onauthdata=${this._namespace}.onAuthData` - ]; - - if (this.result) { - this.go.run(this.result.instance); - await WebAssembly.instantiate( - this.result.module, - this.go.importObject - ); - } else { - throw new Error("Can't find WASM instance."); - } - } - - /** - * Connects to the LNC proxy server - * @returns a promise that resolves when the connection is established - */ - async connect() { - // do not attempt to connect multiple times - if (this.isConnected) return; - - // ensure the WASM binary is loaded - if (!this.isReady) { - await this.run(); - await this.waitTilReady(); - } - - const { pairingPhrase, localKey, remoteKey, serverHost } = - this.credentials; - - // connect to the server - this.wasm.wasmClientConnectServer( - serverHost, - false, - pairingPhrase, - localKey, - remoteKey - ); - - // add an event listener to disconnect if the page is unloaded - if (typeof window !== 'undefined') { - window.addEventListener('unload', this.wasm.wasmClientDisconnect); - } else { - log.info('No unload event listener added. window is not available'); - } - - // repeatedly check if the connection was successful - return new Promise((resolve, reject) => { - let counter = 0; - const interval = setInterval(() => { - counter++; - if (this.isConnected) { - clearInterval(interval); - resolve(); - log.info('The WASM client is connected to the server'); - - // clear the in-memory credentials after connecting if the - // credentials are persisted in local storage - if (this.credentials.password) { - this.credentials.clear(true); - } - } else if (counter > 20) { - clearInterval(interval); - reject( - new Error( - 'Failed to connect the WASM client to the proxy server' - ) - ); - } - }, 500); - }); - } - - /** - * Disconnects from the proxy server - */ - disconnect() { - this.wasm.wasmClientDisconnect(); - } - - /** - * Waits until the WASM client is executed and ready to accept connection info - */ - async waitTilReady() { - return new Promise((resolve, reject) => { - let counter = 0; - const interval = setInterval(() => { - counter++; - if (this.isReady) { - clearInterval(interval); - resolve(); - log.info('The WASM client is ready'); - } else if (counter > 20) { - clearInterval(interval); - reject(new Error('Failed to load the WASM client')); - } - }, 500); - }); - } - - /** - * Emulates a GRPC request but uses the WASM client instead to communicate with the LND node - * @param method the GRPC method to call on the service - * @param request The GRPC request message to send - */ - request(method: string, request?: object): Promise { - return new Promise((resolve, reject) => { - log.debug(`${method} request`, request); - const reqJSON = JSON.stringify(request || {}); - this.wasm.wasmClientInvokeRPC( - method, - reqJSON, - (response: string) => { - try { - const rawRes = JSON.parse(response); - // log.debug(`${method} raw response`, rawRes); - const res = snakeKeysToCamel(rawRes); - log.debug(`${method} response`, res); - resolve(res as TRes); - } catch (error) { - log.debug(`${method} raw response`, response); - reject(new Error(response)); - return; - } - } - ); - }); - } - - /** - * Subscribes to a GRPC server-streaming endpoint and executes the `onMessage` handler - * when a new message is received from the server - * @param method the GRPC method to call on the service - * @param request the GRPC request message to send - * @param onMessage the callback function to execute when a new message is received - * @param onError the callback function to execute when an error is received - */ - subscribe( - method: string, - request?: object, - onMessage?: (res: TRes) => void, - onError?: (res: Error) => void - ) { - log.debug(`${method} request`, request); - const reqJSON = JSON.stringify(request || {}); - this.wasm.wasmClientInvokeRPC(method, reqJSON, (response: string) => { - try { - const rawRes = JSON.parse(response); - const res = snakeKeysToCamel(rawRes); - log.debug(`${method} response`, res); - if (onMessage) onMessage(res as TRes); - } catch (error) { - log.debug(`${method} error`, error); - const err = new Error(response); - if (onError) onError(err); - } - }); - } + }); + }); + } + + /** + * Subscribes to a GRPC server-streaming endpoint and executes the `onMessage` handler + * when a new message is received from the server + * @param method the GRPC method to call on the service + * @param request the GRPC request message to send + * @param onMessage the callback function to execute when a new message is received + * @param onError the callback function to execute when an error is received + */ + subscribe( + method: string, + request?: object, + onMessage?: (res: TRes) => void, + onError?: (res: Error) => void + ) { + log.debug(`${method} request`, request); + const reqJSON = JSON.stringify(request || {}); + this.wasm.wasmClientInvokeRPC(method, reqJSON, (response: string) => { + try { + const rawRes = JSON.parse(response); + const res = snakeKeysToCamel(rawRes); + log.debug(`${method} response`, res); + if (onMessage) onMessage(res as TRes); + } catch (error) { + log.debug(`${method} error`, error); + const err = new Error(response); + if (onError) onError(err); + } + }); + } } diff --git a/lib/types/lnc.ts b/lib/types/lnc.ts index 9a93c51..3237db2 100644 --- a/lib/types/lnc.ts +++ b/lib/types/lnc.ts @@ -1,110 +1,110 @@ export interface WasmGlobal { - /** - * Returns true if the WASM client has been started and is ready to accept - * connections - */ - wasmClientIsReady: () => boolean; - /** - * Returns true if the WASM client is currently connected to the proxy server - */ - wasmClientIsConnected: () => boolean; - /** - * Attempts to connect to the proxy server - */ - wasmClientConnectServer: ( - serverHost: string, - isDevServer: boolean, - pairingPhrase: string, - localKey?: string, - remoteKey?: string - ) => void; - /** - * disconnects from the proxy server - */ - wasmClientDisconnect: () => void; - /** - * Invokes an RPC command with a request object and executes the provided callback - * with the response - */ - wasmClientInvokeRPC: ( - rpcName: string, - request: any, - callback: (response: string) => any - ) => void; - /** - * Returns true if client has specific permissions - * e.g. 'lnrpc.Lightning.GetInfo' - */ - wasmClientHasPerms: (permission: string) => boolean; - /** - * Returns true if the WASM client is read only - */ - wasmClientIsReadOnly: () => boolean; - /** - * Returns the WASM client status - */ - wasmClientStatus: () => string; - /** - * Returns the WASM client expiry time - */ - wasmClientGetExpiry: () => number; - /** - * The callback that is called when the WASM client generates a new local private - * key. This is used to reestablish subsequent connections to the proxy server. - * @param keyHex the hex encoded private key of the local WASM client - */ - onLocalPrivCreate?: (keyHex: string) => void; - /** - * The callback that is called when the WASM client receives the remote node's - * public key. This is used to reestablish subsequent connections to the proxy - * server. - * @param keyHex the hex encoded public key of the remote node - */ - onRemoteKeyReceive?: (keyHex: string) => void; - /** - * The callback that is called when the WASM client receives the macaroon - * associated with the LNC session. - * @param macaroonHex the hex encoded macaroon associated with the LNC session - */ - onAuthData?: (macaroonHex: string) => void; + /** + * Returns true if the WASM client has been started and is ready to accept + * connections + */ + wasmClientIsReady: () => boolean; + /** + * Returns true if the WASM client is currently connected to the proxy server + */ + wasmClientIsConnected: () => boolean; + /** + * Attempts to connect to the proxy server + */ + wasmClientConnectServer: ( + serverHost: string, + isDevServer: boolean, + pairingPhrase: string, + localKey?: string, + remoteKey?: string + ) => void; + /** + * disconnects from the proxy server + */ + wasmClientDisconnect: () => void; + /** + * Invokes an RPC command with a request object and executes the provided callback + * with the response + */ + wasmClientInvokeRPC: ( + rpcName: string, + request: any, + callback: (response: string) => any + ) => void; + /** + * Returns true if client has specific permissions + * e.g. 'lnrpc.Lightning.GetInfo' + */ + wasmClientHasPerms: (permission: string) => boolean; + /** + * Returns true if the WASM client is read only + */ + wasmClientIsReadOnly: () => boolean; + /** + * Returns the WASM client status + */ + wasmClientStatus: () => string; + /** + * Returns the WASM client expiry time + */ + wasmClientGetExpiry: () => number; + /** + * The callback that is called when the WASM client generates a new local private + * key. This is used to reestablish subsequent connections to the proxy server. + * @param keyHex the hex encoded private key of the local WASM client + */ + onLocalPrivCreate?: (keyHex: string) => void; + /** + * The callback that is called when the WASM client receives the remote node's + * public key. This is used to reestablish subsequent connections to the proxy + * server. + * @param keyHex the hex encoded public key of the remote node + */ + onRemoteKeyReceive?: (keyHex: string) => void; + /** + * The callback that is called when the WASM client receives the macaroon + * associated with the LNC session. + * @param macaroonHex the hex encoded macaroon associated with the LNC session + */ + onAuthData?: (macaroonHex: string) => void; } export interface LncConfig { - /** - * Specify a custom Lightning Node Connect proxy server. If not specified we'll - * default to `mailbox.terminal.lightning.today:443`. - */ - serverHost?: string; - /** - * Custom location for the WASM client code. Can be remote or local. If not - * specified we’ll default to our instance on our CDN. - */ - wasmClientCode?: any; // URL or WASM client object - /** - * JavaScript namespace used for the main WASM calls. You can maintain multiple - * connections if you use different namespaces. If not specified we'll default - * to `default`. - */ - namespace?: string; - /** - * The LNC pairing phrase used to initialize the connection to the LNC proxy. - * This value will be passed along to the credential store. - */ - pairingPhrase?: string; - /** - * By default, this module will handle storage of your local and remote keys - * for you in local storage. This password ise used to encrypt the keys for - * future use. If the password is not provided here, it must be - * set directly via `lnc.credentials.password` in order to persist data - * across page loads - */ - password?: string; - /** - * Custom store used to save & load the pairing phrase and keys needed to - * connect to the proxy server. The default store persists data in the - * browser's `localStorage` - */ - credentialStore?: CredentialStore; + /** + * Specify a custom Lightning Node Connect proxy server. If not specified we'll + * default to `mailbox.terminal.lightning.today:443`. + */ + serverHost?: string; + /** + * Custom location for the WASM client code. Can be remote or local. If not + * specified we’ll default to our instance on our CDN. + */ + wasmClientCode?: any; // URL or WASM client object + /** + * JavaScript namespace used for the main WASM calls. You can maintain multiple + * connections if you use different namespaces. If not specified we'll default + * to `default`. + */ + namespace?: string; + /** + * The LNC pairing phrase used to initialize the connection to the LNC proxy. + * This value will be passed along to the credential store. + */ + pairingPhrase?: string; + /** + * By default, this module will handle storage of your local and remote keys + * for you in local storage. This password ise used to encrypt the keys for + * future use. If the password is not provided here, it must be + * set directly via `lnc.credentials.password` in order to persist data + * across page loads + */ + password?: string; + /** + * Custom store used to save & load the pairing phrase and keys needed to + * connect to the proxy server. The default store persists data in the + * browser's `localStorage` + */ + credentialStore?: CredentialStore; } /** @@ -113,30 +113,30 @@ export interface LncConfig { * authentication and connection process. */ export interface CredentialStore { - /** - * Stores the optional password to use for encryption of the data. LNC does not - * read or write the password. This is just exposed publicly to simplify access - * to the field via `lnc.credentials.password` - */ - password?: string; - /** Stores the LNC pairing phrase used to initialize the connection to the LNC proxy */ - pairingPhrase: string; - /** Stores the host:port of the Lightning Node Connect proxy server to connect to */ - serverHost: string; - /** Stores the local private key which LNC uses to reestablish a connection */ - localKey: string; - /** Stores the remote static key which LNC uses to reestablish a connection */ - remoteKey: string; - /** - * Read-only field which should return `true` if the client app has prior - * credentials persisted in the store - */ - isPaired: boolean; - /** - * Clears the in-memory and persisted data in the store. - * @param memoryOnly If `true`, only the in-memory data will be cleared. If - * `false` or `undefined`, the persisted data will be cleared as well. - * The default is `undefined`. - */ - clear(memoryOnly?: boolean): void; + /** + * Stores the optional password to use for encryption of the data. LNC does not + * read or write the password. This is just exposed publicly to simplify access + * to the field via `lnc.credentials.password` + */ + password?: string; + /** Stores the LNC pairing phrase used to initialize the connection to the LNC proxy */ + pairingPhrase: string; + /** Stores the host:port of the Lightning Node Connect proxy server to connect to */ + serverHost: string; + /** Stores the local private key which LNC uses to reestablish a connection */ + localKey: string; + /** Stores the remote static key which LNC uses to reestablish a connection */ + remoteKey: string; + /** + * Read-only field which should return `true` if the client app has prior + * credentials persisted in the store + */ + isPaired: boolean; + /** + * Clears the in-memory and persisted data in the store. + * @param memoryOnly If `true`, only the in-memory data will be cleared. If + * `false` or `undefined`, the persisted data will be cleared as well. + * The default is `undefined`. + */ + clear(memoryOnly?: boolean): void; } diff --git a/lib/typings.d.ts b/lib/typings.d.ts index 52b7a77..fb05721 100644 --- a/lib/typings.d.ts +++ b/lib/typings.d.ts @@ -1,7 +1,7 @@ declare var global: any; interface GoInstance { - run(instance: WebAssembly.Instance): Promise; - importObject: WebAssembly.Imports; - argv?: string[]; + run(instance: WebAssembly.Instance): Promise; + importObject: WebAssembly.Imports; + argv?: string[]; } diff --git a/lib/util/credentialStore.test.ts b/lib/util/credentialStore.test.ts index 8485a8b..94df08d 100644 --- a/lib/util/credentialStore.test.ts +++ b/lib/util/credentialStore.test.ts @@ -1,11 +1,11 @@ import { - afterEach, - beforeEach, - describe, - expect, - it, - MockedFunction, - vi + afterEach, + beforeEach, + describe, + expect, + it, + MockedFunction, + vi } from 'vitest'; import { createMockSetup } from '../../test/utils/mock-factory'; import { testData } from '../../test/utils/test-helpers'; @@ -14,473 +14,470 @@ import { verifyTestCipher } from './encryption'; // Mock the encryption functions vi.mock('../../lib/util/encryption', () => { - return { - createTestCipher: vi.fn(() => 'mocked_cipher'), - decrypt: vi.fn(() => 'decrypted_value'), - encrypt: vi.fn(() => 'encrypted_value'), - generateSalt: vi.fn(() => 'testsalt12345678901234567890123456789012'), - verifyTestCipher: vi.fn(() => true) - }; + return { + createTestCipher: vi.fn(() => 'mocked_cipher'), + decrypt: vi.fn(() => 'decrypted_value'), + encrypt: vi.fn(() => 'encrypted_value'), + generateSalt: vi.fn(() => 'testsalt12345678901234567890123456789012'), + verifyTestCipher: vi.fn(() => true) + }; }); const mockVerifyTestCipher = verifyTestCipher as MockedFunction< - typeof verifyTestCipher + typeof verifyTestCipher >; describe('LncCredentialStore', () => { - let mockSetup: any; + let mockSetup: any; - beforeEach(() => { - // Create a fresh mock setup for each test - mockSetup = createMockSetup(); + beforeEach(() => { + // Create a fresh mock setup for each test + mockSetup = createMockSetup(); + }); + + afterEach(() => { + // Clean up after each test + mockSetup.cleanup(); + }); + + describe('Constructor', () => { + it('should create instance with default namespace', () => { + const store = new LncCredentialStore(); + + expect(store).toBeInstanceOf(LncCredentialStore); + expect(store.password).toBe(''); + }); + + it('should create instance with password', () => { + const password = testData.password; + const store = new LncCredentialStore(undefined, password); + + expect(store).toBeInstanceOf(LncCredentialStore); + // Note: Password gets cleared after encryption setup, so we check localStorage was called + expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); + }); + + it('should create instance with custom namespace and password', () => { + const customNamespace = 'test_namespace'; + const password = testData.password; + const store = new LncCredentialStore(customNamespace, password); + + expect(store).toBeInstanceOf(LncCredentialStore); + // Password gets cleared after encryption setup, verify localStorage was called with correct key + expect(mockSetup.localStorage.setItem).toHaveBeenCalledWith( + `lnc-web:${customNamespace}`, + expect.any(String) + ); + }); + + it('should load existing data from localStorage on construction', () => { + const namespace = 'test'; + const existingData = { + salt: 'testsalt12345678901234567890123456789012', + cipher: 'testcipher', + serverHost: testData.serverHost, + localKey: '', + remoteKey: '', + pairingPhrase: '' + }; + + mockSetup.localStorage.setItem( + `lnc-web:${namespace}`, + JSON.stringify(existingData) + ); + + const store = new LncCredentialStore(namespace); + + // Verify the data was loaded + expect(store.serverHost).toBe(testData.serverHost); + }); + + it('should handle corrupted localStorage data gracefully', () => { + const namespace = 'test'; + const corruptedData = '{ invalid json }'; + + mockSetup.localStorage.setItem(`lnc-web:${namespace}`, corruptedData); + + expect(() => new LncCredentialStore(namespace)).toThrow( + 'Failed to load secure data' + ); + }); + + it('should handle missing localStorage gracefully', () => { + // Mock localStorage to be undefined (like in some environments) + const originalLocalStorage = globalThis.localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: undefined, + writable: true + }); + + expect(() => new LncCredentialStore()).not.toThrow(); + + // Restore localStorage + Object.defineProperty(globalThis, 'localStorage', { + value: originalLocalStorage, + writable: true + }); + }); + + it('should initialize with empty values when no stored data exists', () => { + const store = new LncCredentialStore(); + + expect(store.serverHost).toBe(''); + expect(store.pairingPhrase).toBe(''); + expect(store.localKey).toBe(''); + expect(store.remoteKey).toBe(''); + expect(store.isPaired).toBe(false); + }); + + it('should call _load method during construction', () => { + const store = new LncCredentialStore(); + + // Verify localStorage.getItem was called (from _load method) + expect(mockSetup.localStorage.getItem).toHaveBeenCalledWith( + 'lnc-web:default' + ); + }); + }); + + describe('Password Management', () => { + it('should return empty string when no password is set', () => { + const store = new LncCredentialStore(); + + expect(store.password).toBe(''); + }); + + it('should encrypt existing plain text data when password is set', () => { + const store = new LncCredentialStore(); + + // Set some plain text data first + store.serverHost = testData.serverHost; + store.pairingPhrase = testData.pairingPhrase; + store.localKey = testData.localKey; + store.remoteKey = testData.remoteKey; + + // Now set password - this should encrypt the data + store.password = testData.password; + + // Password gets cleared after encryption, but data should be persisted + expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); + // Verify that in-memory data was cleared after encryption + expect(store.pairingPhrase).toBe(''); + expect(store.localKey).toBe(''); + expect(store.remoteKey).toBe(''); }); - afterEach(() => { - // Clean up after each test - mockSetup.cleanup(); + it('should throw error when incorrect password is provided for encrypted data', () => { + const store = new LncCredentialStore(); + + // Set some plain text data first + store.serverHost = testData.serverHost; + store.pairingPhrase = testData.pairingPhrase; + store.localKey = testData.localKey; + store.remoteKey = testData.remoteKey; + + // Now set password - this should encrypt the data + store.password = testData.password; + + // Password gets cleared after encryption, but data should be persisted + expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); + + mockVerifyTestCipher.mockReturnValueOnce(false); + + // Now set the wrong password, this should throw an error + const wrongPassword = 'wrongpassword'; + expect(() => { + store.password = wrongPassword; + }).toThrow('The password provided is not valid'); }); - describe('Constructor', () => { - it('should create instance with default namespace', () => { - const store = new LncCredentialStore(); - - expect(store).toBeInstanceOf(LncCredentialStore); - expect(store.password).toBe(''); - }); - - it('should create instance with password', () => { - const password = testData.password; - const store = new LncCredentialStore(undefined, password); - - expect(store).toBeInstanceOf(LncCredentialStore); - // Note: Password gets cleared after encryption setup, so we check localStorage was called - expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); - }); - - it('should create instance with custom namespace and password', () => { - const customNamespace = 'test_namespace'; - const password = testData.password; - const store = new LncCredentialStore(customNamespace, password); - - expect(store).toBeInstanceOf(LncCredentialStore); - // Password gets cleared after encryption setup, verify localStorage was called with correct key - expect(mockSetup.localStorage.setItem).toHaveBeenCalledWith( - `lnc-web:${customNamespace}`, - expect.any(String) - ); - }); - - it('should load existing data from localStorage on construction', () => { - const namespace = 'test'; - const existingData = { - salt: 'testsalt12345678901234567890123456789012', - cipher: 'testcipher', - serverHost: testData.serverHost, - localKey: '', - remoteKey: '', - pairingPhrase: '' - }; - - mockSetup.localStorage.setItem( - `lnc-web:${namespace}`, - JSON.stringify(existingData) - ); - - const store = new LncCredentialStore(namespace); - - // Verify the data was loaded - expect(store.serverHost).toBe(testData.serverHost); - }); - - it('should handle corrupted localStorage data gracefully', () => { - const namespace = 'test'; - const corruptedData = '{ invalid json }'; - - mockSetup.localStorage.setItem( - `lnc-web:${namespace}`, - corruptedData - ); - - expect(() => new LncCredentialStore(namespace)).toThrow( - 'Failed to load secure data' - ); - }); - - it('should handle missing localStorage gracefully', () => { - // Mock localStorage to be undefined (like in some environments) - const originalLocalStorage = globalThis.localStorage; - Object.defineProperty(globalThis, 'localStorage', { - value: undefined, - writable: true - }); - - expect(() => new LncCredentialStore()).not.toThrow(); - - // Restore localStorage - Object.defineProperty(globalThis, 'localStorage', { - value: originalLocalStorage, - writable: true - }); - }); - - it('should initialize with empty values when no stored data exists', () => { - const store = new LncCredentialStore(); - - expect(store.serverHost).toBe(''); - expect(store.pairingPhrase).toBe(''); - expect(store.localKey).toBe(''); - expect(store.remoteKey).toBe(''); - expect(store.isPaired).toBe(false); - }); - - it('should call _load method during construction', () => { - const store = new LncCredentialStore(); - - // Verify localStorage.getItem was called (from _load method) - expect(mockSetup.localStorage.getItem).toHaveBeenCalledWith( - 'lnc-web:default' - ); - }); + it('should decrypt persisted encrypted data when correct password provided', () => { + const namespace = 'test'; + const password = testData.password; + const salt = 'testsalt12345678901234567890123456789012'; + + // Pre-populate localStorage with encrypted data including cipher + const encryptedData = { + salt, + cipher: 'testcipher', + serverHost: testData.serverHost, + localKey: 'encrypted_local_key', + remoteKey: 'encrypted_remote_key', + pairingPhrase: 'encrypted_pairing_phrase' + }; + + mockSetup.localStorage.setItem( + `lnc-web:${namespace}`, + JSON.stringify(encryptedData) + ); + + // Create a store that will load the encrypted data + const store = new LncCredentialStore(namespace); + + // Now set the password - this should trigger decryption + store.password = password; + + // Verify that the password was set and decrypted values are available + expect(store.password).toBe(password); + expect(store.pairingPhrase).toBe('decrypted_value'); + expect(store.localKey).toBe('decrypted_value'); + expect(store.remoteKey).toBe('decrypted_value'); }); + }); + + describe('Property Getters and Setters', () => { + it('should get and set serverHost correctly', () => { + const store = new LncCredentialStore(); + const host = testData.serverHost; - describe('Password Management', () => { - it('should return empty string when no password is set', () => { - const store = new LncCredentialStore(); - - expect(store.password).toBe(''); - }); - - it('should encrypt existing plain text data when password is set', () => { - const store = new LncCredentialStore(); - - // Set some plain text data first - store.serverHost = testData.serverHost; - store.pairingPhrase = testData.pairingPhrase; - store.localKey = testData.localKey; - store.remoteKey = testData.remoteKey; - - // Now set password - this should encrypt the data - store.password = testData.password; - - // Password gets cleared after encryption, but data should be persisted - expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); - // Verify that in-memory data was cleared after encryption - expect(store.pairingPhrase).toBe(''); - expect(store.localKey).toBe(''); - expect(store.remoteKey).toBe(''); - }); - - it('should throw error when incorrect password is provided for encrypted data', () => { - const store = new LncCredentialStore(); - - // Set some plain text data first - store.serverHost = testData.serverHost; - store.pairingPhrase = testData.pairingPhrase; - store.localKey = testData.localKey; - store.remoteKey = testData.remoteKey; - - // Now set password - this should encrypt the data - store.password = testData.password; - - // Password gets cleared after encryption, but data should be persisted - expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); - - mockVerifyTestCipher.mockReturnValueOnce(false); - - // Now set the wrong password, this should throw an error - const wrongPassword = 'wrongpassword'; - expect(() => { - store.password = wrongPassword; - }).toThrow('The password provided is not valid'); - }); - - it('should decrypt persisted encrypted data when correct password provided', () => { - const namespace = 'test'; - const password = testData.password; - const salt = 'testsalt12345678901234567890123456789012'; - - // Pre-populate localStorage with encrypted data including cipher - const encryptedData = { - salt, - cipher: 'testcipher', - serverHost: testData.serverHost, - localKey: 'encrypted_local_key', - remoteKey: 'encrypted_remote_key', - pairingPhrase: 'encrypted_pairing_phrase' - }; - - mockSetup.localStorage.setItem( - `lnc-web:${namespace}`, - JSON.stringify(encryptedData) - ); - - // Create a store that will load the encrypted data - const store = new LncCredentialStore(namespace); - - // Now set the password - this should trigger decryption - store.password = password; - - // Verify that the password was set and decrypted values are available - expect(store.password).toBe(password); - expect(store.pairingPhrase).toBe('decrypted_value'); - expect(store.localKey).toBe('decrypted_value'); - expect(store.remoteKey).toBe('decrypted_value'); - }); + store.serverHost = host; + expect(store.serverHost).toBe(host); + expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); }); - describe('Property Getters and Setters', () => { - it('should get and set serverHost correctly', () => { - const store = new LncCredentialStore(); - const host = testData.serverHost; + it('should encrypt pairingPhrase when password is set', () => { + const store = new LncCredentialStore(); + const password = testData.password; + const phrase = testData.pairingPhrase; - store.serverHost = host; - expect(store.serverHost).toBe(host); - expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); - }); + store.password = password; + store.pairingPhrase = phrase; - it('should encrypt pairingPhrase when password is set', () => { - const store = new LncCredentialStore(); - const password = testData.password; - const phrase = testData.pairingPhrase; + expect(store.pairingPhrase).toBe(phrase); + expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); + }); - store.password = password; - store.pairingPhrase = phrase; + it('should encrypt pairingPhrase and save when password exists', () => { + const store = new LncCredentialStore(); + const password = testData.password; + const phrase = testData.pairingPhrase; - expect(store.pairingPhrase).toBe(phrase); - expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); - }); + // Set password first + store.password = password; - it('should encrypt pairingPhrase and save when password exists', () => { - const store = new LncCredentialStore(); - const password = testData.password; - const phrase = testData.pairingPhrase; + // Now set pairingPhrase - this should encrypt and save + store.pairingPhrase = phrase; - // Set password first - store.password = password; + expect(store.pairingPhrase).toBe(phrase); + expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); + }); - // Now set pairingPhrase - this should encrypt and save - store.pairingPhrase = phrase; + it('should encrypt localKey when password is set', () => { + const store = new LncCredentialStore(); + const password = testData.password; + const key = testData.localKey; - expect(store.pairingPhrase).toBe(phrase); - expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); - }); + store.password = password; + store.localKey = key; - it('should encrypt localKey when password is set', () => { - const store = new LncCredentialStore(); - const password = testData.password; - const key = testData.localKey; + expect(store.localKey).toBe(key); + expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); + }); - store.password = password; - store.localKey = key; + it('should encrypt localKey and save when password exists', () => { + const store = new LncCredentialStore(); + const password = testData.password; + const key = testData.localKey; - expect(store.localKey).toBe(key); - expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); - }); + // Set password first + store.password = password; - it('should encrypt localKey and save when password exists', () => { - const store = new LncCredentialStore(); - const password = testData.password; - const key = testData.localKey; + // Now set localKey - this should encrypt and save + store.localKey = key; - // Set password first - store.password = password; + expect(store.localKey).toBe(key); + expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); + }); - // Now set localKey - this should encrypt and save - store.localKey = key; + it('should encrypt remoteKey when password is set', () => { + const store = new LncCredentialStore(); + const password = testData.password; + const key = testData.remoteKey; - expect(store.localKey).toBe(key); - expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); - }); + store.password = password; + store.remoteKey = key; - it('should encrypt remoteKey when password is set', () => { - const store = new LncCredentialStore(); - const password = testData.password; - const key = testData.remoteKey; + expect(store.remoteKey).toBe(key); + expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); + }); - store.password = password; - store.remoteKey = key; + it('should encrypt remoteKey and save when password exists', () => { + const store = new LncCredentialStore(); + const password = testData.password; + const key = testData.remoteKey; - expect(store.remoteKey).toBe(key); - expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); - }); + // Set password first + store.password = password; - it('should encrypt remoteKey and save when password exists', () => { - const store = new LncCredentialStore(); - const password = testData.password; - const key = testData.remoteKey; + // Now set remoteKey - this should encrypt and save + store.remoteKey = key; - // Set password first - store.password = password; + expect(store.remoteKey).toBe(key); + expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); + }); - // Now set remoteKey - this should encrypt and save - store.remoteKey = key; + it('should execute encryption logic in setters when password is set', () => { + const store = new LncCredentialStore(); - expect(store.remoteKey).toBe(key); - expect(mockSetup.localStorage.setItem).toHaveBeenCalled(); - }); + // Spy on the private methods to verify they're called + const encryptSpy = vi.spyOn(store as any, '_encrypt'); + const saveSpy = vi.spyOn(store as any, '_save'); - it('should execute encryption logic in setters when password is set', () => { - const store = new LncCredentialStore(); + // Set password to enable encryption path + store.password = testData.password; - // Spy on the private methods to verify they're called - const encryptSpy = vi.spyOn(store as any, '_encrypt'); - const saveSpy = vi.spyOn(store as any, '_save'); + // After setting password, it gets cleared due to clear(true) call + // So we need to set it again to have it available for the setters + store.password = testData.password; - // Set password to enable encryption path - store.password = testData.password; + // Clear previous spy calls + encryptSpy.mockClear(); + saveSpy.mockClear(); - // After setting password, it gets cleared due to clear(true) call - // So we need to set it again to have it available for the setters - store.password = testData.password; + // Set pairingPhrase - should trigger encryption + store.pairingPhrase = testData.pairingPhrase; - // Clear previous spy calls - encryptSpy.mockClear(); - saveSpy.mockClear(); + // Verify _encrypt and _save were called for pairingPhrase + expect(encryptSpy).toHaveBeenCalledWith(testData.pairingPhrase); + expect(saveSpy).toHaveBeenCalled(); - // Set pairingPhrase - should trigger encryption - store.pairingPhrase = testData.pairingPhrase; + // Clear spy calls again + encryptSpy.mockClear(); + saveSpy.mockClear(); - // Verify _encrypt and _save were called for pairingPhrase - expect(encryptSpy).toHaveBeenCalledWith(testData.pairingPhrase); - expect(saveSpy).toHaveBeenCalled(); + // Set localKey - should trigger encryption + store.localKey = testData.localKey; - // Clear spy calls again - encryptSpy.mockClear(); - saveSpy.mockClear(); + // Verify _encrypt and _save were called for localKey + expect(encryptSpy).toHaveBeenCalledWith(testData.localKey); + expect(saveSpy).toHaveBeenCalled(); - // Set localKey - should trigger encryption - store.localKey = testData.localKey; + // Clear spy calls again + encryptSpy.mockClear(); + saveSpy.mockClear(); - // Verify _encrypt and _save were called for localKey - expect(encryptSpy).toHaveBeenCalledWith(testData.localKey); - expect(saveSpy).toHaveBeenCalled(); + // Set remoteKey - should trigger lines 163-165 + store.remoteKey = testData.remoteKey; - // Clear spy calls again - encryptSpy.mockClear(); - saveSpy.mockClear(); + // Verify _encrypt and _save were called for remoteKey + expect(encryptSpy).toHaveBeenCalledWith(testData.remoteKey); + expect(saveSpy).toHaveBeenCalled(); - // Set remoteKey - should trigger lines 163-165 - store.remoteKey = testData.remoteKey; + // Verify values are accessible + expect(store.pairingPhrase).toBe(testData.pairingPhrase); + expect(store.localKey).toBe(testData.localKey); + expect(store.remoteKey).toBe(testData.remoteKey); - // Verify _encrypt and _save were called for remoteKey - expect(encryptSpy).toHaveBeenCalledWith(testData.remoteKey); - expect(saveSpy).toHaveBeenCalled(); + // Clean up spies + encryptSpy.mockRestore(); + saveSpy.mockRestore(); + }); + }); - // Verify values are accessible - expect(store.pairingPhrase).toBe(testData.pairingPhrase); - expect(store.localKey).toBe(testData.localKey); - expect(store.remoteKey).toBe(testData.remoteKey); + describe('Computed Properties', () => { + it('should return false for isPaired when no credentials exist', () => { + const store = new LncCredentialStore(); - // Clean up spies - encryptSpy.mockRestore(); - saveSpy.mockRestore(); - }); + expect(store.isPaired).toBe(false); }); - describe('Computed Properties', () => { - it('should return false for isPaired when no credentials exist', () => { - const store = new LncCredentialStore(); - - expect(store.isPaired).toBe(false); - }); - - it('should return true for isPaired when remoteKey exists', () => { - const store = new LncCredentialStore(); - store.remoteKey = testData.remoteKey; - - // isPaired checks persisted data, so we need to ensure data is persisted - expect(store.remoteKey).toBe(testData.remoteKey); - // Note: isPaired would be true if the data was persisted, but since we don't have a password set, - // the data isn't encrypted/persisted. Let's test the actual persisted state. - expect(store.isPaired).toBe(false); // Should be false since no persisted data - }); - - it('should return true for isPaired when persisted data exists', () => { - const namespace = 'test'; - const persistedData = { - salt: 'testsalt12345678901234567890123456789012', - cipher: 'testcipher', - serverHost: testData.serverHost, - localKey: '', - remoteKey: 'encrypted_remote_key', - pairingPhrase: 'encrypted_pairing_phrase' - }; - - mockSetup.localStorage.setItem( - `lnc-web:${namespace}`, - JSON.stringify(persistedData) - ); - - const store = new LncCredentialStore(namespace); - - // isPaired should return true because persisted data exists - expect(store.isPaired).toBe(true); - }); + it('should return true for isPaired when remoteKey exists', () => { + const store = new LncCredentialStore(); + store.remoteKey = testData.remoteKey; + + // isPaired checks persisted data, so we need to ensure data is persisted + expect(store.remoteKey).toBe(testData.remoteKey); + // Note: isPaired would be true if the data was persisted, but since we don't have a password set, + // the data isn't encrypted/persisted. Let's test the actual persisted state. + expect(store.isPaired).toBe(false); // Should be false since no persisted data }); - describe('Lifecycle Management', () => { - it('should clear all data when clear() is called without memoryOnly', () => { - const store = new LncCredentialStore(); - - // Set some data - store.serverHost = testData.serverHost; - store.pairingPhrase = testData.pairingPhrase; - store.localKey = testData.localKey; - store.remoteKey = testData.remoteKey; - store.password = testData.password; - - // Clear everything - store.clear(); - - expect(store.serverHost).toBe(testData.serverHost); // serverHost is preserved - expect(store.pairingPhrase).toBe(''); - expect(store.localKey).toBe(''); - expect(store.remoteKey).toBe(''); - expect(store.password).toBe(''); - expect(store.isPaired).toBe(false); - expect(mockSetup.localStorage.removeItem).toHaveBeenCalled(); - }); - - it('should throw error when localStorage operations fail during clear', () => { - const store = new LncCredentialStore(); - - // Mock localStorage.removeItem to throw an error - mockSetup.localStorage.removeItem.mockImplementationOnce(() => { - throw new Error('localStorage error'); - }); - - // Should throw error when localStorage operation fails - expect(() => { - store.clear(); - }).toThrow('localStorage error'); - }); + it('should return true for isPaired when persisted data exists', () => { + const namespace = 'test'; + const persistedData = { + salt: 'testsalt12345678901234567890123456789012', + cipher: 'testcipher', + serverHost: testData.serverHost, + localKey: '', + remoteKey: 'encrypted_remote_key', + pairingPhrase: 'encrypted_pairing_phrase' + }; + + mockSetup.localStorage.setItem( + `lnc-web:${namespace}`, + JSON.stringify(persistedData) + ); + + const store = new LncCredentialStore(namespace); + + // isPaired should return true because persisted data exists + expect(store.isPaired).toBe(true); + }); + }); + + describe('Lifecycle Management', () => { + it('should clear all data when clear() is called without memoryOnly', () => { + const store = new LncCredentialStore(); + + // Set some data + store.serverHost = testData.serverHost; + store.pairingPhrase = testData.pairingPhrase; + store.localKey = testData.localKey; + store.remoteKey = testData.remoteKey; + store.password = testData.password; + + // Clear everything + store.clear(); + + expect(store.serverHost).toBe(testData.serverHost); // serverHost is preserved + expect(store.pairingPhrase).toBe(''); + expect(store.localKey).toBe(''); + expect(store.remoteKey).toBe(''); + expect(store.password).toBe(''); + expect(store.isPaired).toBe(false); + expect(mockSetup.localStorage.removeItem).toHaveBeenCalled(); }); - describe('LocalStorage Operations', () => { - it('should throw error when localStorage setItem fails', () => { - const store = new LncCredentialStore(); - - // Mock localStorage.setItem to throw - mockSetup.localStorage.setItem.mockImplementationOnce(() => { - throw new Error('Storage quota exceeded'); - }); - - // Should throw error when localStorage operation fails - expect(() => { - store.serverHost = testData.serverHost; - }).toThrow('Storage quota exceeded'); - }); - - it('should handle localStorage getItem errors gracefully', () => { - // Mock localStorage.getItem to throw - mockSetup.localStorage.getItem.mockImplementationOnce(() => { - throw new Error('Storage access denied'); - }); - - expect(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _ = new LncCredentialStore(); - }).toThrow('Failed to load secure data'); - }); + it('should throw error when localStorage operations fail during clear', () => { + const store = new LncCredentialStore(); + + // Mock localStorage.removeItem to throw an error + mockSetup.localStorage.removeItem.mockImplementationOnce(() => { + throw new Error('localStorage error'); + }); + + // Should throw error when localStorage operation fails + expect(() => { + store.clear(); + }).toThrow('localStorage error'); + }); + }); + + describe('LocalStorage Operations', () => { + it('should throw error when localStorage setItem fails', () => { + const store = new LncCredentialStore(); + + // Mock localStorage.setItem to throw + mockSetup.localStorage.setItem.mockImplementationOnce(() => { + throw new Error('Storage quota exceeded'); + }); + + // Should throw error when localStorage operation fails + expect(() => { + store.serverHost = testData.serverHost; + }).toThrow('Storage quota exceeded'); + }); + + it('should handle localStorage getItem errors gracefully', () => { + // Mock localStorage.getItem to throw + mockSetup.localStorage.getItem.mockImplementationOnce(() => { + throw new Error('Storage access denied'); + }); + + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _ = new LncCredentialStore(); + }).toThrow('Failed to load secure data'); }); + }); }); diff --git a/lib/util/credentialStore.ts b/lib/util/credentialStore.ts index 02e1e45..78e5f3e 100644 --- a/lib/util/credentialStore.ts +++ b/lib/util/credentialStore.ts @@ -1,10 +1,10 @@ import { CredentialStore } from '../types/lnc'; import { - createTestCipher, - decrypt, - encrypt, - generateSalt, - verifyTestCipher + createTestCipher, + decrypt, + encrypt, + generateSalt, + verifyTestCipher } from './encryption'; const STORAGE_KEY = 'lnc-web'; @@ -15,230 +15,224 @@ const STORAGE_KEY = 'lnc-web'; * data is encrypted at rest using the provided `password`. */ export default class LncCredentialStore implements CredentialStore { - // the data to store in localStorage - private persisted = { + // the data to store in localStorage + private persisted = { + salt: '', + cipher: '', + serverHost: '', + // encrypted fields + localKey: '', + remoteKey: '', + pairingPhrase: '' + }; + // the decrypted credentials in plain text. these fields are separate from the + // persisted encrypted fields in order to be able to set the password at any + // time. we may have plain text values that we need to hold onto until the + // password is set, or may load encrypted values that we delay decrypting until + // the password is provided. + private _localKey: string = ''; + private _remoteKey: string = ''; + private _pairingPhrase: string = ''; + /** The password used to encrypt and decrypt the stored data */ + private _password?: string; + /** The namespace to use in the localStorage key */ + private namespace: string = 'default'; + + /** + * Constructs a new `LncCredentialStore` instance + */ + constructor(namespace?: string, password?: string) { + if (namespace) this.namespace = namespace; + + // load data stored in localStorage + this._load(); + + // set the password after loading the data, otherwise the data will be + // overwritten because the password setter checks for the existence of + // persisted data. + if (password) this.password = password; + } + + // + // Public fields which implement the `CredentialStore` interface + // + + /** + * Stores the optional password to use for encryption of the data. LNC does not + * read or write the password. This is just exposed publicly to simplify access + * to the field via `lnc.credentials.password` + */ + get password() { + return this._password || ''; + } + + /** + * Stores the optional password to use for encryption of the data. LNC does not + * read or write the password. This is just exposed publicly to simplify access + * to the field via `lnc.credentials.password` + */ + set password(password: string) { + // when a password is provided, we need to either decrypt the persisted + // data, or encrypt and store the plain text data + if (this.persisted.cipher) { + // we have encrypted data to decrypt + const { cipher, salt } = this.persisted; + if (!verifyTestCipher(cipher, password, salt)) { + throw new Error('The password provided is not valid'); + } + // set the password before decrypting data + this._password = password; + // decrypt the persisted data + this._pairingPhrase = this._decrypt(this.persisted.pairingPhrase); + this._localKey = this._decrypt(this.persisted.localKey); + this._remoteKey = this._decrypt(this.persisted.remoteKey); + } else { + // we have plain text data to encrypt + this._password = password; + // create new salt and cipher using the password + this.persisted.salt = generateSalt(); + this.persisted.cipher = createTestCipher(password, this.persisted.salt); + // encrypt and persist any in-memory values + if (this.pairingPhrase) + this.persisted.pairingPhrase = this._encrypt(this.pairingPhrase); + if (this.localKey) this.persisted.localKey = this._encrypt(this.localKey); + if (this.remoteKey) + this.persisted.remoteKey = this._encrypt(this.remoteKey); + this._save(); + + // once the encrypted data is persisted, we can clear the plain text + // credentials from memory + this.clear(true); + } + } + + /** Stores the host:port of the Lightning Node Connect proxy server to connect to */ + get serverHost() { + return this.persisted.serverHost; + } + + /** Stores the host:port of the Lightning Node Connect proxy server to connect to */ + set serverHost(host: string) { + this.persisted.serverHost = host; + this._save(); + } + + /** Stores the LNC pairing phrase used to initialize the connection to the LNC proxy */ + get pairingPhrase() { + return this._pairingPhrase; + } + + /** Stores the LNC pairing phrase used to initialize the connection to the LNC proxy */ + set pairingPhrase(phrase: string) { + this._pairingPhrase = phrase; + if (this._password) { + this.persisted.pairingPhrase = this._encrypt(phrase); + this._save(); + } + } + + /** Stores the local private key which LNC uses to reestablish a connection */ + get localKey() { + return this._localKey; + } + + /** Stores the local private key which LNC uses to reestablish a connection */ + set localKey(key: string) { + this._localKey = key; + if (this._password) { + this.persisted.localKey = this._encrypt(key); + this._save(); + } + } + + /** Stores the remote static key which LNC uses to reestablish a connection */ + get remoteKey() { + return this._remoteKey; + } + + /** Stores the remote static key which LNC uses to reestablish a connection */ + set remoteKey(key: string) { + this._remoteKey = key; + if (this._password) { + this.persisted.remoteKey = this._encrypt(key); + this._save(); + } + } + + /** + * Read-only field which should return `true` if the client app has prior + * credentials persisted in teh store + */ + get isPaired() { + return !!this.persisted.remoteKey || !!this.persisted.pairingPhrase; + } + + /** Clears any persisted data in the store */ + clear(memoryOnly?: boolean) { + if (!memoryOnly) { + const key = `${STORAGE_KEY}:${this.namespace}`; + localStorage.removeItem(key); + + this.persisted = { salt: '', cipher: '', - serverHost: '', - // encrypted fields + serverHost: this.persisted.serverHost, localKey: '', remoteKey: '', pairingPhrase: '' - }; - // the decrypted credentials in plain text. these fields are separate from the - // persisted encrypted fields in order to be able to set the password at any - // time. we may have plain text values that we need to hold onto until the - // password is set, or may load encrypted values that we delay decrypting until - // the password is provided. - private _localKey: string = ''; - private _remoteKey: string = ''; - private _pairingPhrase: string = ''; - /** The password used to encrypt and decrypt the stored data */ - private _password?: string; - /** The namespace to use in the localStorage key */ - private namespace: string = 'default'; - - /** - * Constructs a new `LncCredentialStore` instance - */ - constructor(namespace?: string, password?: string) { - if (namespace) this.namespace = namespace; - - // load data stored in localStorage - this._load(); - - // set the password after loading the data, otherwise the data will be - // overwritten because the password setter checks for the existence of - // persisted data. - if (password) this.password = password; - } - - // - // Public fields which implement the `CredentialStore` interface - // - - /** - * Stores the optional password to use for encryption of the data. LNC does not - * read or write the password. This is just exposed publicly to simplify access - * to the field via `lnc.credentials.password` - */ - get password() { - return this._password || ''; - } - - /** - * Stores the optional password to use for encryption of the data. LNC does not - * read or write the password. This is just exposed publicly to simplify access - * to the field via `lnc.credentials.password` - */ - set password(password: string) { - // when a password is provided, we need to either decrypt the persisted - // data, or encrypt and store the plain text data - if (this.persisted.cipher) { - // we have encrypted data to decrypt - const { cipher, salt } = this.persisted; - if (!verifyTestCipher(cipher, password, salt)) { - throw new Error('The password provided is not valid'); - } - // set the password before decrypting data - this._password = password; - // decrypt the persisted data - this._pairingPhrase = this._decrypt(this.persisted.pairingPhrase); - this._localKey = this._decrypt(this.persisted.localKey); - this._remoteKey = this._decrypt(this.persisted.remoteKey); - } else { - // we have plain text data to encrypt - this._password = password; - // create new salt and cipher using the password - this.persisted.salt = generateSalt(); - this.persisted.cipher = createTestCipher( - password, - this.persisted.salt - ); - // encrypt and persist any in-memory values - if (this.pairingPhrase) - this.persisted.pairingPhrase = this._encrypt( - this.pairingPhrase - ); - if (this.localKey) - this.persisted.localKey = this._encrypt(this.localKey); - if (this.remoteKey) - this.persisted.remoteKey = this._encrypt(this.remoteKey); - this._save(); - - // once the encrypted data is persisted, we can clear the plain text - // credentials from memory - this.clear(true); - } - } - - /** Stores the host:port of the Lightning Node Connect proxy server to connect to */ - get serverHost() { - return this.persisted.serverHost; - } - - /** Stores the host:port of the Lightning Node Connect proxy server to connect to */ - set serverHost(host: string) { - this.persisted.serverHost = host; - this._save(); - } - - /** Stores the LNC pairing phrase used to initialize the connection to the LNC proxy */ - get pairingPhrase() { - return this._pairingPhrase; - } - - /** Stores the LNC pairing phrase used to initialize the connection to the LNC proxy */ - set pairingPhrase(phrase: string) { - this._pairingPhrase = phrase; - if (this._password) { - this.persisted.pairingPhrase = this._encrypt(phrase); - this._save(); - } - } - - /** Stores the local private key which LNC uses to reestablish a connection */ - get localKey() { - return this._localKey; - } - - /** Stores the local private key which LNC uses to reestablish a connection */ - set localKey(key: string) { - this._localKey = key; - if (this._password) { - this.persisted.localKey = this._encrypt(key); - this._save(); - } - } - - /** Stores the remote static key which LNC uses to reestablish a connection */ - get remoteKey() { - return this._remoteKey; - } - - /** Stores the remote static key which LNC uses to reestablish a connection */ - set remoteKey(key: string) { - this._remoteKey = key; - if (this._password) { - this.persisted.remoteKey = this._encrypt(key); - this._save(); - } - } - - /** - * Read-only field which should return `true` if the client app has prior - * credentials persisted in teh store - */ - get isPaired() { - return !!this.persisted.remoteKey || !!this.persisted.pairingPhrase; - } - - /** Clears any persisted data in the store */ - clear(memoryOnly?: boolean) { - if (!memoryOnly) { - const key = `${STORAGE_KEY}:${this.namespace}`; - localStorage.removeItem(key); - - this.persisted = { - salt: '', - cipher: '', - serverHost: this.persisted.serverHost, - localKey: '', - remoteKey: '', - pairingPhrase: '' - }; - } - - this._localKey = ''; - this._remoteKey = ''; - this._pairingPhrase = ''; - this._password = undefined; - } - - // - // Private functions only used internally - // - - /** Loads persisted data from localStorage */ - private _load() { - // do nothing if localStorage is not available - if (typeof localStorage === 'undefined') return; - - try { - const key = `${STORAGE_KEY}:${this.namespace}`; - const json = localStorage.getItem(key); - if (!json) return; - this.persisted = JSON.parse(json); - } catch (error) { - const msg = (error as Error).message; - throw new Error(`Failed to load secure data: ${msg}`); - } - } - - /** Saves persisted data to localStorage */ - private _save() { - // do nothing if localStorage is not available - if (typeof localStorage === 'undefined') return; - - const key = `${STORAGE_KEY}:${this.namespace}`; - localStorage.setItem(key, JSON.stringify(this.persisted)); - } - - /** - * A wrapper around `encrypt` which just returns an empty string if the - * value or password have no value - */ - private _encrypt(value: string) { - if (!value || !this._password) return ''; - return encrypt(value, this._password, this.persisted.salt); - } - - /** - * A wrapper around `decrypt` which just returns an empty string if the - * value or password have no value - */ - private _decrypt(value: string) { - if (!value || !this._password) return ''; - return decrypt(value, this._password, this.persisted.salt); - } + }; + } + + this._localKey = ''; + this._remoteKey = ''; + this._pairingPhrase = ''; + this._password = undefined; + } + + // + // Private functions only used internally + // + + /** Loads persisted data from localStorage */ + private _load() { + // do nothing if localStorage is not available + if (typeof localStorage === 'undefined') return; + + try { + const key = `${STORAGE_KEY}:${this.namespace}`; + const json = localStorage.getItem(key); + if (!json) return; + this.persisted = JSON.parse(json); + } catch (error) { + const msg = (error as Error).message; + throw new Error(`Failed to load secure data: ${msg}`); + } + } + + /** Saves persisted data to localStorage */ + private _save() { + // do nothing if localStorage is not available + if (typeof localStorage === 'undefined') return; + + const key = `${STORAGE_KEY}:${this.namespace}`; + localStorage.setItem(key, JSON.stringify(this.persisted)); + } + + /** + * A wrapper around `encrypt` which just returns an empty string if the + * value or password have no value + */ + private _encrypt(value: string) { + if (!value || !this._password) return ''; + return encrypt(value, this._password, this.persisted.salt); + } + + /** + * A wrapper around `decrypt` which just returns an empty string if the + * value or password have no value + */ + private _decrypt(value: string) { + if (!value || !this._password) return ''; + return decrypt(value, this._password, this.persisted.salt); + } } diff --git a/lib/util/encryption.test.ts b/lib/util/encryption.test.ts index 0332e21..f16bca9 100644 --- a/lib/util/encryption.test.ts +++ b/lib/util/encryption.test.ts @@ -1,248 +1,248 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { testData } from '../../test/utils/test-helpers'; import { - createTestCipher, - decrypt, - encrypt, - generateSalt, - verifyTestCipher + createTestCipher, + decrypt, + encrypt, + generateSalt, + verifyTestCipher } from './encryption'; describe('Encryption Utilities', () => { - beforeEach(() => { - // Reset crypto mock calls - vi.clearAllMocks(); - }); - - describe('generateSalt', () => { - it('should generate a salt of correct length', () => { - const salt = generateSalt(); - expect(salt).toHaveLength(32); - }); - - it('should generate salt with valid characters only', () => { - const salt = generateSalt(); - const validChars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (const char of salt) { - expect(validChars).toContain(char); - } - }); - - it('should call crypto.getRandomValues with correct array size', () => { - generateSalt(); - expect(globalThis.crypto.getRandomValues).toHaveBeenCalledWith( - expect.any(Uint8Array) - ); - const callArgs = (globalThis.crypto.getRandomValues as any).mock - .calls[0][0]; - expect(callArgs).toHaveLength(32); - }); - - it('should generate different salts on subsequent calls', () => { - const salt1 = generateSalt(); - const salt2 = generateSalt(); - // Note: In a real implementation, these would likely be different - // but our mock uses Math.random() which could potentially return same values - expect(typeof salt1).toBe('string'); - expect(typeof salt2).toBe('string'); - }); + beforeEach(() => { + // Reset crypto mock calls + vi.clearAllMocks(); + }); + + describe('generateSalt', () => { + it('should generate a salt of correct length', () => { + const salt = generateSalt(); + expect(salt).toHaveLength(32); }); - describe('encrypt', () => { - it('should encrypt data with password and salt', () => { - const data = { test: 'data' }; - const password = testData.password; - const salt = 'testsalt12345678901234567890123456789012'; + it('should generate salt with valid characters only', () => { + const salt = generateSalt(); + const validChars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (const char of salt) { + expect(validChars).toContain(char); + } + }); - const result = encrypt(data, password, salt); + it('should call crypto.getRandomValues with correct array size', () => { + generateSalt(); + expect(globalThis.crypto.getRandomValues).toHaveBeenCalledWith( + expect.any(Uint8Array) + ); + const callArgs = (globalThis.crypto.getRandomValues as any).mock + .calls[0][0]; + expect(callArgs).toHaveLength(32); + }); - expect(typeof result).toBe('string'); - expect(result).toHaveLength(44); // AES encrypted strings are typically 44 chars - }); + it('should generate different salts on subsequent calls', () => { + const salt1 = generateSalt(); + const salt2 = generateSalt(); + // Note: In a real implementation, these would likely be different + // but our mock uses Math.random() which could potentially return same values + expect(typeof salt1).toBe('string'); + expect(typeof salt2).toBe('string'); + }); + }); - it('should produce different results for different data', () => { - const password = testData.password; - const salt = generateSalt(); + describe('encrypt', () => { + it('should encrypt data with password and salt', () => { + const data = { test: 'data' }; + const password = testData.password; + const salt = 'testsalt12345678901234567890123456789012'; - const result1 = encrypt('data1', password, salt); - const result2 = encrypt('data2', password, salt); + const result = encrypt(data, password, salt); - expect(result1).not.toBe(result2); - }); + expect(typeof result).toBe('string'); + expect(result).toHaveLength(44); // AES encrypted strings are typically 44 chars + }); - it('should produce different results for different passwords', () => { - const data = 'test data'; - const salt = generateSalt(); + it('should produce different results for different data', () => { + const password = testData.password; + const salt = generateSalt(); - const result1 = encrypt(data, 'password1', salt); - const result2 = encrypt(data, 'password2', salt); + const result1 = encrypt('data1', password, salt); + const result2 = encrypt('data2', password, salt); - expect(result1).not.toBe(result2); - }); + expect(result1).not.toBe(result2); + }); - it('should produce different results for different salts', () => { - const data = 'test data'; - const password = testData.password; + it('should produce different results for different passwords', () => { + const data = 'test data'; + const salt = generateSalt(); - const result1 = encrypt(data, password, generateSalt()); - const result2 = encrypt(data, password, generateSalt()); + const result1 = encrypt(data, 'password1', salt); + const result2 = encrypt(data, 'password2', salt); - // Salts are random, so results should be different - expect(result1).not.toBe(result2); - }); + expect(result1).not.toBe(result2); }); - describe('decrypt', () => { - it('should decrypt previously encrypted data', () => { - const originalData = { message: 'secret data', number: 42 }; - const password = testData.password; - const salt = generateSalt(); + it('should produce different results for different salts', () => { + const data = 'test data'; + const password = testData.password; - const encrypted = encrypt(originalData, password, salt); - const decrypted = decrypt(encrypted, password, salt); + const result1 = encrypt(data, password, generateSalt()); + const result2 = encrypt(data, password, generateSalt()); - expect(decrypted).toEqual(originalData); - }); + // Salts are random, so results should be different + expect(result1).not.toBe(result2); + }); + }); - it('should throw error for invalid encrypted data', () => { - const invalidData = 'not-a-valid-encrypted-string'; - const password = testData.password; - const salt = generateSalt(); - - expect(() => { - decrypt(invalidData, password, salt); - }).toThrow(); - }); - - it('should throw error for wrong password', () => { - const originalData = 'secret message'; - const correctPassword = testData.password; - const wrongPassword = 'wrongpassword'; - const salt = generateSalt(); + describe('decrypt', () => { + it('should decrypt previously encrypted data', () => { + const originalData = { message: 'secret data', number: 42 }; + const password = testData.password; + const salt = generateSalt(); - const encrypted = encrypt(originalData, correctPassword, salt); + const encrypted = encrypt(originalData, password, salt); + const decrypted = decrypt(encrypted, password, salt); - expect(() => { - decrypt(encrypted, wrongPassword, salt); - }).toThrow(); - }); + expect(decrypted).toEqual(originalData); }); - describe('createTestCipher', () => { - it('should create a test cipher with password and salt', () => { - const password = testData.password; - const salt = generateSalt(); + it('should throw error for invalid encrypted data', () => { + const invalidData = 'not-a-valid-encrypted-string'; + const password = testData.password; + const salt = generateSalt(); - const cipher = createTestCipher(password, salt); - - expect(typeof cipher).toBe('string'); - expect(cipher.length).toBeGreaterThan(0); - }); + expect(() => { + decrypt(invalidData, password, salt); + }).toThrow(); + }); - it('should create different ciphers for different passwords', () => { - const salt = generateSalt(); + it('should throw error for wrong password', () => { + const originalData = 'secret message'; + const correctPassword = testData.password; + const wrongPassword = 'wrongpassword'; + const salt = generateSalt(); - const cipher1 = createTestCipher('password1', salt); - const cipher2 = createTestCipher('password2', salt); + const encrypted = encrypt(originalData, correctPassword, salt); - expect(cipher1).not.toBe(cipher2); - }); + expect(() => { + decrypt(encrypted, wrongPassword, salt); + }).toThrow(); }); + }); - describe('verifyTestCipher', () => { - it('should return true for valid cipher with correct password and salt', () => { - const password = testData.password; - const salt = generateSalt(); - const cipher = createTestCipher(password, salt); + describe('createTestCipher', () => { + it('should create a test cipher with password and salt', () => { + const password = testData.password; + const salt = generateSalt(); - const isValid = verifyTestCipher(cipher, password, salt); + const cipher = createTestCipher(password, salt); - expect(isValid).toBe(true); - }); + expect(typeof cipher).toBe('string'); + expect(cipher.length).toBeGreaterThan(0); + }); - it('should return false for invalid cipher', () => { - const password = testData.password; - const salt = generateSalt(); - const invalidCipher = 'invalid-cipher-string'; + it('should create different ciphers for different passwords', () => { + const salt = generateSalt(); - const isValid = verifyTestCipher(invalidCipher, password, salt); + const cipher1 = createTestCipher('password1', salt); + const cipher2 = createTestCipher('password2', salt); - expect(isValid).toBe(false); - }); + expect(cipher1).not.toBe(cipher2); + }); + }); - it('should return false for wrong password', () => { - const correctPassword = testData.password; - const wrongPassword = 'wrongpassword'; - const salt = generateSalt(); - const cipher = createTestCipher(correctPassword, salt); + describe('verifyTestCipher', () => { + it('should return true for valid cipher with correct password and salt', () => { + const password = testData.password; + const salt = generateSalt(); + const cipher = createTestCipher(password, salt); - const isValid = verifyTestCipher(cipher, wrongPassword, salt); + const isValid = verifyTestCipher(cipher, password, salt); - expect(isValid).toBe(false); - }); + expect(isValid).toBe(true); + }); - it('should handle decryption errors gracefully', () => { - const password = testData.password; - const salt = generateSalt(); - const corruptedCipher = 'corrupted-cipher-data'; + it('should return false for invalid cipher', () => { + const password = testData.password; + const salt = generateSalt(); + const invalidCipher = 'invalid-cipher-string'; - const isValid = verifyTestCipher(corruptedCipher, password, salt); + const isValid = verifyTestCipher(invalidCipher, password, salt); - expect(isValid).toBe(false); - }); + expect(isValid).toBe(false); }); - describe('Integration tests', () => { - it('should successfully encrypt and decrypt complex data structures', () => { - const complexData = { - user: { - id: 123, - name: 'Test User', - preferences: { - theme: 'dark', - notifications: true - } - }, - tokens: ['token1', 'token2', 'token3'], - metadata: { - created: new Date().toISOString(), - version: '1.0.0' - } - }; + it('should return false for wrong password', () => { + const correctPassword = testData.password; + const wrongPassword = 'wrongpassword'; + const salt = generateSalt(); + const cipher = createTestCipher(correctPassword, salt); - const password = testData.password; - const salt = generateSalt(); + const isValid = verifyTestCipher(cipher, wrongPassword, salt); - const encrypted = encrypt(complexData, password, salt); - const decrypted = decrypt(encrypted, password, salt); + expect(isValid).toBe(false); + }); - expect(decrypted).toEqual(complexData); - }); + it('should handle decryption errors gracefully', () => { + const password = testData.password; + const salt = generateSalt(); + const corruptedCipher = 'corrupted-cipher-data'; - it('should maintain data integrity through encrypt/decrypt cycle', () => { - const testCases = [ - 'simple string', - 42, - { key: 'value' }, - [1, 2, 3, 'test'], - { nested: { deeply: { nested: 'value' } } }, - true, - false, - '', - [], - {} - ]; + const isValid = verifyTestCipher(corruptedCipher, password, salt); + + expect(isValid).toBe(false); + }); + }); + + describe('Integration tests', () => { + it('should successfully encrypt and decrypt complex data structures', () => { + const complexData = { + user: { + id: 123, + name: 'Test User', + preferences: { + theme: 'dark', + notifications: true + } + }, + tokens: ['token1', 'token2', 'token3'], + metadata: { + created: new Date().toISOString(), + version: '1.0.0' + } + }; + + const password = testData.password; + const salt = generateSalt(); + + const encrypted = encrypt(complexData, password, salt); + const decrypted = decrypt(encrypted, password, salt); + + expect(decrypted).toEqual(complexData); + }); - const password = testData.password; - const salt = generateSalt(); - - testCases.forEach((data) => { - const encrypted = encrypt(data, password, salt); - const decrypted = decrypt(encrypted, password, salt); - - expect(decrypted).toEqual(data); - }); - }); + it('should maintain data integrity through encrypt/decrypt cycle', () => { + const testCases = [ + 'simple string', + 42, + { key: 'value' }, + [1, 2, 3, 'test'], + { nested: { deeply: { nested: 'value' } } }, + true, + false, + '', + [], + {} + ]; + + const password = testData.password; + const salt = generateSalt(); + + testCases.forEach((data) => { + const encrypted = encrypt(data, password, salt); + const decrypted = decrypt(encrypted, password, salt); + + expect(decrypted).toEqual(data); + }); }); + }); }); diff --git a/lib/util/encryption.ts b/lib/util/encryption.ts index e91df74..1b8e654 100644 --- a/lib/util/encryption.ts +++ b/lib/util/encryption.ts @@ -3,37 +3,37 @@ import { AES, enc } from 'crypto-js'; const TEST_DATA = 'Irrelevant data for password verification'; export const generateSalt = () => { - const validChars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let array = new Uint8Array(32); - globalThis.crypto.getRandomValues(array); - array = array.map((x) => validChars.charCodeAt(x % validChars.length)); - const salt = String.fromCharCode.apply(null, array as any); - return salt; + const validChars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let array = new Uint8Array(32); + globalThis.crypto.getRandomValues(array); + array = array.map((x) => validChars.charCodeAt(x % validChars.length)); + const salt = String.fromCharCode.apply(null, array as any); + return salt; }; export const encrypt = (data: any, password: string, salt: string) => { - return AES.encrypt(JSON.stringify(data), password + salt).toString(); + return AES.encrypt(JSON.stringify(data), password + salt).toString(); }; export const decrypt = (data: any, password: string, salt: string): string => { - const decrypted = AES.decrypt(data, password + salt); - return JSON.parse(decrypted.toString(enc.Utf8)); + const decrypted = AES.decrypt(data, password + salt); + return JSON.parse(decrypted.toString(enc.Utf8)); }; export const createTestCipher = (password: string, salt: string) => { - return encrypt(TEST_DATA, password, salt); + return encrypt(TEST_DATA, password, salt); }; export const verifyTestCipher = ( - testCipher: string, - password: string, - salt: string + testCipher: string, + password: string, + salt: string ) => { - try { - const decrypted = decrypt(testCipher, password, salt); - return decrypted === TEST_DATA; - } catch (error) { - return false; - } + try { + const decrypted = decrypt(testCipher, password, salt); + return decrypted === TEST_DATA; + } catch (error) { + return false; + } }; diff --git a/lib/util/log.test.ts b/lib/util/log.test.ts index 560343d..ce9db0f 100644 --- a/lib/util/log.test.ts +++ b/lib/util/log.test.ts @@ -4,239 +4,231 @@ import { actionLog, grpcLog, log, Logger, LogLevel, wasmLog } from './log'; // Create a reference to the globalThis object with additional property types // needed for testing const testGlobal = globalThis as typeof globalThis & { - lastDebugCall: { - namespace: string; - message: string; - args: any[]; - }; + lastDebugCall: { + namespace: string; + message: string; + args: any[]; + }; }; const resetTestGlobal = () => { - testGlobal.lastDebugCall = { namespace: '', message: '', args: [] }; + testGlobal.lastDebugCall = { namespace: '', message: '', args: [] }; }; // Mock the debug module vi.mock('debug', () => ({ - default: vi.fn((namespace: string) => - vi.fn((message: string, ...args: any[]) => { - // Store the last call for verification - testGlobal.lastDebugCall = { namespace, message, args }; - }) - ) + default: vi.fn((namespace: string) => + vi.fn((message: string, ...args: any[]) => { + // Store the last call for verification + testGlobal.lastDebugCall = { namespace, message, args }; + }) + ) })); describe('Logging System', () => { - beforeEach(() => { - vi.clearAllMocks(); - - // Reset debug call tracking - resetTestGlobal(); - }); - - describe('LogLevel enum', () => { - it('should have correct enum values', () => { - expect(LogLevel.debug).toBe(1); - expect(LogLevel.info).toBe(2); - expect(LogLevel.warn).toBe(3); - expect(LogLevel.error).toBe(4); - expect(LogLevel.none).toBe(5); - }); - - it('should have all expected levels defined', () => { - // In TypeScript enums, Object.values returns both keys and values - const numericValues = Object.values(LogLevel).filter( - (v) => typeof v === 'number' - ); - expect(numericValues).toHaveLength(5); - expect(numericValues).toContain(1); // debug - expect(numericValues).toContain(2); // info - expect(numericValues).toContain(3); // warn - expect(numericValues).toContain(4); // error - expect(numericValues).toContain(5); // none - }); - }); - - describe('Logger constructor', () => { - it('should create logger instance with correct level', () => { - const logger = new Logger(LogLevel.info, 'test'); - expect(logger).toBeInstanceOf(Logger); - }); - - it('should accept different log levels', () => { - const debugLogger = new Logger(LogLevel.debug, 'debug-test'); - const errorLogger = new Logger(LogLevel.error, 'error-test'); - const noneLogger = new Logger(LogLevel.none, 'none-test'); - - expect(debugLogger).toBeDefined(); - expect(errorLogger).toBeDefined(); - expect(noneLogger).toBeDefined(); - }); - - it('should set namespace correctly', () => { - const namespace = 'test-namespace'; - const logger = new Logger(LogLevel.debug, namespace); - - // The namespace should be passed to debug - expect(logger).toBeDefined(); - }); - }); - - describe('Logger.fromEnv', () => { - it('should default to none level when no localStorage', () => { - // Remove localStorage - const originalLocalStorage = globalThis.localStorage; - Object.defineProperty(globalThis, 'localStorage', { - value: undefined, - writable: true - }); - - const logger = Logger.fromEnv('test'); - expect(logger).toBeInstanceOf(Logger); - - // Restore localStorage - Object.defineProperty(globalThis, 'localStorage', { - value: originalLocalStorage, - writable: true - }); - }); - - it('should default to none level when localStorage exists but no debug key', () => { - const logger = Logger.fromEnv('test'); - expect(logger).toBeInstanceOf(Logger); - expect(logger.level).toBe(LogLevel.none); - }); - - it('should use debug level when debug key exists in localStorage', () => { - localStorage.setItem('debug', 'test:*'); - - const logger = Logger.fromEnv('test'); - expect(logger.level).toBe(LogLevel.debug); - }); - - it('should parse debug-level from localStorage', () => { - localStorage.setItem('debug', 'test:*'); - localStorage.setItem('debug-level', 'info'); - - const logger = Logger.fromEnv('test'); - expect(logger.level).toBe(LogLevel.info); - }); - - it('should default to debug level when debug-level is not set', () => { - localStorage.setItem('debug', 'test:*'); - - const logger = Logger.fromEnv('test'); - expect(logger.level).toBe(LogLevel.debug); - }); - }); - - describe('Logger logging methods', () => { - let logger: Logger; - - beforeEach(() => { - logger = new Logger(LogLevel.debug, 'test'); - }); - - describe('debug method', () => { - it('should log debug messages when level allows', () => { - logger.debug('test message'); - expect(testGlobal.lastDebugCall).toBeTruthy(); - expect(testGlobal.lastDebugCall.message).toContain('[debug]'); - expect(testGlobal.lastDebugCall.message).toContain( - 'test message' - ); - }); - - it('should handle additional arguments', () => { - const arg1 = { key: 'value' }; - const arg2 = [1, 2, 3]; - logger.debug('test message', arg1, arg2); - - expect(testGlobal.lastDebugCall).toBeTruthy(); - expect(testGlobal.lastDebugCall.args).toContain(arg1); - expect(testGlobal.lastDebugCall.args).toContain(arg2); - }); - }); - }); - - describe('Log level filtering', () => { - it('should only log messages at or above the configured level', () => { - const infoLogger = new Logger(LogLevel.info, 'test'); + beforeEach(() => { + vi.clearAllMocks(); + + // Reset debug call tracking + resetTestGlobal(); + }); + + describe('LogLevel enum', () => { + it('should have correct enum values', () => { + expect(LogLevel.debug).toBe(1); + expect(LogLevel.info).toBe(2); + expect(LogLevel.warn).toBe(3); + expect(LogLevel.error).toBe(4); + expect(LogLevel.none).toBe(5); + }); + + it('should have all expected levels defined', () => { + // In TypeScript enums, Object.values returns both keys and values + const numericValues = Object.values(LogLevel).filter( + (v) => typeof v === 'number' + ); + expect(numericValues).toHaveLength(5); + expect(numericValues).toContain(1); // debug + expect(numericValues).toContain(2); // info + expect(numericValues).toContain(3); // warn + expect(numericValues).toContain(4); // error + expect(numericValues).toContain(5); // none + }); + }); + + describe('Logger constructor', () => { + it('should create logger instance with correct level', () => { + const logger = new Logger(LogLevel.info, 'test'); + expect(logger).toBeInstanceOf(Logger); + }); + + it('should accept different log levels', () => { + const debugLogger = new Logger(LogLevel.debug, 'debug-test'); + const errorLogger = new Logger(LogLevel.error, 'error-test'); + const noneLogger = new Logger(LogLevel.none, 'none-test'); + + expect(debugLogger).toBeDefined(); + expect(errorLogger).toBeDefined(); + expect(noneLogger).toBeDefined(); + }); + + it('should set namespace correctly', () => { + const namespace = 'test-namespace'; + const logger = new Logger(LogLevel.debug, namespace); + + // The namespace should be passed to debug + expect(logger).toBeDefined(); + }); + }); + + describe('Logger.fromEnv', () => { + it('should default to none level when no localStorage', () => { + // Remove localStorage + const originalLocalStorage = globalThis.localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: undefined, + writable: true + }); + + const logger = Logger.fromEnv('test'); + expect(logger).toBeInstanceOf(Logger); + + // Restore localStorage + Object.defineProperty(globalThis, 'localStorage', { + value: originalLocalStorage, + writable: true + }); + }); + + it('should default to none level when localStorage exists but no debug key', () => { + const logger = Logger.fromEnv('test'); + expect(logger).toBeInstanceOf(Logger); + expect(logger.level).toBe(LogLevel.none); + }); + + it('should use debug level when debug key exists in localStorage', () => { + localStorage.setItem('debug', 'test:*'); - infoLogger.debug('debug message'); - expect(testGlobal.lastDebugCall.message).toBe(''); + const logger = Logger.fromEnv('test'); + expect(logger.level).toBe(LogLevel.debug); + }); + + it('should parse debug-level from localStorage', () => { + localStorage.setItem('debug', 'test:*'); + localStorage.setItem('debug-level', 'info'); + + const logger = Logger.fromEnv('test'); + expect(logger.level).toBe(LogLevel.info); + }); - infoLogger.info('info message'); - expect(testGlobal.lastDebugCall.message).toBe( - '[info] info message' - ); + it('should default to debug level when debug-level is not set', () => { + localStorage.setItem('debug', 'test:*'); - resetTestGlobal(); + const logger = Logger.fromEnv('test'); + expect(logger.level).toBe(LogLevel.debug); + }); + }); - infoLogger.warn('warn message'); - expect(testGlobal.lastDebugCall.message).toBe( - '[warn] warn message' - ); + describe('Logger logging methods', () => { + let logger: Logger; - resetTestGlobal(); + beforeEach(() => { + logger = new Logger(LogLevel.debug, 'test'); + }); - infoLogger.error('error message'); - expect(testGlobal.lastDebugCall.message).toBe( - '[error] error message' - ); - }); + describe('debug method', () => { + it('should log debug messages when level allows', () => { + logger.debug('test message'); + expect(testGlobal.lastDebugCall).toBeTruthy(); + expect(testGlobal.lastDebugCall.message).toContain('[debug]'); + expect(testGlobal.lastDebugCall.message).toContain('test message'); + }); + + it('should handle additional arguments', () => { + const arg1 = { key: 'value' }; + const arg2 = [1, 2, 3]; + logger.debug('test message', arg1, arg2); + + expect(testGlobal.lastDebugCall).toBeTruthy(); + expect(testGlobal.lastDebugCall.args).toContain(arg1); + expect(testGlobal.lastDebugCall.args).toContain(arg2); + }); }); + }); + + describe('Log level filtering', () => { + it('should only log messages at or above the configured level', () => { + const infoLogger = new Logger(LogLevel.info, 'test'); - describe('Pre-configured logger instances', () => { - it('should export main logger', () => { - expect(log).toBeInstanceOf(Logger); - }); + infoLogger.debug('debug message'); + expect(testGlobal.lastDebugCall.message).toBe(''); - it('should export grpc logger', () => { - expect(grpcLog).toBeInstanceOf(Logger); - }); + infoLogger.info('info message'); + expect(testGlobal.lastDebugCall.message).toBe('[info] info message'); - it('should export wasm logger', () => { - expect(wasmLog).toBeInstanceOf(Logger); - }); + resetTestGlobal(); - it('should export action logger', () => { - expect(actionLog).toBeInstanceOf(Logger); - }); + infoLogger.warn('warn message'); + expect(testGlobal.lastDebugCall.message).toBe('[warn] warn message'); + + resetTestGlobal(); + + infoLogger.error('error message'); + expect(testGlobal.lastDebugCall.message).toBe('[error] error message'); + }); + }); + + describe('Pre-configured logger instances', () => { + it('should export main logger', () => { + expect(log).toBeInstanceOf(Logger); }); - describe('Environment detection edge cases', () => { - it('should handle null localStorage gracefully', () => { - const originalLocalStorage = localStorage; - Object.defineProperty(globalThis, 'localStorage', { - value: null, - writable: true - }); + it('should export grpc logger', () => { + expect(grpcLog).toBeInstanceOf(Logger); + }); - const logger = Logger.fromEnv('test'); - expect(logger).toBeInstanceOf(Logger); + it('should export wasm logger', () => { + expect(wasmLog).toBeInstanceOf(Logger); + }); - // Restore localStorage - Object.defineProperty(globalThis, 'localStorage', { - value: originalLocalStorage, - writable: true - }); - }); + it('should export action logger', () => { + expect(actionLog).toBeInstanceOf(Logger); + }); + }); + + describe('Environment detection edge cases', () => { + it('should handle null localStorage gracefully', () => { + const originalLocalStorage = localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: null, + writable: true + }); + + const logger = Logger.fromEnv('test'); + expect(logger).toBeInstanceOf(Logger); + + // Restore localStorage + Object.defineProperty(globalThis, 'localStorage', { + value: originalLocalStorage, + writable: true + }); + }); - it('should handle empty debug-level value', () => { - localStorage.setItem('debug', 'test:*'); - localStorage.setItem('debug-level', ''); + it('should handle empty debug-level value', () => { + localStorage.setItem('debug', 'test:*'); + localStorage.setItem('debug-level', ''); - const logger = Logger.fromEnv('test'); - expect(logger.level).toBe(LogLevel.debug); - }); + const logger = Logger.fromEnv('test'); + expect(logger.level).toBe(LogLevel.debug); }); + }); - describe('Multiple logger instances', () => { - it('should create independent logger instances', () => { - const logger1 = new Logger(LogLevel.debug, 'namespace1'); - const logger2 = new Logger(LogLevel.info, 'namespace2'); + describe('Multiple logger instances', () => { + it('should create independent logger instances', () => { + const logger1 = new Logger(LogLevel.debug, 'namespace1'); + const logger2 = new Logger(LogLevel.info, 'namespace2'); - expect(logger1).not.toBe(logger2); - }); + expect(logger1).not.toBe(logger2); }); + }); }); diff --git a/lib/util/log.ts b/lib/util/log.ts index 46d534e..0111fbc 100644 --- a/lib/util/log.ts +++ b/lib/util/log.ts @@ -1,95 +1,92 @@ import debug, { Debugger } from 'debug'; export enum LogLevel { - debug = 1, - info = 2, - warn = 3, - error = 4, - none = 5 + debug = 1, + info = 2, + warn = 3, + error = 4, + none = 5 } /** * A logger class with support for multiple namespaces and log levels. */ export class Logger { - private _levelToOutput: LogLevel; - private _logger: Debugger; - - constructor(levelToOutput: LogLevel, namespace: string) { - this._levelToOutput = levelToOutput; - this._logger = debug(namespace); - } - - /** - * Get the current log level - */ - get level(): LogLevel { - return this._levelToOutput; + private _levelToOutput: LogLevel; + private _logger: Debugger; + + constructor(levelToOutput: LogLevel, namespace: string) { + this._levelToOutput = levelToOutput; + this._logger = debug(namespace); + } + + /** + * Get the current log level + */ + get level(): LogLevel { + return this._levelToOutput; + } + + /** + * creates a new Logger instance by inspecting the executing environment + */ + static fromEnv(namespace: string): Logger { + // by default, log nothing (assuming prod) + let level = LogLevel.none; + + if (globalThis.localStorage && globalThis.localStorage.getItem('debug')) { + // if a 'debug' key is found in localStorage, use the level in storage or 'debug' by default + const storageLevel = + globalThis.localStorage.getItem('debug-level') || 'debug'; + level = LogLevel[storageLevel as keyof typeof LogLevel]; } - /** - * creates a new Logger instance by inspecting the executing environment - */ - static fromEnv(namespace: string): Logger { - // by default, log nothing (assuming prod) - let level = LogLevel.none; - - if ( - globalThis.localStorage && - globalThis.localStorage.getItem('debug') - ) { - // if a 'debug' key is found in localStorage, use the level in storage or 'debug' by default - const storageLevel = - globalThis.localStorage.getItem('debug-level') || 'debug'; - level = LogLevel[storageLevel as keyof typeof LogLevel]; - } - - return new Logger(level, namespace); - } - - /** - * log a debug message - */ - debug = (message: string, ...args: any[]) => - this._log(LogLevel.debug, message, args); - - /** - * log an info message - */ - info = (message: string, ...args: any[]) => - this._log(LogLevel.info, message, args); - - /** - * log a warn message - */ - warn = (message: string, ...args: any[]) => - this._log(LogLevel.warn, message, args); - - /** - * log an error message - */ - error = (message: string, ...args: any[]) => - this._log(LogLevel.error, message, args); - - /** - * A shared logging function which will only output logs based on the level of this Logger instance - * @param level the level of the message being logged - * @param message the message to log - * @param args optional additional arguments to log - */ - private _log(level: LogLevel, message: string, args: any[]) { - // don't log if the level to output is greater than the level of this message - if (this._levelToOutput > level) return; - - // convert the provided log level number to the string name - const prefix = Object.keys(LogLevel).reduce( - (prev, curr) => - level === LogLevel[curr as keyof typeof LogLevel] ? curr : prev, - '??' - ); - - this._logger(`[${prefix}] ${message}`, ...args); - } + return new Logger(level, namespace); + } + + /** + * log a debug message + */ + debug = (message: string, ...args: any[]) => + this._log(LogLevel.debug, message, args); + + /** + * log an info message + */ + info = (message: string, ...args: any[]) => + this._log(LogLevel.info, message, args); + + /** + * log a warn message + */ + warn = (message: string, ...args: any[]) => + this._log(LogLevel.warn, message, args); + + /** + * log an error message + */ + error = (message: string, ...args: any[]) => + this._log(LogLevel.error, message, args); + + /** + * A shared logging function which will only output logs based on the level of this Logger instance + * @param level the level of the message being logged + * @param message the message to log + * @param args optional additional arguments to log + */ + private _log(level: LogLevel, message: string, args: any[]) { + // don't log if the level to output is greater than the level of this message + if (this._levelToOutput > level) return; + + // convert the provided log level number to the string name + const prefix = Object.keys(LogLevel).reduce( + (prev, curr) => + level === LogLevel[curr as keyof typeof LogLevel] ? curr : prev, + '??' + ); + + this._logger(`[${prefix}] ${message}`, ...args); + } } /** diff --git a/package.json b/package.json index 4a6f048..046e047 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "test:watch": "vitest --watch", "test:run": "vitest run", "test:coverage": "vitest run --coverage", - "prettier": "prettier --check '**/*.ts*'", - "prettier-write": "prettier --check --write '**/*.ts*'", + "prettier": "prettier --check '**/*.ts*' '**/*.js*'", + "prettier-write": "prettier --check --write '**/*.ts*' '**/*.js*'", "lint": "tslint -p tsconfig.json", "prepare": "npm run build", "prepublishOnly": "npm run lint", diff --git a/test/mocks/localStorage.ts b/test/mocks/localStorage.ts index 0aad198..5456f20 100644 --- a/test/mocks/localStorage.ts +++ b/test/mocks/localStorage.ts @@ -4,63 +4,63 @@ import { vi } from 'vitest'; * Enhanced localStorage mock for testing credential storage */ export class MockLocalStorage { - private storage: Map = new Map(); + private storage: Map = new Map(); - // Vitest spies for tracking calls - getItem = vi.fn((key: string): string | null => { - return this.storage.get(key) || null; - }); + // Vitest spies for tracking calls + getItem = vi.fn((key: string): string | null => { + return this.storage.get(key) || null; + }); - setItem = vi.fn((key: string, value: string): void => { - this.storage.set(key, value); - }); + setItem = vi.fn((key: string, value: string): void => { + this.storage.set(key, value); + }); - removeItem = vi.fn((key: string): void => { - this.storage.delete(key); - }); + removeItem = vi.fn((key: string): void => { + this.storage.delete(key); + }); - clear = vi.fn((): void => { - this.storage.clear(); - }); + clear = vi.fn((): void => { + this.storage.clear(); + }); - key = vi.fn((index: number): string | null => { - const keys = Array.from(this.storage.keys()); - return keys[index] || null; - }); + key = vi.fn((index: number): string | null => { + const keys = Array.from(this.storage.keys()); + return keys[index] || null; + }); - get length(): number { - return this.storage.size; - } + get length(): number { + return this.storage.size; + } - // Additional utility methods for testing - hasItem(key: string): boolean { - return this.storage.has(key); - } + // Additional utility methods for testing + hasItem(key: string): boolean { + return this.storage.has(key); + } - getAllItems(): Record { - return Object.fromEntries(this.storage); - } + getAllItems(): Record { + return Object.fromEntries(this.storage); + } - setItems(items: Record): void { - Object.entries(items).forEach(([key, value]) => { - this.setItem(key, value); - }); - } + setItems(items: Record): void { + Object.entries(items).forEach(([key, value]) => { + this.setItem(key, value); + }); + } - reset(): void { - this.storage.clear(); - // Clear mock call history - this.getItem.mockClear(); - this.setItem.mockClear(); - this.removeItem.mockClear(); - this.clear.mockClear(); - this.key.mockClear(); - } + reset(): void { + this.storage.clear(); + // Clear mock call history + this.getItem.mockClear(); + this.setItem.mockClear(); + this.removeItem.mockClear(); + this.clear.mockClear(); + this.key.mockClear(); + } } /** * Create a fresh mock localStorage instance */ export const createMockLocalStorage = (): MockLocalStorage => { - return new MockLocalStorage(); + return new MockLocalStorage(); }; diff --git a/test/mocks/webassembly.ts b/test/mocks/webassembly.ts index 49f97e1..47e3645 100644 --- a/test/mocks/webassembly.ts +++ b/test/mocks/webassembly.ts @@ -5,22 +5,22 @@ import { WasmGlobal } from '../../lib/types/lnc'; * Mock WebAssembly global object for testing */ export const createWasmGlobalMock = (): Mocked => ({ - wasmClientIsReady: vi.fn().mockReturnValue(false), - wasmClientIsConnected: vi.fn().mockReturnValue(false), - wasmClientStatus: vi.fn().mockReturnValue('ready'), - wasmClientGetExpiry: vi.fn().mockReturnValue(Date.now() / 1000), - wasmClientIsReadOnly: vi.fn().mockReturnValue(false), - wasmClientHasPerms: vi.fn().mockReturnValue(false), - wasmClientConnectServer: vi.fn(), - wasmClientDisconnect: vi.fn(), - wasmClientInvokeRPC: vi.fn() + wasmClientIsReady: vi.fn().mockReturnValue(false), + wasmClientIsConnected: vi.fn().mockReturnValue(false), + wasmClientStatus: vi.fn().mockReturnValue('ready'), + wasmClientGetExpiry: vi.fn().mockReturnValue(Date.now() / 1000), + wasmClientIsReadOnly: vi.fn().mockReturnValue(false), + wasmClientHasPerms: vi.fn().mockReturnValue(false), + wasmClientConnectServer: vi.fn(), + wasmClientDisconnect: vi.fn(), + wasmClientInvokeRPC: vi.fn() }); /** * Mock Go instance for WebAssembly execution */ export const createGoInstanceMock = () => ({ - run: vi.fn().mockResolvedValue(undefined), - importObject: {}, - exited: false + run: vi.fn().mockResolvedValue(undefined), + importObject: {}, + exited: false }); diff --git a/test/setup.ts b/test/setup.ts index c173426..f4cb3b6 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -13,62 +13,62 @@ globalThis.localStorage = localStorageMock; // Mock crypto API const cryptoMock = { - getRandomValues: vi.fn((array: Uint8Array) => { - // Fill array with deterministic values for testing - for (let i = 0; i < array.length; i++) { - array[i] = Math.floor(Math.random() * 256); - } - return array; - }) + getRandomValues: vi.fn((array: Uint8Array) => { + // Fill array with deterministic values for testing + for (let i = 0; i < array.length; i++) { + array[i] = Math.floor(Math.random() * 256); + } + return array; + }) }; Object.defineProperty(globalThis, 'crypto', { - value: cryptoMock, - writable: true + value: cryptoMock, + writable: true }); // Mock WebAssembly const webAssemblyMock = { - instantiateStreaming: vi.fn().mockResolvedValue({ - module: {}, - instance: { exports: {} } - }), - compileStreaming: vi.fn().mockResolvedValue({}), - instantiate: vi.fn().mockResolvedValue({ - module: {}, - instance: { exports: {} } - }), - compile: vi.fn().mockResolvedValue({}), - validate: vi.fn().mockReturnValue(true), - Module: vi.fn(), - Instance: vi.fn(), - Memory: vi.fn(), - Table: vi.fn() + instantiateStreaming: vi.fn().mockResolvedValue({ + module: {}, + instance: { exports: {} } + }), + compileStreaming: vi.fn().mockResolvedValue({}), + instantiate: vi.fn().mockResolvedValue({ + module: {}, + instance: { exports: {} } + }), + compile: vi.fn().mockResolvedValue({}), + validate: vi.fn().mockReturnValue(true), + Module: vi.fn(), + Instance: vi.fn(), + Memory: vi.fn(), + Table: vi.fn() }; Object.defineProperty(globalThis, 'WebAssembly', { - value: webAssemblyMock, - writable: true + value: webAssemblyMock, + writable: true }); // Mock Go constructor (used by WebAssembly) const GoMock = vi.fn().mockImplementation(() => ({ - run: vi.fn().mockResolvedValue(undefined), - importObject: {}, - exited: false + run: vi.fn().mockResolvedValue(undefined), + importObject: {}, + exited: false })); Object.defineProperty(globalThis, 'Go', { - value: GoMock, - writable: true + value: GoMock, + writable: true }); // Reset all mocks before each test beforeEach(() => { - vi.clearAllMocks(); - // Reset localStorage mock (clears storage and resets spy history) - localStorageMock.reset(); - // Clear other mocks - cryptoMock.getRandomValues.mockClear(); - webAssemblyMock.instantiateStreaming.mockClear(); + vi.clearAllMocks(); + // Reset localStorage mock (clears storage and resets spy history) + localStorageMock.reset(); + // Clear other mocks + cryptoMock.getRandomValues.mockClear(); + webAssemblyMock.instantiateStreaming.mockClear(); }); diff --git a/test/utils/mock-factory.ts b/test/utils/mock-factory.ts index ee09e04..4426e38 100644 --- a/test/utils/mock-factory.ts +++ b/test/utils/mock-factory.ts @@ -1,7 +1,7 @@ import { WasmGlobal } from '../../lib/types/lnc'; import { - createGoInstanceMock, - createWasmGlobalMock + createGoInstanceMock, + createWasmGlobalMock } from '../../test/mocks/webassembly'; import { MockLocalStorage } from '../mocks/localStorage'; import { globalAccess } from './test-helpers'; @@ -11,11 +11,11 @@ import { globalAccess } from './test-helpers'; */ export interface MockSetup { - localStorage: MockLocalStorage; - wasmGlobal: WasmGlobal | null; - goInstance: any; - namespace: string; - cleanup: () => void; + localStorage: MockLocalStorage; + wasmGlobal: WasmGlobal | null; + goInstance: any; + namespace: string; + cleanup: () => void; } /** @@ -23,43 +23,43 @@ export interface MockSetup { * the WASM global functions */ export const createMockSetup = ( - namespace: string = 'default', - includeWasmGlobal: boolean = true + namespace: string = 'default', + includeWasmGlobal: boolean = true ): MockSetup => { - // Create mocks - const localStorage = new MockLocalStorage(); - const wasmGlobal = includeWasmGlobal ? createWasmGlobalMock() : null; - const goInstance = createGoInstanceMock(); + // Create mocks + const localStorage = new MockLocalStorage(); + const wasmGlobal = includeWasmGlobal ? createWasmGlobalMock() : null; + const goInstance = createGoInstanceMock(); - // Store original values - const originalLocalStorage = globalThis.localStorage; - const originalNamespaceValue = globalAccess.getWasmGlobal(namespace); + // Store original values + const originalLocalStorage = globalThis.localStorage; + const originalNamespaceValue = globalAccess.getWasmGlobal(namespace); - // Setup mocks + // Setup mocks + Object.defineProperty(globalThis, 'localStorage', { + value: localStorage, + writable: true + }); + + // Cleanup function + const cleanup = () => { Object.defineProperty(globalThis, 'localStorage', { - value: localStorage, - writable: true + value: originalLocalStorage, + writable: true }); - // Cleanup function - const cleanup = () => { - Object.defineProperty(globalThis, 'localStorage', { - value: originalLocalStorage, - writable: true - }); - - if (originalNamespaceValue !== undefined) { - globalAccess.setWasmGlobal(namespace, originalNamespaceValue); - } else { - globalAccess.clearWasmGlobal(namespace); - } - }; + if (originalNamespaceValue !== undefined) { + globalAccess.setWasmGlobal(namespace, originalNamespaceValue); + } else { + globalAccess.clearWasmGlobal(namespace); + } + }; - return { - localStorage, - wasmGlobal, - goInstance, - namespace, - cleanup - }; + return { + localStorage, + wasmGlobal, + goInstance, + namespace, + cleanup + }; }; diff --git a/test/utils/test-helpers.ts b/test/utils/test-helpers.ts index f93479f..64467bb 100644 --- a/test/utils/test-helpers.ts +++ b/test/utils/test-helpers.ts @@ -7,96 +7,96 @@ import { createWasmGlobalMock } from '../mocks/webassembly'; * Generate random test data */ const generateRandomString = (length: number = 10): string => { - const chars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; }; /** * Generate a random pairing phrase for testing */ const generateRandomPairingPhrase = (): string => { - return `phrase_${generateRandomString(20)}`; + return `phrase_${generateRandomString(20)}`; }; /** * Generate a random key for testing */ const generateRandomKey = (): string => { - return `key_${generateRandomString(32)}`; + return `key_${generateRandomString(32)}`; }; /** * Generate a random host for testing */ const generateRandomHost = (): string => { - return `host${Math.floor(Math.random() * 1000)}.example.com:443`; + return `host${Math.floor(Math.random() * 1000)}.example.com:443`; }; /** * Test data factory for common test scenarios */ export const testData = { - password: 'testpassword123', // Fixed password for mock compatibility - pairingPhrase: generateRandomPairingPhrase(), - localKey: generateRandomKey(), - remoteKey: generateRandomKey(), - serverHost: generateRandomHost(), - namespace: 'test_namespace' + password: 'testpassword123', // Fixed password for mock compatibility + pairingPhrase: generateRandomPairingPhrase(), + localKey: generateRandomKey(), + remoteKey: generateRandomKey(), + serverHost: generateRandomHost(), + namespace: 'test_namespace' }; /** * Type-safe global access helpers */ export const globalAccess = { - /** - * Set up a WASM global mock in the specified namespace - */ - setupWasmGlobal( - namespace: string = 'default', - mock?: Mocked - ): Mocked { - const wasmMock = mock || createWasmGlobalMock(); - this.setWasmGlobal(namespace, wasmMock); - return wasmMock; - }, + /** + * Set up a WASM global mock in the specified namespace + */ + setupWasmGlobal( + namespace: string = 'default', + mock?: Mocked + ): Mocked { + const wasmMock = mock || createWasmGlobalMock(); + this.setWasmGlobal(namespace, wasmMock); + return wasmMock; + }, - /** - * Set the WASM global for a specific namespace - */ - setWasmGlobal(namespace: string, value: WasmGlobal): void { - lncGlobal[namespace] = value; - }, + /** + * Set the WASM global for a specific namespace + */ + setWasmGlobal(namespace: string, value: WasmGlobal): void { + lncGlobal[namespace] = value; + }, - /** - * Get the WASM global for a specific namespace - */ - getWasmGlobal(namespace: string): Mocked { - return lncGlobal[namespace] as Mocked; - }, + /** + * Get the WASM global for a specific namespace + */ + getWasmGlobal(namespace: string): Mocked { + return lncGlobal[namespace] as Mocked; + }, - /** - * Clean up a namespace-specific global - */ - clearWasmGlobal(namespace: string): void { - delete lncGlobal[namespace]; - }, + /** + * Clean up a namespace-specific global + */ + clearWasmGlobal(namespace: string): void { + delete lncGlobal[namespace]; + }, - /** - * Get the window object (with proper typing for tests) - */ - get window(): Window & typeof globalThis { - return lncGlobal.window || globalThis; - }, + /** + * Get the window object (with proper typing for tests) + */ + get window(): Window & typeof globalThis { + return lncGlobal.window || globalThis; + }, - /** - * Set the window object for testing - */ - set window(value: Window & typeof globalThis) { - lncGlobal.window = value; - } + /** + * Set the window object for testing + */ + set window(value: Window & typeof globalThis) { + lncGlobal.window = value; + } }; diff --git a/tsconfig.json b/tsconfig.json index 6fda9b4..e9d60c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,12 +16,6 @@ "allowSyntheticDefaultImports": true, "types": ["node"] }, - "include": [ - "lib" - ], - "exclude": [ - "node_modules", - "dist", - "**/*.test.ts" - ] + "include": ["lib"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] } diff --git a/tslint.json b/tslint.json index 69826fb..9bd8bd1 100644 --- a/tslint.json +++ b/tslint.json @@ -1,25 +1,20 @@ { - "extends": ["tslint:recommended", "tslint-config-prettier"], - "rules": { - "max-line-length": { - "options": [120] - }, - "new-parens": true, - "no-arg": true, - "no-bitwise": true, - "no-conditional-assignment": true, - "no-consecutive-blank-lines": true, - "no-console": { - "severity": "warning", - "options": ["debug", "info", "log", "time", "timeEnd", "trace"] - } + "extends": ["tslint:recommended", "tslint-config-prettier"], + "rules": { + "max-line-length": { + "options": [120] }, - "linterOptions": { - "exclude": [ - "dist", - "lib/types/**/*", - "lib/wasm_exec.js" - ] + "new-parens": true, + "no-arg": true, + "no-bitwise": true, + "no-conditional-assignment": true, + "no-consecutive-blank-lines": true, + "no-console": { + "severity": "warning", + "options": ["debug", "info", "log", "time", "timeEnd", "trace"] } + }, + "linterOptions": { + "exclude": ["dist", "lib/types/**/*", "lib/wasm_exec.js"] + } } - diff --git a/vitest.config.ts b/vitest.config.ts index 3c0f16d..f191d0c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,29 +2,29 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ - test: { - environment: 'node', - globals: true, - setupFiles: ['./test/setup.ts'], - include: [ - 'test/**/*.test.ts', - 'test/**/*.spec.ts', - 'lib/**/*.test.ts', - 'lib/**/*.spec.ts' - ], - exclude: ['demos/**', 'node_modules/**', 'dist/**'], - coverage: { - exclude: [ - 'demos/**', - 'node_modules/**', - 'dist/**', - 'coverage/**', - 'test/**', - '**/*.config.*', - '**/*.d.ts', - 'lib/wasm_exec.js' - ], - reporter: ['text', 'lcov'] - } + test: { + environment: 'node', + globals: true, + setupFiles: ['./test/setup.ts'], + include: [ + 'test/**/*.test.ts', + 'test/**/*.spec.ts', + 'lib/**/*.test.ts', + 'lib/**/*.spec.ts' + ], + exclude: ['demos/**', 'node_modules/**', 'dist/**'], + coverage: { + exclude: [ + 'demos/**', + 'node_modules/**', + 'dist/**', + 'coverage/**', + 'test/**', + '**/*.config.*', + '**/*.d.ts', + 'lib/wasm_exec.js' + ], + reporter: ['text', 'lcov'] } + } }); diff --git a/webpack.config.js b/webpack.config.js index 8d9edd1..0e86065 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -12,23 +12,20 @@ module.exports = { loader: 'ts-loader', exclude: path.resolve(__dirname, '/node_modules'), options: { allowTsInNodeModules: true } - }, - ], + } + ] }, - plugins: [ - new NodePolyfillPlugin(), - new CleanWebpackPlugin() - ], + plugins: [new NodePolyfillPlugin(), new CleanWebpackPlugin()], resolve: { - extensions: ['.tsx', '.ts', '.js'], + extensions: ['.tsx', '.ts', '.js'] }, output: { filename: 'index.js', library: { name: '@lightninglabs/lnc-web', - type: "umd", // see https://webpack.js.org/configuration/output/#outputlibrarytype + type: 'umd' // see https://webpack.js.org/configuration/output/#outputlibrarytype }, globalObject: 'this', - path: path.resolve(__dirname, 'dist'), - }, + path: path.resolve(__dirname, 'dist') + } };