From b0b6e130f4fbc2e8cd7cbe7c2d7de1311ec27a26 Mon Sep 17 00:00:00 2001 From: Max Kurapov Date: Mon, 6 May 2024 13:42:01 +0200 Subject: [PATCH] feat(backend): SPSPRouteError --- .../src/open_payments/route-errors.test.ts | 20 +++ .../backend/src/open_payments/route-errors.ts | 17 +- .../wallet_address/model.test.ts | 1 + .../ilp/spsp/middleware.test.ts | 147 ++++++++++++------ .../src/payment-method/ilp/spsp/middleware.ts | 36 ++++- .../payment-method/ilp/spsp/routes.test.ts | 68 +++++++- .../src/payment-method/ilp/spsp/routes.ts | 24 ++- 7 files changed, 248 insertions(+), 65 deletions(-) diff --git a/packages/backend/src/open_payments/route-errors.test.ts b/packages/backend/src/open_payments/route-errors.test.ts index c837c2a0e5..e9d9c9c28a 100644 --- a/packages/backend/src/open_payments/route-errors.test.ts +++ b/packages/backend/src/open_payments/route-errors.test.ts @@ -8,6 +8,7 @@ import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '..' import { Config } from '../config/app' import { OpenAPIValidatorMiddlewareError } from '@interledger/openapi' +import { SPSPRouteError } from '../payment-method/ilp/spsp/middleware' describe('openPaymentServerErrorMiddleware', (): void => { let deps: IocContract @@ -68,6 +69,25 @@ describe('openPaymentServerErrorMiddleware', (): void => { expect(next).toHaveBeenCalledTimes(1) }) + test('handles SPSPRouteError error', async (): Promise => { + const error = new SPSPRouteError(400, 'SPSP error') + const next = jest.fn().mockImplementationOnce(() => { + throw error + }) + + const ctxThrowSpy = jest.spyOn(ctx, 'throw') + + await expect( + openPaymentsServerErrorMiddleware(ctx, next) + ).rejects.toMatchObject({ + status: error.status, + message: error.message + }) + + expect(ctxThrowSpy).toHaveBeenCalledWith(error.status, error.message) + expect(next).toHaveBeenCalledTimes(1) + }) + test('handles unspecified error', async (): Promise => { const error = new Error('Some unspecified error') const next = jest.fn().mockImplementationOnce(() => { diff --git a/packages/backend/src/open_payments/route-errors.ts b/packages/backend/src/open_payments/route-errors.ts index 702c9cf184..afd1d90a31 100644 --- a/packages/backend/src/open_payments/route-errors.ts +++ b/packages/backend/src/open_payments/route-errors.ts @@ -1,5 +1,6 @@ import { AppContext } from '../app' import { OpenAPIValidatorMiddlewareError } from '@interledger/openapi' +import { SPSPRouteError } from '../payment-method/ilp/spsp/middleware' export class OpenPaymentsServerRouteError extends Error { public status: number @@ -43,7 +44,9 @@ export async function openPaymentsServerErrorMiddleware( 'Received error when handling Open Payments request' ) ctx.throw(err.status, err.message) - } else if (err instanceof OpenAPIValidatorMiddlewareError) { + } + + if (err instanceof OpenAPIValidatorMiddlewareError) { const finalStatus = err.status || 400 logger.info( @@ -57,6 +60,18 @@ export async function openPaymentsServerErrorMiddleware( ctx.throw(finalStatus, err.message) } + if (err instanceof SPSPRouteError) { + logger.info( + { + ...baseLog, + message: err.message, + details: err.details + }, + 'Received error when handling SPSP request' + ) + ctx.throw(err.status, err.message) + } + logger.error( { ...baseLog, err }, 'Received unhandled error in Open Payments request' diff --git a/packages/backend/src/open_payments/wallet_address/model.test.ts b/packages/backend/src/open_payments/wallet_address/model.test.ts index 7026f4dfa8..3b6d878cb4 100644 --- a/packages/backend/src/open_payments/wallet_address/model.test.ts +++ b/packages/backend/src/open_payments/wallet_address/model.test.ts @@ -60,6 +60,7 @@ export const setup = < options.params ) ctx.walletAddress = options.walletAddress + ctx.walletAddressUrl = options.walletAddress.url ctx.grant = options.grant ctx.client = options.client ctx.accessAction = options.accessAction diff --git a/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts b/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts index 0bf9fd7ed1..a2a655e041 100644 --- a/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts +++ b/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts @@ -1,4 +1,8 @@ -import { createSpspMiddleware, SPSPWalletAddressContext } from './middleware' +import { + createSpspMiddleware, + SPSPRouteError, + SPSPWalletAddressContext +} from './middleware' import { setup } from '../../../open_payments/wallet_address/model.test' import { Config } from '../../../config/app' import { IocContract } from '@adonisjs/fold' @@ -8,18 +12,38 @@ import { createTestApp, TestContainer } from '../../../tests/app' import { createAsset } from '../../../tests/asset' import { createWalletAddress } from '../../../tests/walletAddress' import { truncateTables } from '../../../tests/tableManager' +import { WalletAddress } from '../../../open_payments/wallet_address/model' +import assert from 'assert' +import { SPSPRoutes } from './routes' +import { WalletAddressService } from '../../../open_payments/wallet_address/service' describe('SPSP Middleware', (): void => { let deps: IocContract let appContainer: TestContainer + let spspRoutes: SPSPRoutes + let walletAddressService: WalletAddressService let next: jest.MockedFunction<() => Promise> + let ctx: SPSPWalletAddressContext + let walletAddress: WalletAddress + beforeAll(async (): Promise => { - deps = await initIocContainer(Config) + deps = initIocContainer(Config) appContainer = await createTestApp(deps) + spspRoutes = await deps.use('spspRoutes') + walletAddressService = await deps.use('walletAddressService') }) - beforeEach((): void => { + beforeEach(async (): Promise => { + const asset = await createAsset(deps) + walletAddress = await createWalletAddress(deps, { + assetId: asset.id + }) + ctx = setup({ + reqOpts: {}, + walletAddress + }) + ctx.container = deps next = jest.fn() }) @@ -31,50 +55,79 @@ describe('SPSP Middleware', (): void => { await appContainer.shutdown() }) - describe('Wallet Address', (): void => { - let ctx: SPSPWalletAddressContext - - beforeEach(async (): Promise => { - const asset = await createAsset(deps) - const walletAddress = await createWalletAddress(deps, { - assetId: asset.id - }) - ctx = setup({ - reqOpts: {}, - walletAddress - }) - ctx.container = deps - }) - - test.each` - header | spspEnabled | description - ${'application/json'} | ${true} | ${'calls next'} - ${'application/json'} | ${false} | ${'calls next'} - ${'application/spsp4+json'} | ${true} | ${'calls SPSP route'} - ${'application/spsp4+json'} | ${false} | ${'calls next'} - `( - '$description for accept header: $header and spspEnabled: $spspEnabled', - async ({ header, spspEnabled }): Promise => { - const spspRoutes = await ctx.container.use('spspRoutes') - const spspSpy = jest - .spyOn(spspRoutes, 'get') - .mockResolvedValueOnce(undefined) - ctx.headers['accept'] = header - const spspMiddleware = createSpspMiddleware(spspEnabled) - await expect(spspMiddleware(ctx, next)).resolves.toBeUndefined() - if (!spspEnabled || header == 'application/json') { - expect(spspSpy).not.toHaveBeenCalled() - expect(next).toHaveBeenCalled() - } else { - expect(spspSpy).toHaveBeenCalledTimes(1) - expect(next).not.toHaveBeenCalled() - expect(ctx.paymentTag).toEqual(ctx.walletAddress.id) - expect(ctx.asset).toEqual({ - code: ctx.walletAddress.asset.code, - scale: ctx.walletAddress.asset.scale - }) - } + test.each` + header | spspEnabled | description + ${'application/json'} | ${true} | ${'calls next'} + ${'application/json'} | ${false} | ${'calls next'} + ${'application/spsp4+json'} | ${true} | ${'calls SPSP route'} + ${'application/spsp4+json'} | ${false} | ${'calls next'} + `( + '$description for accept header: $header and spspEnabled: $spspEnabled', + async ({ header, spspEnabled }): Promise => { + const spspSpy = jest + .spyOn(spspRoutes, 'get') + .mockResolvedValueOnce(undefined) + ctx.headers['accept'] = header + const spspMiddleware = createSpspMiddleware(spspEnabled) + await expect(spspMiddleware(ctx, next)).resolves.toBeUndefined() + if (!spspEnabled || header == 'application/json') { + expect(spspSpy).not.toHaveBeenCalled() + expect(next).toHaveBeenCalled() + } else { + expect(spspSpy).toHaveBeenCalledTimes(1) + expect(next).not.toHaveBeenCalled() + expect(ctx.paymentTag).toEqual(walletAddress.id) + expect(ctx.asset).toEqual({ + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }) } - ) + } + ) + + test('throws error if could not find wallet address', async () => { + const spspGetRouteSpy = jest.spyOn(spspRoutes, 'get') + + jest + .spyOn(walletAddressService, 'getByUrl') + .mockResolvedValueOnce(undefined) + + ctx.header['accept'] = 'application/spsp4+json' + + const spspMiddleware = createSpspMiddleware(true) + + expect.assertions(4) + try { + await spspMiddleware(ctx, next) + } catch (err) { + assert.ok(err instanceof SPSPRouteError) + expect(err.status).toBe(404) + expect(err.message).toBe('Could not get wallet address') + expect(next).not.toHaveBeenCalled() + expect(spspGetRouteSpy).not.toHaveBeenCalled() + } + }) + + test('throws error if inactive wallet address', async () => { + const spspGetRouteSpy = jest.spyOn(spspRoutes, 'get') + + ctx.header['accept'] = 'application/spsp4+json' + + await walletAddress + .$query(appContainer.knex) + .patch({ deactivatedAt: new Date() }) + + const spspMiddleware = createSpspMiddleware(true) + + expect.assertions(4) + try { + await spspMiddleware(ctx, next) + } catch (err) { + assert.ok(err instanceof SPSPRouteError) + expect(err.status).toBe(404) + expect(err.message).toBe('Could not get wallet address') + expect(next).not.toHaveBeenCalled() + expect(spspGetRouteSpy).not.toHaveBeenCalled() + } }) }) diff --git a/packages/backend/src/payment-method/ilp/spsp/middleware.ts b/packages/backend/src/payment-method/ilp/spsp/middleware.ts index 362f078e62..69a15c473b 100644 --- a/packages/backend/src/payment-method/ilp/spsp/middleware.ts +++ b/packages/backend/src/payment-method/ilp/spsp/middleware.ts @@ -2,9 +2,25 @@ import { WalletAddressContext, SPSPContext } from '../../../app' export type SPSPWalletAddressContext = WalletAddressContext & SPSPContext & { - incomingPayment?: never + walletAddressUrl: string } +export class SPSPRouteError extends Error { + public status: number + public details?: Record + + constructor( + status: number, + message: string, + details?: Record + ) { + super(message) + this.name = 'SPSPRouteError' + this.status = status + this.details = details + } +} + export function createSpspMiddleware(spspEnabled: boolean) { if (spspEnabled) { return spspMiddleware @@ -22,13 +38,21 @@ const spspMiddleware = async ( ctx: SPSPWalletAddressContext, next: () => Promise ): Promise => { - // Fall back to legacy protocols if client doesn't support Open Payments. if (ctx.accepts('application/spsp4+json')) { - const receiver = ctx.walletAddress ?? ctx.incomingPayment - ctx.paymentTag = receiver.id + const walletAddressService = await ctx.container.use('walletAddressService') + + const walletAddress = await walletAddressService.getByUrl( + ctx.walletAddressUrl + ) + + if (!walletAddress?.isActive) { + throw new SPSPRouteError(404, 'Could not get wallet address') + } + + ctx.paymentTag = walletAddress.id ctx.asset = { - code: receiver.asset.code, - scale: receiver.asset.scale + code: walletAddress.asset.code, + scale: walletAddress.asset.scale } const spspRoutes = await ctx.container.use('spspRoutes') await spspRoutes.get(ctx) diff --git a/packages/backend/src/payment-method/ilp/spsp/routes.test.ts b/packages/backend/src/payment-method/ilp/spsp/routes.test.ts index 95385c7078..7c6f1699ff 100644 --- a/packages/backend/src/payment-method/ilp/spsp/routes.test.ts +++ b/packages/backend/src/payment-method/ilp/spsp/routes.test.ts @@ -1,5 +1,6 @@ import * as crypto from 'crypto' import { v4 as uuid } from 'uuid' +import assert from 'assert' import { AppServices, SPSPContext } from '../../../app' import { SPSPRoutes } from './routes' @@ -13,6 +14,7 @@ import { StreamServer } from '@interledger/stream-receiver' import { randomAsset } from '../../../tests/asset' import { createContext } from '../../../tests/context' import { truncateTables } from '../../../tests/tableManager' +import { SPSPRouteError } from './middleware' type SPSPHeader = { Accept: string @@ -76,17 +78,46 @@ describe('SPSP Routes', (): void => { const ctx = createContext({ headers: { Accept: 'application/json' } }) - await expect(spspRoutes.get(ctx)).rejects.toHaveProperty('status', 406) + + expect.assertions(2) + try { + await spspRoutes.get(ctx) + } catch (err) { + assert.ok(err instanceof SPSPRouteError) + expect(err.status).toBe(406) + expect(err.message).toBe( + 'Request does not support application/spsp4+json' + ) + } }) test('nonce, no secret; returns 400', async () => { const ctx = setup({ nonce }) - await expect(spspRoutes.get(ctx)).rejects.toHaveProperty('status', 400) + + expect.assertions(2) + try { + await spspRoutes.get(ctx) + } catch (err) { + assert.ok(err instanceof SPSPRouteError) + expect(err.status).toBe(400) + expect(err.message).toBe( + 'Failed to generate credentials: receipt nonce and secret must accompany each other' + ) + } }) test('secret; no nonce; returns 400', async () => { const ctx = setup({ secret }) - await expect(spspRoutes.get(ctx)).rejects.toHaveProperty('status', 400) + expect.assertions(2) + try { + await spspRoutes.get(ctx) + } catch (err) { + assert.ok(err instanceof SPSPRouteError) + expect(err.status).toBe(400) + expect(err.message).toBe( + 'Failed to generate credentials: receipt nonce and secret must accompany each other' + ) + } }) test('malformed nonce; returns 400', async () => { @@ -94,7 +125,17 @@ describe('SPSP Routes', (): void => { nonce: Buffer.alloc(15).toString('base64'), secret }) - await expect(spspRoutes.get(ctx)).rejects.toHaveProperty('status', 400) + + expect.assertions(2) + try { + await spspRoutes.get(ctx) + } catch (err) { + assert.ok(err instanceof SPSPRouteError) + expect(err.status).toBe(400) + expect(err.message).toBe( + 'Failed to generate credentials: receipt nonce must be 16 bytes' + ) + } }) const receiptSetup = { @@ -139,6 +180,25 @@ describe('SPSP Routes', (): void => { } ) + test('handle error when generating credentials', async () => { + const ctx = setup() + + jest + .spyOn(streamServer, 'generateCredentials') + .mockImplementationOnce(() => { + throw new Error('Could not generate credentials') + }) + + expect.assertions(2) + try { + await spspRoutes.get(ctx) + } catch (err) { + assert.ok(err instanceof SPSPRouteError) + expect(err.status).toBe(400) + expect(err.message).toBe('Could not generate credentials') + } + }) + /** * Utility functions */ diff --git a/packages/backend/src/payment-method/ilp/spsp/routes.ts b/packages/backend/src/payment-method/ilp/spsp/routes.ts index 5f77ff17d7..0f443703aa 100644 --- a/packages/backend/src/payment-method/ilp/spsp/routes.ts +++ b/packages/backend/src/payment-method/ilp/spsp/routes.ts @@ -2,6 +2,7 @@ import { BaseService } from '../../../shared/baseService' import { SPSPContext } from '../../../app' import base64url from 'base64url' import { StreamServer } from '@interledger/stream-receiver' +import { SPSPRouteError } from './middleware' const CONTENT_TYPE_V4 = 'application/spsp4+json' @@ -34,14 +35,19 @@ async function getSPSP( deps: ServiceDependencies, ctx: SPSPContext ): Promise { - ctx.assert(ctx.accepts(CONTENT_TYPE_V4), 406) + if (!ctx.accepts(CONTENT_TYPE_V4)) { + throw new SPSPRouteError(406, `Request does not support ${CONTENT_TYPE_V4}`) + } + const nonce = ctx.request.headers['receipt-nonce'] const secret = ctx.request.headers['receipt-secret'] - ctx.assert( - !nonce === !secret, - 400, - 'Failed to generate credentials: receipt nonce and secret must accompany each other' - ) + + if (!!nonce !== !!secret) { + throw new SPSPRouteError( + 400, + 'Failed to generate credentials: receipt nonce and secret must accompany each other' + ) + } try { const { ilpAddress, sharedSecret } = deps.streamServer.generateCredentials({ @@ -63,6 +69,10 @@ async function getSPSP( receipts_enabled: !!(nonce && secret) }) } catch (err) { - ctx.throw(400, err instanceof Error && err.message) + const errorMessage = + err instanceof Error ? err.message : 'Could not generate SPSP credentials' + throw new SPSPRouteError(400, errorMessage, { + paymentTag: ctx.paymentTag + }) } }