Skip to content

Commit

Permalink
feat(backend): add Open Payments client service (#595)
Browse files Browse the repository at this point in the history
* fix(backend): return correct ilpStreamConnection type

Update ilp-packet version to match types.

* chore(backend): add configurable DEV_ACCESS_TOKEN to bypass introspection

* feat(backend): add Open Payments client service

Replace calls to Pay.setupPayment
Downgrade axios to v0.26.1

* chore(backend): rename QuoteError.InvalidDestination to InvalidReceiver

* chore(backend): delete completed/expired incoming payment connectionId

* fix(backend): pass correct host to ConnectionService

Remove publicHost from OutgoingPaymentService.
  • Loading branch information
wilsonianb authored Sep 15, 2022
1 parent 68115bb commit e96d44e
Show file tree
Hide file tree
Showing 30 changed files with 793 additions and 280 deletions.
2 changes: 1 addition & 1 deletion docs/transaction-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ The payment must be created with `quoteId`.
- `FIXED_SEND`: Fixed source amount.
- `FIXED_DELIVERY`: Incoming payment, fixed delivery amount.

### `Incoming Payment`
### `IncomingPayment`

| Name | Optional | Type | Description |
| :-------------------------- | :------- | :--------------------- | :---------------------------------------------------------------------------------------- |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ exports.up = function (knex) {
table.bigInteger('incomingAmountValue').nullable()
table.string('state').notNullable()
table.string('externalRef').nullable()
table.uuid('connectionId').notNullable()
table.uuid('connectionId').nullable()

table.uuid('assetId').notNullable()
table.foreign('assetId').references('assets.id')
Expand Down
5 changes: 2 additions & 3 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"apollo-server": "^3.10.1",
"auth": "workspace:../auth",
"cross-fetch": "^3.1.4",
"ilp-packet": "^3.1.2",
"ilp-protocol-ildcp": "^2.2.2",
"ilp-protocol-stream": "^2.7.0",
"jest-openapi": "^0.14.2",
Expand Down Expand Up @@ -59,15 +58,15 @@
"add": "^2.0.6",
"ajv": "^8.11.0",
"apollo-server-koa": "^3.10.1",
"axios": "^0.27.2",
"axios": "0.26.1",
"base64url": "^3.0.1",
"bcrypt": "^5.0.1",
"extensible-error": "^1.0.2",
"fishery": "^2.2.2",
"graphql": "^16.6.0",
"graphql-scalars": "^1.18.0",
"graphql-tools": "^8.3.3",
"ilp-packet": "^3.1.2",
"ilp-packet": "3.1.4-alpha.1",
"ilp-protocol-ccp": "^1.2.2",
"ilp-protocol-ildcp": "^2.2.2",
"ioredis": "^5.2.2",
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const Config = {
nonceRedisKey: envString('NONCE_REDIS_KEY', 'nonceToProject'),
adminKey: envString('ADMIN_KEY', 'qwertyuiop1234567890'),
sessionLength: envInt('SESSION_LENGTH', 30), // in minutes
devAccessToken: envString('DEV_ACCESS_TOKEN', 'dev-access-token'),

ilpAddress: envString('ILP_ADDRESS', 'test.rafiki'),
streamSecret: process.env.STREAM_SECRET
Expand Down Expand Up @@ -102,7 +103,7 @@ export const Config = {

openPaymentsSpec: envString(
'OPEN_PAYMENTS_SPEC',
'https://raw.githubusercontent.com/interledger/open-payments/6a410518c2fe054286bb0a55b0964d80596fea5a/open-api-spec.yaml'
'https://raw.githubusercontent.com/interledger/open-payments/bc90cb63e99e56b85abe25f2018393c7b21f6648/open-api-spec.yaml'
),
authServerSpec: envString(
'AUTH_SERVER_SPEC',
Expand Down
19 changes: 15 additions & 4 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createAssetService } from './asset/service'
import { createAccountingService } from './accounting/service'
import { createPeerService } from './peer/service'
import { createAuthService } from './open_payments/auth/service'
import { createOpenPaymentsClientService } from './open_payments/client/service'
import { createPaymentPointerService } from './open_payments/payment_pointer/service'
import { createSPSPRoutes } from './spsp/routes'
import { createClientKeysRoutes } from './clientKeys/routes'
Expand Down Expand Up @@ -185,6 +186,15 @@ export function initIocContainer(
logger: await deps.use('logger')
})
})
container.singleton('openPaymentsClientService', async (deps) => {
const config = await deps.use('config')
return await createOpenPaymentsClientService({
logger: await deps.use('logger'),
// TODO: https://github.com/interledger/rafiki/issues/583
accessToken: config.devAccessToken,
openApi: await deps.use('openApi')
})
})
container.singleton('incomingPaymentService', async (deps) => {
return await createIncomingPaymentService({
logger: await deps.use('logger'),
Expand Down Expand Up @@ -213,14 +223,15 @@ export function initIocContainer(
})
})
container.singleton('connectionService', async (deps) => {
const config = await deps.use('config')
return await createConnectionService({
logger: await deps.use('logger'),
openPaymentsUrl: config.openPaymentsUrl,
streamServer: await deps.use('streamServer')
})
})
container.singleton('connectionRoutes', async (deps) => {
return createConnectionRoutes({
config: await deps.use('config'),
logger: await deps.use('logger'),
incomingPaymentService: await deps.use('incomingPaymentService'),
connectionService: await deps.use('connectionService')
Expand Down Expand Up @@ -272,6 +283,7 @@ export function initIocContainer(
logger: await deps.use('logger'),
knex: await deps.use('knex'),
makeIlpPlugin: await deps.use('makeIlpPlugin'),
clientService: await deps.use('openPaymentsClientService'),
paymentPointerService: await deps.use('paymentPointerService'),
ratesService: await deps.use('ratesService')
})
Expand All @@ -284,14 +296,13 @@ export function initIocContainer(
})
})
container.singleton('outgoingPaymentService', async (deps) => {
const config = await deps.use('config')
return await createOutgoingPaymentService({
logger: await deps.use('logger'),
knex: await deps.use('knex'),
accountingService: await deps.use('accountingService'),
clientService: await deps.use('openPaymentsClientService'),
makeIlpPlugin: await deps.use('makeIlpPlugin'),
peerService: await deps.use('peerService'),
publicHost: config.publicHost
peerService: await deps.use('peerService')
})
})
container.singleton('outgoingPaymentRoutes', async (deps) => {
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/open_payments/auth/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,12 @@ describe('Auth Middleware', (): void => {
scope.isDone()
}
)
test('bypasses token introspection for configured DEV_ACCESS_TOKEN', async (): Promise<void> => {
ctx.headers.authorization = `GNAP ${Config.devAccessToken}`
const authService = await deps.use('authService')
const introspectSpy = jest.spyOn(authService, 'introspect')
await expect(middleware(ctx, next)).resolves.toBeUndefined()
expect(introspectSpy).not.toHaveBeenCalled()
expect(next).toHaveBeenCalled()
})
})
7 changes: 7 additions & 0 deletions packages/backend/src/open_payments/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export function createAuthMiddleware({
ctx.throw(401, 'Unauthorized')
}
const token = parts[1]
if (
process.env.NODE_ENV !== 'production' &&
token === config.devAccessToken
) {
await next()
return
}
const authService = await ctx.container.use('authService')
const grant = await authService.introspect(token)
if (!grant || !grant.active) {
Expand Down
109 changes: 109 additions & 0 deletions packages/backend/src/open_payments/client/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { IocContract } from '@adonisjs/fold'
import { faker } from '@faker-js/faker'
import { Knex } from 'knex'
import nock from 'nock'
import { URL } from 'url'
import { v4 as uuid } from 'uuid'

import { OpenPaymentsClientService } from './service'
import { createTestApp, TestContainer } from '../../tests/app'
import { Config } from '../../config/app'
import { initIocContainer } from '../..'
import { AppServices } from '../../app'
import { createIncomingPayment } from '../../tests/incomingPayment'
import { createPaymentPointer } from '../../tests/paymentPointer'
import { truncateTables } from '../../tests/tableManager'

describe('Open Payments Client Service', (): void => {
let deps: IocContract<AppServices>
let appContainer: TestContainer
let clientService: OpenPaymentsClientService
let knex: Knex

beforeAll(async (): Promise<void> => {
deps = await initIocContainer(Config)
appContainer = await createTestApp(deps)
clientService = await deps.use('openPaymentsClientService')
knex = await deps.use('knex')
})

afterEach(async (): Promise<void> => {
jest.useRealTimers()
await truncateTables(knex)
})

afterAll(async (): Promise<void> => {
await appContainer.shutdown()
})
describe('incomingPayment.get', (): void => {
test.each`
incomingAmount | description | externalRef
${undefined} | ${undefined} | ${undefined}
${BigInt(123)} | ${'Test'} | ${'#123'}
`(
'resolves incoming payment from Open Payments server',
async ({ incomingAmount, description, externalRef }): Promise<void> => {
const paymentPointer = await createPaymentPointer(deps, {
mockServerPort: appContainer.openPaymentsPort
})
const incomingPayment = await createIncomingPayment(deps, {
paymentPointerId: paymentPointer.id,
description,
incomingAmount: incomingAmount && {
value: incomingAmount,
assetCode: paymentPointer.asset.code,
assetScale: paymentPointer.asset.scale
},
externalRef
})
const resolvedPayment = await clientService.incomingPayment.get(
incomingPayment.url
)
paymentPointer.scope.isDone()
expect(resolvedPayment).toEqual({
...incomingPayment.toJSON(),
id: incomingPayment.url,
paymentPointer: incomingPayment.paymentPointer.url,
ilpStreamConnection: {
id: `${Config.openPaymentsUrl}/connections/${incomingPayment.connectionId}`,
ilpAddress: expect.any(String),
sharedSecret: expect.any(String)
}
})
}
)
test.each`
statusCode
${404}
${500}
`(
'returns undefined for unsuccessful request ($statusCode)',
async ({ statusCode }): Promise<void> => {
const incomingPaymentUrl = new URL(
`${faker.internet.url()}/incoming-payments/${uuid()}`
)
const scope = nock(incomingPaymentUrl.host)
.get(incomingPaymentUrl.pathname)
.reply(statusCode)
scope.isDone()
await expect(
clientService.incomingPayment.get(incomingPaymentUrl.href)
).resolves.toBeUndefined()
}
)
test('returns undefined for invalid incoming payment response', async (): Promise<void> => {
const incomingPaymentUrl = new URL(
`${faker.internet.url()}/incoming-payments/${uuid()}`
)
const scope = nock(incomingPaymentUrl.host)
.get(incomingPaymentUrl.pathname)
.reply(200, () => ({
validPayment: 0
}))
scope.isDone()
await expect(
clientService.incomingPayment.get(incomingPaymentUrl.href)
).resolves.toBeUndefined()
})
})
})
70 changes: 70 additions & 0 deletions packages/backend/src/open_payments/client/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import axios from 'axios'
import { OpenAPI, HttpMethod, ValidateFunction } from 'openapi'

import { BaseService } from '../../shared/baseService'
import { IncomingPaymentJSON } from '../payment/incoming/model'

const REQUEST_TIMEOUT = 5_000 // millseconds

export interface OpenPaymentsClientService {
incomingPayment: {
get(url: string): Promise<IncomingPaymentJSON | undefined>
}
}

export interface ServiceDependencies extends BaseService {
accessToken: string
openApi: OpenAPI
validateResponse: ValidateFunction<IncomingPaymentJSON>
}

export async function createOpenPaymentsClientService(
deps_: Omit<ServiceDependencies, 'validateResponse'>
): Promise<OpenPaymentsClientService> {
const log = deps_.logger.child({
service: 'OpenPaymentsClientService'
})
const validateResponse =
deps_.openApi.createResponseValidator<IncomingPaymentJSON>({
path: '/incoming-payments/{incomingPaymentId}',
method: HttpMethod.GET
})
const deps: ServiceDependencies = {
...deps_,
logger: log,
validateResponse
}
return {
incomingPayment: {
get: (url) => getIncomingPayment(deps, url)
}
}
}

export async function getIncomingPayment(
deps: ServiceDependencies,
url: string
): Promise<IncomingPaymentJSON | undefined> {
const requestHeaders = {
Authorization: `GNAP ${deps.accessToken}`,
'Content-Type': 'application/json'
}
try {
const { status, data } = await axios.get(url, {
headers: requestHeaders,
timeout: REQUEST_TIMEOUT,
validateStatus: (status) => status === 200
})
if (
!deps.validateResponse({
status,
body: data
})
) {
throw new Error('unreachable')
}
return data
} catch (_) {
return undefined
}
}
36 changes: 33 additions & 3 deletions packages/backend/src/open_payments/connection/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { initIocContainer } from '../../'
import { ConnectionRoutes } from './routes'
import { createContext } from '../../tests/context'
import { PaymentPointer } from '../payment_pointer/model'
import { IncomingPayment } from '../payment/incoming/model'
import {
IncomingPayment,
IncomingPaymentState
} from '../payment/incoming/model'
import { createIncomingPayment } from '../../tests/incomingPayment'
import { createPaymentPointer } from '../../tests/paymentPointer'
import base64url from 'base64url'
Expand All @@ -25,7 +28,6 @@ describe('Connection Routes', (): void => {

beforeAll(async (): Promise<void> => {
config = Config
config.publicHost = 'https://wallet.example'
config.authServerGrantUrl = 'https://auth.wallet.example/authorize'
deps = await initIocContainer(config)
appContainer = await createTestApp(deps)
Expand Down Expand Up @@ -82,6 +84,34 @@ describe('Connection Routes', (): void => {
)
})

test.each`
state
${IncomingPaymentState.Completed}
${IncomingPaymentState.Expired}
`(
`returns 404 for $state incoming payment`,
async ({ state }): Promise<void> => {
await incomingPayment.$query(knex).patch({
state,
expiresAt:
state === IncomingPaymentState.Expired ? new Date() : undefined
})
const ctx = createContext<ReadContext>(
{
headers: { Accept: 'application/json' },
url: `/connections/${incomingPayment.connectionId}`
},
{
connectionId: incomingPayment.connectionId
}
)
await expect(connectionRoutes.get(ctx)).rejects.toHaveProperty(
'status',
404
)
}
)

test('returns 200 for correct connection id', async (): Promise<void> => {
const ctx = createContext<ReadContext>(
{
Expand All @@ -100,7 +130,7 @@ describe('Connection Routes', (): void => {
]

expect(ctx.body).toEqual({
id: `${config.publicHost}/connections/${incomingPayment.connectionId}`,
id: `${config.openPaymentsUrl}/connections/${incomingPayment.connectionId}`,
ilpAddress: expect.stringMatching(/^test\.rafiki\.[a-zA-Z0-9_-]{95}$/),
sharedSecret
})
Expand Down
Loading

0 comments on commit e96d44e

Please sign in to comment.