From e60d7aeec78449eab73e2f23ee0301421f29b68f Mon Sep 17 00:00:00 2001 From: Diane Huxley Date: Sat, 3 Feb 2024 01:56:23 -0800 Subject: [PATCH 1/3] Stricten, test, and bugfix http-server --- packages/http-server/package.json | 5 +- packages/http-server/src/fakes.ts | 58 ---- packages/http-server/src/http-server.ts | 64 ++-- .../src/in-memory-exchanges-api.ts | 92 +++++ .../src/in-memory-offerings-api.ts | 72 ++++ .../src/request-handlers/create-exchange.ts | 91 ++--- .../src/request-handlers/get-exchanges.ts | 65 ++-- .../src/request-handlers/get-offerings.ts | 30 +- .../src/request-handlers/submit-close.ts | 81 +++-- .../src/request-handlers/submit-order.ts | 82 +++-- packages/http-server/src/types.ts | 29 +- .../http-server/tests/create-exchange.spec.ts | 314 +++++++++++++++++- .../http-server/tests/get-exchanges.spec.ts | 175 +++++++--- .../http-server/tests/get-offerings.spec.ts | 78 ++++- .../http-server/tests/submit-close.spec.ts | 277 ++++++++++++++- .../http-server/tests/submit-order.spec.ts | 273 ++++++++++++++- packages/http-server/tests/tsconfig.json | 2 + packages/http-server/tsconfig.json | 3 + packages/protocol/tests/exchange.spec.ts | 2 +- pnpm-lock.yaml | 23 +- 20 files changed, 1451 insertions(+), 365 deletions(-) delete mode 100644 packages/http-server/src/fakes.ts create mode 100644 packages/http-server/src/in-memory-exchanges-api.ts create mode 100644 packages/http-server/src/in-memory-offerings-api.ts diff --git a/packages/http-server/package.json b/packages/http-server/package.json index 471a104d..3efa487e 100644 --- a/packages/http-server/package.json +++ b/packages/http-server/package.json @@ -24,17 +24,20 @@ "@tbdex/http-client": "workspace:*", "@tbdex/protocol": "workspace:*", "@web5/dids": "0.2.2", - "cors": "2.8.5", + "cors": "^2.8.5", "express": "4.18.2" }, "devDependencies": { "@types/chai": "4.3.6", + "@types/cors": "^2.8.17", "@types/express": "4.17.17", "@types/http-errors": "2.0.4", "@types/mocha": "10.0.1", "@types/node": "20.9.4", + "@types/sinon": "^17.0.3", "chai": "4.3.10", "rimraf": "5.0.1", + "sinon": "17.0.1", "supertest": "6.3.3", "typescript": "5.2.2" }, diff --git a/packages/http-server/src/fakes.ts b/packages/http-server/src/fakes.ts deleted file mode 100644 index c544f1b4..00000000 --- a/packages/http-server/src/fakes.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { DevTools, Message, Rfq, Quote, Order, OrderStatus, Close } from '@tbdex/protocol' -import { OfferingsApi, ExchangesApi } from './main.js' - -const offering = DevTools.createOffering() - -export const fakeOfferingsApi: OfferingsApi = { - async getOffering() { return offering }, - async getOfferings() { return [offering] } -} - -export interface FakeExchangesApi extends ExchangesApi { - exchangeMessagesMap: Map, - addMessage(message: Message): void - clearMessages(): void -} - -export const fakeExchangesApi: FakeExchangesApi = { - exchangeMessagesMap: new Map(), - - getExchanges: function (): Promise { - throw new Error('Function not implemented.') - }, - - getExchange: function (opts: { id: string} ): Promise { - const messages = this.exchangeMessagesMap.get(opts.id) || undefined - return Promise.resolve(messages) - }, - - getRfq: function (): Promise { - throw new Error('Function not implemented.') - }, - - getQuote: function (): Promise { - throw new Error('Function not implemented.') - }, - - getOrder: function (): Promise { - throw new Error('Function not implemented.') - }, - - getOrderStatuses: function (): Promise { - throw new Error('Function not implemented.') - }, - - getClose: function (): Promise { - throw new Error('Function not implemented.') - }, - - addMessage: function (message: Message): void { - const messages = this.exchangeMessagesMap.get(message.exchangeId) || [] - messages.push(message) - this.exchangeMessagesMap.set(message.exchangeId, messages) - }, - - clearMessages: function (): void { - this.exchangeMessagesMap = new Map() - } -} \ No newline at end of file diff --git a/packages/http-server/src/http-server.ts b/packages/http-server/src/http-server.ts index eca593aa..a99fc0a0 100644 --- a/packages/http-server/src/http-server.ts +++ b/packages/http-server/src/http-server.ts @@ -8,14 +8,15 @@ import type { GetOfferingsCallback, } from './types.js' -import type { Express } from 'express' +import type { Express, Request, Response } from 'express' import express from 'express' import cors from 'cors' import { getExchanges, getOfferings, submitOrder, submitClose, createExchange } from './request-handlers/index.js' import { jsonBodyParser } from './middleware/index.js' -import { fakeExchangesApi, fakeOfferingsApi } from './fakes.js' +import { InMemoryOfferingsApi } from './in-memory-offerings-api.js' +import { InMemoryExchangesApi } from './in-memory-exchanges-api.js' /** * Maps the requests to their respective callbacks handlers @@ -72,8 +73,8 @@ export class TbdexHttpServer { constructor(opts?: NewHttpServerOptions) { this.callbacks = {} - this.exchangesApi = opts?.exchangesApi ?? fakeExchangesApi - this.offeringsApi = opts?.offeringsApi ?? fakeOfferingsApi + this.exchangesApi = opts?.exchangesApi ?? new InMemoryExchangesApi() + this.offeringsApi = opts?.offeringsApi ?? new InMemoryOfferingsApi() this.pfiDid = opts?.pfiDid ?? 'did:ex:pfi' // initialize api here so that consumers can attach custom endpoints @@ -139,25 +140,42 @@ export class TbdexHttpServer { listen(port: number | string, callback?: () => void) { const { offeringsApi, exchangesApi, pfiDid } = this - this.api.post('/exchanges/:exchangeId/rfq', createExchange({ - callback: this.callbacks['rfq'], offeringsApi, exchangesApi, - })) - - this.api.post('/exchanges/:exchangeId/order', submitOrder({ - callback: this.callbacks['order'], exchangesApi - })) - - this.api.post('/exchanges/:exchangeId/close', submitClose({ - callback: this.callbacks['close'], exchangesApi - })) - - this.api.get('/exchanges', getExchanges({ - callback: this.callbacks['exchanges'], exchangesApi, pfiDid - })) - - this.api.get('/offerings', getOfferings({ - callback: this.callbacks['offerings'], offeringsApi - })) + this.api.post('/exchanges/:exchangeId/rfq', (req: Request, res: Response) => + createExchange(req, res, { + callback: this.callbacks['rfq'], + offeringsApi, + exchangesApi, + }) + ) + + this.api.post('/exchanges/:exchangeId/order', (req: Request, res: Response) => + submitOrder(req, res, { + callback: this.callbacks['order'], + exchangesApi + }) + ) + + this.api.post('/exchanges/:exchangeId/close', (req: Request, res: Response) => + submitClose(req, res,{ + callback: this.callbacks.close, + exchangesApi, + }) + ) + + this.api.get('/exchanges', (req: Request, res: Response) => + getExchanges(req, res, { + callback: this.callbacks.exchanges, + exchangesApi, + pfiDid, + }) + ) + + this.api.get('/offerings', (req, res) => + getOfferings(req, res, { + callback: this.callbacks['offerings'], + offeringsApi + }) + ) // TODO: support hostname and backlog arguments return this.api.listen(port, callback) diff --git a/packages/http-server/src/in-memory-exchanges-api.ts b/packages/http-server/src/in-memory-exchanges-api.ts new file mode 100644 index 00000000..0a6c20d6 --- /dev/null +++ b/packages/http-server/src/in-memory-exchanges-api.ts @@ -0,0 +1,92 @@ +import { Exchange } from '@tbdex/protocol' +import { Message, Rfq, Quote, Order, OrderStatus, Close } from '@tbdex/protocol' +import { ExchangesApi, GetExchangesFilter } from './main.js' + +/** + * An in-memory implementation of {@link ExchangesApi} for example and default purposes. + * InMemoryExchangesApi has additional methods {@link InMemoryExchangesApi.addMessage} + * and {@link InMemoryExchangesApi.clearMessages} + */ +export class InMemoryExchangesApi implements ExchangesApi { + /** Map from exchange_id to Exchange */ + exchangeMessagesMap: Map + + constructor() { + this.exchangeMessagesMap = new Map() + } + + async getExchanges(opts?: { filter: GetExchangesFilter }): Promise { + if (opts === undefined || opts.filter === undefined) { + // In production, this should probably return an empty list. + // For example and testing purposes, we return all exchanges. + + return Array.from(this.exchangeMessagesMap.values()) + } + + const exchanges: Exchange[] = [] + if (opts.filter.id) { + // filter has `id` and `from` + + for (const id of opts.filter.id) { + const exchange = this.exchangeMessagesMap.get(id) + if (exchange?.rfq?.from === opts.filter.from) { + exchanges.push(exchange) + } + } + } else { + // filter only has `from` + this.exchangeMessagesMap.forEach((exchange, _id) => { + // You definitely shouldn't use FakeExchangesApi in production. + // This will get really slow + if (exchange?.rfq?.from === opts.filter.from) { + exchanges.push(exchange) + } + }) + } + + return exchanges + } + + async getExchange(opts: { id: string} ): Promise { + const exchange = this.exchangeMessagesMap.get(opts.id) + return Promise.resolve(exchange) + } + + async getRfq(opts: { exchangeId: string }): Promise { + const exchange = this.exchangeMessagesMap.get(opts.exchangeId) + return exchange?.rfq + } + + async getQuote(opts: { exchangeId: string }): Promise { + const exchange = this.exchangeMessagesMap.get(opts.exchangeId) + return Promise.resolve(exchange?.quote) + } + + async getOrder(opts: { exchangeId: string }): Promise { + const exchange = this.exchangeMessagesMap.get(opts.exchangeId) + return exchange?.order + } + + async getOrderStatuses(opts: { exchangeId: string }): Promise { + const exchange = this.exchangeMessagesMap.get(opts.exchangeId) + if (exchange?.orderstatus === undefined) { + return [] + } + return [exchange.orderstatus] + } + + async getClose(opts: { exchangeId: string }): Promise { + const exchange = this.exchangeMessagesMap.get(opts.exchangeId) + return exchange?.close + } + + addMessage(message: Message): void { + const exchange = this.exchangeMessagesMap.get(message.exchangeId) ?? new Exchange() + exchange.addNextMessage(message) + this.exchangeMessagesMap.set(message.exchangeId, exchange) + } + + clearMessages(): void { + this.exchangeMessagesMap = new Map() + } +} \ No newline at end of file diff --git a/packages/http-server/src/in-memory-offerings-api.ts b/packages/http-server/src/in-memory-offerings-api.ts new file mode 100644 index 00000000..cc0c651a --- /dev/null +++ b/packages/http-server/src/in-memory-offerings-api.ts @@ -0,0 +1,72 @@ +import { Offering } from '@tbdex/protocol' +import { GetOfferingsFilter, OfferingsApi } from './types.js' + +/** + * An in-memory implementation of {@link OfferingsApi} for example and default purposes. + * InMemoryOfferingsApi has additional methods {@link InMemoryOfferingsApi.addOffering} + * and {@link InMemoryOfferingsApi.clearOfferings} + */ +export class InMemoryOfferingsApi implements OfferingsApi { + /** Map from offering_id to Offering */ + offeringsMap: Map + + constructor() { + this.offeringsMap = new Map() + } + + /** + * Add a single offering + * @param offering - Offering to be added to the {@link offeringsMap} + */ + addOffering(offering: Offering): void { + this.offeringsMap.set(offering.metadata.id, offering) + } + + /** + * Clear existing list offerings + */ + clearOfferings(): void { + this.offeringsMap.clear() + } + + /** + * Retrieve a single offering if found + * @param opts - Filter with id used to select an offering + * @returns An offering if one exists, else undefined + */ + async getOffering(opts: { id: string }): Promise{ + return this.offeringsMap.get(opts.id) + } + + /** + * + * @param opts - Filter used to select offerings + * @returns A list of offerings matching the filter + */ + async getOfferings(opts?: { filter: GetOfferingsFilter }): Promise { + const allOfferings = Array.from(this.offeringsMap.values()) + + if (opts?.filter === undefined || Object.values(opts.filter).every(v => v === undefined)) { + // If no filter is provided, return all offerings + return allOfferings + } + + const { filter: { + id, + payinCurrency, + payoutCurrency, + payinMethodKind, + payoutMethodKind, + } } = opts + + return allOfferings.filter((offering) => { + // If filter includes a field, make sure the returned offerings match + return (!id || id === offering.metadata.id) && + (!payinCurrency || payinCurrency === offering.data.payinCurrency.currencyCode) && + (!payoutCurrency || payoutCurrency === offering.data.payoutCurrency.currencyCode) && + (!payinMethodKind || offering.data.payinMethods.map(pm => pm.kind).includes(payinMethodKind)) && + (!payoutMethodKind || offering.data.payoutMethods.map(pm => pm.kind).includes(payoutMethodKind)) + }) + } + +} \ No newline at end of file diff --git a/packages/http-server/src/request-handlers/create-exchange.ts b/packages/http-server/src/request-handlers/create-exchange.ts index 6d44a275..bb9a59c6 100644 --- a/packages/http-server/src/request-handlers/create-exchange.ts +++ b/packages/http-server/src/request-handlers/create-exchange.ts @@ -1,69 +1,70 @@ -import type { RequestHandler, OfferingsApi, ExchangesApi, SubmitRfqCallback } from '../types.js' +import type { OfferingsApi, ExchangesApi, SubmitRfqCallback } from '../types.js' import { Rfq } from '@tbdex/protocol' import type { ErrorDetail } from '@tbdex/http-client' import { CallbackError } from '../callback-error.js' +import { Request, Response } from 'express' type CreateExchangeOpts = { - callback: SubmitRfqCallback + callback?: SubmitRfqCallback offeringsApi: OfferingsApi exchangesApi: ExchangesApi } -export function createExchange(options: CreateExchangeOpts): RequestHandler { +export async function createExchange(req: Request, res: Response, options: CreateExchangeOpts): Promise { const { offeringsApi, exchangesApi, callback } = options - return async function (req, res) { - let rfq: Rfq + const replyTo: string | undefined = req.body.replyTo - if (req.body.replyTo && !isValidUrl(req.body.replyTo)) { - return res.status(400).json({ errors: [{ detail: 'replyTo must be a valid url' }] }) - } - - try { - rfq = await Rfq.parse(req.body.rfq) - } catch(e) { - const errorResponse: ErrorDetail = { detail: `Parsing of TBDex Rfq message failed: ${e.message}` } - return res.status(400).json({ errors: [errorResponse] }) - } + let rfq: Rfq - // TODO: check message.from against allowlist + if (replyTo && !isValidUrl(replyTo)) { + return res.status(400).json({ errors: [{ detail: 'replyTo must be a valid url' }] }) + } - const rfqExists = !! await exchangesApi.getRfq({ exchangeId: rfq.id }) - if (rfqExists) { - const errorResponse: ErrorDetail = { detail: `rfq ${rfq.id} already exists`} - return res.status(409).json({ errors: [errorResponse] }) - } + try { + rfq = await Rfq.parse(req.body.rfq) + } catch(e) { + const errorResponse: ErrorDetail = { detail: `Parsing of TBDex Rfq message failed: ${e.message}` } + return res.status(400).json({ errors: [errorResponse] }) + } - const offering = await offeringsApi.getOffering({ id: rfq.data.offeringId }) - if (!offering) { - const errorResponse: ErrorDetail = { detail: `offering ${rfq.data.offeringId} does not exist` } - return res.status(400).json({ errors: [errorResponse] }) - } + // TODO: check message.from against allowlist - try { - await rfq.verifyOfferingRequirements(offering) - } catch(e) { - const errorResponse: ErrorDetail = { detail: `Failed to verify offering requirements: ${e.message}` } - return res.status(400).json({ errors: [errorResponse] }) - } + const rfqExists = !! await exchangesApi.getRfq({ exchangeId: rfq.id }) + if (rfqExists) { + const errorResponse: ErrorDetail = { detail: `rfq ${rfq.id} already exists`} + return res.status(409).json({ errors: [errorResponse] }) + } - if (!callback) { - return res.sendStatus(202) - } + const offering = await offeringsApi.getOffering({ id: rfq.data.offeringId }) + if (!offering) { + const errorResponse: ErrorDetail = { detail: `offering ${rfq.data.offeringId} does not exist` } + return res.status(400).json({ errors: [errorResponse] }) + } - try { - await callback({ request: req, response: res }, rfq, { offering }) - } catch(e) { - if (e instanceof CallbackError) { - return res.status(e.statusCode).json({ errors: e.details }) - } else { - const errorDetail: ErrorDetail = { detail: 'Internal Server Error' } - return res.status(500).json({ errors: [errorDetail] }) - } - } + try { + await rfq.verifyOfferingRequirements(offering) + } catch(e) { + const errorResponse: ErrorDetail = { detail: `Failed to verify offering requirements: ${e.message}` } + return res.status(400).json({ errors: [errorResponse] }) + } + if (!callback) { return res.sendStatus(202) } + + try { + await callback({ request: req, response: res }, rfq, { offering, replyTo }) + } catch(e) { + if (e instanceof CallbackError) { + return res.status(e.statusCode).json({ errors: e.details }) + } else { + const errorDetail: ErrorDetail = { detail: 'Internal Server Error' } + return res.status(500).json({ errors: [errorDetail] }) + } + } + + return res.sendStatus(202) } function isValidUrl(replyToUrl: string) { diff --git a/packages/http-server/src/request-handlers/get-exchanges.ts b/packages/http-server/src/request-handlers/get-exchanges.ts index f31faae6..9130cc53 100644 --- a/packages/http-server/src/request-handlers/get-exchanges.ts +++ b/packages/http-server/src/request-handlers/get-exchanges.ts @@ -1,49 +1,54 @@ -import type { ExchangesApi, GetExchangesCallback, GetExchangesFilter, RequestHandler } from '../types.js' +import type { ExchangesApi, GetExchangesCallback, GetExchangesFilter } from '../types.js' import { TbdexHttpClient } from '@tbdex/http-client' +import { Request, Response } from 'express' type GetExchangesOpts = { - callback: GetExchangesCallback + callback?: GetExchangesCallback exchangesApi: ExchangesApi, pfiDid: string } -export function getExchanges(opts: GetExchangesOpts): RequestHandler { +export async function getExchanges(request: Request, response: Response, opts: GetExchangesOpts) { const { callback, exchangesApi, pfiDid } = opts - return async function (request, response) { - const authzHeader = request.headers['authorization'] - if (!authzHeader) { - return response.status(401).json({ errors: [{ detail: 'Authorization header required' }] }) - } - const [_, requestToken] = authzHeader.split('Bearer ') + const authzHeader = request.headers['authorization'] + if (!authzHeader) { + return response.status(401).json({ errors: [{ detail: 'Authorization header required' }] }) + } - if (!requestToken) { - return response.status(401).json({ errors: [{ detail: 'Malformed Authorization header. Expected: Bearer TOKEN_HERE' }] }) - } + const [_, requestToken] = authzHeader.split('Bearer ') - let requesterDid: string - try { - requesterDid = await TbdexHttpClient.verifyRequestToken({ requestToken: requestToken, pfiDid }) - } catch(e) { - return response.status(401).json({ errors: [{ detail: `Malformed Authorization header: ${e}` }] }) - } + if (!requestToken) { + return response.status(401).json({ errors: [{ detail: 'Malformed Authorization header. Expected: Bearer TOKEN_HERE' }] }) + } - const queryParams: GetExchangesFilter = { from: requesterDid } - for (let param in request.query) { - const val = request.query[param] - queryParams[param] = Array.isArray(val) ? val : [val] - } + let requesterDid: string + try { + requesterDid = await TbdexHttpClient.verifyRequestToken({ requestToken: requestToken, pfiDid }) + } catch(e) { + return response.status(401).json({ errors: [{ detail: `Malformed Authorization header: ${e}` }] }) + } - // check exchanges exist - what to do if some exist but others don't? - const exchanges = await exchangesApi.getExchanges({ filter: queryParams }) + const queryParams: GetExchangesFilter = { + from: requesterDid, + } - if (callback) { - // TODO: figure out what to do with callback result. should we pass through the exchanges we've fetched - // and allow the callback to modify what's returned? (issue #10) - const _result = await callback({ request, response }, queryParams) + if (request.query.id !== undefined) { + if (Array.isArray(request.query.id)) { + queryParams.id = request.query.id.map((id) => id.toString()) + } else { + queryParams.id = [request.query.id.toString()] } + } - return response.status(200).json({ data: exchanges }) + const exchanges = await exchangesApi.getExchanges({ filter: queryParams }) + + if (callback) { + // TODO: figure out what to do with callback result. should we pass through the exchanges we've fetched + // and allow the callback to modify what's returned? (issue #10) + const _result = await callback({ request, response }, queryParams) } + + return response.status(200).json({ data: exchanges }) } \ No newline at end of file diff --git a/packages/http-server/src/request-handlers/get-offerings.ts b/packages/http-server/src/request-handlers/get-offerings.ts index fea90b46..1ee7e3a4 100644 --- a/packages/http-server/src/request-handlers/get-offerings.ts +++ b/packages/http-server/src/request-handlers/get-offerings.ts @@ -1,23 +1,29 @@ -import type { GetOfferingsCallback, GetOfferingsFilter, OfferingsApi, RequestHandler } from '../types.js' +import { Request, Response } from 'express' +import type { GetOfferingsCallback, GetOfferingsFilter, OfferingsApi } from '../types.js' type GetOfferingsOpts = { - callback: GetOfferingsCallback + callback?: GetOfferingsCallback offeringsApi: OfferingsApi } -export function getOfferings(opts: GetOfferingsOpts): RequestHandler { +export async function getOfferings(request: Request, response: Response, opts: GetOfferingsOpts): Promise { const { callback, offeringsApi } = opts - return async function (request, response) { - const queryParams = request.query as GetOfferingsFilter - const offerings = await offeringsApi.getOfferings({ filter: queryParams || {} }) + const filter: GetOfferingsFilter = { + payinCurrency : request.query.payinCurrency?.toString(), + payoutCurrency : request.query.payoutCurrency?.toString(), + payinMethodKind : request.query.payinMethodKind?.toString(), + payoutMethodKind : request.query.payoutMethodKind?.toString(), + id : request.query.id?.toString(), + } - if (callback) { - // TODO: figure out what to do with callback result. should we pass through the offerings we've fetched - // and allow the callback to modify what's returned? (issue #11) - await callback({ request, response }, queryParams) - } + const offerings = await offeringsApi.getOfferings({ filter }) - return response.status(200).json({ data: offerings }) + if (callback) { + // TODO: figure out what to do with callback result. should we pass through the offerings we've fetched + // and allow the callback to modify what's returned? (issue #11) + await callback({ request, response }, filter) } + + return response.status(200).json({ data: offerings }) } \ No newline at end of file diff --git a/packages/http-server/src/request-handlers/submit-close.ts b/packages/http-server/src/request-handlers/submit-close.ts index 4d1bda12..07d8c9d5 100644 --- a/packages/http-server/src/request-handlers/submit-close.ts +++ b/packages/http-server/src/request-handlers/submit-close.ts @@ -1,55 +1,72 @@ -import type { RequestHandler, ExchangesApi, SubmitCloseCallback } from '../types.js' +import type { ExchangesApi, SubmitCloseCallback } from '../types.js' import type { ErrorDetail } from '@tbdex/http-client' import { Close } from '@tbdex/protocol' import { CallbackError } from '../callback-error.js' +import { Request, Response } from 'express' type SubmitCloseOpts = { - callback: SubmitCloseCallback + callback?: SubmitCloseCallback exchangesApi: ExchangesApi } -export function submitClose(opts: SubmitCloseOpts): RequestHandler { +export async function submitClose(req: Request, res: Response, opts: SubmitCloseOpts): Promise { const { callback, exchangesApi } = opts - return async function (req, res) { - let close: Close + let close: Close - try { - close = await Close.parse(req.body) - } catch(e) { - const errorResponse: ErrorDetail = { detail: e.message } - return res.status(400).json({ errors: [errorResponse] }) - } + try { + close = await Close.parse(req.body) + } catch(e) { + const errorResponse: ErrorDetail = { detail: 'Request body was not a valid Close message' } + return res.status(400).json({ errors: [errorResponse] }) + } - const exchange = await exchangesApi.getExchange({id: close.exchangeId}) - if(exchange == undefined) { - const errorResponse: ErrorDetail = { detail: `No exchange found for ${close.exchangeId}` } + // Ensure that an exchange exists to be closed + const exchange = await exchangesApi.getExchange({ id: close.exchangeId }) - return res.status(404).json({ errors: [errorResponse] }) - } + if(exchange === undefined || exchange.messages.length === 0) { + const errorResponse: ErrorDetail = { detail: `No exchange found for ${close.exchangeId}` } - const last = exchange[exchange.length-1] - if(!last.validNext.has(close.kind)) { - const errorResponse: ErrorDetail = { detail: `cannot submit Close for an exchange where the last message is kind: ${last.kind}` } + return res.status(404).json({ errors: [errorResponse] }) + } - return res.status(409).json({ errors: [errorResponse] }) + // Ensure this exchange can be Closed + if(!exchange.isValidNext(close.metadata.kind)) { + const errorResponse: ErrorDetail = { + detail: `cannot submit Close for an exchange where the last message is kind: ${exchange.latestMessage!.metadata.kind}` } - if (!callback) { - return res.sendStatus(202) + return res.status(409).json({ errors: [errorResponse] }) + } + + // Ensure that Close is from either Alice or PFI + const rfq = exchange.rfq! + if (close.metadata.from === rfq.metadata.from && close.metadata.to === rfq.metadata.to) { + // Alice may Close an exchange + } else if (close.metadata.from === rfq.metadata.to && close.metadata.to === rfq.metadata.from) { + // The PFI may Close an exchange + } else { + const errorResponse: ErrorDetail = { + detail: `Only the creator and receiver of an exchange may close the exchange` } - try { - await callback({ request: req, response: res }, close) - return res.sendStatus(202) - } catch(e) { - if (e instanceof CallbackError) { - return res.status(e.statusCode).json({ errors: e.details }) - } else { - const errorDetail: ErrorDetail = { detail: 'Internal Server Error' } - return res.status(500).json({ errors: [errorDetail] }) - } + return res.status(400).json({ errors: [errorResponse] }) + } + + if (!callback) { + return res.sendStatus(202) + } + + try { + await callback({ request: req, response: res }, close) + return res.sendStatus(202) + } catch(e) { + if (e instanceof CallbackError) { + return res.status(e.statusCode).json({ errors: e.details }) + } else { + const errorDetail: ErrorDetail = { detail: 'Internal Server Error' } + return res.status(500).json({ errors: [errorDetail] }) } } } diff --git a/packages/http-server/src/request-handlers/submit-order.ts b/packages/http-server/src/request-handlers/submit-order.ts index dd2e8fad..ee1c1c63 100644 --- a/packages/http-server/src/request-handlers/submit-order.ts +++ b/packages/http-server/src/request-handlers/submit-order.ts @@ -1,67 +1,61 @@ -import type { RequestHandler, ExchangesApi, SubmitOrderCallback } from '../types.js' +import type { ExchangesApi, SubmitOrderCallback } from '../types.js' import type { ErrorDetail } from '@tbdex/http-client' -import { Order, Quote } from '@tbdex/protocol' +import { Order } from '@tbdex/protocol' import { CallbackError } from '../callback-error.js' +import { Request, Response } from 'express' type SubmitOrderOpts = { - callback: SubmitOrderCallback + callback?: SubmitOrderCallback exchangesApi: ExchangesApi } -export function submitOrder(opts: SubmitOrderOpts): RequestHandler { +export async function submitOrder(req: Request, res: Response, opts: SubmitOrderOpts): Promise { const { callback, exchangesApi } = opts - return async function (req, res) { - let order: Order + let order: Order - try { - order = await Order.parse(req.body) - } catch(e) { - const errorResponse: ErrorDetail = { detail: e.message } - return res.status(400).json({ errors: [errorResponse] }) - } - - const exchange = await exchangesApi.getExchange({id: order.exchangeId}) - if(exchange == undefined) { - const errorResponse: ErrorDetail = { detail: `No exchange found for ${order.exchangeId}` } + try { + order = await Order.parse(req.body) + } catch(e) { + const errorResponse: ErrorDetail = { detail: 'Request body was not a valid Order message' } + return res.status(400).json({ errors: [errorResponse] }) + } - return res.status(404).json({ errors: [errorResponse] }) - } + const exchange = await exchangesApi.getExchange({id: order.exchangeId}) + if(exchange == undefined) { + const errorResponse: ErrorDetail = { detail: `No exchange found for ${order.exchangeId}` } - const last = exchange[exchange.length-1] - if(!last.validNext.has('order')) { - const errorResponse: ErrorDetail = { detail: `Cannot submit Order for an exchange where the last message is kind: ${last.kind}` } + return res.status(404).json({ errors: [errorResponse] }) + } - return res.status(409).json({ errors: [errorResponse] }) + if(!exchange.isValidNext('order')) { + const errorResponse: ErrorDetail = { + detail: `Cannot submit Order for an exchange where the last message is kind: ${exchange.latestMessage!.metadata}` } - const quote = exchange.find((message) => message.isQuote()) as Quote - if(quote == undefined) { - const errorResponse: ErrorDetail = { detail: 'Quote not found' } - return res.status(404).json({errors: [errorResponse]}) - } + return res.status(409).json({ errors: [errorResponse] }) + } - if(new Date(quote.data.expiresAt) < new Date(order.metadata.createdAt)){ - const errorResponse: ErrorDetail = { detail: `Quote is expired` } + if(new Date(exchange.quote!.data.expiresAt) < new Date()){ + const errorResponse: ErrorDetail = { detail: 'Quote is expired' } - return res.status(410).json({ errors: [errorResponse] }) - } + return res.status(410).json({ errors: [errorResponse] }) + } - if (!callback) { - return res.sendStatus(202) - } + if (!callback) { + return res.sendStatus(202) + } - try { - await callback({ request: req, response: res }, order) - return res.sendStatus(202) - } catch(e) { - if (e instanceof CallbackError) { - return res.status(e.statusCode).json({ errors: e.details }) - } else { - const errorDetail: ErrorDetail = { detail: 'Internal Server Error' } - return res.status(500).json({ errors: [errorDetail] }) - } + try { + await callback({ request: req, response: res }, order) + return res.sendStatus(202) + } catch(e) { + if (e instanceof CallbackError) { + return res.status(e.statusCode).json({ errors: e.details }) + } else { + const errorDetail: ErrorDetail = { detail: 'Internal Server Error' } + return res.status(500).json({ errors: [errorDetail] }) } } } \ No newline at end of file diff --git a/packages/http-server/src/types.ts b/packages/http-server/src/types.ts index 1956e98a..277e8fd2 100644 --- a/packages/http-server/src/types.ts +++ b/packages/http-server/src/types.ts @@ -1,6 +1,5 @@ import type { Request, Response } from 'express' -import type { Close, Message, Offering, Order, OrderStatus, Quote, Rfq } from '@tbdex/protocol' -import type { ErrorDetail } from '@tbdex/http-client' +import type { Close, Exchange, Offering, Order, OrderStatus, Quote, Rfq } from '@tbdex/protocol' /** * Callback handler for GetExchanges requests @@ -18,7 +17,7 @@ export type GetOfferingsCallback = (ctx: RequestContext, filter: GetOfferingsFil * Callback handler for the SubmitRfq requests * @beta */ -export type SubmitRfqCallback = (ctx: RequestContext, message: Rfq, opts: { offering: Offering }) => Promise +export type SubmitRfqCallback = (ctx: RequestContext, message: Rfq, opts: { offering: Offering, replyTo?: string }) => Promise /** * Callback handler for the SubmitOrder requests @@ -43,6 +42,12 @@ export type GetOfferingsFilter = { /** Currency that the PFI is selling - ISO 3166 currency code string */ payoutCurrency?: string + /** The payin method used to pay money to the PFI */ + payinMethodKind?: string + + /** The payout method to receive money from the PFI */ + payoutMethodKind?: string + /** Offering ID */ id?: string } @@ -69,12 +74,6 @@ export type RequestContext = { response: Response } -/** - * Type alias for the request handler - * @beta - */ -export type RequestHandler = (request: Request, response: Response<{ errors?: ErrorDetail[], data?: any }>) => any - /** * PFI Offerings API * @beta @@ -88,7 +87,7 @@ export interface OfferingsApi { /** * Retrieve a list of offerings based on the given filter */ - getOfferings(opts?: { filter: GetOfferingsFilter }): Promise + getOfferings(opts?: { filter: GetOfferingsFilter }): Promise } /** @@ -99,12 +98,12 @@ export interface ExchangesApi { /** * Retrieve a list of exchanges based on the given filter */ - getExchanges(opts?: { filter: GetExchangesFilter }): Promise + getExchanges(opts?: { filter: GetExchangesFilter }): Promise /** * Retrieve a single exchange if found */ - getExchange(opts: { id: string }): Promise + getExchange(opts: { id: string }): Promise /** * Retrieve a RFQ if found @@ -122,12 +121,12 @@ export interface ExchangesApi { getOrder(opts: { exchangeId: string }): Promise /** - * Retrieve the order statuses if found + * Retrieve the OrderStatuses if found */ - getOrderStatuses(opts: { exchangeId: string }): Promise + getOrderStatuses(opts: { exchangeId: string }): Promise /** - * Retrieve the close reason if found + * Retrieve the Close reason if found */ getClose(opts: { exchangeId: string }): Promise } \ No newline at end of file diff --git a/packages/http-server/tests/create-exchange.spec.ts b/packages/http-server/tests/create-exchange.spec.ts index d64d4d78..1b14307b 100644 --- a/packages/http-server/tests/create-exchange.spec.ts +++ b/packages/http-server/tests/create-exchange.spec.ts @@ -1,18 +1,23 @@ -import type { ErrorDetail } from '@tbdex/http-client' +import { ErrorDetail, Offering, Rfq } from '@tbdex/http-client' import type { Server } from 'http' -import { DevTools, TbdexHttpServer } from '../src/main.js' +import { DevTools, RequestContext, TbdexHttpServer } from '../src/main.js' import { expect } from 'chai' - -let api = new TbdexHttpServer() -let server: Server +import { InMemoryExchangesApi } from '../src/in-memory-exchanges-api.js' +import { InMemoryOfferingsApi } from '../src/in-memory-offerings-api.js' +import { PortableDid } from '@web5/dids' +import Sinon from 'sinon' describe('POST /exchanges/:exchangeId/rfq', () => { - before(() => { + let api: TbdexHttpServer + let server: Server + + beforeEach(() => { + api = new TbdexHttpServer() server = api.listen(8000) }) - after(() => { + afterEach(() => { server.close() server.closeAllConnections() }) @@ -56,7 +61,7 @@ describe('POST /exchanges/:exchangeId/rfq', () => { const resp = await fetch('http://localhost:8000/exchanges/123/rfq', { method : 'POST', - body : JSON.stringify({ rfq: rfq, replyTo: 'foo'}) + body : JSON.stringify({ rfq: rfq, replyTo: 'foo' }) }) expect(resp.status).to.equal(400) @@ -69,10 +74,291 @@ describe('POST /exchanges/:exchangeId/rfq', () => { expect(error.detail).to.include('replyTo must be a valid url') }) - xit('returns a 400 if request body is not a valid RFQ') - xit('returns a 400 if request body if integrity check fails') - xit('returns a 409 if request body if RFQ already exists') - xit('returns a 400 if request body if offering doesnt exist') - xit(`returns a 400 if request body if RFQ does not fulfill offering's requirements`) - xit(`returns a 202 if RFQ is accepted`) + it('returns a 400 if request body is not a valid RFQ', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + const order = await DevTools.createOrder({ sender: aliceDid, receiver: pfiDid }) + await order.sign(aliceDid) + + const resp = await fetch('http://localhost:8000/exchanges/123/rfq', { + method : 'POST', + body : JSON.stringify({ rfq: order }) + }) + + expect(resp.status).to.equal(400) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('Parsing of TBDex Rfq message failed') + }) + + it('returns a 400 if request body if integrity check fails', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + const rfq = await DevTools.createRfq({ sender: aliceDid, receiver: pfiDid }) + // deliberately omit rfq.sign(aliceDid) + + const resp = await fetch('http://localhost:8000/exchanges/123/rfq', { + method : 'POST', + body : JSON.stringify({ rfq }) + }) + + expect(resp.status).to.equal(400) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('Parsing of TBDex Rfq message failed') + }) + + it('returns a 409 if request body if RFQ already exists', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + const rfq = await DevTools.createRfq({ sender: aliceDid, receiver: pfiDid }) + await rfq.sign(aliceDid); + + (api.exchangesApi as InMemoryExchangesApi).addMessage(rfq) + + const resp = await fetch('http://localhost:8000/exchanges/123/rfq', { + method : 'POST', + body : JSON.stringify({ rfq }) + }) + + expect(resp.status).to.equal(409) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('already exists') + }) + + it('returns a 400 if request body if offering doesnt exist', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + const offering = DevTools.createOffering() + // deliberately omit (api.offeringsApi as InMemoryOfferingsApi).addOffering(offering) + const rfq = Rfq.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + }, + data: { + ...await DevTools.createRfqData(), + offeringId: offering.metadata.id, + }, + }) + await rfq.sign(aliceDid) + + const resp = await fetch('http://localhost:8000/exchanges/123/rfq', { + method : 'POST', + body : JSON.stringify({ rfq }) + }) + + expect(resp.status).to.equal(400) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include(`offering ${offering.metadata.id} does not exist`) + }) + + it(`returns a 400 if request body if RFQ does not fulfill offering's requirements`, async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + // Add offering to api.offeringsApi + const offering = DevTools.createOffering() + await offering.sign(pfiDid); + (api.offeringsApi as InMemoryOfferingsApi).addOffering(offering) + + // Create Rfq which doesn't contain the required claims + const rfq = Rfq.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + }, + data: { + ...await DevTools.createRfqData(), + offeringId : offering.metadata.id, + claims : [], + }, + }) + await rfq.sign(aliceDid) + + const resp = await fetch('http://localhost:8000/exchanges/123/rfq', { + method : 'POST', + body : JSON.stringify({ rfq }) + }) + + expect(resp.status).to.equal(400) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('Failed to verify offering requirements') + }) + + describe('RFQ satisfies all requirements', () => { + let aliceDid: PortableDid + let pfiDid: PortableDid + let offering: Offering + let rfq: Rfq + + beforeEach(async () => { + aliceDid = await DevTools.createDid() + pfiDid = await DevTools.createDid() + + // Add offering with no required claims to api.offeringsApi + offering = Offering.create({ + metadata: { + from: pfiDid.did, + }, + data: { + ...DevTools.createOfferingData(), + requiredClaims : undefined, + payinCurrency : { + currencyCode : 'BTC', + minAmount : '1000.0' + }, + payoutCurrency: { + currencyCode : 'BTC', + minAmount : '1000.0', + }, + payinMethods: [{ + kind : 'BTC_ADDRESS', + requiredPaymentDetails : { + $schema : 'http://json-schema.org/draft-07/schema', + type : 'object', + properties : { + btcAddress: { + type : 'string', + description : 'your Bitcoin wallet address' + } + }, + required : ['btcAddress'], + additionalProperties : false + } + }], + payoutMethods: [{ + kind : 'BTC_ADDRESS', + requiredPaymentDetails : { + $schema : 'http://json-schema.org/draft-07/schema', + type : 'object', + properties : { + btcAddress: { + type : 'string', + description : 'your Bitcoin wallet address' + } + }, + required : ['btcAddress'], + additionalProperties : false + } + }] + }, + }) + await offering.sign(pfiDid); + (api.offeringsApi as InMemoryOfferingsApi).addOffering(offering) + + // Create Rfq which satisfies Offering requirements + rfq = Rfq.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + }, + data: { + ...DevTools.createRfqData(), + offeringId : offering.metadata.id, + claims : [], + payinAmount : offering.data.payinCurrency.minAmount!, + payinMethod : { + kind : offering.data.payinMethods[0].kind, + paymentDetails : { + btcAddress: '1234', + } + }, + payoutMethod: { + kind : offering.data.payoutMethods[0].kind, + paymentDetails : { + btcAddress: '1234', + } + } + } + }) + await rfq.sign(aliceDid) + }) + + it('returns a 202 if RFQ is accepted', async () => { + const resp = await fetch('http://localhost:8000/exchanges/123/rfq', { + method : 'POST', + body : JSON.stringify({ rfq }) + }) + + expect(resp.status).to.equal(202) + }) + + it('returns a 202 if the provided callback succeeds and passes correct arguments to callback', async () => { + const callbackSpy = Sinon.spy( + (_ctx: RequestContext, _message: Rfq, _opts: { offering: Offering, replyTo?: string }) => { + return Promise.resolve() + }) + api.onSubmitRfq(callbackSpy) + + const resp = await fetch('http://localhost:8000/exchanges/123/rfq', { + method : 'POST', + body : JSON.stringify({ rfq }) + }) + + expect(resp.status).to.equal(202) + + expect(callbackSpy.callCount).to.eq(1) + expect(callbackSpy.firstCall.args.length).to.eq(3) + + expect(callbackSpy.firstCall.args.at(1)).to.deep.eq(rfq) + const lastCallbackArg = callbackSpy.firstCall.args.at(2) as { offering: Offering, replyTo?: string } + expect(lastCallbackArg.offering).to.deep.eq(offering) + expect(lastCallbackArg.replyTo).to.be.undefined + }) + + it('passes replyTo to the callback if it is provided in the request', async () => { + const callbackSpy = Sinon.spy( + (_ctx: RequestContext, _message: Rfq, _opts: { offering: Offering, replyTo?: string }) =>{ + return Promise.resolve() + }) + api.onSubmitRfq(callbackSpy) + + const replyTo = 'https://tbdex.io/example' + + const resp = await fetch('http://localhost:8000/exchanges/123/rfq', { + method : 'POST', + body : JSON.stringify({ rfq, replyTo }) + }) + + expect(resp.status).to.equal(202) + + expect(callbackSpy.callCount).to.eq(1) + expect(callbackSpy.firstCall.args.length).to.eq(3) + + expect(callbackSpy.firstCall.args.at(1)).to.deep.eq(rfq) + const lastCallbackArg = callbackSpy.firstCall.args.at(2) as { offering: Offering, replyTo?: string } + expect(lastCallbackArg.offering).to.deep.eq(offering) + expect(lastCallbackArg.replyTo).to.eq(replyTo) + }) + + xit('creates the filter for OfferingsApi if it is provided in the request') + }) }) \ No newline at end of file diff --git a/packages/http-server/tests/get-exchanges.spec.ts b/packages/http-server/tests/get-exchanges.spec.ts index 4c7f8cd1..99174d39 100644 --- a/packages/http-server/tests/get-exchanges.spec.ts +++ b/packages/http-server/tests/get-exchanges.spec.ts @@ -1,19 +1,21 @@ -import type { ExchangesApi, GetExchangesFilter, Message } from '../src/main.js' import type { Server } from 'http' +import Sinon, * as sinon from 'sinon' -import { TbdexHttpServer, Rfq, Quote, Order, OrderStatus, Close, TbdexHttpClient } from '../src/main.js' +import { TbdexHttpServer, TbdexHttpClient, ErrorDetail, DevTools, RequestContext, GetExchangesFilter } from '../src/main.js' import { DidKeyMethod } from '@web5/dids' import { expect } from 'chai' - -let api = new TbdexHttpServer() -let server: Server +import { InMemoryExchangesApi } from '../src/in-memory-exchanges-api.js' describe('GET /exchanges', () => { - before(() => { + let server: Server + let api: TbdexHttpServer + + beforeEach(() => { + api = new TbdexHttpServer() server = api.listen(8000) }) - after(() => { + afterEach(() => { server.close() server.closeAllConnections() }) @@ -24,56 +26,137 @@ describe('GET /exchanges', () => { expect(resp.ok).to.be.false expect(resp.status).to.equal(401) - const respBody = await resp.json() + const respBody = await resp.json() as { errors: ErrorDetail[] } expect(respBody['errors']).to.exist expect(respBody['errors'].length).to.equal(1) expect(respBody['errors'][0]['detail']).to.include('Authorization') }) - it(`passes the requester's did to getExchanges method`, async () => { - let functionReached = false - const alice = await DidKeyMethod.create() - - const exchangesApi: ExchangesApi = { - getExchanges: async function (opts: { filter: GetExchangesFilter }): Promise { - functionReached = true - expect(opts.filter.from).to.exist - expect(opts.filter.from).to.equal(alice.did) - - return [] - }, - getExchange: function (): Promise { - throw new Error('Function not implemented.') - }, - getRfq: function (): Promise { - throw new Error('Function not implemented.') - }, - getQuote: function (): Promise { - throw new Error('Function not implemented.') - }, - getOrder: function (): Promise { - throw new Error('Function not implemented.') - }, - getOrderStatuses: function (): Promise { - throw new Error('Function not implemented.') - }, - getClose: function (): Promise { - throw new Error('Function not implemented.') + it('returns 401 if bearer token is missing from the Authorization header', async () => { + const resp = await fetch('http://localhost:8000/exchanges', { + headers: { + 'Authorization': 'Not well formatted token' } - } + }) - const testApi = new TbdexHttpServer({ exchangesApi, pfiDid: 'did:ex:pfi' }) - const server = testApi.listen(8001) - const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: alice, pfiDid: 'did:ex:pfi' }) - const resp = await fetch('http://localhost:8001/exchanges', { + const respBody = await resp.json() as { errors: ErrorDetail[] } + expect(respBody['errors']).to.exist + expect(respBody['errors'].length).to.equal(1) + expect(respBody['errors'][0]['detail']).to.include('Malformed Authorization header. Expected: Bearer TOKEN_HERE') + }) + + it('returns 401 if the bearer token is malformed in the Authorization header', async () => { + const resp = await fetch('http://localhost:8000/exchanges', { headers: { - 'Authorization': `Bearer ${requestToken}` + 'Authorization': 'Bearer MALFORMED' } }) - expect(resp.ok).to.be.true - expect(functionReached).to.be.true + const respBody = await resp.json() as { errors: ErrorDetail[] } + expect(respBody['errors']).to.exist + expect(respBody['errors'].length).to.equal(1) + expect(respBody['errors'][0]['detail']).to.include('Malformed Authorization header') + }) - server.closeAllConnections() + describe('Passes filter to ExchangesApi.getExchanges', () => { + it(`passes the requester's did to the filter of ExchangesApi.getExchanges`, async () => { + const alice = await DidKeyMethod.create() + + const exchangesApiSpy = sinon.spy(api.exchangesApi, 'getExchanges') + + const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: alice, pfiDid: api.pfiDid }) + const resp = await fetch('http://localhost:8000/exchanges', { + headers: { + 'Authorization': `Bearer ${requestToken}` + } + }) + + expect(resp.ok).to.be.true + expect(exchangesApiSpy.calledOnce).to.be.true + expect(exchangesApiSpy.calledWith({ + filter: { + from: alice.did + } + })).to.be.true + + exchangesApiSpy.restore() + }) + + it('passes the id non-array query param as an array to the filter of ExchangesApi.getExchanges', async () => { + const alice = await DidKeyMethod.create() + + const exchangesApiSpy = sinon.spy(api.exchangesApi, 'getExchanges') + + const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: alice, pfiDid: api.pfiDid }) + + // `id` query param contains a single string + const idQueryParam = '1234' + const resp = await fetch(`http://localhost:8000/exchanges?id=${idQueryParam}`, { + headers: { + 'Authorization': `Bearer ${requestToken}` + } + }) + + expect(resp.ok).to.be.true + expect(exchangesApiSpy.calledOnce).to.be.true + expect(exchangesApiSpy.calledWith({ + filter: { + from : alice.did, + id : [idQueryParam] + } + })) + + exchangesApiSpy.restore() + }) + + it('passes the id array query param as an array to the filter of ExchangesApi.getExchanges', async () => { + const alice = await DidKeyMethod.create() + + const exchangesApiSpy = sinon.spy(api.exchangesApi, 'getExchanges') + + const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: alice, pfiDid: api.pfiDid }) + + // `id` query param contains an array + const idQueryParam = ['1234', '5678'] + const resp = await fetch(`http://localhost:8000/exchanges?id=[${idQueryParam.join(',')}]`, { + headers: { + 'Authorization': `Bearer ${requestToken}` + } + }) + + expect(resp.ok).to.be.true + expect(exchangesApiSpy.calledOnce).to.be.true + expect(exchangesApiSpy.calledWith({ + filter: { + from : alice.did, + id : idQueryParam + } + })) + + exchangesApiSpy.restore() + }) + }) + + it('calls the callback if it is provided', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + const rfq = await DevTools.createRfq({ sender: aliceDid, receiver: pfiDid }) + await rfq.sign(aliceDid); + (api.exchangesApi as InMemoryExchangesApi).addMessage(rfq) + + const callbackSpy = Sinon.spy((_ctx: RequestContext, _filter: GetExchangesFilter) => Promise.resolve()) + api.onGetExchanges(callbackSpy) + + const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: aliceDid, pfiDid: api.pfiDid }) + + const resp = await fetch(`http://localhost:8000/exchanges`, { + headers: { + 'Authorization': `Bearer ${requestToken}` + } + }) + expect(resp.status).to.equal(200) + + expect(callbackSpy.callCount).to.eq(1) + // TODO: Check what arguments are passed to callback after we finalize its behavior }) }) \ No newline at end of file diff --git a/packages/http-server/tests/get-offerings.spec.ts b/packages/http-server/tests/get-offerings.spec.ts index 9144a2a1..436d043a 100644 --- a/packages/http-server/tests/get-offerings.spec.ts +++ b/packages/http-server/tests/get-offerings.spec.ts @@ -1,28 +1,86 @@ -import type { Offering } from '@tbdex/protocol' +import { DevTools, Offering } from '@tbdex/protocol' import type { Server } from 'http' -import { TbdexHttpServer } from '../src/main.js' +import { GetOfferingsFilter, RequestContext, TbdexHttpServer } from '../src/main.js' import { expect } from 'chai' - -let api = new TbdexHttpServer() -let server: Server +import { InMemoryOfferingsApi } from '../src/in-memory-offerings-api.js' +import Sinon from 'sinon' describe('GET /offerings', () => { - before(() => { + let api: TbdexHttpServer + let server: Server + + beforeEach(() => { + api = new TbdexHttpServer() server = api.listen(8000) }) - after(() => { + afterEach(() => { server.close() server.closeAllConnections() }) it('returns an array of offerings', async () => { + const pfiDid = await DevTools.createDid() + const offering = DevTools.createOffering() + await offering.sign(pfiDid); + (api.offeringsApi as InMemoryOfferingsApi).addOffering(offering) + const response = await fetch('http://localhost:8000/offerings') expect(response.status).to.equal(200) - const respaunzBody = await response.json() as { data: Offering[] } - expect(respaunzBody.data).to.exist - expect(respaunzBody.data.length).to.equal(1) + const responseBody = await response.json() as { data: Offering[] } + expect(responseBody.data).to.exist + expect(responseBody.data.length).to.equal(1) + expect(responseBody.data[0]).to.deep.eq(offering.toJSON()) + }) + + it('constructs the filter from query params and passes it to OfferingsApi', async () => { + const pfiDid = await DevTools.createDid() + + // Add an offering to OfferingsApi + const offering = DevTools.createOffering() + await offering.sign(pfiDid); + (api.offeringsApi as InMemoryOfferingsApi).addOffering(offering) + + // Set up spy + const exchangesApiSpy = Sinon.spy(api.offeringsApi, 'getOfferings') + + // Specify query params + const queryParams: GetOfferingsFilter = { + id : offering.metadata.id, + payinCurrency : offering.data.payinCurrency.currencyCode, + payoutCurrency : offering.data.payoutCurrency.currencyCode, + payinMethodKind : offering.data.payinMethods[0].kind, + payoutMethodKind : offering.data.payoutMethods[0].kind + } + const queryParamsString: string = + Object.entries(queryParams) + .map(([k, v]) => `${k}=${v}`) + .join('&') + + + const response = await fetch(`http://localhost:8000/offerings?${queryParamsString}`) + + expect(response.status).to.equal(200) + + expect(exchangesApiSpy.calledOnce).to.be.true + expect(exchangesApiSpy.firstCall.args[0]?.filter).to.deep.eq(queryParams) + }) + + it('calls the callback if it is provided', async () => { + const pfiDid = await DevTools.createDid() + const offering = DevTools.createOffering() + await offering.sign(pfiDid); + (api.offeringsApi as InMemoryOfferingsApi).addOffering(offering) + + const callbackSpy = Sinon.spy((_ctx: RequestContext, _filter: GetOfferingsFilter) => Promise.resolve()) + api.onGetOfferings(callbackSpy) + + const response = await fetch('http://localhost:8000/offerings?filter=') + expect(response.status).to.equal(200) + + expect(callbackSpy.callCount).to.eq(1) + // TODO: Check what arguments are passed to callback after we finalize its behavior }) }) \ No newline at end of file diff --git a/packages/http-server/tests/submit-close.spec.ts b/packages/http-server/tests/submit-close.spec.ts index 73db1b19..c9474a71 100644 --- a/packages/http-server/tests/submit-close.spec.ts +++ b/packages/http-server/tests/submit-close.spec.ts @@ -1,24 +1,23 @@ -import type { ErrorDetail } from '@tbdex/http-client' +import { ErrorDetail, Message } from '@tbdex/http-client' import type { Server } from 'http' import { Close, DevTools, TbdexHttpServer } from '../src/main.js' import { expect } from 'chai' -import { FakeExchangesApi } from '../src/fakes.js' +import { InMemoryExchangesApi } from '../src/in-memory-exchanges-api.js' +import Sinon from 'sinon' -let api = new TbdexHttpServer() -let server: Server const did = await DevTools.createDid() describe('POST /exchanges/:exchangeId/close', () => { - before(() => { + let api: TbdexHttpServer + let server: Server + + beforeEach(() => { + api = new TbdexHttpServer() server = api.listen(8000) }) afterEach(() => { - (api.exchangesApi as FakeExchangesApi).clearMessages() - }) - - after(() => { server.close() server.closeAllConnections() }) @@ -54,6 +53,26 @@ describe('POST /exchanges/:exchangeId/close', () => { expect(error.detail).to.include('JSON') }) + it('returns a 400 if request body is not a valid close object', async () => { + const alice = await DevTools.createDid() + const rfq = DevTools.createRfq({ + sender: alice + }) + const resp = await fetch('http://localhost:8000/exchanges/123/close', { + method : 'POST', + body : JSON.stringify(rfq) + }) + + expect(resp.status).to.equal(400) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('Request body was not a valid Close message') + }) + it(`returns a 404 if the exchange doesn't exist`, async () => { const close = Close.create({ metadata: { @@ -80,24 +99,33 @@ describe('POST /exchanges/:exchangeId/close', () => { }) it(`returns a 409 if close is not allowed based on the exchange's current state`, async () => { + const alice = await DevTools.createDid() + const pfi = await DevTools.createDid() + + const rfq = await DevTools.createRfq({ + sender : alice, + receiver : pfi, + }) + await rfq.sign(alice) const close = Close.create({ metadata: { - from : did.did, - to : did.did, - exchangeId : '123' + from : alice.did, + to : pfi.did, + exchangeId : rfq.metadata.exchangeId }, data: {} }) - await close.sign(did) + await close.sign(alice) - const exchangesApi = api.exchangesApi as FakeExchangesApi + const exchangesApi = api.exchangesApi as InMemoryExchangesApi + exchangesApi.addMessage(rfq) exchangesApi.addMessage(close) const close2 = Close.create({ metadata: { from : did.did, to : did.did, - exchangeId : '123' + exchangeId : rfq.metadata.exchangeId }, data: {} }) @@ -117,7 +145,220 @@ describe('POST /exchanges/:exchangeId/close', () => { expect(error.detail).to.include('cannot submit Close for an exchange where the last message is kind: close') }) - xit('returns a 400 if request body is not a valid Close') - xit('returns a 400 if request body if integrity check fails') - xit(`returns a 202 if close is accepted`) + it('returns a 400 if request body if integrity check fails', async () => { + const alice = await DevTools.createDid() + const pfi = await DevTools.createDid() + + const rfq = await DevTools.createRfq({ + sender : alice, + receiver : pfi, + }) + await rfq.sign(alice) + + const exchangesApi = api.exchangesApi as InMemoryExchangesApi + exchangesApi.addMessage(rfq) + + // Create but do not sign Close message + const close = Close.create({ + metadata: { + from : alice.did, + to : pfi.did, + exchangeId : rfq.metadata.exchangeId + }, + data: {} + }) + + + const resp = await fetch('http://localhost:8000/exchanges/123/close', { + method : 'POST', + body : JSON.stringify(close) + }) + + expect(resp.status).to.equal(400) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('Request body was not a valid Close message') + }) + + it('returns a 202 if close is created by alice', async () => { + // scenario: Alice creates an exchange and submits a Close message + + const alice = await DevTools.createDid() + const pfi = await DevTools.createDid() + + const rfq = await DevTools.createRfq({ + sender : alice, + receiver : pfi, + }) + await rfq.sign(alice) + + const exchangesApi = api.exchangesApi as InMemoryExchangesApi + exchangesApi.addMessage(rfq) + + // Close message signed by Alice + const close = Close.create({ + metadata: { + from : alice.did, + to : pfi.did, + exchangeId : rfq.metadata.exchangeId + }, + data: {} + }) + await close.sign(alice) + + const resp = await fetch('http://localhost:8000/exchanges/123/close', { + method : 'POST', + body : JSON.stringify(close) + }) + + expect(resp.status).to.equal(202) + }) + + it('returns a 202 if close is created by pfi', async () => { + // scenario: Alice creates an exchange and submits a Close message + + const alice = await DevTools.createDid() + const pfi = await DevTools.createDid() + + const rfq = await DevTools.createRfq({ + sender : alice, + receiver : pfi, + }) + await rfq.sign(alice) + + const exchangesApi = api.exchangesApi as InMemoryExchangesApi + exchangesApi.addMessage(rfq) + + // Close message signed by PFI + const close = Close.create({ + metadata: { + from : pfi.did, + to : alice.did, + exchangeId : rfq.metadata.exchangeId + }, + data: {} + }) + await close.sign(pfi) + + const resp = await fetch('http://localhost:8000/exchanges/123/close', { + method : 'POST', + body : JSON.stringify(close) + }) + + expect(resp.status).to.equal(202) + }) + + it('returns a 400 if the close is created by neither alice nor pfi', async () => { + + const alice = await DevTools.createDid() + const pfi = await DevTools.createDid() + const imposter = await DevTools.createDid() + + const rfq = await DevTools.createRfq({ + sender : alice, + receiver : pfi, + }) + await rfq.sign(alice) + + const exchangesApi = api.exchangesApi as InMemoryExchangesApi + exchangesApi.addMessage(rfq) + + // Close message signed by the imposter + const close = Close.create({ + metadata: { + from : imposter.did, + to : pfi.did, + exchangeId : rfq.metadata.exchangeId + }, + data: {} + }) + await close.sign(imposter) + + const resp = await fetch('http://localhost:8000/exchanges/123/close', { + method : 'POST', + body : JSON.stringify(close) + }) + + expect(resp.status).to.equal(400) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('Only the creator and receiver of an exchange may close the exchange') + }) + + describe('onSubmitClose callback', () => { + it('does not call the callback if the close is is not valid for the current exchange', async () => { + const alice = await DevTools.createDid() + const pfi = await DevTools.createDid() + + // Close message signed by Alice + const close = Close.create({ + metadata: { + from : alice.did, + to : pfi.did, + exchangeId : Message.generateId('rfq') + }, + data: {} + }) + await close.sign(alice) + + const callbackSpy = Sinon.spy(() => Promise.resolve()) + api.onSubmitClose(callbackSpy) + + const resp = await fetch('http://localhost:8000/exchanges/123/close', { + method : 'POST', + body : JSON.stringify(close) + }) + + expect(resp.status).to.equal(404) + expect(callbackSpy.notCalled).to.be.true + }) + + it('returns a 202 if the provided callback succeeds and passes correct arguments to callback', async () => { + const alice = await DevTools.createDid() + const pfi = await DevTools.createDid() + + const rfq = await DevTools.createRfq({ + sender : alice, + receiver : pfi, + }) + await rfq.sign(alice) + + const exchangesApi = api.exchangesApi as InMemoryExchangesApi + exchangesApi.addMessage(rfq) + + // Close message signed by Alice + const close = Close.create({ + metadata: { + from : alice.did, + to : pfi.did, + exchangeId : rfq.metadata.exchangeId + }, + data: {} + }) + await close.sign(alice) + + const callbackSpy = Sinon.spy(() => Promise.resolve()) + api.onSubmitClose(callbackSpy) + + const resp = await fetch('http://localhost:8000/exchanges/123/close', { + method : 'POST', + body : JSON.stringify(close) + }) + + expect(resp.status).to.equal(202) + + expect(callbackSpy.calledOnce).to.be.true + expect(callbackSpy.firstCall.lastArg).to.deep.eq(close) + }) + + xit('returns error if the callback throws a CallbackError', async () => {}) + }) }) \ No newline at end of file diff --git a/packages/http-server/tests/submit-order.spec.ts b/packages/http-server/tests/submit-order.spec.ts index 6d6f49b2..da9f399f 100644 --- a/packages/http-server/tests/submit-order.spec.ts +++ b/packages/http-server/tests/submit-order.spec.ts @@ -1,19 +1,22 @@ -import type { ErrorDetail } from '@tbdex/http-client' +import { ErrorDetail, Message, Quote, Rfq } from '@tbdex/http-client' import type { Server } from 'http' -import { DevTools, Order, TbdexHttpServer } from '../src/main.js' +import { DevTools, Order, RequestContext, TbdexHttpServer } from '../src/main.js' import { expect } from 'chai' +import { InMemoryExchangesApi } from '../src/in-memory-exchanges-api.js' +import Sinon from 'sinon' -let api = new TbdexHttpServer() -let server: Server -const did = await DevTools.createDid() describe('POST /exchanges/:exchangeId/order', () => { - before(() => { + let api: TbdexHttpServer + let server: Server + + beforeEach(() => { + api = new TbdexHttpServer() server = api.listen(8000) }) - after(() => { + afterEach(() => { server.close() server.closeAllConnections() }) @@ -50,14 +53,16 @@ describe('POST /exchanges/:exchangeId/order', () => { }) it(`returns a 404 if the exchange doesn't exist`, async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() const order = Order.create({ metadata: { - from : did.did, - to : did.did, + from : aliceDid.did, + to : pfiDid.did, exchangeId : '123' } }) - await order.sign(did) + await order.sign(aliceDid) const resp = await fetch('http://localhost:8000/exchanges/123/order', { method : 'POST', body : JSON.stringify(order) @@ -73,9 +78,247 @@ describe('POST /exchanges/:exchangeId/order', () => { expect(error.detail).to.include('No exchange found for') }) - xit(`returns a 409 if order is not allowed based on the exchange's current state`) - xit(`returns a 400 if quote has expired`) - xit('returns a 400 if request body is not a valid Order') - xit('returns a 400 if request body if integrity check fails') - xit(`returns a 202 if order is accepted`) + it('returns a 400 if request body is not a valid order object', async () => { + // scenario: Send an Rfq to the submitOrder endpoint + + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + const rfq = await DevTools.createRfq({ sender: aliceDid, receiver: pfiDid }) + await rfq.sign(aliceDid) + + const resp = await fetch('http://localhost:8000/exchanges/123/order', { + method : 'POST', + body : JSON.stringify(rfq) + }) + + expect(resp.status).to.equal(400) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('Request body was not a valid Order message') + }) + + it('returns a 400 if request body if integrity check fails', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + const order = Order.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + exchangeId : Message.generateId('rfq'), + }, + }) + // deliberately omit await order.sign(aliceDid) + + const resp = await fetch('http://localhost:8000/exchanges/123/order', { + method : 'POST', + body : JSON.stringify(order) + }) + + expect(resp.status).to.equal(400) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include( 'Request body was not a valid Order message') + }) + + it(`returns a 409 if order is not allowed based on the exchange's current state`, async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + const rfq = Rfq.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did + }, + data: await DevTools.createRfqData() + }) + await rfq.sign(aliceDid); + (api.exchangesApi as InMemoryExchangesApi).addMessage(rfq) + + const order = Order.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + }) + await order.sign(aliceDid) + + const resp = await fetch('http://localhost:8000/exchanges/123/order', { + method : 'POST', + body : JSON.stringify(order) + }) + + expect(resp.status).to.equal(409) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('Cannot submit Order for an exchange where the last message is kind:') + }) + + it(`returns a 400 if quote has expired`, async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + // Add an exchange which has a Quote that expired 10 seconds ago + const rfq = Rfq.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did + }, + data: await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + const quote = Quote.create({ + metadata: { + from : pfiDid.did, + to : aliceDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + data: { + ...DevTools.createQuoteData(), + expiresAt: new Date(Date.now() - 10_000).toISOString() + } + }) + await quote.sign(pfiDid); + (api.exchangesApi as InMemoryExchangesApi).addMessage(rfq); + (api.exchangesApi as InMemoryExchangesApi).addMessage(quote) + + const order = Order.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + }) + await order.sign(aliceDid) + + const resp = await fetch('http://localhost:8000/exchanges/123/order', { + method : 'POST', + body : JSON.stringify(order) + }) + + expect(resp.status).to.equal(410) + + const responseBody = await resp.json() as { errors: ErrorDetail[] } + expect(responseBody.errors.length).to.equal(1) + + const [ error ] = responseBody.errors + expect(error.detail).to.exist + expect(error.detail).to.include('Quote is expired') + }) + + it('returns a 202 if order is accepted', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + // Add an exchange of Rfq and Quote to the exchangesApi + const rfq = Rfq.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did + }, + data: await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + const quote = Quote.create({ + metadata: { + from : pfiDid.did, + to : aliceDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + data: { + ...DevTools.createQuoteData(), + expiresAt: new Date(Date.now() + 10_000).toISOString() + } + }) + await quote.sign(pfiDid); + + (api.exchangesApi as InMemoryExchangesApi).addMessage(rfq); + (api.exchangesApi as InMemoryExchangesApi).addMessage(quote) + + // Create order that is valid within the existing exchange + const order = Order.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + }) + await order.sign(aliceDid) + + const resp = await fetch('http://localhost:8000/exchanges/123/order', { + method : 'POST', + body : JSON.stringify(order) + }) + + expect(resp.status).to.equal(202) + }) + + describe('onSubmitClose callback', () => { + it('returns a 202 if the provided callback succeeds and passes correct arguments to callback', async () => { + const aliceDid = await DevTools.createDid() + const pfiDid = await DevTools.createDid() + + // Add an exchange of Rfq and Quote to the exchangesApi + const rfq = Rfq.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did + }, + data: await DevTools.createRfqData() + }) + await rfq.sign(aliceDid) + const quote = Quote.create({ + metadata: { + from : pfiDid.did, + to : aliceDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + data: { + ...DevTools.createQuoteData(), + expiresAt: new Date(Date.now() + 10_000).toISOString() + } + }) + await quote.sign(pfiDid); + + (api.exchangesApi as InMemoryExchangesApi).addMessage(rfq); + (api.exchangesApi as InMemoryExchangesApi).addMessage(quote) + + // Create order that is valid within the existing exchange + const order = Order.create({ + metadata: { + from : aliceDid.did, + to : pfiDid.did, + exchangeId : rfq.metadata.exchangeId, + }, + }) + await order.sign(aliceDid) + + const callbackSpy = Sinon.spy((_ctx: RequestContext, _message: Order) => Promise.resolve()) + + api.onSubmitOrder(callbackSpy) + + const resp = await fetch('http://localhost:8000/exchanges/123/order', { + method : 'POST', + body : JSON.stringify(order) + }) + + expect(resp.status).to.equal(202) + + expect(callbackSpy.calledOnce).to.be.true + expect(callbackSpy.firstCall.lastArg).to.deep.eq(order) + }) + }) }) \ No newline at end of file diff --git a/packages/http-server/tests/tsconfig.json b/packages/http-server/tests/tsconfig.json index 7c6d2c8e..359d3749 100644 --- a/packages/http-server/tests/tsconfig.json +++ b/packages/http-server/tests/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "strict": true, + "useUnknownInCatchVariables": false, "outDir": "compiled", "declarationDir": "compiled/types", "sourceMap": true, diff --git a/packages/http-server/tsconfig.json b/packages/http-server/tsconfig.json index eb52ab80..ae6f0159 100644 --- a/packages/http-server/tsconfig.json +++ b/packages/http-server/tsconfig.json @@ -1,5 +1,8 @@ { "compilerOptions": { + "strict": true, + "strictFunctionTypes": true, + "useUnknownInCatchVariables": false, "lib": ["es2022"], "target": "es2022", "module": "node16", diff --git a/packages/protocol/tests/exchange.spec.ts b/packages/protocol/tests/exchange.spec.ts index e07a3d5a..1b42bfa2 100644 --- a/packages/protocol/tests/exchange.spec.ts +++ b/packages/protocol/tests/exchange.spec.ts @@ -337,4 +337,4 @@ describe('Exchange', () => { expect(exchange.messages).to.deep.eq([rfq, quote, order, orderStatus]) }) }) -}) \ No newline at end of file +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d1b3d43..fa7d88a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,7 +161,7 @@ importers: specifier: 0.2.2 version: 0.2.2 cors: - specifier: 2.8.5 + specifier: ^2.8.5 version: 2.8.5 express: specifier: 4.18.2 @@ -170,6 +170,9 @@ importers: '@types/chai': specifier: 4.3.6 version: 4.3.6 + '@types/cors': + specifier: ^2.8.17 + version: 2.8.17 '@types/express': specifier: 4.17.17 version: 4.17.17 @@ -182,12 +185,18 @@ importers: '@types/node': specifier: 20.9.4 version: 20.9.4 + '@types/sinon': + specifier: ^17.0.3 + version: 17.0.3 chai: specifier: 4.3.10 version: 4.3.10 rimraf: specifier: 5.0.1 version: 5.0.1 + sinon: + specifier: 17.0.1 + version: 17.0.1 supertest: specifier: 6.3.3 version: 6.3.3 @@ -1249,6 +1258,12 @@ packages: '@types/node': 7.10.14 dev: true + /@types/cors@2.8.17: + resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + dependencies: + '@types/node': 7.10.14 + dev: true + /@types/debounce@1.2.4: resolution: {integrity: sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==} dev: true @@ -1430,6 +1445,12 @@ packages: '@types/sinonjs__fake-timers': 8.1.5 dev: true + /@types/sinon@17.0.3: + resolution: {integrity: sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==} + dependencies: + '@types/sinonjs__fake-timers': 8.1.5 + dev: true + /@types/sinonjs__fake-timers@8.1.5: resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==} dev: true From 79f1958cb7e048b19ba2f9381cbadab2dfb015c1 Mon Sep 17 00:00:00 2001 From: Diane Huxley Date: Fri, 16 Feb 2024 14:15:38 -0800 Subject: [PATCH 2/3] PR comments --- .../src/in-memory-exchanges-api.ts | 7 ++--- .../src/request-handlers/create-exchange.ts | 29 ++++++++++++------- .../src/request-handlers/get-exchanges.ts | 13 +++++---- .../src/request-handlers/get-offerings.ts | 4 +-- .../src/request-handlers/submit-close.ts | 23 +++++++++------ .../src/request-handlers/submit-order.ts | 23 +++++++++------ packages/protocol/src/exchange.ts | 12 ++++---- .../src/message-kinds/order-status.ts | 2 +- packages/protocol/tests/exchange.spec.ts | 4 +-- 9 files changed, 69 insertions(+), 48 deletions(-) diff --git a/packages/http-server/src/in-memory-exchanges-api.ts b/packages/http-server/src/in-memory-exchanges-api.ts index 0a6c20d6..16053041 100644 --- a/packages/http-server/src/in-memory-exchanges-api.ts +++ b/packages/http-server/src/in-memory-exchanges-api.ts @@ -36,7 +36,7 @@ export class InMemoryExchangesApi implements ExchangesApi { } else { // filter only has `from` this.exchangeMessagesMap.forEach((exchange, _id) => { - // You definitely shouldn't use FakeExchangesApi in production. + // You definitely shouldn't use InMemoryExchangesApi in production. // This will get really slow if (exchange?.rfq?.from === opts.filter.from) { exchanges.push(exchange) @@ -69,10 +69,7 @@ export class InMemoryExchangesApi implements ExchangesApi { async getOrderStatuses(opts: { exchangeId: string }): Promise { const exchange = this.exchangeMessagesMap.get(opts.exchangeId) - if (exchange?.orderstatus === undefined) { - return [] - } - return [exchange.orderstatus] + return exchange?.orderstatus ?? [] } async getClose(opts: { exchangeId: string }): Promise { diff --git a/packages/http-server/src/request-handlers/create-exchange.ts b/packages/http-server/src/request-handlers/create-exchange.ts index bb9a59c6..3205c2ed 100644 --- a/packages/http-server/src/request-handlers/create-exchange.ts +++ b/packages/http-server/src/request-handlers/create-exchange.ts @@ -11,21 +11,23 @@ type CreateExchangeOpts = { exchangesApi: ExchangesApi } -export async function createExchange(req: Request, res: Response, options: CreateExchangeOpts): Promise { +export async function createExchange(req: Request, res: Response, options: CreateExchangeOpts): Promise { const { offeringsApi, exchangesApi, callback } = options const replyTo: string | undefined = req.body.replyTo let rfq: Rfq if (replyTo && !isValidUrl(replyTo)) { - return res.status(400).json({ errors: [{ detail: 'replyTo must be a valid url' }] }) + res.status(400).json({ errors: [{ detail: 'replyTo must be a valid url' }] }) + return } try { rfq = await Rfq.parse(req.body.rfq) } catch(e) { const errorResponse: ErrorDetail = { detail: `Parsing of TBDex Rfq message failed: ${e.message}` } - return res.status(400).json({ errors: [errorResponse] }) + res.status(400).json({ errors: [errorResponse] }) + return } // TODO: check message.from against allowlist @@ -33,38 +35,45 @@ export async function createExchange(req: Request, res: Response, options: Creat const rfqExists = !! await exchangesApi.getRfq({ exchangeId: rfq.id }) if (rfqExists) { const errorResponse: ErrorDetail = { detail: `rfq ${rfq.id} already exists`} - return res.status(409).json({ errors: [errorResponse] }) + res.status(409).json({ errors: [errorResponse] }) + return } const offering = await offeringsApi.getOffering({ id: rfq.data.offeringId }) if (!offering) { const errorResponse: ErrorDetail = { detail: `offering ${rfq.data.offeringId} does not exist` } - return res.status(400).json({ errors: [errorResponse] }) + res.status(400).json({ errors: [errorResponse] }) + return } try { await rfq.verifyOfferingRequirements(offering) } catch(e) { const errorResponse: ErrorDetail = { detail: `Failed to verify offering requirements: ${e.message}` } - return res.status(400).json({ errors: [errorResponse] }) + res.status(400).json({ errors: [errorResponse] }) + return } if (!callback) { - return res.sendStatus(202) + res.sendStatus(202) + return } try { await callback({ request: req, response: res }, rfq, { offering, replyTo }) } catch(e) { if (e instanceof CallbackError) { - return res.status(e.statusCode).json({ errors: e.details }) + res.status(e.statusCode).json({ errors: e.details }) + return } else { const errorDetail: ErrorDetail = { detail: 'Internal Server Error' } - return res.status(500).json({ errors: [errorDetail] }) + res.status(500).json({ errors: [errorDetail] }) + return } } - return res.sendStatus(202) + res.sendStatus(202) + return } function isValidUrl(replyToUrl: string) { diff --git a/packages/http-server/src/request-handlers/get-exchanges.ts b/packages/http-server/src/request-handlers/get-exchanges.ts index 9130cc53..0c94d90d 100644 --- a/packages/http-server/src/request-handlers/get-exchanges.ts +++ b/packages/http-server/src/request-handlers/get-exchanges.ts @@ -9,25 +9,28 @@ type GetExchangesOpts = { pfiDid: string } -export async function getExchanges(request: Request, response: Response, opts: GetExchangesOpts) { +export async function getExchanges(request: Request, response: Response, opts: GetExchangesOpts): Promise { const { callback, exchangesApi, pfiDid } = opts const authzHeader = request.headers['authorization'] if (!authzHeader) { - return response.status(401).json({ errors: [{ detail: 'Authorization header required' }] }) + response.status(401).json({ errors: [{ detail: 'Authorization header required' }] }) + return } const [_, requestToken] = authzHeader.split('Bearer ') if (!requestToken) { - return response.status(401).json({ errors: [{ detail: 'Malformed Authorization header. Expected: Bearer TOKEN_HERE' }] }) + response.status(401).json({ errors: [{ detail: 'Malformed Authorization header. Expected: Bearer TOKEN_HERE' }] }) + return } let requesterDid: string try { requesterDid = await TbdexHttpClient.verifyRequestToken({ requestToken: requestToken, pfiDid }) } catch(e) { - return response.status(401).json({ errors: [{ detail: `Malformed Authorization header: ${e}` }] }) + response.status(401).json({ errors: [{ detail: `Malformed Authorization header: ${e}` }] }) + return } const queryParams: GetExchangesFilter = { @@ -50,5 +53,5 @@ export async function getExchanges(request: Request, response: Response, opts: G const _result = await callback({ request, response }, queryParams) } - return response.status(200).json({ data: exchanges }) + response.status(200).json({ data: exchanges }) } \ No newline at end of file diff --git a/packages/http-server/src/request-handlers/get-offerings.ts b/packages/http-server/src/request-handlers/get-offerings.ts index 1ee7e3a4..8ddb8e14 100644 --- a/packages/http-server/src/request-handlers/get-offerings.ts +++ b/packages/http-server/src/request-handlers/get-offerings.ts @@ -6,7 +6,7 @@ type GetOfferingsOpts = { offeringsApi: OfferingsApi } -export async function getOfferings(request: Request, response: Response, opts: GetOfferingsOpts): Promise { +export async function getOfferings(request: Request, response: Response, opts: GetOfferingsOpts): Promise { const { callback, offeringsApi } = opts const filter: GetOfferingsFilter = { @@ -25,5 +25,5 @@ export async function getOfferings(request: Request, response: Response, opts: G await callback({ request, response }, filter) } - return response.status(200).json({ data: offerings }) + response.status(200).json({ data: offerings }) } \ No newline at end of file diff --git a/packages/http-server/src/request-handlers/submit-close.ts b/packages/http-server/src/request-handlers/submit-close.ts index 07d8c9d5..c20a4dc5 100644 --- a/packages/http-server/src/request-handlers/submit-close.ts +++ b/packages/http-server/src/request-handlers/submit-close.ts @@ -10,7 +10,7 @@ type SubmitCloseOpts = { exchangesApi: ExchangesApi } -export async function submitClose(req: Request, res: Response, opts: SubmitCloseOpts): Promise { +export async function submitClose(req: Request, res: Response, opts: SubmitCloseOpts): Promise { const { callback, exchangesApi } = opts let close: Close @@ -19,7 +19,8 @@ export async function submitClose(req: Request, res: Response, opts: SubmitClose close = await Close.parse(req.body) } catch(e) { const errorResponse: ErrorDetail = { detail: 'Request body was not a valid Close message' } - return res.status(400).json({ errors: [errorResponse] }) + res.status(400).json({ errors: [errorResponse] }) + return } // Ensure that an exchange exists to be closed @@ -28,7 +29,8 @@ export async function submitClose(req: Request, res: Response, opts: SubmitClose if(exchange === undefined || exchange.messages.length === 0) { const errorResponse: ErrorDetail = { detail: `No exchange found for ${close.exchangeId}` } - return res.status(404).json({ errors: [errorResponse] }) + res.status(404).json({ errors: [errorResponse] }) + return } // Ensure this exchange can be Closed @@ -37,7 +39,8 @@ export async function submitClose(req: Request, res: Response, opts: SubmitClose detail: `cannot submit Close for an exchange where the last message is kind: ${exchange.latestMessage!.metadata.kind}` } - return res.status(409).json({ errors: [errorResponse] }) + res.status(409).json({ errors: [errorResponse] }) + return } // Ensure that Close is from either Alice or PFI @@ -51,22 +54,24 @@ export async function submitClose(req: Request, res: Response, opts: SubmitClose detail: `Only the creator and receiver of an exchange may close the exchange` } - return res.status(400).json({ errors: [errorResponse] }) + res.status(400).json({ errors: [errorResponse] }) + return } if (!callback) { - return res.sendStatus(202) + res.sendStatus(202) + return } try { await callback({ request: req, response: res }, close) - return res.sendStatus(202) + res.sendStatus(202) } catch(e) { if (e instanceof CallbackError) { - return res.status(e.statusCode).json({ errors: e.details }) + res.status(e.statusCode).json({ errors: e.details }) } else { const errorDetail: ErrorDetail = { detail: 'Internal Server Error' } - return res.status(500).json({ errors: [errorDetail] }) + res.status(500).json({ errors: [errorDetail] }) } } } diff --git a/packages/http-server/src/request-handlers/submit-order.ts b/packages/http-server/src/request-handlers/submit-order.ts index ee1c1c63..f0bde3a5 100644 --- a/packages/http-server/src/request-handlers/submit-order.ts +++ b/packages/http-server/src/request-handlers/submit-order.ts @@ -10,7 +10,7 @@ type SubmitOrderOpts = { exchangesApi: ExchangesApi } -export async function submitOrder(req: Request, res: Response, opts: SubmitOrderOpts): Promise { +export async function submitOrder(req: Request, res: Response, opts: SubmitOrderOpts): Promise { const { callback, exchangesApi } = opts let order: Order @@ -19,14 +19,16 @@ export async function submitOrder(req: Request, res: Response, opts: SubmitOrder order = await Order.parse(req.body) } catch(e) { const errorResponse: ErrorDetail = { detail: 'Request body was not a valid Order message' } - return res.status(400).json({ errors: [errorResponse] }) + res.status(400).json({ errors: [errorResponse] }) + return } const exchange = await exchangesApi.getExchange({id: order.exchangeId}) if(exchange == undefined) { const errorResponse: ErrorDetail = { detail: `No exchange found for ${order.exchangeId}` } - return res.status(404).json({ errors: [errorResponse] }) + res.status(404).json({ errors: [errorResponse] }) + return } if(!exchange.isValidNext('order')) { @@ -34,28 +36,31 @@ export async function submitOrder(req: Request, res: Response, opts: SubmitOrder detail: `Cannot submit Order for an exchange where the last message is kind: ${exchange.latestMessage!.metadata}` } - return res.status(409).json({ errors: [errorResponse] }) + res.status(409).json({ errors: [errorResponse] }) + return } if(new Date(exchange.quote!.data.expiresAt) < new Date()){ const errorResponse: ErrorDetail = { detail: 'Quote is expired' } - return res.status(410).json({ errors: [errorResponse] }) + res.status(410).json({ errors: [errorResponse] }) + return } if (!callback) { - return res.sendStatus(202) + res.sendStatus(202) + return } try { await callback({ request: req, response: res }, order) - return res.sendStatus(202) + res.sendStatus(202) } catch(e) { if (e instanceof CallbackError) { - return res.status(e.statusCode).json({ errors: e.details }) + res.status(e.statusCode).json({ errors: e.details }) } else { const errorDetail: ErrorDetail = { detail: 'Internal Server Error' } - return res.status(500).json({ errors: [errorDetail] }) + res.status(500).json({ errors: [errorDetail] }) } } } \ No newline at end of file diff --git a/packages/protocol/src/exchange.ts b/packages/protocol/src/exchange.ts index 85080aff..391154c3 100644 --- a/packages/protocol/src/exchange.ts +++ b/packages/protocol/src/exchange.ts @@ -21,11 +21,13 @@ export class Exchange { /** Message sent by Alice to the PFI to accept a quote*/ order: Order | undefined /** Message sent by the PFI to Alice to convet the current status of the order */ - orderstatus: OrderStatus | undefined + orderstatus: OrderStatus[] /** Message sent by either the PFI or Alice to terminate an exchange */ close: Close | undefined - constructor() {} + constructor() { + this.orderstatus = [] + } /** * Add a list of unsorted messages to an exchange. @@ -73,7 +75,7 @@ export class Exchange { } else if (message.isOrder()) { this.order = message } else if (message.isOrderStatus()) { - this.orderstatus = message + this.orderstatus.push(message) } else { // Unreachable throw new Error('Unrecognized message kind') @@ -95,7 +97,7 @@ export class Exchange { */ get latestMessage(): Message | undefined { return this.close ?? - this.orderstatus ?? + this.orderstatus[this.orderstatus.length - 1] ?? this.order ?? this.quote ?? this.rfq @@ -116,7 +118,7 @@ export class Exchange { this.rfq, this.quote, this.order, - this.orderstatus, + ...this.orderstatus, this.close ] return allPossibleMessages.filter((message): message is Message => message !== undefined) diff --git a/packages/protocol/src/message-kinds/order-status.ts b/packages/protocol/src/message-kinds/order-status.ts index 0f34ee35..9f9b5da8 100644 --- a/packages/protocol/src/message-kinds/order-status.ts +++ b/packages/protocol/src/message-kinds/order-status.ts @@ -18,7 +18,7 @@ export type CreateOrderStatusOptions = { */ export class OrderStatus extends Message { /** a set of valid Message kinds that can come after an order status */ - readonly validNext = new Set([]) + readonly validNext = new Set(['orderstatus']) /** The message kind (orderstatus) */ readonly kind = 'orderstatus' diff --git a/packages/protocol/tests/exchange.spec.ts b/packages/protocol/tests/exchange.spec.ts index 1b42bfa2..dfc77e94 100644 --- a/packages/protocol/tests/exchange.spec.ts +++ b/packages/protocol/tests/exchange.spec.ts @@ -97,7 +97,7 @@ describe('Exchange', () => { expect(exchange.rfq).to.deep.eq(rfq) expect(exchange.quote).to.deep.eq(quote) expect(exchange.order).to.deep.eq(order) - expect(exchange.orderstatus).to.deep.eq(orderStatus) + expect(exchange.orderstatus).to.deep.eq([orderStatus]) }) it('throws if the messages listed do not form a valid exchange', async () => { @@ -267,7 +267,7 @@ describe('Exchange', () => { exchange.addMessages([rfq, quote, order]) exchange.addNextMessage(orderStatus) - expect(exchange.orderstatus).to.deep.eq(orderStatus) + expect(exchange.orderstatus).to.deep.eq([orderStatus]) }) it('cannot add Rfq, Quote, Order, or Close after Order', async () => { From 43c1390947887b91a2092cde6f93ca3f1774c929 Mon Sep 17 00:00:00 2001 From: Diane Huxley Date: Fri, 16 Feb 2024 14:40:08 -0800 Subject: [PATCH 3/3] Update packages/http-server/tests/submit-close.spec.ts Co-authored-by: Jiyoon Koo --- packages/http-server/tests/submit-close.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http-server/tests/submit-close.spec.ts b/packages/http-server/tests/submit-close.spec.ts index 048640a7..2da13d12 100644 --- a/packages/http-server/tests/submit-close.spec.ts +++ b/packages/http-server/tests/submit-close.spec.ts @@ -220,7 +220,7 @@ describe('POST /exchanges/:exchangeId/close', () => { }) it('returns a 202 if close is created by pfi', async () => { - // scenario: Alice creates an exchange and submits a Close message + // scenario: Alice creates an exchange and PFI submits a Close message const alice = await DevTools.createDid() const pfi = await DevTools.createDid()