diff --git a/wallet-gateway/remote/src/auth/jwt-auth-service.test.ts b/wallet-gateway/remote/src/auth/jwt-auth-service.test.ts new file mode 100644 index 000000000..55927e778 --- /dev/null +++ b/wallet-gateway/remote/src/auth/jwt-auth-service.test.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import { pino, Logger } from 'pino' +import { sink } from 'pino-test' +import { SignJWT } from 'jose' +import { AuthContext } from '@canton-network/core-wallet-auth' +import { Network } from '@canton-network/core-wallet-store' +import { StoreInternal } from '@canton-network/core-wallet-store-inmemory' +import { jwtAuthService } from './jwt-auth-service.js' + +const mockJwtVerify = vi.hoisted(() => vi.fn()) +const mockCreateRemoteJWKSet = vi.hoisted(() => vi.fn(() => 'jwks')) +const mockFetch = vi.hoisted(() => vi.fn()) + +vi.mock('jose', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + jwtVerify: mockJwtVerify, + createRemoteJWKSet: mockCreateRemoteJWKSet, + } +}) + +const authContext: AuthContext = { + userId: 'test-user-id', + accessToken: 'test-access-token', +} + +const SELF_SIGNED_ISSUER = 'unsafe-auth' +const OAUTH_ISSUER = 'https://oauth.example.com' + +async function bearerToken(claims: Record): Promise { + const jwt = await new SignJWT(claims) + .setProtectedHeader({ alg: 'HS256' }) + .sign(new TextEncoder().encode('test-secret')) + return `Bearer ${jwt}` +} + +const createNetwork = ( + id: string, + identityProviderId: string, + audience = 'test-audience' +): Network => ({ + id, + name: `Network ${id}`, + synchronizerId: `${id}-sync`, + identityProviderId, + description: `Test Network ${id}`, + ledgerApi: { baseUrl: `http://${id}` }, + auth: { + method: 'authorization_code' as const, + clientId: 'cid', + scope: 'openid', + audience, + }, +}) + +describe('jwtAuthService', () => { + let mockLogger: Logger + let store: StoreInternal + + beforeEach(async () => { + mockLogger = pino(sink()) as Logger + store = new StoreInternal( + { idps: [], networks: [] }, + mockLogger, + authContext + ) + vi.stubGlobal('fetch', mockFetch) + mockFetch.mockReset() + mockJwtVerify.mockReset() + mockCreateRemoteJWKSet.mockClear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('returns undefined when access token is missing', async () => { + const service = jwtAuthService(store, mockLogger) + await expect(service.verifyToken(undefined)).resolves.toBeUndefined() + }) + + it('returns undefined when authorization header is not Bearer', async () => { + const service = jwtAuthService(store, mockLogger) + await expect(service.verifyToken('Basic abc')).resolves.toBeUndefined() + }) + + it('returns undefined when JWT has no issuer', async () => { + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ sub: 'user-1', scope: 'openid' }) + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns undefined when no identity provider matches issuer', async () => { + await store.addIdp({ + id: 'idp-self', + type: 'self_signed', + issuer: SELF_SIGNED_ISSUER, + }) + + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: 'unknown-issuer', + sub: 'user-1', + scope: 'openid', + }) + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns undefined when JWT has no scope or scp claim', async () => { + await store.addIdp({ + id: 'idp-self', + type: 'self_signed', + issuer: SELF_SIGNED_ISSUER, + }) + + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: SELF_SIGNED_ISSUER, + sub: 'user-1', + }) + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + describe('self_signed identity provider', () => { + beforeEach(async () => { + await store.addIdp({ + id: 'idp-self', + type: 'self_signed', + issuer: SELF_SIGNED_ISSUER, + }) + }) + + it('returns auth context for a valid self-signed token', async () => { + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: SELF_SIGNED_ISSUER, + sub: 'user-1', + scope: 'openid', + }) + + const result = await service.verifyToken(token) + expect(result).toEqual({ + userId: 'user-1', + accessToken: token.split(' ')[1], + }) + }) + + it('accepts scp claim instead of scope', async () => { + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: SELF_SIGNED_ISSUER, + sub: 'user-1', + scp: 'openid', + }) + + const result = await service.verifyToken(token) + expect(result?.userId).toBe('user-1') + }) + + it('includes email when present in token', async () => { + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: SELF_SIGNED_ISSUER, + sub: 'user-1', + scope: 'openid', + email: 'user@example.com', + }) + + const result = await service.verifyToken(token) + expect(result).toEqual({ + userId: 'user-1', + accessToken: token.split(' ')[1], + email: 'user@example.com', + }) + }) + + it('returns undefined when JWT has no subject', async () => { + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: SELF_SIGNED_ISSUER, + scope: 'openid', + }) + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + }) + + describe('oauth identity provider', () => { + const configUrl = + 'https://oauth.example.com/.well-known/openid-configuration' + + beforeEach(async () => { + await store.addIdp({ + id: 'idp-oauth', + type: 'oauth', + issuer: OAUTH_ISSUER, + configUrl, + }) + mockFetch.mockResolvedValue({ + json: async () => ({ + jwks_uri: 'https://oauth.example.com/jwks', + }), + } as Response) + }) + + it('returns auth context when JWT verifies and audience matches', async () => { + await store.addNetwork( + createNetwork('network-1', 'idp-oauth', 'ledger-audience') + ) + + mockJwtVerify.mockResolvedValue({ + payload: { sub: 'oauth-user', aud: 'ledger-audience' }, + }) + + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: OAUTH_ISSUER, + sub: 'oauth-user', + scope: 'openid', + email: 'oauth@example.com', + }) + const rawJwt = token.split(' ')[1] + + const result = await service.verifyToken(token) + + expect(mockFetch).toHaveBeenCalledWith(configUrl) + expect(mockCreateRemoteJWKSet).toHaveBeenCalledWith( + new URL('https://oauth.example.com/jwks') + ) + expect(mockJwtVerify).toHaveBeenCalledWith(rawJwt, 'jwks', { + algorithms: ['RS256'], + }) + expect(result).toEqual({ + userId: 'oauth-user', + accessToken: rawJwt, + email: 'oauth@example.com', + }) + }) + + it('returns undefined when no networks are configured for the IDP', async () => { + mockJwtVerify.mockResolvedValue({ + payload: { sub: 'oauth-user', aud: 'ledger-audience' }, + }) + + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: OAUTH_ISSUER, + sub: 'oauth-user', + scope: 'openid', + }) + + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns undefined when audience does not match configured networks', async () => { + await store.addNetwork( + createNetwork('network-1', 'idp-oauth', 'expected-audience') + ) + mockJwtVerify.mockResolvedValue({ + payload: { sub: 'oauth-user', aud: 'other-audience' }, + }) + + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: OAUTH_ISSUER, + sub: 'oauth-user', + scope: 'openid', + }) + + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns undefined when verified JWT has no audience', async () => { + await store.addNetwork( + createNetwork('network-1', 'idp-oauth', 'expected-audience') + ) + mockJwtVerify.mockResolvedValue({ + payload: { sub: 'oauth-user' }, + }) + + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: OAUTH_ISSUER, + sub: 'oauth-user', + scope: 'openid', + }) + + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns undefined when verified JWT has no subject', async () => { + await store.addNetwork( + createNetwork('network-1', 'idp-oauth', 'expected-audience') + ) + mockJwtVerify.mockResolvedValue({ + payload: { aud: 'expected-audience' }, + }) + + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: OAUTH_ISSUER, + sub: 'oauth-user', + scope: 'openid', + }) + + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns undefined when JWT verification fails', async () => { + await store.addNetwork( + createNetwork('network-1', 'idp-oauth', 'expected-audience') + ) + mockJwtVerify.mockRejectedValue(new Error('invalid signature')) + + const service = jwtAuthService(store, mockLogger) + const token = await bearerToken({ + iss: OAUTH_ISSUER, + sub: 'oauth-user', + scope: 'openid', + }) + + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + }) +}) diff --git a/wallet-gateway/remote/src/auth/jwt-unsafe-auth-service.test.ts b/wallet-gateway/remote/src/auth/jwt-unsafe-auth-service.test.ts new file mode 100644 index 000000000..d07c4eea7 --- /dev/null +++ b/wallet-gateway/remote/src/auth/jwt-unsafe-auth-service.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeEach } from 'vitest' +import { pino, Logger } from 'pino' +import { sink } from 'pino-test' +import { SignJWT } from 'jose' +import { AuthContext } from '@canton-network/core-wallet-auth' +import { StoreInternal } from '@canton-network/core-wallet-store-inmemory' +import { jwtAuthService as jwtUnsafeAuthService } from './jwt-unsafe-auth-service.js' + +const authContext: AuthContext = { + userId: 'test-user-id', + accessToken: 'test-access-token', +} + +const SELF_SIGNED_ISSUER = 'unsafe-auth' + +async function bearerToken(claims: Record): Promise { + const jwt = await new SignJWT(claims) + .setProtectedHeader({ alg: 'HS256' }) + .sign(new TextEncoder().encode('test-secret')) + return `Bearer ${jwt}` +} + +describe('jwtAuthService (unsafe)', () => { + let mockLogger: Logger + let store: StoreInternal + + beforeEach(async () => { + mockLogger = pino(sink()) as Logger + store = new StoreInternal( + { idps: [], networks: [] }, + mockLogger, + authContext + ) + await store.addIdp({ + id: 'idp-self', + type: 'self_signed', + issuer: SELF_SIGNED_ISSUER, + }) + }) + + it('returns undefined when access token is missing', async () => { + const service = jwtUnsafeAuthService(store, mockLogger) + await expect(service.verifyToken(undefined)).resolves.toBeUndefined() + }) + + it('returns undefined when authorization header is not Bearer', async () => { + const service = jwtUnsafeAuthService(store, mockLogger) + await expect( + service.verifyToken('NotBearer abc') + ).resolves.toBeUndefined() + }) + + it('returns undefined when JWT has no issuer', async () => { + const service = jwtUnsafeAuthService(store, mockLogger) + const token = await bearerToken({ + sub: 'user-1', + scope: 'openid', + }) + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns undefined when JWT has no subject', async () => { + const service = jwtUnsafeAuthService(store, mockLogger) + const token = await bearerToken({ + iss: SELF_SIGNED_ISSUER, + scope: 'openid', + }) + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns undefined when JWT has no scope claim', async () => { + const service = jwtUnsafeAuthService(store, mockLogger) + const token = await bearerToken({ + iss: SELF_SIGNED_ISSUER, + sub: 'user-1', + }) + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns undefined when no identity provider matches issuer', async () => { + const service = jwtUnsafeAuthService(store, mockLogger) + const token = await bearerToken({ + iss: 'unknown-issuer', + sub: 'user-1', + scope: 'openid', + }) + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns undefined for oauth identity providers', async () => { + await store.addIdp({ + id: 'idp-oauth', + type: 'oauth', + issuer: 'https://oauth.example.com', + configUrl: + 'https://oauth.example.com/.well-known/openid-configuration', + }) + + const service = jwtUnsafeAuthService(store, mockLogger) + const token = await bearerToken({ + iss: 'https://oauth.example.com', + sub: 'user-1', + scope: 'openid', + }) + await expect(service.verifyToken(token)).resolves.toBeUndefined() + }) + + it('returns auth context for a valid self-signed token', async () => { + const service = jwtUnsafeAuthService(store, mockLogger) + const token = await bearerToken({ + iss: SELF_SIGNED_ISSUER, + sub: 'user-1', + scope: 'openid', + }) + + const result = await service.verifyToken(token) + expect(result).toEqual({ + userId: 'user-1', + accessToken: token.split(' ')[1], + }) + }) + + it('includes email when present in token', async () => { + const service = jwtUnsafeAuthService(store, mockLogger) + const token = await bearerToken({ + iss: SELF_SIGNED_ISSUER, + sub: 'user-1', + scope: 'openid', + email: 'user@example.com', + }) + + const result = await service.verifyToken(token) + expect(result).toEqual({ + userId: 'user-1', + accessToken: token.split(' ')[1], + email: 'user@example.com', + }) + }) + + it('returns undefined for malformed JWT', async () => { + const service = jwtUnsafeAuthService(store, mockLogger) + await expect( + service.verifyToken('Bearer not-a-jwt') + ).resolves.toBeUndefined() + }) +}) diff --git a/wallet-gateway/remote/src/ledger/transaction-service.test.ts b/wallet-gateway/remote/src/ledger/transaction-service.test.ts new file mode 100644 index 000000000..008b1634a --- /dev/null +++ b/wallet-gateway/remote/src/ledger/transaction-service.test.ts @@ -0,0 +1,538 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { pino } from 'pino' +import { sink } from 'pino-test' +import type { Logger } from 'pino' +import type { LedgerClient } from '@canton-network/core-ledger-client' +import type { + Network, + Store, + Transaction, + Wallet, +} from '@canton-network/core-wallet-store' +import { + SigningProvider, + type SigningDriverInterface, +} from '@canton-network/core-signing-lib' +import type { Notifier } from '../notification/NotificationService.js' +import { TransactionService } from './transaction-service.js' + +const userId = 'user-1' + +const wallet: Wallet = { + primary: true, + partyId: 'party::namespace', + status: 'allocated', + hint: 'party', + signingProviderId: SigningProvider.WALLET_KERNEL, + publicKey: 'wallet-public-key', + namespace: 'namespace', + networkId: 'network1', + rights: [], +} + +const pendingTransaction: Transaction = { + id: 'tx-1', + commandId: 'cmd-1', + status: 'pending', + preparedTransaction: 'prepared-tx', + preparedTransactionHash: 'tx-hash', + origin: 'https://dapp.example', + createdAt: new Date('2026-01-01T00:00:00.000Z'), +} + +const signParams = { + transactionId: pendingTransaction.id, + partyId: wallet.partyId, +} + +const executeParams = { + transactionId: pendingTransaction.id, + partyId: wallet.partyId, + signature: 'signature', + signedBy: wallet.namespace, +} + +const network: Network = { + id: 'network1', + name: 'testnet', + synchronizerId: 'sync::fingerprint', + description: 'Test', + identityProviderId: 'idp1', + ledgerApi: { baseUrl: 'http://ledger.test' }, + auth: { + method: 'authorization_code', + clientId: 'cid', + scope: 'scope', + audience: 'aud', + }, +} + +function createDriver(options: { + signTransaction?: ReturnType + getTransaction?: ReturnType +}): SigningDriverInterface { + return { + controller: vi.fn().mockReturnValue({ + signTransaction: + options.signTransaction ?? + vi.fn().mockResolvedValue({ signature: 'driver-signature' }), + getTransaction: + options.getTransaction ?? vi.fn().mockResolvedValue({}), + }), + } as unknown as SigningDriverInterface +} + +function createStore( + transaction: Transaction | undefined = pendingTransaction +): Store & { + getTransaction: ReturnType + setTransactionSigned: ReturnType + setTransactionStatus: ReturnType +} { + return { + getTransaction: vi.fn().mockResolvedValue(transaction), + setTransactionSigned: vi.fn().mockResolvedValue(undefined), + setTransactionStatus: vi.fn().mockResolvedValue(undefined), + } as unknown as Store & { + getTransaction: ReturnType + setTransactionSigned: ReturnType + setTransactionStatus: ReturnType + } +} + +function createService( + store: Store, + drivers: Partial>, + notifier: Notifier, + logger: Logger +) { + return new TransactionService(store, logger, drivers, notifier) +} + +describe('TransactionService', () => { + let logger: Logger + let notifier: Notifier + let emit: ReturnType + + beforeEach(() => { + logger = pino({ level: 'silent' }, sink()) + emit = vi.fn() + notifier = { emit } as unknown as Notifier + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('signWithParticipant', () => { + it('returns a signed result without calling external drivers', () => { + const store = createStore() + const service = createService(store, {}, notifier, logger) + + const result = service.signWithParticipant(wallet) + + expect(result).toEqual({ + status: 'signed', + signature: 'none', + signedBy: wallet.namespace, + partyId: wallet.partyId, + }) + expect(store.getTransaction).not.toHaveBeenCalled() + }) + }) + + describe('signWithWalletKernel', () => { + it('signs the transaction and persists the signed state', async () => { + const signTransaction = vi + .fn() + .mockResolvedValue({ signature: 'kernel-signature' }) + const store = createStore() + const service = createService( + store, + { + [SigningProvider.WALLET_KERNEL]: createDriver({ + signTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.signWithWalletKernel( + userId, + wallet, + signParams + ) + + expect(signTransaction).toHaveBeenCalledWith({ + tx: pendingTransaction.preparedTransaction, + txHash: pendingTransaction.preparedTransactionHash, + keyIdentifier: { publicKey: wallet.publicKey }, + }) + expect(store.setTransactionSigned).toHaveBeenCalledWith( + pendingTransaction.id, + expect.any(Date) + ) + expect(emit).toHaveBeenCalledWith( + 'txChanged', + expect.objectContaining({ + id: pendingTransaction.id, + status: 'signed', + }) + ) + expect(result).toEqual({ + status: 'signed', + signature: 'kernel-signature', + signedBy: wallet.namespace, + partyId: wallet.partyId, + }) + }) + + it('throws when the wallet-kernel driver is missing', async () => { + const service = createService(createStore(), {}, notifier, logger) + + await expect( + service.signWithWalletKernel(userId, wallet, signParams) + ).rejects.toThrow('Wallet Gateway signing driver not available') + }) + + it('throws when the transaction does not exist', async () => { + const store = createStore() + store.getTransaction.mockResolvedValue(undefined) + const service = createService( + store, + { + [SigningProvider.WALLET_KERNEL]: createDriver({}), + }, + notifier, + logger + ) + + await expect( + service.signWithWalletKernel(userId, wallet, signParams) + ).rejects.toThrow('Transaction not found with id: tx-1') + }) + + it('throws when the driver returns an RPC error', async () => { + const signTransaction = vi.fn().mockResolvedValue({ + error: 'access_denied', + error_description: 'Signing rejected', + }) + const service = createService( + createStore(), + { + [SigningProvider.WALLET_KERNEL]: createDriver({ + signTransaction, + }), + }, + notifier, + logger + ) + + await expect( + service.signWithWalletKernel(userId, wallet, signParams) + ).rejects.toThrow('Error from signing driver: Signing rejected') + }) + }) + + describe('signWithBlockdaemon', () => { + it('starts signing when there is no external transaction id yet', async () => { + const signTransaction = vi.fn().mockResolvedValue({ + status: 'pending', + txId: 'external-tx-1', + }) + const store = createStore() + const service = createService( + store, + { + [SigningProvider.BLOCKDAEMON]: createDriver({ + signTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.signWithBlockdaemon( + userId, + wallet, + signParams + ) + + expect(signTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + tx: pendingTransaction.preparedTransaction, + internalTxId: expect.any(String), + }) + ) + expect(store.setTransactionStatus).toHaveBeenCalledWith( + pendingTransaction.id, + 'pending', + { externalTxId: 'external-tx-1' } + ) + expect(result).toEqual({ + status: 'pending', + externalTxId: 'external-tx-1', + partyId: wallet.partyId, + }) + }) + + it('polls the driver when an external transaction id already exists', async () => { + const getTransaction = vi.fn().mockResolvedValue({ + status: 'signed', + txId: 'external-tx-1', + signature: 'bd-signature', + }) + const store = createStore({ + ...pendingTransaction, + externalTxId: 'external-tx-1', + }) + const service = createService( + store, + { + [SigningProvider.BLOCKDAEMON]: createDriver({ + getTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.signWithBlockdaemon( + userId, + wallet, + signParams + ) + + expect(getTransaction).toHaveBeenCalledWith({ + userId, + txId: 'external-tx-1', + }) + expect(store.setTransactionSigned).toHaveBeenCalledWith( + pendingTransaction.id, + expect.any(Date), + 'external-tx-1' + ) + expect(result).toMatchObject({ + status: 'signed', + signature: 'bd-signature', + externalTxId: 'external-tx-1', + }) + }) + }) + + describe('signWithFireblocks', () => { + it('returns a base64 signature when signing completes', async () => { + const hexSignature = Buffer.from('fireblocks-signature').toString( + 'hex' + ) + const signTransaction = vi.fn().mockResolvedValue({ + status: 'signed', + txId: 'fb-tx-1', + signature: hexSignature, + }) + const store = createStore() + const service = createService( + store, + { + [SigningProvider.FIREBLOCKS]: createDriver({ + signTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.signWithFireblocks( + userId, + wallet, + signParams + ) + + expect(signTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + txHash: Buffer.from( + pendingTransaction.preparedTransactionHash, + 'base64' + ).toString('hex'), + }) + ) + expect(result).toMatchObject({ + status: 'signed', + signature: Buffer.from(hexSignature, 'hex').toString('base64'), + externalTxId: 'fb-tx-1', + }) + }) + }) + + describe('signWithDfns', () => { + it('persists the update id as the signature when signing completes', async () => { + const signTransaction = vi.fn().mockResolvedValue({ + status: 'signed', + txId: 'dfns-tx-1', + signature: 'update-id-123', + }) + const store = createStore() + const service = createService( + store, + { + [SigningProvider.DFNS]: createDriver({ + signTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.signWithDfns( + userId, + wallet, + signParams + ) + + expect(result).toEqual({ + status: 'signed', + signature: 'update-id-123', + signedBy: wallet.namespace, + partyId: wallet.partyId, + externalTxId: 'dfns-tx-1', + }) + }) + }) + + describe('executeWithDfns', () => { + it('marks the transaction executed using the external tx id', async () => { + const signedTransaction: Transaction = { + ...pendingTransaction, + status: 'signed', + externalTxId: 'dfns-update-id', + } + const store = createStore(signedTransaction) + const service = createService(store, {}, notifier, logger) + + const result = await service.executeWithDfns(signedTransaction) + + expect(store.setTransactionStatus).toHaveBeenCalledWith( + signedTransaction.id, + 'executed', + { externalTxId: 'dfns-update-id' } + ) + expect(emit).toHaveBeenCalledWith( + 'txChanged', + expect.objectContaining({ status: 'executed' }) + ) + expect(result).toEqual({ updateId: 'dfns-update-id' }) + }) + + it('throws when the transaction has no external tx id', async () => { + const service = createService(createStore(), {}, notifier, logger) + + await expect( + service.executeWithDfns(pendingTransaction) + ).rejects.toThrow( + 'Cannot execute Dfns transaction without externalTxId from Dfns' + ) + }) + }) + + describe('executeWithParticipant', () => { + it('submits the prepared transaction to the ledger', async () => { + const store = createStore({ + ...pendingTransaction, + payload: { + commandId: pendingTransaction.commandId, + commands: [], + }, + }) + const postWithRetry = vi + .fn() + .mockResolvedValue({ updateId: 'ledger-update-1' }) + const ledgerClient = { + postWithRetry, + getSynchronizerId: vi.fn(), + } as unknown as LedgerClient + const service = createService(store, {}, notifier, logger) + + const result = await service.executeWithParticipant( + userId, + executeParams, + { + ...pendingTransaction, + payload: { + commandId: pendingTransaction.commandId, + commands: [], + }, + }, + ledgerClient, + network + ) + + expect(postWithRetry).toHaveBeenCalledWith( + '/v2/commands/submit-and-wait', + expect.objectContaining({ + commandId: pendingTransaction.commandId, + userId, + synchronizerId: network.synchronizerId, + }) + ) + expect(store.setTransactionStatus).toHaveBeenCalledWith( + pendingTransaction.id, + 'executed', + { payload: { updateId: 'ledger-update-1' } } + ) + expect(result).toEqual({ updateId: 'ledger-update-1' }) + }) + }) + + describe('executeWithExternal', () => { + it('executes the prepared transaction with the provided signature', async () => { + const store = createStore({ + ...pendingTransaction, + status: 'signed', + }) + const postWithRetry = vi + .fn() + .mockResolvedValue({ updateId: 'external-update-1' }) + const ledgerClient = { + postWithRetry, + } as unknown as LedgerClient + const service = createService(store, {}, notifier, logger) + + const result = await service.executeWithExternal( + userId, + executeParams, + { + ...pendingTransaction, + status: 'signed', + }, + ledgerClient + ) + + expect(postWithRetry).toHaveBeenCalledWith( + '/v2/interactive-submission/executeAndWait', + expect.objectContaining({ + userId, + preparedTransaction: pendingTransaction.preparedTransaction, + submissionId: pendingTransaction.commandId, + partySignatures: expect.objectContaining({ + signatures: [ + expect.objectContaining({ + party: wallet.partyId, + }), + ], + }), + }) + ) + expect(store.setTransactionStatus).toHaveBeenCalledWith( + pendingTransaction.id, + 'executed', + { payload: { updateId: 'external-update-1' } } + ) + expect(result).toEqual({ updateId: 'external-update-1' }) + }) + }) +}) diff --git a/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts b/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts index c2611d4fe..abd368089 100644 --- a/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts +++ b/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts @@ -49,6 +49,15 @@ const createAllocatedParty = ( }) function createFireblocksDriver(options: { + getKeysResult?: + | { + keys: Array<{ + id: string + name: string + publicKey: string + }> + } + | { error: string; error_description: string } signTransactionResult?: { status: string; txId: string } getTransactionResult?: { txId: string @@ -56,7 +65,7 @@ function createFireblocksDriver(options: { signature?: string } }): SigningDriverInterface { - const getKeysResult = { + const getKeysResult = options.getKeysResult ?? { keys: [{ id: 'key-1', name: 'Canton Party', publicKey: 'fb-pk' }], } const signTransactionResult = options.signTransactionResult ?? { @@ -69,13 +78,16 @@ function createFireblocksDriver(options: { controller: vi.fn().mockReturnValue({ getKeys: vi .fn< - () => Promise<{ - keys: Array<{ - id: string - name: string - publicKey: string - }> - }> + () => Promise< + | { + keys: Array<{ + id: string + name: string + publicKey: string + }> + } + | { error: string; error_description: string } + > >() .mockResolvedValue(getKeysResult), signTransaction: vi @@ -95,13 +107,20 @@ function createFireblocksDriver(options: { } function createBlockdaemonDriver(options: { + createKeyResult?: + | { publicKey: string } + | { error: string; error_description: string } signTransactionResult?: { status: string; txId: string } getTransactionResult?: { txId: string status: string signature?: string + metadata?: unknown } }): SigningDriverInterface { + const createKeyResult = options.createKeyResult ?? { + publicKey: 'bd-pk', + } const signTransactionResult = options.signTransactionResult ?? { status: 'pending', txId: 'tx-1', @@ -111,10 +130,61 @@ function createBlockdaemonDriver(options: { return { controller: vi.fn().mockReturnValue({ createKey: vi - .fn<() => Promise<{ publicKey: string }>>() - .mockResolvedValue({ - publicKey: 'bd-pk', - }), + .fn< + () => Promise< + | { publicKey: string } + | { error: string; error_description: string } + > + >() + .mockResolvedValue(createKeyResult), + signTransaction: vi + .fn<() => Promise<{ status: string; txId: string }>>() + .mockResolvedValue(signTransactionResult), + getTransaction: vi + .fn< + () => Promise<{ + txId: string + status: string + signature?: string + }> + >() + .mockResolvedValue(getTransactionResult), + }), + } as unknown as SigningDriverInterface +} + +function createDfnsDriver(options: { + createKeyResult?: + | { id: string; publicKey: string } + | { error: string; error_description: string } + signTransactionResult?: { status: string; txId: string } + getTransactionResult?: { + txId: string + status: string + signature?: string + metadata?: unknown + } +}): SigningDriverInterface { + const createKeyResult = options.createKeyResult ?? { + id: 'key-1', + publicKey: 'dfns-pk', + } + const signTransactionResult = options.signTransactionResult ?? { + status: 'pending', + txId: 'tx-1', + } + const getTransactionResult = + options.getTransactionResult ?? signTransactionResult + return { + controller: vi.fn().mockReturnValue({ + createKey: vi + .fn< + () => Promise< + | { id: string; publicKey: string } + | { error: string; error_description: string } + > + >() + .mockResolvedValue(createKeyResult), signTransaction: vi .fn<() => Promise<{ status: string; txId: string }>>() .mockResolvedValue(signTransactionResult), @@ -124,6 +194,7 @@ function createBlockdaemonDriver(options: { txId: string status: string signature?: string + metadata?: unknown }> >() .mockResolvedValue(getTransactionResult), @@ -392,6 +463,121 @@ describe('WalletAllocationService', () => { ) ).rejects.toThrow('Wallet Gateway signing driver not available') }) + + it('throws when createKey returns a signing error', async () => { + mockController.createKey.mockResolvedValue({ + error: 'denied', + error_description: 'Key creation failed', + }) + + await expect( + service.createWallet( + 'user-1', + undefined, + 'bob', + false, + SigningProvider.WALLET_KERNEL + ) + ).rejects.toThrow('Error from signing driver: Key creation failed') + }) + + it('throws when signing the topology hash returns a driver error', async () => { + mockPartyAllocator.allocateParty.mockImplementation( + async ( + _userId, + _hint, + _publicKey?, + signingCallback?: (hash: string) => Promise + ) => { + await signingCallback?.('hash') + return createAllocatedParty( + 'bob::fingerprint', + 'bob', + 'fingerprint' + ) + } + ) + mockController.signTransaction.mockResolvedValue({ + error: 'denied', + error_description: 'Signing failed', + }) + + await expect( + service.createWallet( + 'user-1', + undefined, + 'bob', + false, + SigningProvider.WALLET_KERNEL + ) + ).rejects.toThrow('Error from signing driver: Signing failed') + }) + + it('throws when signing the topology hash returns no signature', async () => { + mockPartyAllocator.allocateParty.mockImplementation( + async ( + _userId, + _hint, + _publicKey?, + signingCallback?: (hash: string) => Promise + ) => { + await signingCallback?.('hash') + return createAllocatedParty( + 'bob::fingerprint', + 'bob', + 'fingerprint' + ) + } + ) + mockController.signTransaction.mockResolvedValue({ + signature: undefined, + }) + + await expect( + service.createWallet( + 'user-1', + undefined, + 'bob', + false, + SigningProvider.WALLET_KERNEL + ) + ).rejects.toThrow('No signature returned from signing driver') + }) + + it('throws when allocateParty signing callback returns no signature', async () => { + mockPartyAllocator.allocateParty.mockImplementation( + async ( + _userId, + _hint, + _publicKey?, + signingCallback?: (hash: string) => Promise + ) => { + await signingCallback?.('hash') + return createAllocatedParty( + 'bob::fingerprint', + 'bob', + 'fingerprint' + ) + } + ) + mockController.signTransaction.mockResolvedValue({ + signature: undefined, + }) + const existingWallet = createWallet('bob::namespace', { + hint: 'bob', + publicKey: 'existing-public-key', + signingProviderId: SigningProvider.WALLET_KERNEL, + }) + + await expect( + service.allocateParty( + 'user-1', + undefined, + existingWallet, + SigningProvider.WALLET_KERNEL + ) + ).rejects.toThrow('No signature returned from signing driver') + }) }) describe('Fireblocks', () => { @@ -498,6 +684,208 @@ describe('WalletAllocationService', () => { expect(mockStore.addWallet).toHaveBeenCalled() } ) + + it('createWallet handles missing topology transactions from party allocator', async () => { + mockPartyAllocator.generateTopologyTransactions.mockResolvedValue({ + topologyTransactions: undefined, + multiHash: 'multi-hash', + }) + const serviceWithFireblocks = createService({ + [SigningProvider.FIREBLOCKS]: createFireblocksDriver({ + signTransactionResult: { status: 'pending', txId: 'tx-1' }, + }), + }) + + const result = await serviceWithFireblocks.createWallet( + 'user-1', + undefined, + 'alice', + false, + SigningProvider.FIREBLOCKS + ) + + expect(result.topologyTransactions).toBe('') + }) + + it('throws when getKeys returns a signing error', async () => { + const serviceWithFireblocks = createService({ + [SigningProvider.FIREBLOCKS]: createFireblocksDriver({ + getKeysResult: { + error: 'denied', + error_description: 'Keys unavailable', + }, + }), + }) + + await expect( + serviceWithFireblocks.createWallet( + 'user-1', + undefined, + 'alice', + false, + SigningProvider.FIREBLOCKS + ) + ).rejects.toThrow('Error from signing driver: Keys unavailable') + }) + + it('throws when the Canton Party key is missing', async () => { + const serviceWithFireblocks = createService({ + [SigningProvider.FIREBLOCKS]: createFireblocksDriver({ + getKeysResult: { keys: [] }, + }), + }) + + await expect( + serviceWithFireblocks.createWallet( + 'user-1', + undefined, + 'alice', + false, + SigningProvider.FIREBLOCKS + ) + ).rejects.toThrow('Fireblocks key not found') + }) + + it('throws when a signed createWallet has no signature in getTransaction', async () => { + const serviceWithFireblocks = createService({ + [SigningProvider.FIREBLOCKS]: createFireblocksDriver({ + signTransactionResult: { status: 'signed', txId: 'tx-1' }, + getTransactionResult: { + txId: 'tx-1', + status: 'signed', + }, + }), + }) + + await expect( + serviceWithFireblocks.createWallet( + 'user-1', + undefined, + 'alice', + false, + SigningProvider.FIREBLOCKS + ) + ).rejects.toThrow( + 'Transaction signed but no signature found in result' + ) + }) + + it('throws when allocateParty wallet is missing topology metadata', async () => { + const serviceWithFireblocks = createService({ + [SigningProvider.FIREBLOCKS]: createFireblocksDriver({}), + }) + + await expect( + serviceWithFireblocks.allocateParty( + 'user-1', + undefined, + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.FIREBLOCKS, + topologyTransactions: undefined, + externalTxId: 'tx-1', + }), + SigningProvider.FIREBLOCKS + ) + ).rejects.toThrow( + 'Existing wallet is missing field externalTxId or topologyTransactions' + ) + }) + + it('allocateParty updates wallet to pending state', async () => { + const serviceWithFireblocks = createService({ + [SigningProvider.FIREBLOCKS]: createFireblocksDriver({ + getTransactionResult: { txId: 'tx-1', status: 'pending' }, + }), + }) + + await serviceWithFireblocks.allocateParty( + 'user-1', + undefined, + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.FIREBLOCKS, + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.FIREBLOCKS + ) + + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + partyId: 'alice::fingerprint', + networkId: 'network1', + status: 'initialized', + reason: WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_PENDING, + }) + }) + + it('allocateParty updates wallet to allocated when transaction is signed', async () => { + const hexSignature = 'deadbeef' + const serviceWithFireblocks = createService({ + [SigningProvider.FIREBLOCKS]: createFireblocksDriver({ + getTransactionResult: { + txId: 'tx-1', + status: 'signed', + signature: hexSignature, + }, + }), + }) + mockPartyAllocator.allocatePartyWithExistingWallet.mockResolvedValue( + 'alice::namespace' + ) + + await serviceWithFireblocks.allocateParty( + 'user-1', + undefined, + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.FIREBLOCKS, + namespace: 'fingerprint', + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.FIREBLOCKS + ) + + expect( + mockPartyAllocator.allocatePartyWithExistingWallet + ).toHaveBeenCalledWith( + 'fingerprint', + ['tx1'], + Buffer.from(hexSignature, 'hex').toString('base64'), + 'user-1' + ) + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + networkId: 'network1', + partyId: 'alice::namespace', + status: 'allocated', + reason: '', + }) + }) + + it('allocateParty disables wallet when transaction is rejected', async () => { + const serviceWithFireblocks = createService({ + [SigningProvider.FIREBLOCKS]: createFireblocksDriver({ + getTransactionResult: { txId: 'tx-1', status: 'rejected' }, + }), + }) + + await serviceWithFireblocks.allocateParty( + 'user-1', + undefined, + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.FIREBLOCKS, + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.FIREBLOCKS + ) + + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + partyId: 'alice::fingerprint', + networkId: 'network1', + status: 'removed', + disabled: true, + reason: WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_REJECTED, + }) + }) }) describe('Blockdaemon', () => { @@ -572,5 +960,516 @@ describe('WalletAllocationService', () => { ).toHaveBeenCalled() expect(mockStore.addWallet).toHaveBeenCalled() }) + + it('throws when createKey returns an error object', async () => { + const serviceWithBlockdaemon = createService({ + [SigningProvider.BLOCKDAEMON]: createBlockdaemonDriver({ + createKeyResult: { + error: 'denied', + error_description: 'Cannot create key', + }, + }), + }) + + await expect( + serviceWithBlockdaemon.createWallet( + 'user-1', + 'user-1@example.com', + 'alice', + false, + SigningProvider.BLOCKDAEMON + ) + ).rejects.toThrow('Failed to create key: Cannot create key') + }) + + it('throws when a signed createWallet has no signature in getTransaction', async () => { + const serviceWithBlockdaemon = createService({ + [SigningProvider.BLOCKDAEMON]: createBlockdaemonDriver({ + signTransactionResult: { status: 'signed', txId: 'tx-1' }, + getTransactionResult: { + txId: 'tx-1', + status: 'signed', + }, + }), + }) + + await expect( + serviceWithBlockdaemon.createWallet( + 'user-1', + 'user-1@example.com', + 'alice', + false, + SigningProvider.BLOCKDAEMON + ) + ).rejects.toThrow( + 'Transaction signed but no signature found in result' + ) + }) + + it.each([ + ['failed', WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_FAILED], + ['rejected', WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_REJECTED], + ] as const)( + 'createWallet marks wallet removed when signTransaction returns %s', + async (status, expectedReason) => { + const serviceWithBlockdaemon = createService({ + [SigningProvider.BLOCKDAEMON]: createBlockdaemonDriver({ + signTransactionResult: { status, txId: 'tx-1' }, + }), + }) + + const result = await serviceWithBlockdaemon.createWallet( + 'user-1', + 'user-1@example.com', + 'alice', + false, + SigningProvider.BLOCKDAEMON + ) + + expect(result.status).toBe('removed') + expect(result.disabled).toBe(true) + expect(result.reason).toBe(expectedReason) + } + ) + + it('throws when allocateParty wallet is missing topology metadata', async () => { + const serviceWithBlockdaemon = createService({ + [SigningProvider.BLOCKDAEMON]: createBlockdaemonDriver({}), + }) + + await expect( + serviceWithBlockdaemon.allocateParty( + 'user-1', + 'user-1@example.com', + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.BLOCKDAEMON, + topologyTransactions: 'tx1', + externalTxId: undefined, + }), + SigningProvider.BLOCKDAEMON + ) + ).rejects.toThrow( + 'Existing wallet is missing field externalTxId or topologyTransactions' + ) + }) + + it('allocateParty updates wallet to allocated when transaction is signed', async () => { + const serviceWithBlockdaemon = createService({ + [SigningProvider.BLOCKDAEMON]: createBlockdaemonDriver({ + getTransactionResult: { + txId: 'tx-1', + status: 'signed', + signature: 'sig-base64', + }, + }), + }) + mockPartyAllocator.allocatePartyWithExistingWallet.mockResolvedValue( + 'alice::namespace' + ) + + await serviceWithBlockdaemon.allocateParty( + 'user-1', + 'user-1@example.com', + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.BLOCKDAEMON, + namespace: 'fingerprint', + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.BLOCKDAEMON + ) + + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + networkId: 'network1', + partyId: 'alice::namespace', + status: 'allocated', + reason: '', + }) + }) + + it('allocateParty updates wallet to pending state', async () => { + const serviceWithBlockdaemon = createService({ + [SigningProvider.BLOCKDAEMON]: createBlockdaemonDriver({ + getTransactionResult: { txId: 'tx-1', status: 'pending' }, + }), + }) + + await serviceWithBlockdaemon.allocateParty( + 'user-1', + 'user-1@example.com', + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.BLOCKDAEMON, + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.BLOCKDAEMON + ) + + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + partyId: 'alice::fingerprint', + networkId: 'network1', + status: 'initialized', + reason: WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_PENDING, + }) + }) + + it('allocateParty disables wallet when transaction is rejected', async () => { + const serviceWithBlockdaemon = createService({ + [SigningProvider.BLOCKDAEMON]: createBlockdaemonDriver({ + getTransactionResult: { txId: 'tx-1', status: 'rejected' }, + }), + }) + + await serviceWithBlockdaemon.allocateParty( + 'user-1', + 'user-1@example.com', + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.BLOCKDAEMON, + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.BLOCKDAEMON + ) + + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + partyId: 'alice::fingerprint', + networkId: 'network1', + status: 'removed', + disabled: true, + reason: WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_REJECTED, + }) + }) + + it('allocateParty disables wallet when transaction failed', async () => { + const warnSpy = vi.spyOn(mockLogger, 'warn') + const serviceWithBlockdaemon = createService({ + [SigningProvider.BLOCKDAEMON]: createBlockdaemonDriver({ + getTransactionResult: { + txId: 'tx-1', + status: 'failed', + metadata: { code: 'X' }, + }, + }), + }) + + await serviceWithBlockdaemon.allocateParty( + 'user-1', + 'user-1@example.com', + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.BLOCKDAEMON, + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.BLOCKDAEMON + ) + + expect(warnSpy).toHaveBeenCalled() + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + partyId: 'alice::fingerprint', + networkId: 'network1', + status: 'removed', + disabled: true, + reason: WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_FAILED, + }) + }) + }) + + describe('Dfns', () => { + it('throws when Dfns signing driver not available', async () => { + const serviceWithoutDfns = createService({}) + + await expect( + serviceWithoutDfns.createWallet( + 'user-1', + undefined, + 'alice', + false, + SigningProvider.DFNS + ) + ).rejects.toThrow('Dfns signing driver not available') + }) + + it('createWallet returns initialized when signTransaction returns pending', async () => { + const serviceWithDfns = createService({ + [SigningProvider.DFNS]: createDfnsDriver({ + signTransactionResult: { status: 'pending', txId: 'tx-1' }, + getTransactionResult: { status: 'pending', txId: 'tx-1' }, + }), + }) + + const result = await serviceWithDfns.createWallet( + 'user-1', + undefined, + 'alice', + false, + SigningProvider.DFNS + ) + + expect(result.status).toBe('initialized') + expect(result.reason).toBe( + WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_PENDING + ) + expect(result.externalTxId).toBe('tx-1') + expect(result.partyId).toBe('alice::fingerprint') + expect(mockStore.addWallet).toHaveBeenCalled() + }) + + it('createWallet returns allocated when signTransaction returns signed', async () => { + const serviceWithDfns = createService({ + [SigningProvider.DFNS]: createDfnsDriver({ + createKeyResult: { id: 'key-1', publicKey: 'dfns-pk' }, + signTransactionResult: { status: 'signed', txId: 'tx-1' }, + getTransactionResult: { + txId: 'tx-1', + status: 'signed', + signature: 'sig-base64', + }, + }), + }) + mockPartyAllocator.allocatePartyWithExistingWallet.mockResolvedValue( + 'alice::namespace' + ) + + const result = await serviceWithDfns.createWallet( + 'user-1', + undefined, + 'alice', + false, + SigningProvider.DFNS + ) + + expect(result.status).toBe('allocated') + expect(result.partyId).toBe('alice::namespace') + expect( + mockPartyAllocator.allocatePartyWithExistingWallet + ).toHaveBeenCalled() + expect(mockStore.addWallet).toHaveBeenCalled() + }) + + it.each([ + ['failed', WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_FAILED], + ['rejected', WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_REJECTED], + ] as const)( + 'createWallet returns status removed with reason when signTransaction returns %s', + async (status, expectedReason) => { + const serviceWithDfns = createService({ + [SigningProvider.DFNS]: createDfnsDriver({ + signTransactionResult: { status, txId: 'tx-1' }, + getTransactionResult: { status, txId: 'tx-1' }, + }), + }) + + const result = await serviceWithDfns.createWallet( + 'user-1', + undefined, + 'alice', + false, + SigningProvider.DFNS + ) + + expect(result.status).toBe('removed') + expect(result.reason).toBe(expectedReason) + expect(result.disabled).toBe(true) + expect(mockStore.addWallet).toHaveBeenCalled() + } + ) + + it('throws when createKey returns a signing error', async () => { + const serviceWithDfns = createService({ + [SigningProvider.DFNS]: createDfnsDriver({ + createKeyResult: { + error: 'denied', + error_description: 'Dfns key error', + }, + }), + }) + + await expect( + serviceWithDfns.createWallet( + 'user-1', + undefined, + 'alice', + false, + SigningProvider.DFNS + ) + ).rejects.toThrow('Error from signing driver: Dfns key error') + }) + + it('throws when a signed createWallet has no signature in getTransaction', async () => { + const serviceWithDfns = createService({ + [SigningProvider.DFNS]: createDfnsDriver({ + signTransactionResult: { status: 'signed', txId: 'tx-1' }, + getTransactionResult: { + txId: 'tx-1', + status: 'signed', + }, + }), + }) + + await expect( + serviceWithDfns.createWallet( + 'user-1', + undefined, + 'alice', + false, + SigningProvider.DFNS + ) + ).rejects.toThrow( + 'Transaction signed but no signature found in result' + ) + }) + + it('throws when allocateParty wallet is missing topology metadata', async () => { + const serviceWithDfns = createService({ + [SigningProvider.DFNS]: createDfnsDriver({}), + }) + + await expect( + serviceWithDfns.allocateParty( + 'user-1', + undefined, + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.DFNS, + topologyTransactions: 'tx1', + externalTxId: undefined, + }), + SigningProvider.DFNS + ) + ).rejects.toThrow( + 'Existing wallet is missing field externalTxId or topologyTransactions' + ) + }) + + it('allocateParty throws when signed transaction has no signature', async () => { + const serviceWithDfns = createService({ + [SigningProvider.DFNS]: createDfnsDriver({ + getTransactionResult: { + txId: 'tx-1', + status: 'signed', + }, + }), + }) + + await expect( + serviceWithDfns.allocateParty( + 'user-1', + undefined, + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.DFNS, + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.DFNS + ) + ).rejects.toThrow( + 'Transaction signed but no signature found in result' + ) + }) + + it('allocateParty updates wallet to allocated when transaction is signed', async () => { + const driver = createDfnsDriver({ + getTransactionResult: { + txId: 'tx-1', + status: 'signed', + signature: 'sig-base64', + }, + }) + const serviceWithDfns = createService({ + [SigningProvider.DFNS]: driver, + }) + mockPartyAllocator.allocatePartyWithExistingWallet.mockResolvedValue( + 'alice::namespace' + ) + + await serviceWithDfns.allocateParty( + 'user-1', + undefined, + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.DFNS, + namespace: 'fingerprint', + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.DFNS + ) + + expect( + mockPartyAllocator.allocatePartyWithExistingWallet + ).toHaveBeenCalledWith( + 'fingerprint', + ['tx1'], + 'sig-base64', + 'user-1' + ) + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + networkId: 'network1', + partyId: 'alice::namespace', + status: 'allocated', + reason: '', + }) + }) + + it('allocateParty updates wallet to initialized when transaction is pending', async () => { + const serviceWithDfns = createService({ + [SigningProvider.DFNS]: createDfnsDriver({ + getTransactionResult: { txId: 'tx-1', status: 'pending' }, + }), + }) + + await serviceWithDfns.allocateParty( + 'user-1', + undefined, + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.DFNS, + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.DFNS + ) + + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + partyId: 'alice::fingerprint', + networkId: 'network1', + status: 'initialized', + reason: WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_PENDING, + }) + }) + + it.each([ + ['failed', WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_FAILED], + ['rejected', WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_REJECTED], + ] as const)( + 'allocateParty disables wallet when transaction is %s', + async (status, expectedReason) => { + const serviceWithDfns = createService({ + [SigningProvider.DFNS]: createDfnsDriver({ + getTransactionResult: { + txId: 'tx-1', + status, + metadata: { cause: 'test' }, + }, + }), + }) + + await serviceWithDfns.allocateParty( + 'user-1', + undefined, + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.DFNS, + topologyTransactions: 'tx1', + externalTxId: 'tx-1', + }), + SigningProvider.DFNS + ) + + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + partyId: 'alice::fingerprint', + networkId: 'network1', + status: 'removed', + disabled: true, + reason: expectedReason, + }) + } + ) }) }) diff --git a/wallet-gateway/remote/src/middleware/jsonRpcHandler.test.ts b/wallet-gateway/remote/src/middleware/jsonRpcHandler.test.ts new file mode 100644 index 000000000..8a2e5aea5 --- /dev/null +++ b/wallet-gateway/remote/src/middleware/jsonRpcHandler.test.ts @@ -0,0 +1,323 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' +import { pino } from 'pino' +import { sink } from 'pino-test' +import type { JsonRpcResponse } from '@canton-network/core-types' +import { rpcErrors, toHttpErrorCode } from '@canton-network/core-rpc-errors' +import { handleRpcError, jsonRpcHandler } from './jsonRpcHandler.js' + +function errorPayload(body: JsonRpcResponse) { + if (!('error' in body)) { + throw new Error('expected JSON-RPC error response') + } + return body.error +} + +async function waitForRpcResponse(res: Response) { + const json = res.json as ReturnType + await vi.waitUntil(() => json.mock.calls.length > 0) +} + +describe('jsonRpcHandler', () => { + const logger = pino({ level: 'silent' }, sink()) + + type TestController = { + resolve: (params?: unknown) => Promise + reject: (params?: unknown) => Promise + rpcError: (params?: unknown) => Promise + } + + const resolve = vi.fn(async () => 'response') + const reject = vi.fn(async () => { + throw new Error('error') + }) + const rpcError = vi.fn(async () => { + throw rpcErrors.invalidParams({ message: 'bad params' }) + }) + + beforeEach(() => { + resolve.mockClear() + reject.mockClear() + rpcError.mockClear() + }) + + function makeHandler() { + return jsonRpcHandler({ + controller: { resolve, reject, rpcError }, + logger, + }) + } + + function makeRes() { + const res = { + status: vi.fn(), + json: vi.fn(), + } + + // Express response methods are chainable, like `res.status(500).json(body)` + // Make the mocked status() return this fake res object so .json() can be called after it. + res.status.mockReturnValue(res) + + return res as unknown as Response + } + + it('delegates to next() if method is not POST', () => { + const handler = makeHandler() + const next = vi.fn() + const res = makeRes() + const req = { + method: 'GET', + body: {}, + } as Request + + handler(req, res, next) + + expect(next).toHaveBeenCalledOnce() + expect(res.status).not.toHaveBeenCalled() + }) + + it('responds with invalid request when body is not valid JSON-RPC 2.0', async () => { + const handler = makeHandler() + const next = vi.fn() + const res = makeRes() + const req = { + method: 'POST', + body: { jsonrpc: '1.0', method: 'resolve', id: 1 }, + } as Request + + handler(req, res, next) + await waitForRpcResponse(res) + + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + id: null, + error: expect.objectContaining({ + code: expect.any(Number), + }), + }) + ) + }) + + it('returns method not found when controller has no such method', async () => { + const handler = makeHandler() + const next = vi.fn() + const res = makeRes() + const req = { + method: 'POST', + body: { + jsonrpc: '2.0', + id: 42, + method: 'missing', + params: [], + }, + } as Request + + handler(req, res, next) + await waitForRpcResponse(res) + + expect(res.status).toHaveBeenCalled() + const payload = (res.json as ReturnType).mock.calls[0][0] + expect(payload.id).toBeNull() + expect(payload.error.message).toContain('missing') + }) + + it('returns JSON-RPC success when the method resolves', async () => { + const handler = makeHandler() + const next = vi.fn() + const res = makeRes() + const req = { + method: 'POST', + body: { + jsonrpc: '2.0', + id: 7, + method: 'resolve', + params: { x: 1 }, + }, + authContext: { userId: 'u', accessToken: 't' }, + } as Request + + handler(req, res, next) + await waitForRpcResponse(res) + + expect(resolve).toHaveBeenCalledWith({ x: 1 }) + expect(res.json).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: 7, + result: 'response', + }) + }) + + it('maps thrown Error to JSON-RPC error with HTTP 500', async () => { + const handler = makeHandler() + const next = vi.fn() + const res = makeRes() + const req = { + method: 'POST', + body: { + jsonrpc: '2.0', + id: 'rid', + method: 'reject', + params: [], + }, + } as Request + + handler(req, res, next) + await waitForRpcResponse(res) + + expect(reject).toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(500) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + id: 'rid', + error: expect.objectContaining({ + message: 'error', + }), + }) + ) + }) + + it('maps JsonRpcError to the corresponding HTTP status', async () => { + const handler = makeHandler() + const next = vi.fn() + const res = makeRes() + const req = { + method: 'POST', + body: { + jsonrpc: '2.0', + id: 0, + method: 'rpcError', + params: [], + }, + } as Request + + handler(req, res, next) + await waitForRpcResponse(res) + + expect(rpcError).toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith( + toHttpErrorCode(rpcErrors.invalidParams().code) + ) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + id: 0, + error: expect.objectContaining({ + message: 'bad params', + }), + }) + ) + }) +}) +describe('handleRpcError', () => { + const logger = pino({ level: 'silent' }, sink()) + const errorLog = vi.spyOn(logger, 'error') + + it('maps JsonRpcError to HTTP status from toHttpErrorCode and does not log RPC response as error', () => { + const err = rpcErrors.invalidParams({ message: 'bad' }) + const [status, body] = handleRpcError(err, 99, logger, 'whateverMethod') + + expect(status).toBe(toHttpErrorCode(err.code)) + expect(status).toBe(400) + expect(body).toEqual({ + jsonrpc: '2.0', + id: 99, + error: err, + }) + expect(errorLog).not.toHaveBeenCalled() + }) + + it('uses generic method-specific message for non-JsonRpcError then replaces with Error.message', () => { + const [status, body] = handleRpcError( + new Error('some error'), + 'id', + logger, + 'submit' + ) + + expect(status).toBe(500) + expect(body).toEqual({ + jsonrpc: '2.0', + id: 'id', + error: expect.objectContaining({ + code: rpcErrors.internal().code, + message: 'some error', + data: expect.any(Error), + }), + }) + expect(errorLog).toHaveBeenCalledOnce() + }) + + it('uses generic message when method name is omitted', () => { + const [status, body] = handleRpcError(new Error('x'), null, logger) + + expect(status).toBe(500) + expect(errorPayload(body)).toMatchObject({ + message: 'x', + }) + }) + + it('maps string errors to the error message', () => { + const [status, body] = handleRpcError('plain', 0, logger) + + expect(status).toBe(500) + expect(errorPayload(body)).toMatchObject({ + message: 'plain', + }) + }) + + it('accepts a full ErrorResponse object when safeParse succeeds', () => { + const custom = { + error: { + code: -32000, + message: 'from client', + data: { hint: 1 }, + }, + } + const [status, body] = handleRpcError(custom, 3, logger) + + expect(status).toBe(500) + expect(body).toEqual({ + jsonrpc: '2.0', + id: 3, + error: custom.error, + }) + }) + + it('maps JsCantonError objects to internal code with cause as message', () => { + const ledgerErr = { + code: 'CODE', + cause: 'something went wrong', + } + const [status, body] = handleRpcError(ledgerErr, null, logger) + + expect(status).toBe(500) + expect(errorPayload(body)).toMatchObject({ + code: rpcErrors.internal().code, + message: 'something went wrong', + data: ledgerErr, + }) + }) + + it('falls back to generic internal error for unknown payloads', () => { + const [status, body] = handleRpcError( + { foo: 'bar' }, + 2, + logger, + 'wrongMethod' + ) + + expect(status).toBe(500) + expect(errorPayload(body)).toMatchObject({ + code: rpcErrors.internal().code, + message: 'Something went wrong while calling wrongMethod', + data: { foo: 'bar' }, + }) + }) +}) diff --git a/wallet-gateway/remote/src/middleware/jsonRpcHandler.ts b/wallet-gateway/remote/src/middleware/jsonRpcHandler.ts index f89f3f9cc..5e83877e9 100644 --- a/wallet-gateway/remote/src/middleware/jsonRpcHandler.ts +++ b/wallet-gateway/remote/src/middleware/jsonRpcHandler.ts @@ -29,7 +29,7 @@ interface JsonRpcHttpOptions { * @param method The name of the JSON-RPC method being called. * @returns A tuple containing the HTTP status code and the JSON-RPC response. */ -const handleRpcError = ( +export const handleRpcError = ( error: unknown, id: string | number | null, logger: Logger, @@ -97,7 +97,7 @@ export const jsonRpcHandler = return (req: Request, res: Response, next: NextFunction) => { if (req.method !== 'POST') { - next() + return next() } const parsed = JsonRpcRequest.safeParse(req.body) @@ -119,7 +119,7 @@ export const jsonRpcHandler = logger ) - res.status(status).json(response) + return res.status(status).json(response) } else { const { method, params, id = null } = parsed.data @@ -148,7 +148,7 @@ export const jsonRpcHandler = method ) - res.status(status).json(response) + return res.status(status).json(response) } // TODO: validate params match the expected schema for the method diff --git a/wallet-gateway/remote/src/middleware/jwtAuth.test.ts b/wallet-gateway/remote/src/middleware/jwtAuth.test.ts new file mode 100644 index 000000000..16fe1028a --- /dev/null +++ b/wallet-gateway/remote/src/middleware/jwtAuth.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { Request, Response, NextFunction } from 'express' +import { jwtAuth } from './jwtAuth.js' +import { pino } from 'pino' +import { sink } from 'pino-test' + +describe('jwtAuth', () => { + const verifyToken = vi.fn() + const authService = { verifyToken } + const logger = pino({ level: 'silent' }, sink()) + let next: NextFunction + let status: ReturnType + let json: ReturnType + + beforeEach(() => { + verifyToken.mockReset() + next = vi.fn() as NextFunction + status = vi.fn().mockReturnThis() + json = vi.fn() + }) + + function makeReq( + partial: Partial & { + headers?: { authorization?: string } + query?: Record + } + ): Request { + return { + headers: {}, + query: {}, + ...partial, + } as Request + } + + function makeRes(): Response { + return { status, json } as unknown as Response + } + + it('sets authContext and calls next when verification succeeds', async () => { + const ctx = { userId: 'alice', accessToken: 'tok' } + verifyToken.mockResolvedValue(ctx) + + const req = makeReq({ + headers: { authorization: 'Bearer abc' }, + }) + const res = makeRes() + const middleware = jwtAuth(authService, logger) + + await middleware(req, res, next) + + expect(verifyToken).toHaveBeenCalledWith('Bearer abc') + expect(req.authContext).toEqual(ctx) + expect(next).toHaveBeenCalledOnce() + expect(status).not.toHaveBeenCalled() + }) + + it('uses Bearer token from query when Authorization header is absent', async () => { + const ctx = { userId: 'bob', accessToken: 'tok' } + verifyToken.mockResolvedValue(ctx) + + const req = makeReq({ + query: { token: 'query-jwt' }, + }) + const res = makeRes() + const middleware = jwtAuth(authService, logger) + + await middleware(req, res, next) + + expect(verifyToken).toHaveBeenCalledWith('Bearer query-jwt') + expect(req.authContext).toEqual(ctx) + expect(next).toHaveBeenCalledOnce() + }) + + it('passes undefined to verifyToken when no credentials are present', async () => { + verifyToken.mockResolvedValue(undefined) + + const req = makeReq({}) + const res = makeRes() + const middleware = jwtAuth(authService, logger) + + await middleware(req, res, next) + + expect(verifyToken).toHaveBeenCalledWith(undefined) + expect(req.authContext).toBeUndefined() + expect(next).toHaveBeenCalledOnce() + }) + + it('returns 401 JSON when verification throws', async () => { + verifyToken.mockRejectedValue(new Error('bad sig')) + + const req = makeReq({ + headers: { authorization: 'Bearer x' }, + }) + const res = makeRes() + const middleware = jwtAuth(authService, logger) + + await middleware(req, res, next) + + expect(next).not.toHaveBeenCalled() + expect(status).toHaveBeenCalledWith(401) + expect(json).toHaveBeenCalledWith({ + error: 'Invalid or expired token: bad sig', + }) + }) + + it('stringifies non-Error rejection values in the response', async () => { + verifyToken.mockRejectedValue('rejected') + + const req = makeReq({ + headers: { authorization: 'Bearer x' }, + }) + const res = makeRes() + const middleware = jwtAuth(authService, logger) + + await middleware(req, res, next) + + expect(json).toHaveBeenCalledWith({ + error: 'Invalid or expired token: rejected', + }) + }) +}) diff --git a/wallet-gateway/remote/src/middleware/rateLimit.test.ts b/wallet-gateway/remote/src/middleware/rateLimit.test.ts index 7ad7fd886..2c9aa6981 100644 --- a/wallet-gateway/remote/src/middleware/rateLimit.test.ts +++ b/wallet-gateway/remote/src/middleware/rateLimit.test.ts @@ -32,6 +32,22 @@ describe('ipRateLimitKeyGenerator', () => { expect(ipRateLimitKeyGenerator(req)).toBe('ip:2001:db8:abcd::/56') }) + + test('falls back to socket.remoteAddress when ip is unset', () => { + const req = { + socket: { remoteAddress: '10.0.0.2' }, + } as Request + + expect(ipRateLimitKeyGenerator(req)).toBe('ip:10.0.0.2') + }) + + test('uses unknown when neither ip nor remoteAddress is set', () => { + const req = { + socket: {}, + } as Request + + expect(ipRateLimitKeyGenerator(req)).toBe('ip:unknown') + }) }) describe('rateLimitKeyGenerator', () => { diff --git a/wallet-gateway/remote/src/middleware/sessionHandler.test.ts b/wallet-gateway/remote/src/middleware/sessionHandler.test.ts new file mode 100644 index 000000000..b85db0b3b --- /dev/null +++ b/wallet-gateway/remote/src/middleware/sessionHandler.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response, NextFunction } from 'express' +import type { AuthAware, AuthContext } from '@canton-network/core-wallet-auth' +import { pino } from 'pino' +import { sink } from 'pino-test' +import { sessionHandler } from './sessionHandler.js' +import { Store } from '@canton-network/core-wallet-store' + +describe('sessionHandler', () => { + const getSession = vi.fn() + const withAuthContext = vi.fn(() => ({ getSession })) + const store = { withAuthContext } as unknown as Store & AuthAware + const logger = pino({ level: 'silent' }, sink()) + + const allowedPaths = { + '/api/v0/user': ['addSession', 'listNetworks', 'getUser'], + '/api/v0/dapp': ['*'], + } + + let next: NextFunction + let status: ReturnType + let json: ReturnType + + const authContext: AuthContext = { + userId: 'user-1', + accessToken: 'access-token', + } + + beforeEach(() => { + getSession.mockReset() + withAuthContext.mockClear() + withAuthContext.mockReturnValue({ getSession }) + next = vi.fn() as NextFunction + status = vi.fn().mockReturnThis() + json = vi.fn() + }) + + function makeReq( + partial: Partial & { + method?: string + baseUrl?: string + body?: { method?: string } + authContext?: AuthContext + } + ): Request { + return { + method: 'POST', + baseUrl: '/api/v0/user', + body: { method: 'listWallets' }, + authContext, + ...partial, + } as Request + } + + function makeRes(): Response { + return { status, json } as unknown as Response + } + + it('skips session check for non-POST requests', async () => { + const req = makeReq({ method: 'GET' }) + const res = makeRes() + const middleware = sessionHandler(store, allowedPaths, logger) + + await middleware(req, res, next) + + expect(withAuthContext).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledOnce() + expect(status).not.toHaveBeenCalled() + }) + + it('allows unauthenticated POST when the RPC method is on the allow list', async () => { + const req = makeReq({ body: { method: 'addSession' } }) + const res = makeRes() + const middleware = sessionHandler(store, allowedPaths, logger) + + await middleware(req, res, next) + + expect(withAuthContext).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledOnce() + expect(status).not.toHaveBeenCalled() + }) + + it('allows unauthenticated POST when the path uses a wildcard allow list', async () => { + const req = makeReq({ + baseUrl: '/api/v0/dapp', + body: { method: 'connect' }, + }) + const res = makeRes() + const middleware = sessionHandler(store, allowedPaths, logger) + + await middleware(req, res, next) + + expect(withAuthContext).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledOnce() + }) + + it('calls next when an active session exists for a protected method', async () => { + getSession.mockResolvedValue({ + id: 'session-1', + network: 'network1', + accessToken: 'session-token', + }) + const req = makeReq({ body: { method: 'listWallets' } }) + const res = makeRes() + const middleware = sessionHandler(store, allowedPaths, logger) + + await middleware(req, res, next) + + expect(withAuthContext).toHaveBeenCalledWith(authContext) + expect(getSession).toHaveBeenCalled() + expect(next).toHaveBeenCalledOnce() + expect(status).not.toHaveBeenCalled() + }) + + it('returns 401 when no session exists for a protected method', async () => { + getSession.mockResolvedValue(undefined) + const req = makeReq({ body: { method: 'listWallets' } }) + const res = makeRes() + const middleware = sessionHandler(store, allowedPaths, logger) + + await middleware(req, res, next) + + expect(withAuthContext).toHaveBeenCalledWith(authContext) + expect(next).not.toHaveBeenCalled() + expect(status).toHaveBeenCalledWith(401) + expect(json).toHaveBeenCalledWith({ + error: 'No active session found', + }) + }) + + it('requires a session when the path is not in the allow list config', async () => { + getSession.mockResolvedValue(undefined) + const req = makeReq({ + baseUrl: '/api/v0/other', + body: { method: 'addSession' }, + }) + const res = makeRes() + const middleware = sessionHandler(store, allowedPaths, logger) + + await middleware(req, res, next) + + expect(withAuthContext).toHaveBeenCalledWith(authContext) + expect(status).toHaveBeenCalledWith(401) + }) + + it('requires a session when the RPC method is not on the path allow list', async () => { + getSession.mockResolvedValue(undefined) + const req = makeReq({ body: { method: 'removeSession' } }) + const res = makeRes() + const middleware = sessionHandler(store, allowedPaths, logger) + + await middleware(req, res, next) + + expect(withAuthContext).toHaveBeenCalledWith(authContext) + expect(status).toHaveBeenCalledWith(401) + }) +}) diff --git a/wallet-gateway/remote/src/middleware/sessionHandler.ts b/wallet-gateway/remote/src/middleware/sessionHandler.ts index c3ec69ad0..e58aa4474 100644 --- a/wallet-gateway/remote/src/middleware/sessionHandler.ts +++ b/wallet-gateway/remote/src/middleware/sessionHandler.ts @@ -24,7 +24,7 @@ export function sessionHandler( if (req.method !== 'POST') { logger.debug( - 'Skipping authentication for OPTIONS request to ' + req.baseUrl + `Skipping authentication for ${req.method} request to ${req.baseUrl}` ) next() } else if ( diff --git a/wallet-gateway/remote/src/notification/NotificationService.test.ts b/wallet-gateway/remote/src/notification/NotificationService.test.ts new file mode 100644 index 000000000..02ba88c40 --- /dev/null +++ b/wallet-gateway/remote/src/notification/NotificationService.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import pino, { Logger } from 'pino' +import { consecutive, once, sink } from 'pino-test' +import { NotificationService } from './NotificationService.js' + +describe('NotificationService', () => { + let mockLogger: Logger + let logStream: ReturnType + + beforeEach(() => { + logStream = sink() + mockLogger = pino({ level: 'debug' }, logStream) as Logger + }) + + it('creates a notifier for a new notifierId', () => { + const service = new NotificationService(mockLogger) + + const notifier = service.getNotifier('user-1') + + expect(notifier).toBeDefined() + expect(typeof notifier.on).toBe('function') + expect(typeof notifier.emit).toBe('function') + expect(typeof notifier.removeListener).toBe('function') + }) + + it('returns the same notifier instance for the same notifierId', () => { + const service = new NotificationService(mockLogger) + + const notifier1 = service.getNotifier('user-1') + const notifier2 = service.getNotifier('user-1') + + expect(notifier1).toBe(notifier2) + }) + + it('returns different notifier instances for different notifierIds', () => { + const service = new NotificationService(mockLogger) + + const notifier1 = service.getNotifier('user-1') + const notifier2 = service.getNotifier('user-2') + + expect(notifier1).not.toBe(notifier2) + }) + + it('calls listeners when an event is emitted', () => { + const service = new NotificationService(mockLogger) + const notifier = service.getNotifier('user-1') + + const listener = vi.fn() + notifier.on('txChanged', listener) + + const result = notifier.emit('txChanged', { id: 123 }) + + expect(result).toBe(true) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith({ id: 123 }) + }) + + it('returns false when emitting an event with no listeners', async () => { + const service = new NotificationService(mockLogger) + const notifier = service.getNotifier('user-1') + + const result = notifier.emit('unknown-event') + + expect(result).toBe(false) + await once(logStream, { + level: 20, + event: 'unknown-event', + args: [], + msg: 'Notifier emitted event: unknown-event for user-1', + }) + }) + + it('logs every emitted event', async () => { + const service = new NotificationService(mockLogger) + const notifier = service.getNotifier('user-1') + + notifier.emit('txChanged', { id: 123 }) + + await once(logStream, { + level: 20, + event: 'txChanged', + args: [{ id: 123 }], + msg: 'Notifier emitted event: txChanged for user-1', + }) + }) + + it('removes listeners with removeListener', () => { + const service = new NotificationService(mockLogger) + const notifier = service.getNotifier('user-1') + + const listener = vi.fn() + + notifier.on('txChanged', listener) + notifier.removeListener('txChanged', listener) + + const result = notifier.emit('txChanged', { id: 123 }) + + expect(result).toBe(false) + expect(listener).not.toHaveBeenCalled() + }) + + it('keeps listeners isolated between notifiers', () => { + const service = new NotificationService(mockLogger) + + const notifier1 = service.getNotifier('user-1') + const notifier2 = service.getNotifier('user-2') + + const listener1 = vi.fn() + const listener2 = vi.fn() + + notifier1.on('txChanged', listener1) + notifier2.on('txChanged', listener2) + + notifier1.emit('txChanged', { id: 1 }) + + expect(listener1).toHaveBeenCalledWith({ id: 1 }) + expect(listener2).not.toHaveBeenCalled() + }) + + it('logs with the correct notifierId for each notifier', async () => { + const service = new NotificationService(mockLogger) + + const notifier1 = service.getNotifier('user-1') + const notifier2 = service.getNotifier('user-2') + + notifier1.emit('txChanged') + notifier2.emit('statusChanged') + + await consecutive(logStream, [ + { + level: 20, + event: 'txChanged', + args: [], + msg: 'Notifier emitted event: txChanged for user-1', + }, + { + level: 20, + event: 'statusChanged', + args: [], + msg: 'Notifier emitted event: statusChanged for user-2', + }, + ]) + }) +})