From 750f4148618990b3b0d85d1d987b39fc46c1ac19 Mon Sep 17 00:00:00 2001 From: Max Kurapov Date: Fri, 23 Aug 2024 18:41:07 +0200 Subject: [PATCH] refactor(backend): handle grant lookup more gracefully in remote incoming payment service (#2888) * feat(backend): add grantService.getOrCreate method * feat(backend): update grant lookup call * feat(backend): add deletedAt to grants table * feat(backend): add migrations for grants table * chore(backend): separating into functions * chore(backend): adding authServerService to list of services * test(backend): adding tests for getOrCreate and getExistingGrant functions * refactor(backend): let remote incoming payment use new grantService.getOrCreate function * feat(backend): getting incoming payment with retry * chore(backend): remove unused grant service functions * chore(backend): merge migrations * feat(backend): add subset actions if present * feat(backend): add subset actions if present * chore(backend): dont need some deps in receiver service * test(backend): adding tests for receiver service * test(backend): adding remote incoming payment service tests * chore(backend): dont throw when resolving receiver, return undefined * chore(backend): add some logging --- packages/backend/src/index.ts | 7 +- .../src/open_payments/grant/service.test.ts | 556 ++++++++++---- .../src/open_payments/grant/service.ts | 40 +- .../payment/incoming_remote/errors.ts | 4 + .../payment/incoming_remote/service.test.ts | 689 ++++++++++++------ .../payment/incoming_remote/service.ts | 293 +++++--- .../open_payments/receiver/service.test.ts | 664 +++++++---------- .../src/open_payments/receiver/service.ts | 149 +--- 8 files changed, 1387 insertions(+), 1015 deletions(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index e438e8ba81..a34d784ef7 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -336,19 +336,14 @@ export function initIocContainer( }) }) container.singleton('receiverService', async (deps) => { - const config = await deps.use('config') return await createReceiverService({ logger: await deps.use('logger'), streamCredentialsService: await deps.use('streamCredentialsService'), - grantService: await deps.use('grantService'), incomingPaymentService: await deps.use('incomingPaymentService'), - openPaymentsUrl: config.openPaymentsUrl, walletAddressService: await deps.use('walletAddressService'), - openPaymentsClient: await deps.use('openPaymentsClient'), remoteIncomingPaymentService: await deps.use( 'remoteIncomingPaymentService' - ), - config: await deps.use('config') + ) }) }) diff --git a/packages/backend/src/open_payments/grant/service.test.ts b/packages/backend/src/open_payments/grant/service.test.ts index 3e70c936ac..eb10c65d71 100644 --- a/packages/backend/src/open_payments/grant/service.test.ts +++ b/packages/backend/src/open_payments/grant/service.test.ts @@ -3,12 +3,7 @@ import { faker } from '@faker-js/faker' import { Knex } from 'knex' import assert from 'assert' import { Grant } from './model' -import { - CreateOptions, - GrantService, - ServiceDependencies, - getExistingGrant -} from './service' +import { GrantService, ServiceDependencies, getExistingGrant } from './service' import { AuthServer } from '../authServer/model' import { initIocContainer } from '../..' import { AppServices } from '../../app' @@ -58,166 +53,455 @@ describe('Grant Service', (): void => { await appContainer.shutdown() }) - describe('Create and Get Grant by options', (): void => { - describe.each` - existingAuthServer | description - ${false} | ${'new auth server'} - ${true} | ${'existing auth server'} - `('$description', ({ existingAuthServer }): void => { - let authServerId: string | undefined - let grant: Grant | undefined - const authServerUrl = faker.internet.url({ appendSlash: false }) - - beforeEach(async (): Promise => { - if (existingAuthServer) { - const authServerService = await deps.use('authServerService') - authServerId = (await authServerService.getOrCreate(authServerUrl)).id - } else { - await expect( - AuthServer.query(knex).findOne({ - url: authServerUrl - }) - ).resolves.toBeUndefined() - authServerId = undefined - } + describe('getOrCreate', (): void => { + let authServer: AuthServer + + beforeEach(async (): Promise => { + const authServerService = await deps.use('authServerService') + const url = faker.internet.url({ appendSlash: false }) + authServer = await authServerService.getOrCreate(url) + }) + + test('gets existing grant', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] }) - afterEach(async (): Promise => { - if (existingAuthServer) { - expect(grant?.authServerId).toEqual(authServerId) - } else { - await expect( - AuthServer.query(knex).findOne({ - url: authServerUrl - }) - ).resolves.toMatchObject({ - id: grant?.authServerId - }) - } + const openPaymentsGrantRequestSpy = jest.spyOn( + openPaymentsClient.grant, + 'request' + ) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant.id).toBe(existingGrant.id) + expect(openPaymentsGrantRequestSpy).not.toHaveBeenCalled() + }) + + test('updates expired grant (by rotating existing token)', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.ReadAll, + AccessAction.Create, + AccessAction.Complete + ], + expiresAt: new Date(Date.now() - 1000) }) - test.each` - expiresIn | description - ${undefined} | ${'without expiresIn'} - ${600} | ${'with expiresIn'} - `( - 'Grant can be created and fetched ($description)', - async ({ expiresIn }): Promise => { - const accessToken = uuid() - const options: CreateOptions = { - accessToken, - managementUrl: `${faker.internet.url({ - appendSlash: false - })}/${uuid()}`, - authServer: authServerUrl, - accessType: AccessType.IncomingPayment, - accessActions: [AccessAction.ReadAll] + const openPaymentsGrantRequestSpy = jest.spyOn( + openPaymentsClient.grant, + 'request' + ) + + const rotatedAccessToken = mockAccessToken() + const managementId = uuid() + rotatedAccessToken.access_token.manage = `${faker.internet.url()}token/${managementId}` + + const openPaymentsTokenRotationSpy = jest + .spyOn(openPaymentsClient.token, 'rotate') + .mockResolvedValueOnce(rotatedAccessToken) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + } + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant).toMatchObject({ + id: existingGrant.id, + authServerId: existingGrant.authServerId, + accessToken: rotatedAccessToken.access_token.value, + expiresAt: new Date( + Date.now() + rotatedAccessToken.access_token.expires_in! * 1000 + ), + managementId + }) + expect(openPaymentsGrantRequestSpy).not.toHaveBeenCalled() + expect(openPaymentsTokenRotationSpy).toHaveBeenCalledWith({ + url: existingGrant.getManagementUrl(authServer.url), + accessToken: existingGrant.accessToken + }) + }) + + test('creates new grant when no prior existing grant', async () => { + const managementId = uuid() + const newOpenPaymentsGrant = mockGrant() + newOpenPaymentsGrant.access_token.manage = `${faker.internet.url()}token/${managementId}` + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce({ + ...newOpenPaymentsGrant + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.Read] + } + + const authServerServiceGetOrCreateSoy = jest.spyOn( + authServerService, + 'getOrCreate' + ) + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant).toMatchObject({ + authServerId: authServer.id, + accessType: options.accessType, + accessActions: options.accessActions, + accessToken: newOpenPaymentsGrant.access_token.value, + expiresAt: new Date( + Date.now() + newOpenPaymentsGrant.access_token.expires_in! * 1000 + ), + managementId + }) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalledWith( + { url: options.authServer }, + { + access_token: { + access: [ + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: options.accessType as any, + actions: options.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } + ) + expect(authServerServiceGetOrCreateSoy).toHaveBeenCalled() + }) + + test('creates new grant with additional subset actions', async () => { + const newOpenPaymentsGrant = mockGrant() + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce({ + ...newOpenPaymentsGrant + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.Create, + AccessAction.ReadAll, + AccessAction.ListAll + ] + } + + const authServerServiceGetOrCreateSoy = jest.spyOn( + authServerService, + 'getOrCreate' + ) + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant.accessActions.sort()).toEqual( + [ + AccessAction.Create, + AccessAction.ReadAll, + AccessAction.ListAll, + AccessAction.List, + AccessAction.Read + ].sort() + ) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalledWith( + { url: options.authServer }, + { + access_token: { + access: [ + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: options.accessType as any, + actions: options.accessActions + } + ] + }, + interact: { + start: ['redirect'] } - grant = await grantService.create({ - ...options, - expiresIn - }) - expect(grant).toMatchObject({ - accessType: options.accessType, - accessActions: options.accessActions, - expiresAt: expiresIn - ? new Date(Date.now() + expiresIn * 1000) - : null - }) - expect(grant.expired).toBe(false) - await expect(grantService.get(options)).resolves.toEqual(grant) } ) + expect(authServerServiceGetOrCreateSoy).toHaveBeenCalled() }) - test('cannot fetch non-existing grant', async (): Promise => { - const options: CreateOptions = { + test('creates new grant and deletes old one after being unable to rotate existing token', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, accessToken: uuid(), - managementUrl: `${faker.internet.url({ - appendSlash: false - })}/gt5hy6ju7ki8`, - authServer: faker.internet.url({ appendSlash: false }), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.Read, + AccessAction.Create, + AccessAction.Complete + ], + expiresAt: new Date(Date.now() - 1000) + }) + + const managementId = uuid() + const newOpenPaymentsGrant = mockGrant() + newOpenPaymentsGrant.access_token.manage = `${faker.internet.url()}token/${managementId}` + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce(newOpenPaymentsGrant) + + const openPaymentsTokenRotationSpy = jest + .spyOn(openPaymentsClient.token, 'rotate') + .mockImplementationOnce(() => { + throw new Error('Could not rotate token') + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.Read] + } + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant.id).not.toBe(existingGrant.id) + expect(grant).toMatchObject({ + accessType: options.accessType, + accessActions: options.accessActions, + authServerId: authServer.id, + accessToken: newOpenPaymentsGrant.access_token.value, + expiresAt: new Date( + Date.now() + newOpenPaymentsGrant.access_token.expires_in! * 1000 + ), + managementId + }) + expect(openPaymentsTokenRotationSpy).toHaveBeenCalled() + expect(openPaymentsGrantRequestSpy).toHaveBeenCalled() + + const originalGrant = await Grant.query(knex).findById(existingGrant.id) + expect(originalGrant?.deletedAt).toBeDefined() + }) + + test('returns error if Open Payments grant request fails', async () => { + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockImplementationOnce(() => { + throw new Error('Could not request grant') + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + } + + const error = await grantService.getOrCreate(options) + + expect(error).toBe(GrantError.InvalidGrantRequest) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalled() + }) + + test('returns error if Open Payments grant request returns a pending grant', async () => { + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce(mockPendingGrant()) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + } + + const error = await grantService.getOrCreate(options) + + expect(error).toBe(GrantError.GrantRequiresInteraction) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalled() + }) + }) + + describe('getExistingGrant', (): void => { + let authServer: AuthServer + + beforeEach(async (): Promise => { + const authServerService = await deps.use('authServerService') + const url = faker.internet.url({ appendSlash: false }) + authServer = await authServerService.getOrCreate(url) + }) + + test('gets existing grant (identical match)', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: authServer.url, accessType: AccessType.IncomingPayment, accessActions: [AccessAction.ReadAll] } - await grantService.create(options) + await expect( - grantService.get({ - ...options, - authServer: faker.internet.url({ appendSlash: false }) - }) + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toEqual({ ...existingGrant, authServer }) + }) + + test('gets existing grant (requested actions are a subset of saved actions)', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.Complete, + AccessAction.Create, + AccessAction.ReadAll + ] + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll, AccessAction.Create] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toEqual({ ...existingGrant, authServer }) + }) + + test('ignores deleted grants', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll], + deletedAt: new Date() + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) ).resolves.toBeUndefined() + }) + + test('ignores different accessType', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.OutgoingPayment, + accessActions: [AccessAction.ReadAll] + } + await expect( - grantService.get({ - ...options, - accessType: AccessType.Quote - }) + getExistingGrant({ knex } as ServiceDependencies, options) ).resolves.toBeUndefined() + }) + + test('ignores different auth server url', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + await expect( - grantService.get({ - ...options, - accessActions: [AccessAction.Read] - }) + getExistingGrant({ knex } as ServiceDependencies, options) ).resolves.toBeUndefined() }) - test('cannot store grant with missing management url', async (): Promise => { - const options: CreateOptions = { + test('ignores insufficient accessActions', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, accessToken: uuid(), - managementUrl: '', - authServer: faker.internet.url({ appendSlash: false }), + managementId: uuid(), accessType: AccessType.IncomingPayment, accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: authServer.id, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll, AccessAction.Create] } - await expect(grantService.create(options)).rejects.toThrow( - 'invalid management id' - ) + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toBeUndefined() }) }) - describe.each` - expiresIn | description - ${undefined} | ${'without prior expiresIn'} - ${3000} | ${'with prior expiresIn'} - `('Update Grant ($description)', ({ expiresIn }): void => { - let grant: Grant + describe('delete', (): void => { + let authServer: AuthServer + beforeEach(async (): Promise => { - const options = { - authServer: faker.internet.url({ appendSlash: false }), - accessType: AccessType.IncomingPayment, - accessActions: [AccessAction.ReadAll], + const authServerService = await deps.use('authServerService') + const url = faker.internet.url({ appendSlash: false }) + authServer = await authServerService.getOrCreate(url) + }) + + test('deletes grant', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, accessToken: uuid(), - managementUrl: `${faker.internet.url({ - appendSlash: false - })}/gt5hy6ju7ki8`, - expiresIn - } - grant = await grantService.create(options) - }) - test.each` - expiresIn | description - ${undefined} | ${'without expiresIn'} - ${6000} | ${'with expiresIn'} - `( - 'can update grant ($description)', - async ({ expiresIn }): Promise => { - const updateOptions = { - accessToken: uuid(), - managementUrl: `${faker.internet.url({ - appendSlash: false - })}/${uuid()}`, - expiresIn - } - const updatedGrant = await grantService.update(grant, updateOptions) - expect(updatedGrant).toEqual({ - ...grant, - accessToken: updateOptions.accessToken, - managementId: updateOptions.managementUrl.split('/').pop(), - expiresAt: expiresIn ? new Date(Date.now() + expiresIn * 1000) : null, - updatedAt: updatedGrant.updatedAt - }) - } - ) + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const now = new Date() + jest.setSystemTime(now) + + const grant = await grantService.delete(existingGrant.id) + + expect(grant.id).toBe(existingGrant.id) + expect(grant.deletedAt).toEqual(now) + }) }) describe('getOrCreate', (): void => { diff --git a/packages/backend/src/open_payments/grant/service.ts b/packages/backend/src/open_payments/grant/service.ts index b6ef161e80..a57f259763 100644 --- a/packages/backend/src/open_payments/grant/service.ts +++ b/packages/backend/src/open_payments/grant/service.ts @@ -11,9 +11,6 @@ import { import { GrantError } from './errors' export interface GrantService { - create(options: CreateOptions): Promise - get(options: GrantOptions): Promise - update(grant: Grant, options: UpdateOptions): Promise getOrCreate(options: GrantOptions): Promise delete(id: string): Promise } @@ -34,56 +31,23 @@ export async function createGrantService( } return { - get: (options) => getGrant(deps, options), - create: (options) => createGrant(deps, options), - update: (grant, options) => updateGrant(deps, grant, options), getOrCreate: (options) => getOrCreateGrant(deps, options), delete: (id) => deleteGrant(deps, id) } } -export interface GrantOptions { +interface GrantOptions { authServer: string accessType: AccessType accessActions: AccessAction[] } -export interface UpdateOptions { +interface UpdateOptions { accessToken: string managementUrl: string expiresIn?: number } -export type CreateOptions = GrantOptions & UpdateOptions - -async function createGrant(deps: ServiceDependencies, options: CreateOptions) { - const { id: authServerId } = await deps.authServerService.getOrCreate( - options.authServer - ) - return Grant.query(deps.knex) - .insertAndFetch({ - accessType: options.accessType, - accessActions: options.accessActions, - accessToken: options.accessToken, - managementId: retrieveManagementId(options.managementUrl), - authServerId, - expiresAt: options.expiresIn - ? new Date(Date.now() + options.expiresIn * 1000) - : undefined - }) - .withGraphFetched('authServer') -} - -async function getGrant(deps: ServiceDependencies, options: GrantOptions) { - return Grant.query(deps.knex) - .findOne({ - accessType: options.accessType, - accessActions: options.accessActions - }) - .withGraphJoined('authServer') - .where('authServer.url', options.authServer) -} - async function updateGrant( deps: ServiceDependencies, grant: Grant, diff --git a/packages/backend/src/open_payments/payment/incoming_remote/errors.ts b/packages/backend/src/open_payments/payment/incoming_remote/errors.ts index 599ef02183..0b51bfe58c 100644 --- a/packages/backend/src/open_payments/payment/incoming_remote/errors.ts +++ b/packages/backend/src/open_payments/payment/incoming_remote/errors.ts @@ -1,6 +1,7 @@ import { GraphQLErrorCode } from '../../../graphql/errors' export enum RemoteIncomingPaymentError { + NotFound = 'NotFound', UnknownWalletAddress = 'UnknownWalletAddress', InvalidRequest = 'InvalidRequest', InvalidGrant = 'InvalidGrant' @@ -15,6 +16,7 @@ export const isRemoteIncomingPaymentError = ( export const errorToHTTPCode: { [key in RemoteIncomingPaymentError]: number } = { + [RemoteIncomingPaymentError.NotFound]: 404, [RemoteIncomingPaymentError.UnknownWalletAddress]: 404, [RemoteIncomingPaymentError.InvalidRequest]: 500, [RemoteIncomingPaymentError.InvalidGrant]: 500 @@ -23,6 +25,7 @@ export const errorToHTTPCode: { export const errorToCode: { [key in RemoteIncomingPaymentError]: GraphQLErrorCode } = { + [RemoteIncomingPaymentError.NotFound]: GraphQLErrorCode.NotFound, [RemoteIncomingPaymentError.UnknownWalletAddress]: GraphQLErrorCode.NotFound, [RemoteIncomingPaymentError.InvalidRequest]: GraphQLErrorCode.BadUserInput, [RemoteIncomingPaymentError.InvalidGrant]: GraphQLErrorCode.Forbidden @@ -30,6 +33,7 @@ export const errorToCode: { export const errorToMessage: { [key in RemoteIncomingPaymentError]: string } = { + [RemoteIncomingPaymentError.NotFound]: 'unknown incoming payment', [RemoteIncomingPaymentError.UnknownWalletAddress]: 'unknown wallet address', [RemoteIncomingPaymentError.InvalidRequest]: 'invalid remote incoming payment request', diff --git a/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts b/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts index ac7ea45c38..a971c3523a 100644 --- a/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts @@ -1,4 +1,5 @@ import { Knex } from 'knex' +import { v4 as uuid } from 'uuid' import { RemoteIncomingPaymentService } from './service' import { createTestApp, TestContainer } from '../../../tests/app' import { Config } from '../../../config/app' @@ -11,15 +12,15 @@ import { AuthenticatedClient as OpenPaymentsClient, AccessAction, AccessType, - mockPendingGrant, - mockGrant, mockWalletAddress, - mockIncomingPaymentWithPaymentMethods + mockIncomingPaymentWithPaymentMethods, + OpenPaymentsClientError, + mockPublicIncomingPayment } from '@interledger/open-payments' import { GrantService } from '../../grant/service' import { RemoteIncomingPaymentError } from './errors' -import { AccessToken } from '@interledger/open-payments' import { Grant } from '../../grant/model' +import { GrantError } from '../../grant/errors' describe('Remote Incoming Payment Service', (): void => { let deps: IocContract @@ -50,19 +51,16 @@ describe('Remote Incoming Payment Service', (): void => { }) describe('create', (): void => { - const amount: Amount = { - value: BigInt(123), - assetCode: 'USD', - assetScale: 2 - } - const walletAddress = mockWalletAddress({ id: 'https://example.com' }) - const grantOptions = { - accessType: AccessType.IncomingPayment, - accessActions: [AccessAction.Create, AccessAction.ReadAll], - accessToken: 'OZB8CDFONP219RP1LT0OS9M2PMHKUR64TB8N6BW7', - authServer: walletAddress.authServer, - managementUrl: `${walletAddress.authServer}/token/aq1sw2de3fr4` - } + const walletAddress = mockWalletAddress({ + id: 'https://example.com/mocked', + resourceServer: 'https://example.com/' + }) + + beforeEach(() => { + jest + .spyOn(openPaymentsClient.walletAddress, 'get') + .mockResolvedValue(walletAddress) + }) test('throws if wallet address not found', async () => { const clientGetWalletAddressSpy = jest @@ -81,270 +79,493 @@ describe('Remote Incoming Payment Service', (): void => { }) }) - describe('with existing grant', () => { - beforeAll(() => { - jest - .spyOn(openPaymentsClient.walletAddress, 'get') - .mockResolvedValue(walletAddress) + const incomingAmount: Amount = { + value: BigInt(123), + assetCode: 'USD', + assetScale: 2 + } + + test.each` + incomingAmount | expiresAt | metadata + ${undefined} | ${undefined} | ${undefined} + ${incomingAmount} | ${new Date(Date.now() + 30_000)} | ${{ description: 'Test incoming payment', externalRef: '#123' }} + `('creates remote incoming payment ($#)', async (args): Promise => { + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + ...args, + walletAddress: walletAddress.id + }) + + const clientCreateIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'create') + .mockResolvedValueOnce(mockedIncomingPayment) + + const accessToken = uuid() + const grantGetOrCreateSpy = jest + .spyOn(grantService, 'getOrCreate') + .mockResolvedValueOnce({ + accessToken + } as Grant) + + const incomingPayment = await remoteIncomingPaymentService.create({ + ...args, + walletAddressUrl: walletAddress.id }) - test('returns error if grant expired and token cannot be rotated', async () => { - await grantService.create({ - ...grantOptions, - expiresIn: -10 + expect(incomingPayment).toStrictEqual(mockedIncomingPayment) + expect(grantGetOrCreateSpy).toHaveBeenCalledWith({ + authServer: walletAddress.authServer, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + }) + expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledWith( + { + url: walletAddress.resourceServer, + accessToken + }, + { + ...args, + walletAddress: walletAddress.id, + expiresAt: args.expiresAt ? args.expiresAt.toISOString() : undefined, + incomingAmount: args.incomingAmount + ? serializeAmount(args.incomingAmount) + : undefined + } + ) + expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledTimes(1) + }) + + test('returns error if invalid grant', async () => { + jest + .spyOn(grantService, 'getOrCreate') + .mockResolvedValueOnce(GrantError.InvalidGrantRequest) + + const clientCreateIncomingPaymentSpy = jest.spyOn( + openPaymentsClient.incomingPayment, + 'create' + ) + + await expect( + remoteIncomingPaymentService.create({ + walletAddressUrl: walletAddress.id }) - const clientRequestRotateTokenSpy = jest - .spyOn(openPaymentsClient.token, 'rotate') - .mockRejectedValueOnce(new Error('Error in rotating client')) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidGrant) + expect(clientCreateIncomingPaymentSpy).not.toHaveBeenCalled() + }) - await expect( - remoteIncomingPaymentService.create({ - walletAddressUrl: walletAddress.id + test('returns error without retrying if not OpenPaymentsClientError', async () => { + jest.spyOn(grantService, 'getOrCreate').mockResolvedValueOnce({ + accessToken: uuid() + } as Grant) + + const clientCreateIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'create') + .mockImplementationOnce(() => { + throw new Error('unexpected') + }) + + await expect( + remoteIncomingPaymentService.create({ + walletAddressUrl: walletAddress.id + }) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledTimes(1) + }) + + test('returns error without retrying if non-auth related OpenPaymentsClientError', async () => { + jest.spyOn(grantService, 'getOrCreate').mockResolvedValueOnce({ + accessToken: uuid() + } as Grant) + + const clientCreateIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'create') + .mockImplementationOnce(() => { + throw new OpenPaymentsClientError('unexpected error', { + description: 'unexpected error' }) - ).rejects.toThrowError('Error in rotating client') - expect(clientRequestRotateTokenSpy).toHaveBeenCalled() - }) + }) - test('returns error if grant expired and management url cannot be retrieved', async () => { - const grant = await grantService.create({ - ...grantOptions, - expiresIn: -10 + await expect( + remoteIncomingPaymentService.create({ + walletAddressUrl: walletAddress.id }) - const getExistingGrantSpy = jest - .spyOn(grantService, 'get') - .mockResolvedValueOnce({ - ...grant, - authServer: undefined, - expired: true - } as Grant) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledTimes(1) + }) - await expect( - remoteIncomingPaymentService.create({ - walletAddressUrl: walletAddress.id + test('returns error without retrying if non-auth related OpenPaymentsClientError', async () => { + jest.spyOn(grantService, 'getOrCreate').mockResolvedValueOnce({ + accessToken: uuid() + } as Grant) + + const clientCreateIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'create') + .mockImplementationOnce(() => { + throw new OpenPaymentsClientError('unexpected error', { + description: 'unexpected error' }) - ).rejects.toThrow('unknown auth server') - expect(getExistingGrantSpy).toHaveBeenCalled() - }) - test.each` - incomingAmount | expiresAt | metadata - ${undefined} | ${undefined} | ${undefined} - ${amount} | ${new Date(Date.now() + 30_000)} | ${{ description: 'Test incoming payment', externalRef: '#123' }} - `('creates remote incoming payment ($#)', async (args): Promise => { - const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ - ...args, + }) + + await expect( + remoteIncomingPaymentService.create({ walletAddressUrl: walletAddress.id }) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledTimes(1) + }) - const grant = await grantService.create(grantOptions) + test('returns error after retrying auth related OpenPaymentsClientError', async () => { + const mockedGrant1 = { + id: uuid(), + accessToken: uuid() + } as Grant + const mockedGrant2 = { + id: uuid(), + accessToken: uuid() + } as Grant + + const grantGetOrCreateSpy = jest + .spyOn(grantService, 'getOrCreate') + .mockResolvedValueOnce(mockedGrant1) + .mockResolvedValueOnce(mockedGrant2) + + const grantDeleteSpy = jest + .spyOn(grantService, 'delete') + .mockResolvedValueOnce(mockedGrant1) + + const failedOpenPaymentsClientRequest = () => { + throw new OpenPaymentsClientError('Invalid token', { + status: 401, + description: 'Invalid token' + }) + } - const clientCreateIncomingPaymentSpy = jest - .spyOn(openPaymentsClient.incomingPayment, 'create') - .mockResolvedValueOnce(mockedIncomingPayment) + const clientCreateIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'create') + .mockImplementationOnce(failedOpenPaymentsClientRequest) + .mockImplementationOnce(failedOpenPaymentsClientRequest) - const incomingPayment = await remoteIncomingPaymentService.create({ - ...args, + await expect( + remoteIncomingPaymentService.create({ walletAddressUrl: walletAddress.id }) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledTimes(2) + expect(grantGetOrCreateSpy).toHaveBeenCalledTimes(2) + expect(grantDeleteSpy).toHaveBeenCalledTimes(1) + expect(grantDeleteSpy).toHaveBeenCalledWith(mockedGrant1.id) + }) - expect(incomingPayment).toStrictEqual(mockedIncomingPayment) - expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledWith( - { - url: walletAddress.id, - accessToken: grant.accessToken - }, - { - ...args, - walletAddress: walletAddress.id, - expiresAt: args.expiresAt - ? args.expiresAt.toISOString() - : undefined, - incomingAmount: args.incomingAmount - ? serializeAmount(args.incomingAmount) - : undefined - } - ) - }) + test.each([401, 403])( + 'creates incoming payment after retrying %s OpenPaymentsClientError', + async (status: number) => { + const mockedGrant1 = { + id: uuid(), + accessToken: uuid() + } as Grant + const mockedGrant2 = { + id: uuid(), + accessToken: uuid() + } as Grant + + const grantGetOrCreateSpy = jest + .spyOn(grantService, 'getOrCreate') + .mockResolvedValueOnce(mockedGrant1) + .mockResolvedValueOnce(mockedGrant2) + + const grantDeleteSpy = jest + .spyOn(grantService, 'delete') + .mockResolvedValueOnce(mockedGrant1) - test('returns error if fails to create the incoming payment', async () => { - await grantService.create(grantOptions) - jest + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + walletAddress: walletAddress.id + }) + + const clientCreateIncomingPaymentSpy = jest .spyOn(openPaymentsClient.incomingPayment, 'create') .mockImplementationOnce(() => { - throw new Error('Error in client') + throw new OpenPaymentsClientError('Invalid token', { + status, + description: 'Invalid token' + }) }) + .mockResolvedValueOnce(mockedIncomingPayment) await expect( remoteIncomingPaymentService.create({ walletAddressUrl: walletAddress.id }) - ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + ).resolves.toStrictEqual(mockedIncomingPayment) + expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledTimes(2) + expect(grantGetOrCreateSpy).toHaveBeenCalledTimes(2) + expect(grantDeleteSpy).toHaveBeenCalledTimes(1) + expect(grantDeleteSpy).toHaveBeenCalledWith(mockedGrant1.id) + } + ) + }) + + describe('get', (): void => { + const walletAddress = mockWalletAddress({ + id: 'https://example.com/mocked', + resourceServer: 'https://example.com/' + }) + + const publicIncomingPayment = mockPublicIncomingPayment({ + authServer: 'https://auth.example.com/' + }) + + beforeEach(() => { + jest + .spyOn(openPaymentsClient.incomingPayment, 'getPublic') + .mockResolvedValue(publicIncomingPayment) + }) + + test('returns NotFound error if 404 error getting public incoming payment', async () => { + const clientGetPublicIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'getPublic') + .mockImplementationOnce(() => { + throw new OpenPaymentsClientError( + 'Could not find public incoming payment', + { + status: 404, + description: 'Could not find public incoming payment' + } + ) + }) + + const incomingPaymentUrl = `https://example.com/incoming-payment/${uuid()}` + + await expect( + remoteIncomingPaymentService.get(incomingPaymentUrl) + ).resolves.toEqual(RemoteIncomingPaymentError.NotFound) + expect(clientGetPublicIncomingPaymentSpy).toHaveBeenCalledWith({ + url: incomingPaymentUrl }) + }) - describe.each` - incomingAmount | expiresAt | metadata - ${undefined} | ${undefined} | ${undefined} - ${amount} | ${new Date(Date.now() + 30_000)} | ${{ description: 'Test incoming payment', externalRef: '#123' }} - `('creates remote incoming payment ($#)', (args): void => { - const newToken = { - access_token: { - value: 'T0OS9M2PMHKUR64TB8N6BW7OZB8CDFONP219RP1L', - manage: `${grantOptions.authServer}/token/d3f288c2-0b41-42f0-9b2f-66ff4bf45a7a`, - expires_in: 3600, - access: [ - { - type: grantOptions.accessType, - actions: grantOptions.accessActions - } - ] - } - } as AccessToken - const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ - ...args, - walletAddressUrl: walletAddress.id + test('returns InvalidRequest error if unhandled error getting public incoming payment', async () => { + const clientGetPublicIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'getPublic') + .mockImplementationOnce(() => { + throw new Error('No public incoming payment') }) - test.each` - grantExpired - ${false} - ${true} - `( - '- grant expired: $grantExpired', - async ({ grantExpired }): Promise => { - const options = !grantExpired - ? grantOptions - : { ...grantOptions, expiresIn: -10 } - const grant = await grantService.create(options) - - const clientCreateIncomingPaymentSpy = jest - .spyOn(openPaymentsClient.incomingPayment, 'create') - .mockResolvedValueOnce(mockedIncomingPayment) - - const clientRequestRotateTokenSpy = jest - .spyOn(openPaymentsClient.token, 'rotate') - .mockResolvedValueOnce(newToken) - - const incomingPayment = await remoteIncomingPaymentService.create({ - ...args, - walletAddressUrl: walletAddress.id - }) + const incomingPaymentUrl = `https://example.com/incoming-payment/${uuid()}` - expect(incomingPayment).toStrictEqual(mockedIncomingPayment) - expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledWith( - { - url: walletAddress.id, - accessToken: grant.expired - ? newToken.access_token.value - : grant.accessToken - }, - { - ...args, - walletAddress: walletAddress.id, - expiresAt: args.expiresAt - ? args.expiresAt.toISOString() - : undefined, - incomingAmount: args.incomingAmount - ? serializeAmount(args.incomingAmount) - : undefined - } - ) - if (grantExpired) { - expect(clientRequestRotateTokenSpy).toHaveBeenCalledWith({ - url: grantOptions.managementUrl, - accessToken: grantOptions.accessToken - }) - } else { - expect(clientRequestRotateTokenSpy).not.toHaveBeenCalled() - } - } - ) + await expect( + remoteIncomingPaymentService.get(incomingPaymentUrl) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + expect(clientGetPublicIncomingPaymentSpy).toHaveBeenCalledWith({ + url: incomingPaymentUrl }) }) - describe('with new grant', () => { - beforeAll(() => { - jest - .spyOn(openPaymentsClient.walletAddress, 'get') - .mockResolvedValue(walletAddress) + test('gets incoming payment', async (): Promise => { + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + walletAddress: walletAddress.id }) - test.each` - incomingAmount | expiresAt | metadata - ${undefined} | ${undefined} | ${undefined} - ${amount} | ${new Date(Date.now() + 30_000)} | ${{ description: 'Test incoming payment', externalRef: '#123' }} - `('creates remote incoming payment ($#)', async (args): Promise => { - const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ - ...args, - walletAddressUrl: walletAddress.id - }) + const clientGetIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'get') + .mockResolvedValueOnce(mockedIncomingPayment) + + const accessToken = uuid() + const grantGetOrCreateSpy = jest + .spyOn(grantService, 'getOrCreate') + .mockResolvedValueOnce({ + accessToken + } as Grant) + + const incomingPayment = await remoteIncomingPaymentService.get( + mockedIncomingPayment.id + ) + + expect(incomingPayment).toStrictEqual(mockedIncomingPayment) + expect(grantGetOrCreateSpy).toHaveBeenCalledWith({ + authServer: publicIncomingPayment.authServer, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + expect(clientGetIncomingPaymentSpy).toHaveBeenCalledWith({ + url: mockedIncomingPayment.id, + accessToken + }) + expect(clientGetIncomingPaymentSpy).toHaveBeenCalledTimes(1) + }) - const grant = mockGrant() + test('returns error if invalid grant', async () => { + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + walletAddress: walletAddress.id + }) - const clientCreateIncomingPaymentSpy = jest - .spyOn(openPaymentsClient.incomingPayment, 'create') - .mockResolvedValueOnce(mockedIncomingPayment) + jest + .spyOn(grantService, 'getOrCreate') + .mockResolvedValueOnce(GrantError.InvalidGrantRequest) - const clientRequestGrantSpy = jest - .spyOn(openPaymentsClient.grant, 'request') - .mockResolvedValueOnce(grant) + const clientGetIncomingPaymentSpy = jest.spyOn( + openPaymentsClient.incomingPayment, + 'get' + ) - const grantCreateSpy = jest.spyOn(grantService, 'create') - const incomingPayment = await remoteIncomingPaymentService.create({ - ...args, - walletAddressUrl: walletAddress.id - }) + await expect( + remoteIncomingPaymentService.get(mockedIncomingPayment.id) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidGrant) + expect(clientGetIncomingPaymentSpy).not.toHaveBeenCalled() + }) - expect(incomingPayment).toStrictEqual(mockedIncomingPayment) - expect(clientRequestGrantSpy).toHaveBeenCalledWith( - { url: walletAddress.authServer }, - { - access_token: { - access: [ - { - type: grantOptions.accessType, - actions: grantOptions.accessActions - } - ] - }, - interact: { - start: ['redirect'] - } - } - ) - expect(grantCreateSpy).toHaveBeenCalledWith({ - ...grantOptions, - accessToken: grant.access_token.value, - expiresIn: grant.access_token.expires_in, - managementUrl: grant.access_token.manage + test('returns error without retrying if not OpenPaymentsClientError', async () => { + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + walletAddress: walletAddress.id + }) + + jest.spyOn(grantService, 'getOrCreate').mockResolvedValueOnce({ + accessToken: uuid() + } as Grant) + + const clientGetIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'get') + .mockImplementationOnce(() => { + throw new Error('unexpected') }) - expect(clientCreateIncomingPaymentSpy).toHaveBeenCalledWith( - { - url: walletAddress.id, - accessToken: grant.access_token.value - }, - { - ...args, - walletAddress: walletAddress.id, - expiresAt: args.expiresAt - ? args.expiresAt.toISOString() - : undefined, - incomingAmount: args.incomingAmount - ? serializeAmount(args.incomingAmount) - : undefined - } - ) + + await expect( + remoteIncomingPaymentService.get(mockedIncomingPayment.id) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + expect(clientGetIncomingPaymentSpy).toHaveBeenCalledTimes(1) + }) + + test('returns error without retrying if non-auth related OpenPaymentsClientError', async () => { + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + walletAddress: walletAddress.id }) - test('returns error if created grant is interactive', async () => { - jest - .spyOn(openPaymentsClient.grant, 'request') - .mockResolvedValueOnce(mockPendingGrant()) + jest.spyOn(grantService, 'getOrCreate').mockResolvedValueOnce({ + accessToken: uuid() + } as Grant) - await expect( - remoteIncomingPaymentService.create({ - walletAddressUrl: walletAddress.id + const clientGetIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'get') + .mockImplementationOnce(() => { + throw new OpenPaymentsClientError('unexpected error', { + description: 'unexpected error' + }) + }) + + await expect( + remoteIncomingPaymentService.get(mockedIncomingPayment.id) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + expect(clientGetIncomingPaymentSpy).toHaveBeenCalledTimes(1) + }) + + test('returns error without retrying if non-auth related OpenPaymentsClientError', async () => { + jest.spyOn(grantService, 'getOrCreate').mockResolvedValueOnce({ + accessToken: uuid() + } as Grant) + + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + walletAddress: walletAddress.id + }) + + const clientGetIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'get') + .mockImplementationOnce(() => { + throw new OpenPaymentsClientError('unexpected error', { + description: 'unexpected error' }) - ).resolves.toEqual(RemoteIncomingPaymentError.InvalidGrant) + }) + + await expect( + remoteIncomingPaymentService.get(mockedIncomingPayment.id) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + expect(clientGetIncomingPaymentSpy).toHaveBeenCalledTimes(1) + }) + + test('returns error after retrying auth related OpenPaymentsClientError', async () => { + const mockedGrant1 = { + id: uuid(), + accessToken: uuid() + } as Grant + const mockedGrant2 = { + id: uuid(), + accessToken: uuid() + } as Grant + + const grantGetOrCreateSpy = jest + .spyOn(grantService, 'getOrCreate') + .mockResolvedValueOnce(mockedGrant1) + .mockResolvedValueOnce(mockedGrant2) + + const grantDeleteSpy = jest + .spyOn(grantService, 'delete') + .mockResolvedValueOnce(mockedGrant1) + + const failedOpenPaymentsClientRequest = () => { + throw new OpenPaymentsClientError('Invalid token', { + status: 401, + description: 'Invalid token' + }) + } + + const clientGetIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'get') + .mockImplementationOnce(failedOpenPaymentsClientRequest) + .mockImplementationOnce(failedOpenPaymentsClientRequest) + + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + walletAddress: walletAddress.id }) + + await expect( + remoteIncomingPaymentService.get(mockedIncomingPayment.id) + ).resolves.toEqual(RemoteIncomingPaymentError.InvalidRequest) + expect(clientGetIncomingPaymentSpy).toHaveBeenCalledTimes(2) + expect(grantGetOrCreateSpy).toHaveBeenCalledTimes(2) + expect(grantDeleteSpy).toHaveBeenCalledTimes(1) + expect(grantDeleteSpy).toHaveBeenCalledWith(mockedGrant1.id) }) + + test.each([401, 403])( + 'gets incoming payment after retrying %s OpenPaymentsClientError', + async (status: number) => { + const mockedGrant1 = { + id: uuid(), + accessToken: uuid() + } as Grant + const mockedGrant2 = { + id: uuid(), + accessToken: uuid() + } as Grant + + const grantGetOrCreateSpy = jest + .spyOn(grantService, 'getOrCreate') + .mockResolvedValueOnce(mockedGrant1) + .mockResolvedValueOnce(mockedGrant2) + + const grantDeleteSpy = jest + .spyOn(grantService, 'delete') + .mockResolvedValueOnce(mockedGrant1) + + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + walletAddress: walletAddress.id + }) + + const clientGetIncomingPaymentSpy = jest + .spyOn(openPaymentsClient.incomingPayment, 'get') + .mockImplementationOnce(() => { + throw new OpenPaymentsClientError('Invalid token', { + status, + description: 'Invalid token' + }) + }) + .mockResolvedValueOnce(mockedIncomingPayment) + + await expect( + remoteIncomingPaymentService.get(mockedIncomingPayment.id) + ).resolves.toStrictEqual(mockedIncomingPayment) + expect(clientGetIncomingPaymentSpy).toHaveBeenCalledTimes(2) + expect(grantGetOrCreateSpy).toHaveBeenCalledTimes(2) + expect(grantDeleteSpy).toHaveBeenCalledTimes(1) + expect(grantDeleteSpy).toHaveBeenCalledWith(mockedGrant1.id) + } + ) }) }) diff --git a/packages/backend/src/open_payments/payment/incoming_remote/service.ts b/packages/backend/src/open_payments/payment/incoming_remote/service.ts index d97e0d67fb..84920d13c3 100644 --- a/packages/backend/src/open_payments/payment/incoming_remote/service.ts +++ b/packages/backend/src/open_payments/payment/incoming_remote/service.ts @@ -1,18 +1,18 @@ import { AuthenticatedClient, IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethods, - isPendingGrant, AccessAction, - WalletAddress as OpenPaymentsWalletAddress + WalletAddress as OpenPaymentsWalletAddress, + AccessType, + PublicIncomingPayment, + OpenPaymentsClientError, + WalletAddress } from '@interledger/open-payments' -import { Grant } from '../../grant/model' import { GrantService } from '../../grant/service' import { BaseService } from '../../../shared/baseService' import { Amount, serializeAmount } from '../../amount' -import { - isRemoteIncomingPaymentError, - RemoteIncomingPaymentError -} from './errors' +import { RemoteIncomingPaymentError } from './errors' +import { isGrantError } from '../../grant/errors' interface CreateRemoteIncomingPaymentArgs { walletAddressUrl: string @@ -22,6 +22,11 @@ interface CreateRemoteIncomingPaymentArgs { } export interface RemoteIncomingPaymentService { + get( + url: string + ): Promise< + OpenPaymentsIncomingPaymentWithPaymentMethods | RemoteIncomingPaymentError + > create( args: CreateRemoteIncomingPaymentArgs ): Promise< @@ -47,6 +52,7 @@ export async function createRemoteIncomingPaymentService( } return { + get: (url) => get(deps, url), create: (args) => create(deps, args) } } @@ -57,114 +63,229 @@ async function create( ): Promise< OpenPaymentsIncomingPaymentWithPaymentMethods | RemoteIncomingPaymentError > { - const { walletAddressUrl } = args - const grantOrError = await getGrant(deps, walletAddressUrl, [ - AccessAction.Create, - AccessAction.ReadAll - ]) - - if (isRemoteIncomingPaymentError(grantOrError)) { - return grantOrError + let walletAddress: OpenPaymentsWalletAddress + + try { + walletAddress = await deps.openPaymentsClient.walletAddress.get({ + url: args.walletAddressUrl + }) + } catch (err) { + const errorMessage = 'Could not get wallet address' + deps.logger.error( + { err, walletAddressUrl: args.walletAddressUrl }, + errorMessage + ) + return RemoteIncomingPaymentError.UnknownWalletAddress + } + + return createIncomingPayment(deps, { + createArgs: args, + walletAddress + }) +} + +async function createIncomingPayment( + deps: ServiceDependencies, + { + createArgs, + walletAddress, + retryOnTokenError = true + }: { + createArgs: CreateRemoteIncomingPaymentArgs + walletAddress: WalletAddress + retryOnTokenError?: boolean + } +): Promise< + OpenPaymentsIncomingPaymentWithPaymentMethods | RemoteIncomingPaymentError +> { + const resourceServerUrl = + walletAddress.resourceServer ?? new URL(walletAddress.id).origin + + const grantOptions = { + authServer: walletAddress.authServer, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + } + + const grant = await deps.grantService.getOrCreate(grantOptions) + + if (isGrantError(grant)) { + return RemoteIncomingPaymentError.InvalidGrant } try { - const url = new URL(walletAddressUrl) return await deps.openPaymentsClient.incomingPayment.create( { - url: url.origin, - accessToken: grantOrError.accessToken + url: resourceServerUrl, + accessToken: grant.accessToken }, { - walletAddress: walletAddressUrl, - incomingAmount: args.incomingAmount - ? serializeAmount(args.incomingAmount) + walletAddress: walletAddress.id, + incomingAmount: createArgs.incomingAmount + ? serializeAmount(createArgs.incomingAmount) : undefined, - expiresAt: args.expiresAt?.toISOString(), - metadata: args.metadata ?? undefined + expiresAt: createArgs.expiresAt?.toISOString(), + metadata: createArgs.metadata ?? undefined } ) } catch (err) { const errorMessage = 'Error creating remote incoming payment' - deps.logger.error({ err, walletAddressUrl }, errorMessage) + + const baseErrorLog = { + walletAddressUrl: walletAddress.id, + resourceServerUrl, + grantId: grant.id + } + + if (err instanceof OpenPaymentsClientError) { + if ((err.status === 401 || err.status === 403) && retryOnTokenError) { + deps.logger.warn( + { + ...baseErrorLog, + errStatus: err.status, + errDescription: err.description + }, + `Retrying request after receiving ${err.status} error code when creating incoming payment` + ) + + await deps.grantService.delete(grant.id) // force new grant creation + + return createIncomingPayment(deps, { + createArgs, + walletAddress, + retryOnTokenError: false + }) + } + + deps.logger.error( + { + ...baseErrorLog, + errStatus: err.status, + errDescription: err.description, + errMessage: err.message, + errValidation: err.validationErrors + }, + errorMessage + ) + } else { + deps.logger.error({ ...baseErrorLog, err }, errorMessage) + } return RemoteIncomingPaymentError.InvalidRequest } } -async function getGrant( +async function get( deps: ServiceDependencies, - walletAddressUrl: string, - accessActions: AccessAction[] -): Promise { - let walletAddress: OpenPaymentsWalletAddress - + url: string +): Promise< + OpenPaymentsIncomingPaymentWithPaymentMethods | RemoteIncomingPaymentError +> { + let publicIncomingPayment: PublicIncomingPayment try { - walletAddress = await deps.openPaymentsClient.walletAddress.get({ - url: walletAddressUrl - }) + publicIncomingPayment = + await deps.openPaymentsClient.incomingPayment.getPublic({ + url + }) } catch (err) { - const errorMessage = 'Could not get wallet address' - deps.logger.error({ err, walletAddressUrl }, errorMessage) - return RemoteIncomingPaymentError.UnknownWalletAddress + if (err instanceof OpenPaymentsClientError) { + if (err.status === 404) { + return RemoteIncomingPaymentError.NotFound + } + } + + deps.logger.warn({ url, err }, 'Could not get public incoming payment') + return RemoteIncomingPaymentError.InvalidRequest } + return getIncomingPayment(deps, { + url, + authServerUrl: publicIncomingPayment.authServer + }) +} + +async function getIncomingPayment( + deps: ServiceDependencies, + { + url, + authServerUrl, + retryOnTokenError = true + }: { url: string; authServerUrl: string; retryOnTokenError?: boolean } +): Promise< + OpenPaymentsIncomingPaymentWithPaymentMethods | RemoteIncomingPaymentError +> { const grantOptions = { - authServer: walletAddress.authServer, - accessType: 'incoming-payment' as const, - accessActions + authServer: authServerUrl, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] } - const existingGrant = await deps.grantService.get(grantOptions) + const grant = await deps.grantService.getOrCreate(grantOptions) - if (existingGrant) { - if (existingGrant.expired) { - if (!existingGrant.authServer) { - throw new Error('unknown auth server') - } - try { - const rotatedToken = await deps.openPaymentsClient.token.rotate({ - url: existingGrant.getManagementUrl(existingGrant.authServer.url), - accessToken: existingGrant.accessToken - }) - return deps.grantService.update(existingGrant, { - accessToken: rotatedToken.access_token.value, - managementUrl: rotatedToken.access_token.manage, - expiresIn: rotatedToken.access_token.expires_in - }) - } catch (err) { - deps.logger.error({ err, grantOptions }, 'Grant token rotation failed.') - throw err - } - } - return existingGrant + if (isGrantError(grant)) { + return RemoteIncomingPaymentError.InvalidGrant } - const grant = await deps.openPaymentsClient.grant.request( - { url: walletAddress.authServer }, - { - access_token: { - access: [ + try { + const incomingPayment = await deps.openPaymentsClient.incomingPayment.get({ + url, + accessToken: grant.accessToken + }) + + // TODO: remove after #2889 is completed + if (!incomingPayment.walletAddress) { + throw new OpenPaymentsClientError('Got invalid incoming payment', { + status: 401, + description: 'Received public incoming payment instead of private' + }) + } + + return incomingPayment + } catch (err) { + const errorMessage = 'Could not get remote incoming payment' + + const baseErrorLog = { + incomingPaymentUrl: url, + grantId: grant.id + } + + if (err instanceof OpenPaymentsClientError) { + if (err.status === 404) { + return RemoteIncomingPaymentError.NotFound + } + + if ((err.status === 403 || err.status === 401) && retryOnTokenError) { + deps.logger.warn( { - type: grantOptions.accessType, - actions: grantOptions.accessActions - } - ] - }, - interact: { - start: ['redirect'] + ...baseErrorLog, + errStatus: err.status, + errDescription: err.description + }, + `Retrying request after receiving ${err.status} error code when getting incoming payment` + ) + + await deps.grantService.delete(grant.id) // force new grant creation + + return getIncomingPayment(deps, { + url, + authServerUrl, + retryOnTokenError: false + }) } + + deps.logger.error( + { + ...baseErrorLog, + errStatus: err.status, + errDescription: err.description, + errMessage: err.message, + errValidation: err.validationErrors + }, + errorMessage + ) + } else { + deps.logger.error({ ...baseErrorLog, err }, errorMessage) } - ) - - if (!isPendingGrant(grant)) { - return deps.grantService.create({ - ...grantOptions, - accessToken: grant.access_token.value, - managementUrl: grant.access_token.manage, - expiresIn: grant.access_token.expires_in - }) - } - const errorMessage = 'Grant is pending/requires interaction' - deps.logger.warn({ grantOptions }, errorMessage) - return RemoteIncomingPaymentError.InvalidGrant + return RemoteIncomingPaymentError.InvalidRequest + } } diff --git a/packages/backend/src/open_payments/receiver/service.test.ts b/packages/backend/src/open_payments/receiver/service.test.ts index 7c0c32b8c1..a603ba1bc1 100644 --- a/packages/backend/src/open_payments/receiver/service.test.ts +++ b/packages/backend/src/open_payments/receiver/service.test.ts @@ -1,20 +1,16 @@ import { IocContract } from '@adonisjs/fold' -import { faker } from '@faker-js/faker' import { Knex } from 'knex' import { - AuthenticatedClient, - AccessType, - AccessAction, - IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethods, - WalletAddress as OpenPaymentsWalletAddress, mockWalletAddress, - Grant as OpenPaymentsGrant, - GrantRequest, mockIncomingPaymentWithPaymentMethods } from '@interledger/open-payments' import { v4 as uuid } from 'uuid' -import { ReceiverService } from './service' +import { + getLocalIncomingPayment, + ReceiverService, + ServiceDependencies +} from './service' import { createTestApp, TestContainer } from '../../tests/app' import { Config } from '../../config/app' import { initIocContainer } from '../..' @@ -25,7 +21,6 @@ import { MockWalletAddress } from '../../tests/walletAddress' import { truncateTables } from '../../tests/tableManager' -import { GrantService } from '../grant/service' import { WalletAddressService } from '../wallet_address/service' import { Amount, parseAmount } from '../amount' import { RemoteIncomingPaymentService } from '../payment/incoming_remote/service' @@ -36,34 +31,40 @@ import { ReceiverError } from './errors' import { RemoteIncomingPaymentError } from '../payment/incoming_remote/errors' import assert from 'assert' import { Receiver } from './model' -import { Grant } from '../grant/model' -import { IncomingPaymentState } from '../payment/incoming/model' -import { PublicIncomingPayment } from '@interledger/open-payments/dist/types' -import { mockPublicIncomingPayment } from '@interledger/open-payments/dist/test/helpers' +import { IncomingPayment } from '../payment/incoming/model' +import { StreamCredentialsService } from '../../payment-method/ilp/stream-credentials/service' +import { WalletAddress } from '../wallet_address/model' describe('Receiver Service', (): void => { let deps: IocContract let appContainer: TestContainer let receiverService: ReceiverService let incomingPaymentService: IncomingPaymentService - let openPaymentsClient: AuthenticatedClient let knex: Knex let walletAddressService: WalletAddressService - let grantService: GrantService + let streamCredentialsService: StreamCredentialsService let remoteIncomingPaymentService: RemoteIncomingPaymentService + let serviceDeps: ServiceDependencies beforeAll(async (): Promise => { deps = initIocContainer(Config) appContainer = await createTestApp(deps) receiverService = await deps.use('receiverService') incomingPaymentService = await deps.use('incomingPaymentService') - openPaymentsClient = await deps.use('openPaymentsClient') walletAddressService = await deps.use('walletAddressService') - grantService = await deps.use('grantService') + streamCredentialsService = await deps.use('streamCredentialsService') remoteIncomingPaymentService = await deps.use( 'remoteIncomingPaymentService' ) knex = appContainer.knex + serviceDeps = { + knex, + logger: await deps.use('logger'), + incomingPaymentService, + remoteIncomingPaymentService, + walletAddressService, + streamCredentialsService + } }) afterEach(async (): Promise => { @@ -76,7 +77,7 @@ describe('Receiver Service', (): void => { }) describe('get', () => { - describe('incoming payments', () => { + describe('local incoming payment', () => { test('resolves local incoming payment', async () => { const walletAddress = await createWalletAddress(deps, { mockServerPort: Config.openPaymentsPort @@ -100,13 +101,13 @@ describe('Receiver Service', (): void => { incomingPayment: { id: incomingPayment.getUrl(walletAddress), walletAddress: walletAddress.url, - completed: incomingPayment.completed, - receivedAmount: incomingPayment.receivedAmount, incomingAmount: incomingPayment.incomingAmount, - metadata: incomingPayment.metadata || undefined, + receivedAmount: incomingPayment.receivedAmount, + completed: false, + metadata: undefined, expiresAt: incomingPayment.expiresAt, - updatedAt: new Date(incomingPayment.updatedAt), - createdAt: new Date(incomingPayment.createdAt), + createdAt: incomingPayment.createdAt, + updatedAt: incomingPayment.updatedAt, methods: [ { type: 'ilp', @@ -119,398 +120,159 @@ describe('Receiver Service', (): void => { }) }) - describe.each` - existingGrant | description - ${false} | ${'no grant'} - ${true} | ${'existing grant'} - `('remote ($description)', ({ existingGrant }): void => { - let walletAddress: OpenPaymentsWalletAddress - let incomingPayment: OpenPaymentsIncomingPaymentWithPaymentMethods - let publicIncomingPayment: PublicIncomingPayment - const authServer = faker.internet.url({ appendSlash: false }) - const INCOMING_PAYMENT_PATH = 'incoming-payments' - const grantOptions = { - accessType: AccessType.IncomingPayment, - accessActions: [AccessAction.ReadAll], - accessToken: 'OZB8CDFONP219RP1LT0OS9M2PMHKUR64TB8N6BW7', - managementUrl: `${authServer}/token/8f69de01-5bf9-4603-91ed-eeca101081f1` - } - const grantRequest: GrantRequest = { - access_token: { - access: [ - { - type: grantOptions.accessType, - actions: grantOptions.accessActions - } - ] - }, - interact: { - start: ['redirect'] - } - } as GrantRequest - const grant: OpenPaymentsGrant = { - access_token: { - value: grantOptions.accessToken, - manage: grantOptions.managementUrl, - expires_in: 3600, - access: grantRequest.access_token.access - }, - continue: { - access_token: { - value: '33OMUKMKSKU80UPRY5NM' - }, - uri: `${authServer}/continue/4CF492MLVMSW9MKMXKHQ`, - wait: 30 - } - } - const newToken = { - access_token: { - value: 'T0OS9M2PMHKUR64TB8N6BW7OZB8CDFONP219RP1L', - manage: `${authServer}/token/d3f288c2-0b41-42f0-9b2f-66ff4bf45a7a`, - expires_in: 3600, - access: grantRequest.access_token.access - } - } - - beforeEach(async (): Promise => { - walletAddress = mockWalletAddress({ - authServer - }) - incomingPayment = mockIncomingPaymentWithPaymentMethods({ - id: `${walletAddress.id}/incoming-payments/${uuid()}`, - walletAddress: walletAddress.id - }) - publicIncomingPayment = mockPublicIncomingPayment({ - authServer, - receivedAmount: incomingPayment.receivedAmount - }) - if (existingGrant) { - await expect( - grantService.create({ - ...grantOptions, - authServer - }) - ).resolves.toMatchObject({ - accessType: grantOptions.accessType, - accessActions: grantOptions.accessActions, - accessToken: grantOptions.accessToken, - managementId: '8f69de01-5bf9-4603-91ed-eeca101081f1' - }) - } - jest - .spyOn(walletAddressService, 'getByUrl') + describe('getLocalIncomingPayment helper', () => { + test('returns undefined if could not parse id from url', async () => { + const incomingPaymentServiceSpy = jest + .spyOn(incomingPaymentService, 'get') .mockResolvedValueOnce(undefined) - }) - afterEach(() => { - jest.restoreAllMocks() - }) - - test.each` - rotate | description - ${false} | ${''} - ${true} | ${'- after rotating access token'} - `('resolves incoming payment $description', async ({ rotate }) => { - const clientRequestGrantSpy = jest - .spyOn(openPaymentsClient.grant, 'request') - .mockResolvedValueOnce(grant) - - const clientGetPublicIncomingPaymentSpy = jest - .spyOn(openPaymentsClient.incomingPayment, 'getPublic') - .mockResolvedValueOnce(publicIncomingPayment) - - const clientGetIncomingPaymentSpy = jest - .spyOn(openPaymentsClient.incomingPayment, 'get') - .mockResolvedValueOnce(incomingPayment) - - const clientRequestRotateTokenSpy = jest - .spyOn(openPaymentsClient.token, 'rotate') - .mockResolvedValueOnce(newToken) - - if (existingGrant && rotate) { - const fetchedGrant = await grantService.get({ - ...grantOptions, - authServer - }) - await fetchedGrant?.$query(knex).patch({ expiresAt: new Date() }) - } await expect( - receiverService.get(incomingPayment.id) - ).resolves.toEqual({ - assetCode: incomingPayment.receivedAmount.assetCode, - assetScale: incomingPayment.receivedAmount.assetScale, - ilpAddress: expect.any(String), - sharedSecret: expect.any(Buffer), - incomingPayment: { - id: incomingPayment.id, - walletAddress: incomingPayment.walletAddress, - updatedAt: new Date(incomingPayment.updatedAt), - createdAt: new Date(incomingPayment.createdAt), - completed: incomingPayment.completed, - receivedAmount: - incomingPayment.receivedAmount && - parseAmount(incomingPayment.receivedAmount), - incomingAmount: - incomingPayment.incomingAmount && - parseAmount(incomingPayment.incomingAmount), - expiresAt: incomingPayment.expiresAt, - methods: [ - { - type: 'ilp', - ilpAddress: expect.any(String), - sharedSecret: expect.any(String) - } - ] - }, - isLocal: false - }) - if (!existingGrant) { - expect(clientRequestGrantSpy).toHaveBeenCalledWith( - { url: authServer }, - grantRequest + getLocalIncomingPayment( + serviceDeps, + `https://example.com/incoming-payments` ) - } - expect(clientGetPublicIncomingPaymentSpy).toHaveBeenCalledWith({ - url: incomingPayment.id - }) - expect(clientGetIncomingPaymentSpy).toHaveBeenCalledWith({ - url: incomingPayment.id, - accessToken: - existingGrant && rotate - ? newToken.access_token.value - : grantOptions.accessToken - }) - if (existingGrant && rotate) { - expect(clientRequestRotateTokenSpy).toHaveBeenCalledWith({ - url: grant.access_token.manage, - accessToken: grantOptions.accessToken - }) - } + ).resolves.toBeUndefined() + expect(incomingPaymentServiceSpy).not.toHaveBeenCalled() }) - test('returns undefined for invalid remote public incoming payment', async (): Promise => { - const clientGetPublicIncomingPaymentSpy = jest - .spyOn(openPaymentsClient.incomingPayment, 'getPublic') - .mockRejectedValueOnce({ status: 404 }) + test('returns undefined if no payment found', async () => { + jest + .spyOn(incomingPaymentService, 'get') + .mockResolvedValueOnce(undefined) await expect( - receiverService.get( - `${ - new URL(incomingPayment.id).origin - }/${INCOMING_PAYMENT_PATH}/${uuid()}` + getLocalIncomingPayment( + serviceDeps, + `https://example.com/incoming-payments/${uuid()}` ) ).resolves.toBeUndefined() - expect(clientGetPublicIncomingPaymentSpy).toHaveBeenCalledTimes(1) }) - if (existingGrant) { - test('returns undefined for invalid remote auth server', async (): Promise => { - const grant = await grantService.get({ - ...grantOptions, - authServer - }) - assert.ok(grant) - const getExistingGrantSpy = jest - .spyOn(grantService, 'get') - .mockResolvedValueOnce({ - ...grant, - authServer: undefined, - expired: true - } as Grant) - jest - .spyOn(openPaymentsClient.incomingPayment, 'getPublic') - .mockResolvedValueOnce(publicIncomingPayment) - const clientRequestGrantSpy = jest.spyOn( - openPaymentsClient.grant, - 'request' - ) + test('throws error if wallet address does not exist on incoming payment', async () => { + jest.spyOn(incomingPaymentService, 'get').mockResolvedValueOnce({ + id: uuid() + } as IncomingPayment) - await expect( - receiverService.get(incomingPayment.id) - ).resolves.toBeUndefined() - expect(getExistingGrantSpy).toHaveBeenCalled() - expect(clientRequestGrantSpy).not.toHaveBeenCalled() - }) - test('returns undefined for expired grant that cannot be rotated', async (): Promise => { - const grant = await grantService.get({ - ...grantOptions, - authServer - }) - await grant?.$query(knex).patch({ expiresAt: new Date() }) - jest - .spyOn(openPaymentsClient.incomingPayment, 'getPublic') - .mockResolvedValueOnce(publicIncomingPayment) - const clientRequestGrantSpy = jest.spyOn( - openPaymentsClient.grant, - 'request' + await expect( + getLocalIncomingPayment( + serviceDeps, + `https://example.com/incoming-payments/${uuid()}` ) + ).rejects.toThrow( + 'Wallet address does not exist for incoming payment' + ) + }) - await expect( - receiverService.get(incomingPayment.id) - ).resolves.toBeUndefined() - expect(clientRequestGrantSpy).not.toHaveBeenCalled() - }) - } else { - test('returns undefined for invalid grant', async (): Promise => { - jest - .spyOn(openPaymentsClient.incomingPayment, 'getPublic') - .mockResolvedValueOnce(publicIncomingPayment) - const clientRequestGrantSpy = jest - .spyOn(openPaymentsClient.grant, 'request') - .mockRejectedValueOnce(new Error('Could not request grant')) - - await expect( - receiverService.get(incomingPayment.id) - ).resolves.toBeUndefined() - expect(clientRequestGrantSpy).toHaveBeenCalledWith( - { url: authServer }, - grantRequest - ) - }) + test('throws error if stream credentials could not be generated', async () => { + jest.spyOn(incomingPaymentService, 'get').mockResolvedValueOnce({ + id: uuid(), + walletAddress: { + id: 'https://example.com/wallet-address' + } as WalletAddress + } as IncomingPayment) - test('returns undefined for interactive grant', async (): Promise => { - jest - .spyOn(openPaymentsClient.incomingPayment, 'getPublic') - .mockResolvedValueOnce(publicIncomingPayment) - const clientRequestGrantSpy = jest - .spyOn(openPaymentsClient.grant, 'request') - .mockResolvedValueOnce({ - continue: grant.continue, - interact: { - redirect: `${authServer}/4CF492MLVMSW9MKMXKHQ`, - finish: 'MBDOFXG4Y5CVJCX821LH' - } - }) - - await expect( - receiverService.get(incomingPayment.id) - ).resolves.toBeUndefined() - expect(clientRequestGrantSpy).toHaveBeenCalledWith( - { url: authServer }, - grantRequest - ) - }) - } - - test('returns undefined when fetching remote incoming payment throws', async (): Promise => { jest - .spyOn(openPaymentsClient.incomingPayment, 'getPublic') - .mockResolvedValueOnce(publicIncomingPayment) - jest - .spyOn(openPaymentsClient.grant, 'request') - .mockResolvedValueOnce(grant) - const clientGetIncomingPaymentSpy = jest - .spyOn(openPaymentsClient.incomingPayment, 'get') - .mockRejectedValueOnce(new Error('Could not get incoming payment')) + .spyOn(streamCredentialsService, 'get') + .mockReturnValueOnce(undefined) await expect( - receiverService.get(incomingPayment.id) - ).resolves.toBeUndefined() - expect(clientGetIncomingPaymentSpy).toHaveBeenCalledWith({ - url: incomingPayment.id, - accessToken: expect.any(String) - }) + getLocalIncomingPayment( + serviceDeps, + `https://example.com/incoming-payments/${uuid()}` + ) + ).rejects.toThrow( + 'Could not get stream credentials for local incoming payment' + ) }) }) }) - }) - describe('create', () => { describe('remote incoming payment', () => { - const walletAddress = mockWalletAddress({ - assetCode: 'USD', - assetScale: 2 + test('gets receiver from remote incoming payment', async () => { + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods() + + jest + .spyOn(incomingPaymentService, 'get') + .mockResolvedValueOnce(undefined) + + jest + .spyOn(remoteIncomingPaymentService, 'get') + .mockResolvedValueOnce(mockedIncomingPayment) + + await expect( + receiverService.get(mockedIncomingPayment.id) + ).resolves.toEqual({ + assetCode: mockedIncomingPayment.receivedAmount.assetCode, + assetScale: mockedIncomingPayment.receivedAmount.assetScale, + ilpAddress: mockedIncomingPayment.methods[0].ilpAddress, + sharedSecret: expect.any(Buffer), + incomingPayment: { + id: mockedIncomingPayment.id, + walletAddress: mockedIncomingPayment.walletAddress, + incomingAmount: mockedIncomingPayment.incomingAmount + ? parseAmount(mockedIncomingPayment.incomingAmount) + : undefined, + receivedAmount: parseAmount(mockedIncomingPayment.receivedAmount), + completed: mockedIncomingPayment.completed, + metadata: mockedIncomingPayment.metadata, + expiresAt: mockedIncomingPayment.expiresAt + ? new Date(mockedIncomingPayment.expiresAt) + : undefined, + createdAt: new Date(mockedIncomingPayment.createdAt), + updatedAt: new Date(mockedIncomingPayment.updatedAt), + methods: [ + { + type: 'ilp', + ilpAddress: expect.any(String), + sharedSecret: expect.any(String) + } + ] + }, + isLocal: false + }) }) - const amount: Amount = { - value: BigInt(123), - assetCode: 'USD', - assetScale: 2 - } + test('returns undefined if could not get remote incoming payment', async () => { + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods() - test.each` - incomingAmount | expiresAt | metadata - ${undefined} | ${undefined} | ${undefined} - ${amount} | ${new Date(Date.now() + 30_000)} | ${{ description: 'Test incoming payment', externalRef: '#123' }} - `( - 'creates receiver from remote incoming payment ($#)', - async ({ metadata, expiresAt, incomingAmount }): Promise => { - const incomingPayment = mockIncomingPaymentWithPaymentMethods({ - metadata, - expiresAt, - incomingAmount - }) - const remoteIncomingPaymentServiceSpy = jest - .spyOn(remoteIncomingPaymentService, 'create') - .mockResolvedValueOnce(incomingPayment) + const localIncomingPaymentServiceGetSpy = jest + .spyOn(incomingPaymentService, 'get') + .mockResolvedValueOnce(undefined) - const localIncomingPaymentCreateSpy = jest.spyOn( - incomingPaymentService, - 'create' - ) + const remoteIncomingPaymentServiceGetSpy = jest + .spyOn(remoteIncomingPaymentService, 'get') + .mockResolvedValueOnce(RemoteIncomingPaymentError.InvalidGrant) - const receiver = await receiverService.create({ - walletAddressUrl: walletAddress.id, - incomingAmount, - expiresAt, - metadata - }) + await expect( + receiverService.get(mockedIncomingPayment.id) + ).resolves.toBeUndefined() + expect(localIncomingPaymentServiceGetSpy).toHaveBeenCalledTimes(1) + expect(remoteIncomingPaymentServiceGetSpy).toHaveBeenCalledTimes(1) + }) - expect(receiver).toEqual({ - assetCode: incomingPayment.receivedAmount.assetCode, - assetScale: incomingPayment.receivedAmount.assetScale, - ilpAddress: incomingPayment.methods[0].ilpAddress, - sharedSecret: expect.any(Buffer), - incomingPayment: { - id: incomingPayment.id, - walletAddress: incomingPayment.walletAddress, - completed: incomingPayment.completed, - receivedAmount: parseAmount(incomingPayment.receivedAmount), - incomingAmount: - incomingPayment.incomingAmount && - parseAmount(incomingPayment.incomingAmount), - metadata: incomingPayment.metadata || undefined, - updatedAt: new Date(incomingPayment.updatedAt), - createdAt: new Date(incomingPayment.createdAt), - expiresAt: - incomingPayment.expiresAt && - new Date(incomingPayment.expiresAt), - methods: [ - { - type: 'ilp', - ilpAddress: incomingPayment.methods[0].ilpAddress, - sharedSecret: expect.any(String) - } - ] - }, - isLocal: false - }) + test('returns undefined if error getting receiver from remote incoming payment', async () => { + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + completed: true // cannot get receiver with a completed incoming payment + }) - expect(remoteIncomingPaymentServiceSpy).toHaveBeenCalledWith({ - walletAddressUrl: walletAddress.id, - incomingAmount, - expiresAt, - metadata - }) - expect(localIncomingPaymentCreateSpy).not.toHaveBeenCalled() - } - ) + const localIncomingPaymentServiceGetSpy = jest + .spyOn(incomingPaymentService, 'get') + .mockResolvedValueOnce(undefined) - test('returns error if could not create remote incoming payment', async (): Promise => { - jest - .spyOn(remoteIncomingPaymentService, 'create') - .mockResolvedValueOnce( - RemoteIncomingPaymentError.UnknownWalletAddress - ) + const remoteIncomingPaymentServiceGetSpy = jest + .spyOn(remoteIncomingPaymentService, 'get') + .mockResolvedValueOnce(mockedIncomingPayment) await expect( - receiverService.create({ - walletAddressUrl: walletAddress.id - }) - ).resolves.toEqual(ReceiverError.UnknownWalletAddress) + receiverService.get(mockedIncomingPayment.id) + ).resolves.toBeUndefined() + expect(localIncomingPaymentServiceGetSpy).toHaveBeenCalledTimes(1) + expect(remoteIncomingPaymentServiceGetSpy).toHaveBeenCalledTimes(1) }) }) + }) + describe('create', () => { describe('local incoming payment', () => { let walletAddress: MockWalletAddress const amount: Amount = { @@ -602,31 +364,137 @@ describe('Receiver Service', (): void => { ).resolves.toEqual(ReceiverError.InvalidAmount) }) - test.each([IncomingPaymentState.Completed, IncomingPaymentState.Expired])( - 'throws if local incoming payment state is %s', - async (paymentState): Promise => { - const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id, - incomingAmount: { - value: BigInt(5), - assetCode: walletAddress.asset.code, - assetScale: walletAddress.asset.scale - } + test('throws error if stream credentials could not be generated', async () => { + jest + .spyOn(streamCredentialsService, 'get') + .mockReturnValueOnce(undefined) + + await expect( + receiverService.create({ + walletAddressUrl: walletAddress.url }) - incomingPayment.state = paymentState + ).rejects.toThrow( + 'Could not get stream credentials for local incoming payment' + ) + }) + }) + + describe('remote incoming payment', () => { + const walletAddress = mockWalletAddress({ + assetCode: 'USD', + assetScale: 2 + }) + + const amount: Amount = { + value: BigInt(123), + assetCode: 'USD', + assetScale: 2 + } + + test.each` + incomingAmount | expiresAt | metadata + ${undefined} | ${undefined} | ${undefined} + ${amount} | ${new Date(Date.now() + 30_000)} | ${{ description: 'Test incoming payment', externalRef: '#123' }} + `( + 'creates receiver from remote incoming payment ($#)', + async ({ metadata, expiresAt, incomingAmount }): Promise => { jest - .spyOn(incomingPaymentService, 'create') - .mockResolvedValueOnce(incomingPayment) + .spyOn(walletAddressService, 'getByUrl') + .mockResolvedValueOnce(undefined) + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + metadata, + expiresAt, + incomingAmount + }) + const remoteIncomingPaymentServiceSpy = jest + .spyOn(remoteIncomingPaymentService, 'create') + .mockResolvedValueOnce(mockedIncomingPayment) - await expect( - receiverService.create({ - walletAddressUrl: walletAddress.url - }) - ).rejects.toThrow( - 'Could not get stream credentials for local incoming payment' + const localIncomingPaymentCreateSpy = jest.spyOn( + incomingPaymentService, + 'create' ) + + const receiver = await receiverService.create({ + walletAddressUrl: walletAddress.id, + incomingAmount, + expiresAt, + metadata + }) + + expect(receiver).toEqual({ + assetCode: mockedIncomingPayment.receivedAmount.assetCode, + assetScale: mockedIncomingPayment.receivedAmount.assetScale, + ilpAddress: mockedIncomingPayment.methods[0].ilpAddress, + sharedSecret: expect.any(Buffer), + incomingPayment: { + id: mockedIncomingPayment.id, + walletAddress: mockedIncomingPayment.walletAddress, + incomingAmount: mockedIncomingPayment.incomingAmount + ? parseAmount(mockedIncomingPayment.incomingAmount) + : undefined, + receivedAmount: parseAmount(mockedIncomingPayment.receivedAmount), + completed: mockedIncomingPayment.completed, + metadata: mockedIncomingPayment.metadata, + expiresAt: mockedIncomingPayment.expiresAt + ? new Date(mockedIncomingPayment.expiresAt) + : undefined, + createdAt: new Date(mockedIncomingPayment.createdAt), + updatedAt: new Date(mockedIncomingPayment.updatedAt), + methods: [ + { + type: 'ilp', + ilpAddress: expect.any(String), + sharedSecret: expect.any(String) + } + ] + }, + isLocal: false + }) + expect(remoteIncomingPaymentServiceSpy).toHaveBeenCalledWith({ + walletAddressUrl: walletAddress.id, + incomingAmount, + expiresAt, + metadata + }) + expect(localIncomingPaymentCreateSpy).not.toHaveBeenCalled() } ) + + test('returns error if could not create remote incoming payment', async (): Promise => { + jest + .spyOn(remoteIncomingPaymentService, 'create') + .mockResolvedValueOnce( + RemoteIncomingPaymentError.UnknownWalletAddress + ) + + await expect( + receiverService.create({ + walletAddressUrl: walletAddress.id + }) + ).resolves.toEqual(ReceiverError.UnknownWalletAddress) + }) + + test('throws if error creating receiver from remote incoming payment', async () => { + const mockedIncomingPayment = mockIncomingPaymentWithPaymentMethods({ + completed: true // cannot get receiver with a completed incoming payment + }) + + jest + .spyOn(walletAddressService, 'getByUrl') + .mockResolvedValueOnce(undefined) + + const remoteIncomingPaymentServiceCreateSpy = jest + .spyOn(remoteIncomingPaymentService, 'create') + .mockResolvedValueOnce(mockedIncomingPayment) + + await expect( + receiverService.create({ + walletAddressUrl: mockedIncomingPayment.walletAddress + }) + ).rejects.toThrow('Could not create receiver from incoming payment') + expect(remoteIncomingPaymentServiceCreateSpy).toHaveBeenCalledTimes(1) + }) }) }) }) diff --git a/packages/backend/src/open_payments/receiver/service.ts b/packages/backend/src/open_payments/receiver/service.ts index fe2cd8ca4d..adb022e78f 100644 --- a/packages/backend/src/open_payments/receiver/service.ts +++ b/packages/backend/src/open_payments/receiver/service.ts @@ -1,13 +1,5 @@ -import { - AuthenticatedClient, - IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethods, - isPendingGrant, - AccessType, - AccessAction -} from '@interledger/open-payments' +import { IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethods } from '@interledger/open-payments' import { StreamCredentialsService } from '../../payment-method/ilp/stream-credentials/service' -import { Grant } from '../grant/model' -import { GrantService } from '../grant/service' import { WalletAddressService } from '../wallet_address/service' import { BaseService } from '../../shared/baseService' import { IncomingPaymentService } from '../payment/incoming/service' @@ -21,7 +13,7 @@ import { ReceiverError, errorToMessage as receiverErrorToMessage } from './errors' -import { IAppConfig } from '../../config/app' +import { isRemoteIncomingPaymentError } from '../payment/incoming_remote/errors' interface CreateReceiverArgs { walletAddressUrl: string @@ -36,15 +28,11 @@ export interface ReceiverService { create(args: CreateReceiverArgs): Promise } -interface ServiceDependencies extends BaseService { +export interface ServiceDependencies extends BaseService { streamCredentialsService: StreamCredentialsService - grantService: GrantService incomingPaymentService: IncomingPaymentService - openPaymentsUrl: string walletAddressService: WalletAddressService - openPaymentsClient: AuthenticatedClient remoteIncomingPaymentService: RemoteIncomingPaymentService - config: IAppConfig } const INCOMING_PAYMENT_URL_REGEX = @@ -149,10 +137,7 @@ async function getReceiver( url: string ): Promise { try { - const localIncomingPayment = await getLocalIncomingPayment({ - deps, - url - }) + const localIncomingPayment = await getLocalIncomingPayment(deps, url) if (localIncomingPayment) { return new Receiver(localIncomingPayment, true) } @@ -161,14 +146,12 @@ async function getReceiver( if (remoteIncomingPayment) { return new Receiver(remoteIncomingPayment, false) } - } catch (error) { + } catch (err) { deps.logger.error( - { errorMessage: error instanceof Error && error.message }, + { errorMessage: err instanceof Error && err.message }, 'Could not get incoming payment' ) } - - return undefined } function parseIncomingPaymentUrl( @@ -185,45 +168,39 @@ function parseIncomingPaymentUrl( } } -async function getRemoteIncomingPayment( +export async function getLocalIncomingPayment( deps: ServiceDependencies, url: string ): Promise { - const grant = await getIncomingPaymentGrant(deps, url) - if (!grant) { - throw new Error('Could not find grant') - } else { - return await deps.openPaymentsClient.incomingPayment.get({ - url, - accessToken: grant.accessToken - }) - } -} - -async function getLocalIncomingPayment({ - deps, - url -}: { - deps: ServiceDependencies - url: string -}): Promise { - const { id } = parseIncomingPaymentUrl(url) ?? {} - if (!id) { + const urlParseResult = parseIncomingPaymentUrl(url) + if (!urlParseResult) { + deps.logger.error({ url }, 'Could not parse incoming payment url') return undefined } const incomingPayment = await deps.incomingPaymentService.get({ - id + id: urlParseResult.id }) - if (!incomingPayment || !incomingPayment.walletAddress) { + if (!incomingPayment) { return undefined } + if (!incomingPayment.walletAddress) { + const errorMessage = 'Wallet address does not exist for incoming payment' + deps.logger.error({ incomingPaymentId: incomingPayment.id }, errorMessage) + + throw new Error(errorMessage) + } + const streamCredentials = deps.streamCredentialsService.get(incomingPayment) if (!streamCredentials) { - return undefined + const errorMessage = + 'Could not get stream credentials for local incoming payment' + deps.logger.error({ incomingPaymentId: incomingPayment.id }, errorMessage) + + throw new Error(errorMessage) } return incomingPayment.toOpenPaymentsTypeWithMethods( @@ -232,78 +209,16 @@ async function getLocalIncomingPayment({ ) } -async function getIncomingPaymentGrant( +async function getRemoteIncomingPayment( deps: ServiceDependencies, - incomingPaymentUrl: string -): Promise { - const publicIncomingPayment = - await deps.openPaymentsClient.incomingPayment.getPublic({ - url: incomingPaymentUrl - }) - if (!publicIncomingPayment || !publicIncomingPayment.authServer) { - return undefined - } - const grantOptions = { - authServer: publicIncomingPayment.authServer, - accessType: AccessType.IncomingPayment, - accessActions: [AccessAction.ReadAll] - } + url: string +): Promise { + const incomingPaymentOrError = + await deps.remoteIncomingPaymentService.get(url) - const existingGrant = await deps.grantService.get(grantOptions) - if (existingGrant) { - if (existingGrant.expired) { - if (!existingGrant.authServer) { - deps.logger.warn('Unknown auth server.') - return undefined - } - try { - const rotatedToken = await deps.openPaymentsClient.token.rotate({ - url: existingGrant.getManagementUrl(existingGrant.authServer.url), - accessToken: existingGrant.accessToken - }) - return deps.grantService.update(existingGrant, { - accessToken: rotatedToken.access_token.value, - managementUrl: rotatedToken.access_token.manage, - expiresIn: rotatedToken.access_token.expires_in - }) - } catch (err) { - deps.logger.warn({ err }, 'Grant token rotation failed.') - return undefined - } - } - return existingGrant + if (isRemoteIncomingPaymentError(incomingPaymentOrError)) { + return undefined } - const grant = await deps.openPaymentsClient.grant.request( - { url: publicIncomingPayment.authServer }, - { - access_token: { - access: [ - { - type: grantOptions.accessType as 'incoming-payment', - actions: grantOptions.accessActions - } - ] - }, - interact: { - start: ['redirect'] - } - } - ) - - if (!isPendingGrant(grant)) { - try { - return await deps.grantService.create({ - ...grantOptions, - accessToken: grant.access_token.value, - managementUrl: grant.access_token.manage, - expiresIn: grant.access_token.expires_in - }) - } catch (err) { - deps.logger.warn({ grantOptions }, 'Grant has wrong format') - return undefined - } - } - deps.logger.warn({ grantOptions }, 'Grant is pending/requires interaction') - return undefined + return incomingPaymentOrError }