diff --git a/packages/mock-account-service-lib/src/index.ts b/packages/mock-account-service-lib/src/index.ts index 31945c8618..ca84a9516e 100644 --- a/packages/mock-account-service-lib/src/index.ts +++ b/packages/mock-account-service-lib/src/index.ts @@ -1,4 +1,12 @@ -export { Peering, Account, Config, Webhook, WebhookEventType } from './types' +export { + Peering, + Account, + Config, + Webhook, + WebhookEventType, + Fee, + SeedInstance +} from './types' export { AccountProvider } from './account-provider' export { setupFromSeed } from './seed' diff --git a/packages/mock-account-service-lib/src/types.ts b/packages/mock-account-service-lib/src/types.ts index 829351a78d..4c45ffcd1f 100644 --- a/packages/mock-account-service-lib/src/types.ts +++ b/packages/mock-account-service-lib/src/types.ts @@ -25,14 +25,14 @@ export interface Account { skipWalletAddressCreation?: boolean } -interface Fee { +export interface Fee { fixed: number basisPoints: number asset: string scale: number } -interface SeedInstance { +export interface SeedInstance { assets: Array peeringAsset: string peers: Array diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea81ec1ba3..a609b3deca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -650,9 +650,6 @@ importers: '@types/node': specifier: ^18.19.19 version: 18.19.19 - '@types/uuid': - specifier: ^9.0.8 - version: 9.0.8 dotenv: specifier: ^16.4.1 version: 16.4.5 @@ -665,9 +662,6 @@ importers: mock-account-service-lib: specifier: workspace:* version: link:../../packages/mock-account-service-lib - uuid: - specifier: ^9.0.1 - version: 9.0.1 yaml: specifier: ^2.3.4 version: 2.4.1 diff --git a/test/integration/integration.test.ts b/test/integration/integration.test.ts index df17af97f6..091989ed36 100644 --- a/test/integration/integration.test.ts +++ b/test/integration/integration.test.ts @@ -1,31 +1,16 @@ import assert from 'assert' -import { validate as isUuid } from 'uuid' -import { - isPendingGrant, - isFinalizedGrant, - WalletAddress, - IncomingPayment, - Quote, - PendingGrant, - Grant, - OutgoingPayment -} from '@interledger/open-payments' import { C9_CONFIG, HLB_CONFIG } from './lib/config' import { MockASE } from './lib/mock-ase' -import { WebhookEventType } from 'mock-account-service-lib' -import { parseCookies, poll, pollCondition, wait } from './lib/utils' -import { - Receiver as ReceiverGql, - Quote as QuoteGql, - OutgoingPayment as OutgoingPaymentGql, - OutgoingPaymentState -} from './lib/generated/graphql' +import { Fee, WebhookEventType } from 'mock-account-service-lib' +import { poll } from './lib/utils' +import { TestActions, createTestActions } from './lib/test-actions' -jest.setTimeout(20000) +jest.setTimeout(20_000) describe('Integration tests', (): void => { let c9: MockASE let hlb: MockASE + let testActions: TestActions beforeAll(async () => { try { @@ -33,11 +18,12 @@ describe('Integration tests', (): void => { hlb = await MockASE.create(HLB_CONFIG) } catch (e) { console.error(e) - // Prevents jest from running all tests, which obfuscates error, - // when beforeAll errors. + // Prevents jest from running all tests, which obfuscates errors in beforeAll // https://github.com/jestjs/jest/issues/2713 process.exit(1) } + + testActions = createTestActions({ sendingASE: c9, receivingASE: hlb }) }) afterAll(async () => { @@ -45,38 +31,8 @@ describe('Integration tests', (): void => { hlb.shutdown() }) - describe('Open Payments Flow', (): void => { - const receiverWalletAddressUrl = - 'http://happy-life-bank-test-backend:4100/accounts/pfry' - const senderWalletAddressUrl = - 'http://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' - const amountValueToSend = '100' - - let receiverWalletAddress: WalletAddress - let senderWalletAddress: WalletAddress - let accessToken: string - let incomingPayment: IncomingPayment - let quote: Quote - let outgoingPaymentGrant: PendingGrant - let grantContinue: Grant - let outgoingPayment: OutgoingPayment - - test('Can Get Existing Wallet Address', async (): Promise => { - receiverWalletAddress = await c9.opClient.walletAddress.get({ - url: receiverWalletAddressUrl - }) - senderWalletAddress = await c9.opClient.walletAddress.get({ - url: senderWalletAddressUrl - }) - - expect(receiverWalletAddress.id).toBe( - receiverWalletAddressUrl.replace('http', 'https') - ) - expect(senderWalletAddress.id).toBe( - senderWalletAddressUrl.replace('http', 'https') - ) - }) - + // Individual requests + describe('Requests', (): void => { test('Can Get Non-Existing Wallet Address', async (): Promise => { const notFoundWalletAddress = 'https://happy-life-bank-test-backend:4100/accounts/asmith' @@ -109,493 +65,260 @@ describe('Integration tests', (): void => { }) ) }) + }) - test('Grant Request Incoming Payment', async (): Promise => { - const grant = await c9.opClient.grant.request( - { - url: receiverWalletAddress.authServer - }, - { - access_token: { - access: [ - { - type: 'incoming-payment', - actions: ['create', 'read', 'list', 'complete'] - } - ] - } - } - ) - - assert(!isPendingGrant(grant)) - accessToken = grant.access_token.value - }) + // Series of requests depending on eachother + describe('Flows', () => { + test('Open Payments with Continuation via Polling', async (): Promise => { + const { + grantRequestIncomingPayment, + createIncomingPayment, + grantRequestQuote, + createQuote, + grantRequestOutgoingPayment, + pollGrantContinue, + createOutgoingPayment, + getOutgoingPayment, + getPublicIncomingPayment + } = testActions.openPayments + const { consentInteraction } = testActions + + const receiverWalletAddressUrl = + 'https://happy-life-bank-test-backend:4100/accounts/pfry' + const senderWalletAddressUrl = + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' + const amountValueToSend = '100' - test('Create Incoming Payment', async (): Promise => { - const now = new Date() - const tomorrow = new Date(now) - tomorrow.setDate(now.getDate() + 1) + const receiverWalletAddress = await c9.opClient.walletAddress.get({ + url: receiverWalletAddressUrl + }) + expect(receiverWalletAddress.id).toBe(receiverWalletAddressUrl) - const handleWebhookEventSpy = jest.spyOn( - hlb.integrationServer.webhookEventHandler, - 'handleWebhookEvent' - ) + const senderWalletAddress = await c9.opClient.walletAddress.get({ + url: senderWalletAddressUrl + }) + expect(senderWalletAddress.id).toBe(senderWalletAddressUrl) - incomingPayment = await c9.opClient.incomingPayment.create( - { - url: receiverWalletAddress.resourceServer, - accessToken - }, - { - walletAddress: receiverWalletAddressUrl.replace('http', 'https'), - incomingAmount: { - value: amountValueToSend, - assetCode: receiverWalletAddress.assetCode, - assetScale: receiverWalletAddress.assetScale - }, - metadata: { description: 'Free Money!' }, - expiresAt: tomorrow.toISOString() - } + const incomingPaymentGrant = await grantRequestIncomingPayment( + receiverWalletAddress ) - - await pollCondition( - () => { - return handleWebhookEventSpy.mock.calls.some( - (call) => call[0]?.type === WebhookEventType.IncomingPaymentCreated - ) - }, - 5, - 0.5 + const incomingPayment = await createIncomingPayment( + receiverWalletAddress, + amountValueToSend, + incomingPaymentGrant.access_token.value ) - - expect(handleWebhookEventSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: WebhookEventType.IncomingPaymentCreated, - data: expect.any(Object) - }) + const quoteGrant = await grantRequestQuote(senderWalletAddress) + const quote = await createQuote( + senderWalletAddress, + quoteGrant.access_token.value, + incomingPayment ) - }) - - test('Grant Request Quote', async (): Promise => { - const grant = await c9.opClient.grant.request( - { - url: senderWalletAddress.authServer - }, - { - access_token: { - access: [ - { - type: 'quote', - actions: ['read', 'create'] - } - ] - } - } + const outgoingPaymentGrant = await grantRequestOutgoingPayment( + senderWalletAddress, + quote ) - - assert(!isPendingGrant(grant)) - accessToken = grant.access_token.value - }) - - test('Create Quote', async (): Promise => { - quote = await c9.opClient.quote.create( - { - url: senderWalletAddress.resourceServer, - accessToken - }, - { - walletAddress: senderWalletAddressUrl.replace('http', 'https'), - receiver: incomingPayment.id.replace('https', 'http'), - method: 'ilp' - } + await consentInteraction(outgoingPaymentGrant, senderWalletAddress) + const grantContinue = await pollGrantContinue(outgoingPaymentGrant) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddress, + grantContinue, + quote ) - }) - - // --- GRANT CONTINUATION WITH FINISH METHOD --- - // TODO: Grant Continuation w/ finish in another Open Payments Flow test - test.skip('Grant Request Outgoing Payment', async (): Promise => { - const grant = await hlb.opClient.grant.request( - { - url: senderWalletAddress.authServer - }, - { - access_token: { - access: [ - { - type: 'outgoing-payment', - actions: ['create', 'read', 'list'], - identifier: senderWalletAddressUrl.replace('http', 'https'), - limits: { - debitAmount: quote.debitAmount, - receiveAmount: quote.receiveAmount - } - } - ] - }, - interact: { - start: ['redirect'], - finish: { - method: 'redirect', - uri: 'https://example.com', - nonce: '456' - } - } - } + await getOutgoingPayment( + outgoingPayment.id, + grantContinue, + amountValueToSend ) + await getPublicIncomingPayment(incomingPayment.id, amountValueToSend) - assert(isPendingGrant(grant)) - outgoingPaymentGrant = grant - - // Delay following request according to the continue wait time - await wait((outgoingPaymentGrant.continue.wait ?? 5) * 1000) + const incomingPayment_ = await hlb.opClient.incomingPayment.getPublic({ + url: incomingPayment.id + }) + assert(incomingPayment_.receivedAmount) + expect(incomingPayment_.receivedAmount.value).toBe(amountValueToSend) }) + test('Open Payments with Continuation via finish method', async (): Promise => { + const { + grantRequestIncomingPayment, + createIncomingPayment, + grantRequestQuote, + createQuote, + grantRequestOutgoingPayment, + grantContinue, + createOutgoingPayment, + getOutgoingPayment, + getPublicIncomingPayment + } = testActions.openPayments + const { consentInteractionWithInteractRef } = testActions + + const receiverWalletAddressUrl = + 'https://happy-life-bank-test-backend:4100/accounts/pfry' + const senderWalletAddressUrl = + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' + const amountValueToSend = '100' - test.skip('Continuation Request', async (): Promise => { - const { redirect: startInteractionUrl } = outgoingPaymentGrant.interact - const tokens = startInteractionUrl.split('/interact/') - const interactId = tokens[1] ? tokens[1].split('/')[0] : null - const nonce = outgoingPaymentGrant.interact.finish - assert(interactId) - - // Start interaction - const interactResponse = await fetch(startInteractionUrl, { - redirect: 'manual' // dont follow redirects + const receiverWalletAddress = await c9.opClient.walletAddress.get({ + url: receiverWalletAddressUrl }) - expect(interactResponse.status).toBe(302) + expect(receiverWalletAddress.id).toBe(receiverWalletAddressUrl) - const cookie = parseCookies(interactResponse) + const senderWalletAddress = await c9.opClient.walletAddress.get({ + url: senderWalletAddressUrl + }) + expect(senderWalletAddress.id).toBe(senderWalletAddressUrl) - // Accept - const acceptResponse = await fetch( - `${senderWalletAddress.authServer}/grant/${interactId}/${nonce}/accept`, - { - method: 'POST', - headers: { - 'x-idp-secret': 'replace-me', - cookie - } - } + const incomingPaymentGrant = await grantRequestIncomingPayment( + receiverWalletAddress ) - expect(acceptResponse.status).toBe(202) - - // Finish interaction - const finishResponse = await fetch( - `${senderWalletAddress.authServer}/interact/${interactId}/${nonce}/finish`, - { - method: 'GET', - headers: { - 'x-idp-secret': 'replace-me', - cookie - }, - redirect: 'manual' // dont follow redirects - } + const incomingPayment = await createIncomingPayment( + receiverWalletAddress, + amountValueToSend, + incomingPaymentGrant.access_token.value ) - expect(finishResponse.status).toBe(302) - - const redirectURI = finishResponse.headers.get('location') - assert(redirectURI) - - const url = new URL(redirectURI) - const interact_ref = url.searchParams.get('interact_ref') - assert(interact_ref) - - const { access_token, uri } = outgoingPaymentGrant.continue - const grantContinue_ = await c9.opClient.grant.continue( - { - accessToken: access_token.value, - url: uri - }, - { interact_ref } + const quoteGrant = await grantRequestQuote(senderWalletAddress) + const quote = await createQuote( + senderWalletAddress, + quoteGrant.access_token.value, + incomingPayment ) - assert(isFinalizedGrant(grantContinue_)) - grantContinue = grantContinue_ - }) - // --- GRANT CONTINUATION WITH FINISH METHOD --- - - test('Grant Request Outgoing Payment', async (): Promise => { - const grant = await hlb.opClient.grant.request( + const outgoingPaymentGrant = await grantRequestOutgoingPayment( + senderWalletAddress, + quote, { - url: senderWalletAddress.authServer - }, - { - access_token: { - access: [ - { - type: 'outgoing-payment', - actions: ['create', 'read', 'list'], - identifier: senderWalletAddressUrl.replace('http', 'https'), - limits: { - debitAmount: quote.debitAmount, - receiveAmount: quote.receiveAmount - } - } - ] - }, - interact: { - start: ['redirect'] - } + method: 'redirect', + uri: 'https://example.com', + nonce: '456' } ) - - assert(isPendingGrant(grant)) - outgoingPaymentGrant = grant - - // Delay following request according to the continue wait time - await wait((outgoingPaymentGrant.continue.wait ?? 5) * 1000) - }) - - test('Continuation Request', async (): Promise => { - const { redirect: startInteractionUrl } = outgoingPaymentGrant.interact - const tokens = startInteractionUrl.split('/interact/') - const interactId = tokens[1] ? tokens[1].split('/')[0] : null - const nonce = outgoingPaymentGrant.interact.finish - assert(interactId) - - // Start interaction - const interactResponse = await fetch(startInteractionUrl, { - redirect: 'manual' // dont follow redirects - }) - expect(interactResponse.status).toBe(302) - - const cookie = parseCookies(interactResponse) - - // Accept - const acceptResponse = await fetch( - `${senderWalletAddress.authServer}/grant/${interactId}/${nonce}/accept`, - { - method: 'POST', - headers: { - 'x-idp-secret': 'replace-me', - cookie - } - } + const interactRef = await consentInteractionWithInteractRef( + outgoingPaymentGrant, + senderWalletAddress ) - - expect(acceptResponse.status).toBe(202) - - // Finish interaction - const finishResponse = await fetch( - `${senderWalletAddress.authServer}/interact/${interactId}/${nonce}/finish`, - { - method: 'GET', - headers: { - 'x-idp-secret': 'replace-me', - cookie - } - } + const finalizedGrant = await grantContinue( + outgoingPaymentGrant, + interactRef ) - expect(finishResponse.status).toBe(202) - - const { access_token, uri } = outgoingPaymentGrant.continue - const grantContinue_ = await poll( - async () => - c9.opClient.grant.continue({ - accessToken: access_token.value, - url: uri - }), - (responseData) => 'access_token' in responseData, - 20, - 5 + const outgoingPayment = await createOutgoingPayment( + senderWalletAddress, + finalizedGrant, + quote ) - - assert(isFinalizedGrant(grantContinue_)) - grantContinue = grantContinue_ + await getOutgoingPayment( + outgoingPayment.id, + finalizedGrant, + amountValueToSend + ) + await getPublicIncomingPayment(incomingPayment.id, amountValueToSend) }) - - test('Create Outgoing Payment', async (): Promise => { - const handleWebhookEventSpy = jest.spyOn( - c9.integrationServer.webhookEventHandler, - 'handleWebhookEvent' + test('Peer to Peer', async (): Promise => { + const { + createReceiver, + createQuote, + createOutgoingPayment, + getOutgoingPayment + } = testActions.admin + + const senderWalletAddress = await c9.accounts.getByWalletAddressUrl( + 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' ) - - outgoingPayment = await c9.opClient.outgoingPayment.create( - { - url: senderWalletAddress.resourceServer, - accessToken: grantContinue.access_token.value + assert(senderWalletAddress?.walletAddressID) + const senderWalletAddressId = senderWalletAddress.walletAddressID + const value = '500' + const createReceiverInput = { + metadata: { + description: 'For lunch!' }, - { - walletAddress: senderWalletAddressUrl.replace('http', 'https'), - metadata: {}, - quoteId: quote.id - } - ) - - await pollCondition( - () => { - return ( - handleWebhookEventSpy.mock.calls.some( - (call) => - call[0]?.type === WebhookEventType.OutgoingPaymentCreated - ) && - handleWebhookEventSpy.mock.calls.some( - (call) => - call[0]?.type === WebhookEventType.OutgoingPaymentCompleted - ) - ) + incomingAmount: { + assetCode: 'USD', + assetScale: 2, + value: value as unknown as bigint }, - 5, - 0.5 - ) + walletAddressUrl: + 'https://happy-life-bank-test-backend:4100/accounts/pfry' + } - expect(handleWebhookEventSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: WebhookEventType.OutgoingPaymentCreated, - data: expect.any(Object) - }) + const receiver = await createReceiver(createReceiverInput) + const quote = await createQuote(senderWalletAddressId, receiver) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddressId, + quote ) - expect(handleWebhookEventSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: WebhookEventType.OutgoingPaymentCompleted, - data: expect.any(Object) - }) + const outgoingPayment_ = await getOutgoingPayment( + outgoingPayment.id, + value ) + expect(outgoingPayment_.sentAmount.value).toBe(BigInt(value)) }) - - test('Get Outgoing Payment', async (): Promise => { - const id = outgoingPayment.id.split('/').pop() - assert(id) - expect(isUuid(id)).toBe(true) - - const outgoingPayment_ = await c9.opClient.outgoingPayment.get({ - url: `${senderWalletAddress.resourceServer}/outgoing-payments/${id}`, - accessToken: grantContinue.access_token.value - }) - - expect(outgoingPayment_.id).toBe(outgoingPayment.id) - expect(outgoingPayment_.receiveAmount.value).toBe(amountValueToSend) - expect(outgoingPayment_.sentAmount.value).toBe(amountValueToSend) - }) - - test('Get Incoming Payment', async (): Promise => { - const incomingPayment_ = await hlb.opClient.incomingPayment.getPublic({ - url: `${incomingPayment.id}` - }) - assert(incomingPayment_.receivedAmount) - expect(incomingPayment_.receivedAmount.value).toBe(amountValueToSend) - }) - }) - - describe('Peer to Peer Flow', (): void => { - const receiverWalletAddressUrl = - 'https://happy-life-bank-test-backend:4100/accounts/pfry' - const amountValueToSend = '500' - - let gfranklinWalletAddressId: string - let receiver: ReceiverGql - let quote: QuoteGql - let outgoingPayment: OutgoingPaymentGql - - beforeAll(async () => { - const gfranklinWalletAddress = await c9.accounts.getByWalletAddressUrl( + test('Peer to Peer - Cross Currency', async (): Promise => { + const { + createReceiver, + createQuote, + createOutgoingPayment, + getOutgoingPayment + } = testActions.admin + + const senderWalletAddress = await c9.accounts.getByWalletAddressUrl( 'https://cloud-nine-wallet-test-backend:3100/accounts/gfranklin' ) - assert(gfranklinWalletAddress?.walletAddressID) - gfranklinWalletAddressId = gfranklinWalletAddress.walletAddressID - }) - - test('Create Receiver (remote Incoming Payment)', async (): Promise => { - const handleWebhookEventSpy = jest.spyOn( - hlb.integrationServer.webhookEventHandler, - 'handleWebhookEvent' - ) - const response = await c9.adminClient.createReceiver({ + assert(senderWalletAddress) + const senderAssetCode = senderWalletAddress.assetCode + const senderWalletAddressId = senderWalletAddress.walletAddressID + const value = '500' + const createReceiverInput = { metadata: { - description: 'For lunch!' + description: 'cross-currency' }, incomingAmount: { - assetCode: 'USD', + assetCode: 'EUR', assetScale: 2, - value: amountValueToSend as unknown as bigint + value: value as unknown as bigint }, - walletAddressUrl: receiverWalletAddressUrl + walletAddressUrl: + 'https://happy-life-bank-test-backend:4100/accounts/lars' + } + + const receiver = await createReceiver(createReceiverInput) + assert(receiver.incomingAmount) + + const quote = await createQuote(senderWalletAddressId, receiver) + const outgoingPayment = await createOutgoingPayment( + senderWalletAddressId, + quote + ) + const payment = await getOutgoingPayment(outgoingPayment.id, value) + + const receiverAssetCode = receiver.incomingAmount.assetCode + const exchangeRate = + hlb.config.seed.rates[senderAssetCode][receiverAssetCode] + const fee = c9.config.seed.fees.find((fee: Fee) => fee.asset === 'USD') + + // Expected amounts depend on the configuration of asset codes, scale, exchange rate, and fees. + assert(receiverAssetCode === 'EUR') + assert(senderAssetCode === 'USD') + assert( + receiver.incomingAmount.assetScale === senderWalletAddress.assetScale + ) + assert(senderWalletAddress.assetScale === 2) + assert(exchangeRate === 0.91) + assert(fee.fixed === 100) + assert(fee.basisPoints === 200) + assert(fee.asset === 'USD') + assert(fee.scale === 2) + expect(payment.receiveAmount).toMatchObject({ + assetCode: 'EUR', + assetScale: 2, + value: 500n }) - - expect(response.code).toBe('200') - assert(response.receiver) - - receiver = response.receiver - - await pollCondition( - () => { - return handleWebhookEventSpy.mock.calls.some( - (call) => call[0]?.type === WebhookEventType.IncomingPaymentCreated - ) - }, - 5, - 0.5 - ) - - expect(handleWebhookEventSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: WebhookEventType.IncomingPaymentCreated, - data: expect.any(Object) - }) - ) - }) - test('Create Quote', async (): Promise => { - const response = await c9.adminClient.createQuote({ - walletAddressId: gfranklinWalletAddressId, - receiver: receiver.id + expect(payment.debitAmount).toMatchObject({ + assetCode: 'USD', + assetScale: 2, + value: 668n }) - - expect(response.code).toBe('200') - assert(response.quote) - - quote = response.quote - }) - test('Create Outgoing Payment', async (): Promise => { - const handleWebhookEventSpy = jest.spyOn( - c9.integrationServer.webhookEventHandler, - 'handleWebhookEvent' - ) - - const response = await c9.adminClient.createOutgoingPayment({ - walletAddressId: gfranklinWalletAddressId, - quoteId: quote.id + expect(payment.sentAmount).toMatchObject({ + assetCode: 'USD', + assetScale: 2, + value: 550n }) - - expect(response.code).toBe('200') - assert(response.payment) - - outgoingPayment = response.payment - - await pollCondition( - () => { - return ( - handleWebhookEventSpy.mock.calls.some( - (call) => - call[0]?.type === WebhookEventType.OutgoingPaymentCreated - ) && - handleWebhookEventSpy.mock.calls.some( - (call) => - call[0]?.type === WebhookEventType.OutgoingPaymentCompleted - ) - ) - }, - 5, - 0.5 - ) - - expect(handleWebhookEventSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: WebhookEventType.OutgoingPaymentCreated, - data: expect.any(Object) - }) - ) - expect(handleWebhookEventSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: WebhookEventType.OutgoingPaymentCompleted, - data: expect.any(Object) - }) - ) - }) - test('Get Outgoing Payment', async (): Promise => { - const payment = await c9.adminClient.getOutgoingPayment( - outgoingPayment.id - ) - expect(payment.state).toBe(OutgoingPaymentState.Completed) - expect(payment.receiveAmount.value).toBe(amountValueToSend) - expect(payment.sentAmount.value).toBe(amountValueToSend) }) }) }) diff --git a/test/integration/lib/integration-server.ts b/test/integration/lib/integration-server.ts index a9eb50b96c..703ab57984 100644 --- a/test/integration/lib/integration-server.ts +++ b/test/integration/lib/integration-server.ts @@ -1,7 +1,7 @@ +import crypto from 'crypto' import Koa from 'koa' import bodyParser from '@koa/bodyparser' import http from 'http' -import { v4 as uuid } from 'uuid' import { AccountProvider, WebhookEventType, @@ -171,7 +171,7 @@ export class WebhookEventHandler { const response = await this.adminClient.depositOutgoingPaymentLiquidity({ outgoingPaymentId: payment.id, - idempotencyKey: uuid() + idempotencyKey: crypto.randomUUID() }) if (response.code !== '200') { diff --git a/test/integration/lib/mock-ase.ts b/test/integration/lib/mock-ase.ts index 76da7bab73..4642907b4f 100644 --- a/test/integration/lib/mock-ase.ts +++ b/test/integration/lib/mock-ase.ts @@ -11,9 +11,9 @@ import { TestConfig } from './config' /** Mock Account Servicing Entity */ export class MockASE { - private config: TestConfig private apolloClient: ApolloClient + public config: TestConfig public adminClient: AdminClient public accounts: AccountProvider public opClient!: AuthenticatedClient diff --git a/test/integration/lib/test-actions/admin.ts b/test/integration/lib/test-actions/admin.ts new file mode 100644 index 0000000000..aa03bf063c --- /dev/null +++ b/test/integration/lib/test-actions/admin.ts @@ -0,0 +1,157 @@ +import assert from 'assert' +import { + Receiver, + Quote, + OutgoingPayment, + OutgoingPaymentState, + CreateReceiverInput +} from '../generated/graphql' +import { MockASE } from '../mock-ase' +import { pollCondition } from '../utils' +import { WebhookEventType } from 'mock-account-service-lib' + +interface AdminActionsDeps { + sendingASE: MockASE + receivingASE: MockASE +} + +export interface AdminActions { + createReceiver(createReceiverInput: CreateReceiverInput): Promise + createQuote(senderWalletAddressId: string, receiver: Receiver): Promise + createOutgoingPayment( + senderWalletAddressId: string, + quote: Quote + ): Promise + getOutgoingPayment( + outgoingPaymentId: string, + amountValueToSend: string + ): Promise +} + +export function createAdminActions(deps: AdminActionsDeps): AdminActions { + return { + createReceiver: (createReceiverInput) => + createReceiver(deps, createReceiverInput), + createQuote: (senderWalletAddressId, receiver) => + createQuote(deps, senderWalletAddressId, receiver), + createOutgoingPayment: (senderWalletAddressId, quote) => + createOutgoingPayment(deps, senderWalletAddressId, quote), + getOutgoingPayment: (outgoingPaymentId, amountValueToSend) => + getOutgoingPayment(deps, outgoingPaymentId, amountValueToSend) + } +} + +async function createReceiver( + deps: AdminActionsDeps, + createReceiverInput: CreateReceiverInput +): Promise { + const { receivingASE, sendingASE } = deps + const handleWebhookEventSpy = jest.spyOn( + receivingASE.integrationServer.webhookEventHandler, + 'handleWebhookEvent' + ) + const response = + await sendingASE.adminClient.createReceiver(createReceiverInput) + + expect(response.code).toBe('200') + assert(response.receiver) + + await pollCondition( + () => { + return handleWebhookEventSpy.mock.calls.some( + (call) => call[0]?.type === WebhookEventType.IncomingPaymentCreated + ) + }, + 5, + 0.5 + ) + + expect(handleWebhookEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: WebhookEventType.IncomingPaymentCreated, + data: expect.any(Object) + }) + ) + + return response.receiver +} +async function createQuote( + deps: AdminActionsDeps, + senderWalletAddressId: string, + receiver: Receiver +): Promise { + const { sendingASE } = deps + const response = await sendingASE.adminClient.createQuote({ + walletAddressId: senderWalletAddressId, + receiver: receiver.id + }) + + expect(response.code).toBe('200') + assert(response.quote) + + return response.quote +} +async function createOutgoingPayment( + deps: AdminActionsDeps, + senderWalletAddressId: string, + quote: Quote +): Promise { + const { sendingASE } = deps + const handleWebhookEventSpy = jest.spyOn( + sendingASE.integrationServer.webhookEventHandler, + 'handleWebhookEvent' + ) + + const response = await sendingASE.adminClient.createOutgoingPayment({ + walletAddressId: senderWalletAddressId, + quoteId: quote.id + }) + + expect(response.code).toBe('200') + assert(response.payment) + + await pollCondition( + () => { + return ( + handleWebhookEventSpy.mock.calls.some( + (call) => call[0]?.type === WebhookEventType.OutgoingPaymentCreated + ) && + handleWebhookEventSpy.mock.calls.some( + (call) => call[0]?.type === WebhookEventType.OutgoingPaymentCompleted + ) + ) + }, + 5, + 0.5 + ) + + expect(handleWebhookEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: WebhookEventType.OutgoingPaymentCreated, + data: expect.any(Object) + }) + ) + expect(handleWebhookEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: WebhookEventType.OutgoingPaymentCompleted, + data: expect.any(Object) + }) + ) + + return response.payment +} +async function getOutgoingPayment( + deps: AdminActionsDeps, + outgoingPaymentId: string, + amountValueToSend: string +): Promise { + const { sendingASE } = deps + const payment = + await sendingASE.adminClient.getOutgoingPayment(outgoingPaymentId) + payment.receiveAmount.value = BigInt(payment.receiveAmount.value) + payment.sentAmount.value = BigInt(payment.sentAmount.value) + payment.debitAmount.value = BigInt(payment.debitAmount.value) + expect(payment.state).toBe(OutgoingPaymentState.Completed) + expect(payment.receiveAmount.value).toBe(BigInt(amountValueToSend)) + return payment +} diff --git a/test/integration/lib/test-actions/index.ts b/test/integration/lib/test-actions/index.ts new file mode 100644 index 0000000000..b67aef23ce --- /dev/null +++ b/test/integration/lib/test-actions/index.ts @@ -0,0 +1,136 @@ +import assert from 'assert' +import { MockASE } from '../mock-ase' +import { parseCookies } from '../utils' +import { WalletAddress, PendingGrant } from '@interledger/open-payments' +import { AdminActions, createAdminActions } from './admin' +import { OpenPaymentsActions, createOpenPaymentsActions } from './open-payments' + +export interface TestActionsDeps { + sendingASE: MockASE + receivingASE: MockASE +} + +export interface TestActions { + consentInteraction( + outgoingPaymentGrant: PendingGrant, + senderWalletAddress: WalletAddress + ): Promise + consentInteractionWithInteractRef( + outgoingPaymentGrant: PendingGrant, + senderWalletAddress: WalletAddress + ): Promise + admin: AdminActions + openPayments: OpenPaymentsActions +} + +export function createTestActions(deps: TestActionsDeps): TestActions { + return { + consentInteraction: (outgoingPaymentGrant, senderWalletAddress) => + consentInteraction(deps, outgoingPaymentGrant, senderWalletAddress), + consentInteractionWithInteractRef: ( + outgoingPaymentGrant, + senderWalletAddress + ) => + consentInteractionWithInteractRef( + deps, + outgoingPaymentGrant, + senderWalletAddress + ), + admin: createAdminActions(deps), + openPayments: createOpenPaymentsActions(deps) + } +} + +async function consentInteraction( + deps: TestActionsDeps, + outgoingPaymentGrant: PendingGrant, + senderWalletAddress: WalletAddress +) { + const { interactId, nonce, cookie } = await _startAndAcceptInteraction( + outgoingPaymentGrant, + senderWalletAddress + ) + + // Finish interacton + const finishResponse = await fetch( + `${senderWalletAddress.authServer}/interact/${interactId}/${nonce}/finish`, + { + method: 'GET', + headers: { + 'x-idp-secret': 'replace-me', + cookie + } + } + ) + expect(finishResponse.status).toBe(202) +} + +async function consentInteractionWithInteractRef( + deps: TestActionsDeps, + outgoingPaymentGrant: PendingGrant, + senderWalletAddress: WalletAddress +): Promise { + const { interactId, nonce, cookie } = await _startAndAcceptInteraction( + outgoingPaymentGrant, + senderWalletAddress + ) + + // Finish interacton + const finishResponse = await fetch( + `${senderWalletAddress.authServer}/interact/${interactId}/${nonce}/finish`, + { + method: 'GET', + headers: { + 'x-idp-secret': 'replace-me', + cookie + }, + redirect: 'manual' // dont follow redirects + } + ) + expect(finishResponse.status).toBe(302) + + const redirectURI = finishResponse.headers.get('location') + assert(redirectURI) + + const url = new URL(redirectURI) + const interact_ref = url.searchParams.get('interact_ref') + assert(interact_ref) + + return interact_ref +} + +async function _startAndAcceptInteraction( + outgoingPaymentGrant: PendingGrant, + senderWalletAddress: WalletAddress +): Promise<{ nonce: string; interactId: string; cookie: string }> { + const { redirect: startInteractionUrl } = outgoingPaymentGrant.interact + + // Start interaction + const interactResponse = await fetch(startInteractionUrl, { + redirect: 'manual' // dont follow redirects + }) + expect(interactResponse.status).toBe(302) + + const cookie = parseCookies(interactResponse) + + const nonce = outgoingPaymentGrant.interact.finish + const tokens = startInteractionUrl.split('/interact/') + const interactId = tokens[1] ? tokens[1].split('/')[0] : null + assert(interactId) + + // Accept + const acceptResponse = await fetch( + `${senderWalletAddress.authServer}/grant/${interactId}/${nonce}/accept`, + { + method: 'POST', + headers: { + 'x-idp-secret': 'replace-me', + cookie + } + } + ) + + expect(acceptResponse.status).toBe(202) + + return { nonce, interactId, cookie } +} diff --git a/test/integration/lib/test-actions/open-payments.ts b/test/integration/lib/test-actions/open-payments.ts new file mode 100644 index 0000000000..c10a97f9ab --- /dev/null +++ b/test/integration/lib/test-actions/open-payments.ts @@ -0,0 +1,392 @@ +import assert from 'assert' +import { + Grant, + GrantRequest, + IncomingPayment, + OutgoingPayment, + PendingGrant, + PublicIncomingPayment, + Quote, + WalletAddress, + isFinalizedGrant, + isPendingGrant +} from '@interledger/open-payments' +import { MockASE } from '../mock-ase' +import { poll, pollCondition, wait } from '../utils' +import { WebhookEventType } from 'mock-account-service-lib' + +export interface OpenPaymentsActionsDeps { + sendingASE: MockASE + receivingASE: MockASE +} + +export interface OpenPaymentsActions { + grantRequestIncomingPayment( + receiverWalletAddress: WalletAddress + ): Promise + createIncomingPayment( + receiverWalletAddress: WalletAddress, + amountValueToSend: string, + accessToken: string + ): Promise + grantRequestQuote(senderWalletAddress: WalletAddress): Promise + createQuote( + senderWalletAddress: WalletAddress, + accessToken: string, + incomingPayment: IncomingPayment + ): Promise + grantRequestOutgoingPayment( + senderWalletAddress: WalletAddress, + quote: Quote, + finish?: InteractFinish + ): Promise + pollGrantContinue(outgoingPaymentGrant: PendingGrant): Promise + grantContinue( + outgoingPaymentGrant: PendingGrant, + interact_ref: string + ): Promise + createOutgoingPayment( + senderWalletAddress: WalletAddress, + grant: Grant, + quote: Quote + ): Promise + getOutgoingPayment( + url: string, + grantContinue: Grant, + amountValueToSend: string + ): Promise + getPublicIncomingPayment( + url: string, + amountValueToSend: string + ): Promise +} + +export function createOpenPaymentsActions( + deps: OpenPaymentsActionsDeps +): OpenPaymentsActions { + return { + grantRequestIncomingPayment: (receiverWalletAddress) => + grantRequestIncomingPayment(deps, receiverWalletAddress), + createIncomingPayment: ( + receiverWalletAddress, + amountValueToSend, + accessToken + ) => + createIncomingPayment( + deps, + receiverWalletAddress, + amountValueToSend, + accessToken + ), + grantRequestQuote: (senderWalletAddress) => + grantRequestQuote(deps, senderWalletAddress), + createQuote: (senderWalletAddress, accessToken, incomingPayment) => + createQuote(deps, senderWalletAddress, accessToken, incomingPayment), + grantRequestOutgoingPayment: (senderWalletAddress, quote, finish) => + grantRequestOutgoingPayment(deps, senderWalletAddress, quote, finish), + pollGrantContinue: (outgoingPaymentGrant) => + pollGrantContinue(deps, outgoingPaymentGrant), + grantContinue: (outgoingPaymentGrant, interact_ref) => + grantContinue(deps, outgoingPaymentGrant, interact_ref), + createOutgoingPayment: (senderWalletAddress, grant, quote) => + createOutgoingPayment(deps, senderWalletAddress, grant, quote), + getOutgoingPayment: (url, grantContinue, amountValueToSend) => + getOutgoingPayment(deps, url, grantContinue, amountValueToSend), + getPublicIncomingPayment: (url, amountValueToSend) => + getPublicIncomingPayment(deps, url, amountValueToSend) + } +} +async function grantRequestIncomingPayment( + deps: OpenPaymentsActionsDeps, + receiverWalletAddress: WalletAddress +): Promise { + const { sendingASE } = deps + const grant = await sendingASE.opClient.grant.request( + { + url: receiverWalletAddress.authServer + }, + { + access_token: { + access: [ + { + type: 'incoming-payment', + actions: ['create', 'read', 'list', 'complete'] + } + ] + } + } + ) + assert(!isPendingGrant(grant)) + return grant +} + +async function createIncomingPayment( + deps: OpenPaymentsActionsDeps, + receiverWalletAddress: WalletAddress, + amountValueToSend: string, + accessToken: string +) { + const { sendingASE, receivingASE } = deps + const now = new Date() + const tomorrow = new Date(now) + tomorrow.setDate(now.getDate() + 1) + + const handleWebhookEventSpy = jest.spyOn( + receivingASE.integrationServer.webhookEventHandler, + 'handleWebhookEvent' + ) + + const incomingPayment = await sendingASE.opClient.incomingPayment.create( + { + url: receiverWalletAddress.resourceServer, + accessToken + }, + { + walletAddress: receiverWalletAddress.id, + incomingAmount: { + value: amountValueToSend, + assetCode: receiverWalletAddress.assetCode, + assetScale: receiverWalletAddress.assetScale + }, + metadata: { description: 'Free Money!' }, + expiresAt: tomorrow.toISOString() + } + ) + + await pollCondition( + () => { + return handleWebhookEventSpy.mock.calls.some( + (call) => call[0]?.type === WebhookEventType.IncomingPaymentCreated + ) + }, + 5, + 0.5 + ) + + expect(handleWebhookEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: WebhookEventType.IncomingPaymentCreated, + data: expect.any(Object) + }) + ) + + return incomingPayment +} + +async function grantRequestQuote( + deps: OpenPaymentsActionsDeps, + senderWalletAddress: WalletAddress +): Promise { + const { sendingASE } = deps + const grant = await sendingASE.opClient.grant.request( + { + url: senderWalletAddress.authServer + }, + { + access_token: { + access: [ + { + type: 'quote', + actions: ['read', 'create'] + } + ] + } + } + ) + assert(!isPendingGrant(grant)) + return grant +} + +async function createQuote( + deps: OpenPaymentsActionsDeps, + senderWalletAddress: WalletAddress, + accessToken: string, + incomingPayment: IncomingPayment +): Promise { + const { sendingASE } = deps + return await sendingASE.opClient.quote.create( + { + url: senderWalletAddress.resourceServer, + accessToken + }, + { + walletAddress: senderWalletAddress.id, + receiver: incomingPayment.id.replace('https', 'http'), + method: 'ilp' + } + ) +} + +type InteractFinish = NonNullable['finish'] + +async function grantRequestOutgoingPayment( + deps: OpenPaymentsActionsDeps, + senderWalletAddress: WalletAddress, + quote: Quote, + finish?: InteractFinish +): Promise { + const { receivingASE } = deps + const grant = await receivingASE.opClient.grant.request( + { + url: senderWalletAddress.authServer + }, + { + access_token: { + access: [ + { + type: 'outgoing-payment', + actions: ['create', 'read', 'list'], + identifier: senderWalletAddress.id, + limits: { + debitAmount: quote.debitAmount, + receiveAmount: quote.receiveAmount + } + } + ] + }, + interact: { + start: ['redirect'], + finish + } + } + ) + + assert(isPendingGrant(grant)) + + if (grant.continue.wait) { + // Delay following request according to the continue wait time (if any) + await wait(grant.continue.wait * 1000) + } + + return grant +} + +async function pollGrantContinue( + deps: OpenPaymentsActionsDeps, + outgoingPaymentGrant: PendingGrant +): Promise { + const { sendingASE } = deps + const { access_token, uri } = outgoingPaymentGrant.continue + const grantContinue = await poll( + async () => + sendingASE.opClient.grant.continue({ + accessToken: access_token.value, + url: uri + }), + (responseData) => 'access_token' in responseData, + 20, + 5 + ) + + assert(isFinalizedGrant(grantContinue)) + return grantContinue +} + +async function grantContinue( + deps: OpenPaymentsActionsDeps, + outgoingPaymentGrant: PendingGrant, + interact_ref: string +): Promise { + const { sendingASE } = deps + const { access_token, uri } = outgoingPaymentGrant.continue + const grantContinue = await sendingASE.opClient.grant.continue( + { + accessToken: access_token.value, + url: uri + }, + { interact_ref } + ) + + assert(isFinalizedGrant(grantContinue)) + return grantContinue +} + +async function createOutgoingPayment( + deps: OpenPaymentsActionsDeps, + senderWalletAddress: WalletAddress, + grantContinue: Grant, + quote: Quote +): Promise { + const { sendingASE } = deps + const handleWebhookEventSpy = jest.spyOn( + sendingASE.integrationServer.webhookEventHandler, + 'handleWebhookEvent' + ) + + const outgoingPayment = await sendingASE.opClient.outgoingPayment.create( + { + url: senderWalletAddress.resourceServer, + accessToken: grantContinue.access_token.value + }, + { + walletAddress: senderWalletAddress.id, + metadata: {}, + quoteId: quote.id + } + ) + + await pollCondition( + () => { + return ( + handleWebhookEventSpy.mock.calls.some( + (call) => call[0]?.type === WebhookEventType.OutgoingPaymentCreated + ) && + handleWebhookEventSpy.mock.calls.some( + (call) => call[0]?.type === WebhookEventType.OutgoingPaymentCompleted + ) + ) + }, + 5, + 0.5 + ) + + expect(handleWebhookEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: WebhookEventType.OutgoingPaymentCreated, + data: expect.any(Object) + }) + ) + expect(handleWebhookEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: WebhookEventType.OutgoingPaymentCompleted, + data: expect.any(Object) + }) + ) + + return outgoingPayment +} + +async function getOutgoingPayment( + deps: OpenPaymentsActionsDeps, + url: string, + grantContinue: Grant, + amountValueToSend: string +): Promise { + const { sendingASE } = deps + const outgoingPayment = await sendingASE.opClient.outgoingPayment.get({ + url, + accessToken: grantContinue.access_token.value + }) + + expect(outgoingPayment.id).toBe(outgoingPayment.id) + expect(outgoingPayment.receiveAmount.value).toBe(amountValueToSend) + expect(outgoingPayment.sentAmount.value).toBe(amountValueToSend) + + return outgoingPayment +} + +async function getPublicIncomingPayment( + deps: OpenPaymentsActionsDeps, + url: string, + amountValueToSend: string +): Promise { + const { receivingASE } = deps + const incomingPayment = await receivingASE.opClient.incomingPayment.getPublic( + { url } + ) + + assert(incomingPayment.receivedAmount) + expect(incomingPayment.receivedAmount.value).toBe(amountValueToSend) + + return incomingPayment +} diff --git a/test/integration/package.json b/test/integration/package.json index 0612a809d6..55754fd7b9 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -21,12 +21,10 @@ "@types/koa": "2.14.0", "@types/koa-bodyparser": "^4.3.12", "@types/node": "^18.19.19", - "@types/uuid": "^9.0.8", "dotenv": "^16.4.1", "hostile": "^1.4.0", "koa": "^2.15.0", "mock-account-service-lib": "workspace:*", - "uuid": "^9.0.1", "yaml": "^2.3.4" } }