From 36475984368426f50323322da622f0af4c5d046b Mon Sep 17 00:00:00 2001 From: Ivaylo Andonov Date: Wed, 1 Mar 2023 10:35:59 +0200 Subject: [PATCH] feat(cardano-services): metadata fetching logic - implement the main SP metadata fetching logic --- .../HttpStakePoolMetadataService.ts | 143 +++++++- .../StakePool/HttpStakePoolMetadata/errors.ts | 17 + .../StakePool/HttpStakePoolMetadata/index.ts | 1 + .../StakePool/HttpStakePoolMetadata/types.ts | 8 + .../cardano-services/src/StakePool/types.ts | 8 +- .../HttpMetadataService.test.ts | 342 +++++++++++++++--- .../HttpStakePoolMetadataService/mocks.ts | 8 + packages/core/src/errors.ts | 6 +- 8 files changed, 468 insertions(+), 65 deletions(-) create mode 100644 packages/cardano-services/src/StakePool/HttpStakePoolMetadata/errors.ts diff --git a/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/HttpStakePoolMetadataService.ts b/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/HttpStakePoolMetadataService.ts index 1c7e93d0e43..5ab433c5558 100644 --- a/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/HttpStakePoolMetadataService.ts +++ b/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/HttpStakePoolMetadataService.ts @@ -1,13 +1,23 @@ -import { Cardano, ProviderError, ProviderFailure } from '@cardano-sdk/core'; +/* eslint-disable max-len */ +/* eslint-disable complexity */ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable max-depth */ + +import * as Crypto from '@cardano-sdk/crypto'; +import { CML, Cardano } from '@cardano-sdk/core'; +import { Hash32ByteBase16 } from '@cardano-sdk/crypto'; +import { HexBlob } from '@cardano-sdk/util'; import { Logger } from 'ts-log'; import { StakePoolExtMetadataResponse, StakePoolMetadataService } from '../types'; +import { StakePoolMetadataResponse, StakePoolMetadataServiceError, StakePoolMetadataServiceFailure } from '../..'; import { ValidationError, validate } from 'jsonschema'; import { getExtMetadataUrl, getSchemaFormat, loadJsonSchema } from './util'; import { mapToExtendedMetadata } from './mappers'; -import axios, { AxiosInstance } from 'axios'; +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; -const HTTP_CLIENT_TIMEOUT = 1 * 1000; +const HTTP_CLIENT_TIMEOUT = 2 * 1000; const HTTP_CLIENT_MAX_CONTENT_LENGTH = 5000; +const SERVICE_NAME = 'StakePoolMetadataService'; export const createHttpStakePoolMetadataService = ( logger: Logger, @@ -16,38 +26,145 @@ export const createHttpStakePoolMetadataService = ( timeout: HTTP_CLIENT_TIMEOUT }) ): StakePoolMetadataService => ({ - async getStakePoolExtendedMetadata(metadata: Cardano.StakePoolMetadata): Promise { + async getStakePoolExtendedMetadata( + metadata: Cardano.StakePoolMetadata, + config: AxiosRequestConfig = {} + ): Promise { const url = getExtMetadataUrl(metadata); try { logger.debug('About to fetch stake pool extended metadata'); - const { data } = await axiosClient.get(url); + const { data } = await axiosClient.get(url, config); const schema = loadJsonSchema(getSchemaFormat(metadata)); validate(data, schema, { throwError: true }); return mapToExtendedMetadata(data); } catch (error) { if (axios.isAxiosError(error)) { if (error.response?.status === 404) { - throw new ProviderError( - ProviderFailure.NotFound, + throw new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.FailedToFetchExtendedMetadata, error, - `StakePoolMetadataService failed to fetch extended metadata from ${url} due to resource not found` + `${SERVICE_NAME} failed to fetch extended metadata from ${url} due to resource not found` ); } - throw new ProviderError( - ProviderFailure.ConnectionFailure, + throw new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.FailedToFetchExtendedMetadata, error, - `StakePoolMetadataService failed to fetch extended metadata from ${url} due to connection error` + `${SERVICE_NAME} failed to fetch extended metadata from ${url} due to connection error` ); } + if (error instanceof ValidationError) { - throw new ProviderError( - ProviderFailure.InvalidResponse, + throw new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.InvalidExtendedMetadataFormat, error, 'Extended metadata JSON format validation failed against the corresponding schema for correctness' ); } + throw error; } + }, + async getStakePoolMetadata(hash: Hash32ByteBase16, url: string): Promise { + const errors = []; + let metadata: Cardano.StakePoolMetadata | undefined; + let extMetadata: Cardano.ExtendedStakePoolMetadata | undefined; + + try { + logger.debug(`About to fetch stake pool metadata JSON from ${url}`); + + // Fetch metadata as byte array + const { data } = await axiosClient.get(url, { responseType: 'arraybuffer' }); + + // Produce metadata hash + const metadataHash = Crypto.blake2b(Crypto.blake2b.BYTES).update(data).digest('hex'); + + // Verify base hashes + if (metadataHash !== hash) { + return { + errors: [ + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.InvalidStakePoolHash, + null, + `Invalid stake pool hash. Computed '${metadataHash}', expected '${hash}'` + ) + ], + metadata + }; + } + + // Transform fetched metadata from bytes array to JSON + metadata = JSON.parse(data.toString()); + + if (metadata?.extDataUrl || metadata?.extended) { + // Validate CIP-6 ext metadata fields + if (metadata.extDataUrl && (!metadata.extSigUrl || !metadata.extVkey)) { + return { + errors: [ + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.InvalidMetadata, + null, + 'Missing ext signature or public key' + ) + ], + metadata + }; + } + + // Fetch extended metadata (supports both cip-6 and ada pools formats already) + extMetadata = await this.getStakePoolExtendedMetadata(metadata); + + // In case of CIP-6 standard -> perform signature verification + if (metadata.extDataUrl && metadata.extSigUrl && metadata.extVkey) { + // Based on the CIP-6, we have `extSigUrl` (A URL with the extended metadata signature), so we need to make another HTTP request to get the actual signature + try { + const signature = (await axiosClient.get(metadata.extSigUrl)).data; + const message = HexBlob.fromBytes(Buffer.from(JSON.stringify(extMetadata))); + const publicKey = Crypto.Ed25519PublicKeyHex(metadata.extVkey); + const bip32Ed25519 = new Crypto.CmlBip32Ed25519(CML); + + // Verify the signature + const isSignatureValid = await bip32Ed25519.verify(signature, message, publicKey); + + // If not valid -> omit extended metadata from response and add specific error + if (!isSignatureValid) { + extMetadata = undefined; + errors.push( + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.InvalidExtendedMetadataSignature, + null, + 'Invalid extended metadata signature' + ) + ); + } + + // If signature url failed -> omit extended metadata from response and add specific error + } catch (error) { + extMetadata = undefined; + errors.push( + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.FailedToFetchExtendedSignature, + error, + `${SERVICE_NAME} failed to fetch extended signature from ${metadata.extSigUrl} due to connection error` + ) + ); + } + } + } + } catch (error) { + if (axios.isAxiosError(error)) { + errors.push( + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.FailedToFetchMetadata, + error.toJSON(), + `${SERVICE_NAME} failed to fetch metadata JSON from ${url} due to ${error.message}` + ) + ); + } else if (error instanceof StakePoolMetadataServiceError) { + errors.push(error); + } + } + + return { errors, metadata: { ...metadata!, ext: extMetadata } }; } }); diff --git a/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/errors.ts b/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/errors.ts new file mode 100644 index 00000000000..df07ea63ffc --- /dev/null +++ b/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/errors.ts @@ -0,0 +1,17 @@ +import { ComposableError, formatErrorMessage } from '@cardano-sdk/util'; + +export enum StakePoolMetadataServiceFailure { + FailedToFetchExtendedMetadata = 'FAILED_TO_FETCH_EXTENDED_METADATA', + FailedToFetchMetadata = 'FAILED_TO_FETCH_METADATA', + FailedToFetchExtendedSignature = 'FAILED_TO_FETCH_EXTENDED_SIGNATURE', + InvalidExtendedMetadataFormat = 'INVALID_EXTENDED_METADATA_FORMAT', + InvalidExtendedMetadataSignature = 'INVALID_EXTENDED_METADATA_SIGNATURE', + InvalidStakePoolHash = 'INVALID_STAKE_POOL_HASH', + InvalidMetadata = 'INVALID_METADATA' +} + +export class StakePoolMetadataServiceError extends ComposableError { + constructor(public reason: StakePoolMetadataServiceFailure, innerError?: InnerError, public detail?: string) { + super(formatErrorMessage(reason, detail), innerError); + } +} diff --git a/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/index.ts b/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/index.ts index 50bd77df254..685cc7be692 100644 --- a/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/index.ts +++ b/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/index.ts @@ -2,3 +2,4 @@ export * from './HttpStakePoolMetadataService'; export * from './types'; export * from './util'; export * from './mappers'; +export * from './errors'; diff --git a/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/types.ts b/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/types.ts index c94b1d28a58..89691a86840 100644 --- a/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/types.ts +++ b/packages/cardano-services/src/StakePool/HttpStakePoolMetadata/types.ts @@ -1,3 +1,6 @@ +import { Cardano } from '@cardano-sdk/core'; +import { CustomError } from 'ts-custom-error'; + /** * AdaPools format response types * Based on: https://a.adapools.org/extended-example @@ -89,3 +92,8 @@ export type Cip6ExtMetadataResponse = { serial: number; pool: Cip6ExtendedStakePoolMetadataFields; }; + +export type StakePoolMetadataResponse = { + metadata: Cardano.StakePoolMetadata | undefined; + errors: CustomError[]; +}; diff --git a/packages/cardano-services/src/StakePool/types.ts b/packages/cardano-services/src/StakePool/types.ts index afbd8dfb655..2877606aa20 100644 --- a/packages/cardano-services/src/StakePool/types.ts +++ b/packages/cardano-services/src/StakePool/types.ts @@ -1,10 +1,10 @@ -import { APExtMetadataResponse, Cip6ExtMetadataResponse } from './HttpStakePoolMetadata'; +import { APExtMetadataResponse, Cip6ExtMetadataResponse, StakePoolMetadataResponse } from './HttpStakePoolMetadata'; import { Cardano } from '@cardano-sdk/core'; +import { Hash32ByteBase16 } from '@cardano-sdk/crypto'; export interface StakePoolMetadataService { - getStakePoolExtendedMetadata( - poolMetadata: Cardano.StakePoolMetadata - ): Promise; + getStakePoolMetadata(hash: Hash32ByteBase16, url: string): Promise; + getStakePoolExtendedMetadata(poolMetadata: Cardano.StakePoolMetadata): Promise; } export enum ExtMetadataFormat { diff --git a/packages/cardano-services/test/StakePool/HttpStakePoolMetadataService/HttpMetadataService.test.ts b/packages/cardano-services/test/StakePool/HttpStakePoolMetadataService/HttpMetadataService.test.ts index e90943e179e..83ebfc07f4b 100644 --- a/packages/cardano-services/test/StakePool/HttpStakePoolMetadataService/HttpMetadataService.test.ts +++ b/packages/cardano-services/test/StakePool/HttpStakePoolMetadataService/HttpMetadataService.test.ts @@ -1,12 +1,23 @@ /* eslint-disable max-len */ -import { Cardano, ProviderError, ProviderFailure } from '@cardano-sdk/core'; +/* eslint-disable prefer-const */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as Crypto from '@cardano-sdk/crypto'; +import { Cardano } from '@cardano-sdk/core'; import { DataMocks } from '../../data-mocks'; import { ExtMetadataFormat } from '../../../src/StakePool/types'; -import { adaPoolsExtMetadataMock, cip6ExtMetadataMock, mainExtMetadataMock } from './mocks'; +import { Hash32ByteBase16 } from '@cardano-sdk/crypto'; +import { + StakePoolMetadataServiceError, + StakePoolMetadataServiceFailure, + createHttpStakePoolMetadataService +} from '../../../src'; +import { adaPoolsExtMetadataMock, cip6ExtMetadataMock, mainExtMetadataMock, stakePoolMetadata } from './mocks'; import { createGenericMockServer, logger } from '@cardano-sdk/util-dev'; -import { createHttpStakePoolMetadataService } from '../../../src'; import url from 'url'; +const UNFETCHABLE = 'http://some_url/unfetchable'; +const INVALID_KEY = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'; + export const mockPoolExtMetadataServer = createGenericMockServer((handler) => async (req, res) => { const result = handler(req); @@ -19,74 +30,313 @@ export const mockPoolExtMetadataServer = createGenericMockServer((handler) => as const reqUrl = url.parse(req.url!).pathname; - if (reqUrl === `/${ExtMetadataFormat.AdaPools}`) { - return res.end(JSON.stringify(adaPoolsExtMetadataMock)); - } else if (reqUrl === `/${ExtMetadataFormat.CIP6}`) { - return res.end(JSON.stringify(cip6ExtMetadataMock)); + switch (reqUrl) { + case `/${ExtMetadataFormat.AdaPools}`: { + return res.end(JSON.stringify(adaPoolsExtMetadataMock)); + } + case `/${ExtMetadataFormat.CIP6}`: { + return res.end(JSON.stringify(cip6ExtMetadataMock)); + } + // No default } return res.end(JSON.stringify(adaPoolsExtMetadataMock)); }); describe('StakePoolMetadataService', () => { - describe('healthy state', () => { - let closeMock: () => Promise = jest.fn(); - let serverUrl = ''; - const extendedMetadataService = createHttpStakePoolMetadataService(logger); + let closeMock: () => Promise = jest.fn(); + let serverUrl = ''; + const metadataService = createHttpStakePoolMetadataService(logger); - beforeAll(async () => { - ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({}))); + afterEach(async () => { + await closeMock(); + }); + + describe('getStakePoolMetadata', () => { + it('fetch stake pool JSON metadata without extended data', async () => { + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({ body: mainExtMetadataMock, code: 200 }))); + + const result = await metadataService.getStakePoolMetadata( + '0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8' as Hash32ByteBase16, + `${serverUrl}/metadata` + ); + + expect(result).toEqual({ + errors: [], + metadata: { ...mainExtMetadataMock } + }); + }); + + it('fetch stake pool JSON metadata with extended metadata', async () => { + let metadata: any; + + // First fetch returns the metadata, second fetch return the extended metadata. + let alreadyCalled = false; + const handler = () => { + if (alreadyCalled) return { body: adaPoolsExtMetadataMock, code: 200 }; + alreadyCalled = true; + + return { + body: metadata, + code: 200 + }; + }; + + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(handler)); + metadata = { ...stakePoolMetadata, extended: `${serverUrl}/extendedMetadata` }; + + // Since the extended metadata URL will change each run and its part of the metadata, we must + // recalculate metadata the hash. + const metadataHash = Crypto.blake2b(Crypto.blake2b.BYTES) + .update(Buffer.from(JSON.stringify(metadata), 'ascii')) + .digest('hex'); + + const result = await metadataService.getStakePoolMetadata( + metadataHash as Hash32ByteBase16, + `${serverUrl}/metadata` + ); + + expect(result).toEqual({ + errors: [], + metadata: { ...metadata, ext: DataMocks.Pool.adaPoolExtendedMetadata } + }); + }); + + it('returns StakePoolMetadataServiceError with FailedToFetchMetadata error code when it gets resource not found server error', async () => { + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({ body: {}, code: 500 }))); + + const result = await metadataService.getStakePoolMetadata( + '4781fffc4cc4a0d6074ae905869e8596f13246b79888af5a8d9580d3a372729a' as Hash32ByteBase16, + serverUrl + ); + + expect(result).toEqual({ + errors: [ + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.FailedToFetchMetadata, + null, + `StakePoolMetadataService failed to fetch metadata JSON from ${serverUrl} due to Request failed with status code 500` + ) + ], + metadata: { ext: undefined } + }); + }); + + it('returns StakePoolMetadataServiceError with InvalidStakePoolHash error code when the hash doesnt match', async () => { + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({ body: mainExtMetadataMock, code: 200 }))); + + const result = await metadataService.getStakePoolMetadata( + '0000000000000000000000000000000000000000000000000000000000000000' as Hash32ByteBase16, + `${serverUrl}/metadata` + ); + + expect(result).toEqual({ + errors: [ + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.InvalidStakePoolHash, + null, + "Invalid stake pool hash. Computed '0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8', expected '0000000000000000000000000000000000000000000000000000000000000000'" + ) + ], + metadata: undefined + }); + }); + + it('returns StakePoolMetadataServiceError with InvalidMetadata error code when metadata has extDataUrl but is missing extSigUrl', async () => { + const metadata = { ...mainExtMetadataMock, extDataUrl: UNFETCHABLE }; + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({ + body: metadata, + code: 200 + }))); + + const result = await metadataService.getStakePoolMetadata( + '71782c81f1e90c08e3f9083db36c9966362ac08356bf10cbd50bb91eabf19135' as Hash32ByteBase16, + `${serverUrl}/metadata` + ); + + expect(result).toEqual({ + errors: [ + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.InvalidMetadata, + null, + 'Missing ext signature or public key' + ) + ], + metadata + }); + }); + + it('returns StakePoolMetadataServiceError with InvalidMetadata error code when metadata has extDataUrl and extSigUrl but is missing extVkey', async () => { + const metadata = { + ...mainExtMetadataMock, + extDataUrl: UNFETCHABLE, + extSigUrl: UNFETCHABLE + }; + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({ + body: metadata, + code: 200 + }))); + + const result = await metadataService.getStakePoolMetadata( + 'bc8b5da244f49515e97c874e9830b709faaef18ef32ce63cd0b6a8af15e4b01b' as Hash32ByteBase16, + `${serverUrl}/metadata` + ); + + expect(result).toEqual({ + errors: [ + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.InvalidMetadata, + null, + 'Missing ext signature or public key' + ) + ], + metadata + }); + }); + + it('returns StakePoolMetadataServiceError with FailedToFetchExtendedSignature error code when it cant fetch the signature', async () => { + let metadata: any; + + let alreadyCalled = false; + const handler = () => { + if (alreadyCalled) return { body: cip6ExtMetadataMock, code: 200 }; + alreadyCalled = true; + + return { + body: metadata, + code: 200 + }; + }; + + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(handler)); + + metadata = { + ...mainExtMetadataMock, + extDataUrl: `${serverUrl}/${ExtMetadataFormat.CIP6}`, + extSigUrl: UNFETCHABLE, + extVkey: '00000000000000000000000000000000' + }; + + const metadataHash = Crypto.blake2b(Crypto.blake2b.BYTES) + .update(Buffer.from(JSON.stringify(metadata), 'ascii')) + .digest('hex'); + + const result = await metadataService.getStakePoolMetadata( + metadataHash as Hash32ByteBase16, + `${serverUrl}/metadata` + ); + + expect(result).toEqual({ + errors: [ + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.FailedToFetchExtendedSignature, + new Error('getaddrinfo EAI_AGAIN some_url'), + `StakePoolMetadataService failed to fetch extended signature from ${UNFETCHABLE} due to connection error` + ) + ], + metadata + }); }); - afterAll(async () => { - await closeMock(); + it('returns StakePoolMetadataServiceError with InvalidExtendedMetadataSignature error code when the signature is invalid', async () => { + let metadata: any; + + let numFetch = 0; + const handler = () => { + if (numFetch === 0) { + ++numFetch; + return { + body: metadata, + code: 200 + }; + } else if (numFetch === 1) { + ++numFetch; + return { body: DataMocks.Pool.cip6ExtendedMetadata, code: 200 }; + } + + return { + body: + 'e5564300c360ac729086e2cc806e828a' + + '84877f1eb8e5d974d873e06522490155' + + '5fb8821590a33bacc61e39701cf9b46b' + + 'd25bf5f0595bbe24655141438e7a100b', + code: 200 + }; + }; + + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(handler)); + + metadata = { + ...mainExtMetadataMock, + extDataUrl: `${serverUrl}/${ExtMetadataFormat.CIP6}`, + extSigUrl: `${serverUrl}/invalidSignature`, + extVkey: INVALID_KEY + }; + + const metadataHash = Crypto.blake2b(Crypto.blake2b.BYTES) + .update(Buffer.from(JSON.stringify(metadata), 'ascii')) + .digest('hex'); + + const result = await metadataService.getStakePoolMetadata( + metadataHash as Hash32ByteBase16, + `${serverUrl}/metadata` + ); + + expect(result).toEqual({ + errors: [ + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.InvalidExtendedMetadataSignature, + null, + 'Invalid extended metadata signature' + ) + ], + metadata + }); }); + }); + describe('getStakePoolExtendedMetadata', () => { it('returns ada pools format when extended key is present in the metadata', async () => { + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({}))); + const extMetadata: Cardano.StakePoolMetadata = { ...mainExtMetadataMock(), extended: `${serverUrl}/${ExtMetadataFormat.AdaPools}` }; - const result = await extendedMetadataService.getStakePoolExtendedMetadata(extMetadata); + const result = await metadataService.getStakePoolExtendedMetadata(extMetadata); expect(result).not.toBeNull(); expect(result).toMatchShapeOf(DataMocks.Pool.adaPoolExtendedMetadata); }); it('returns CIP-6 format when extDataUrl is present in the metadata', async () => { + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({}))); + const extMetadata: Cardano.StakePoolMetadata = { ...mainExtMetadataMock(), extDataUrl: `${serverUrl}/${ExtMetadataFormat.CIP6}` }; - const result = await extendedMetadataService.getStakePoolExtendedMetadata(extMetadata); + const result = await metadataService.getStakePoolExtendedMetadata(extMetadata); expect(result).not.toBeNull(); expect(result).toMatchShapeOf(DataMocks.Pool.cip6ExtendedMetadata); }); it('returns CIP-6 format with priority when the metadata including both extended properties', async () => { + ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({}))); + const extMetadata: Cardano.StakePoolMetadata = { ...mainExtMetadataMock(), extDataUrl: `${serverUrl}/${ExtMetadataFormat.CIP6}`, extended: `${serverUrl}/${ExtMetadataFormat.AdaPools}` }; - const result = await extendedMetadataService.getStakePoolExtendedMetadata(extMetadata); + const result = await metadataService.getStakePoolExtendedMetadata(extMetadata); expect(result).not.toBeNull(); expect(result?.serial).toBeDefined(); }); - }); - - describe('error cases are correctly handled', () => { - const extendedMetadataService = createHttpStakePoolMetadataService(logger); - let closeMock: () => Promise = jest.fn(); - let serverUrl: string; - - beforeEach(() => (closeMock = jest.fn())); - - afterEach(async () => await closeMock()); - it('invalid CIP-6 response format', async () => { + it('throws StakePoolMetadataServiceError with InvalidExtendedMetadataFormat error code when it gets an invalid CIP-6 response format', async () => { const invalidCip6ResponseFormat = { pool1: { ...cip6ExtMetadataMock.pool }, serial: 12_345 }; ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({ body: invalidCip6ResponseFormat }))); @@ -95,16 +345,16 @@ describe('StakePoolMetadataService', () => { extDataUrl: `${serverUrl}/${ExtMetadataFormat.CIP6}` }; - await expect(extendedMetadataService.getStakePoolExtendedMetadata(extMetadata)).rejects.toThrow( - new ProviderError( - ProviderFailure.InvalidResponse, + await expect(metadataService.getStakePoolExtendedMetadata(extMetadata)).rejects.toThrow( + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.InvalidExtendedMetadataFormat, 'instance requires property "pool"', 'Extended metadata JSON format validation failed against the corresponding schema for correctness' ) ); }); - it('invalid AP response format', async () => { + it('throws StakePoolMetadataServiceError with InvalidExtendedMetadataFormat error code when it gets an invalid AP response format', async () => { const invalidAdaPoolsResponseFormat = { info1: { ...adaPoolsExtMetadataMock.info } }; ({ closeMock, serverUrl } = await mockPoolExtMetadataServer(() => ({ body: invalidAdaPoolsResponseFormat }))); @@ -113,16 +363,16 @@ describe('StakePoolMetadataService', () => { extended: `${serverUrl}/${ExtMetadataFormat.AdaPools}` }; - await expect(extendedMetadataService.getStakePoolExtendedMetadata(extMetadata)).rejects.toThrow( - new ProviderError( - ProviderFailure.InvalidResponse, + await expect(metadataService.getStakePoolExtendedMetadata(extMetadata)).rejects.toThrow( + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.InvalidExtendedMetadataFormat, 'instance requires property "info"', 'Extended metadata JSON format validation failed against the corresponding schema for correctness' ) ); }); - it('internal server error', async () => { + it('throws StakePoolMetadataServiceError with FailedToFetchExtendedMetadata error code when it gets internal server error response', async () => { let alreadyCalled = false; const handler = () => { if (alreadyCalled) return { body: {}, code: 500 }; @@ -140,19 +390,19 @@ describe('StakePoolMetadataService', () => { extDataUrl: `${serverUrl}/${ExtMetadataFormat.CIP6}` }; - const result = await extendedMetadataService.getStakePoolExtendedMetadata(extMetadata); + const result = await metadataService.getStakePoolExtendedMetadata(extMetadata); expect(result).toBeDefined(); - await expect(extendedMetadataService.getStakePoolExtendedMetadata(extMetadata)).rejects.toThrow( - new ProviderError( - ProviderFailure.ConnectionFailure, + await expect(metadataService.getStakePoolExtendedMetadata(extMetadata)).rejects.toThrow( + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.FailedToFetchExtendedMetadata, null, `StakePoolMetadataService failed to fetch extended metadata from ${serverUrl}/${ExtMetadataFormat.CIP6} due to connection error` ) ); }); - it('resource not found server error', async () => { + it('throws StakePoolMetadataServiceError with FailedToFetchExtendedMetadata error code when it gets resource not found server error', async () => { let alreadyCalled = false; const handler = () => { if (alreadyCalled) return { body: {}, code: 404 }; @@ -170,12 +420,12 @@ describe('StakePoolMetadataService', () => { extDataUrl: `${serverUrl}/${ExtMetadataFormat.CIP6}` }; - const result = await extendedMetadataService.getStakePoolExtendedMetadata(extMetadata); + const result = await metadataService.getStakePoolExtendedMetadata(extMetadata); expect(result).toBeDefined(); - await expect(extendedMetadataService.getStakePoolExtendedMetadata(extMetadata)).rejects.toThrow( - new ProviderError( - ProviderFailure.NotFound, + await expect(metadataService.getStakePoolExtendedMetadata(extMetadata)).rejects.toThrow( + new StakePoolMetadataServiceError( + StakePoolMetadataServiceFailure.FailedToFetchExtendedMetadata, null, `StakePoolMetadataService failed to fetch extended metadata from ${serverUrl}/${ExtMetadataFormat.CIP6} due to resource not found` ) diff --git a/packages/cardano-services/test/StakePool/HttpStakePoolMetadataService/mocks.ts b/packages/cardano-services/test/StakePool/HttpStakePoolMetadataService/mocks.ts index 6652ac494d5..0942744f39c 100644 --- a/packages/cardano-services/test/StakePool/HttpStakePoolMetadataService/mocks.ts +++ b/packages/cardano-services/test/StakePool/HttpStakePoolMetadataService/mocks.ts @@ -66,3 +66,11 @@ export const cip6ExtMetadataMock: Cip6ExtMetadataResponse = { }, serial: 2_020_072_001 }; + +export const stakePoolMetadata: Cardano.StakePoolMetadata = { + description: 'Stakepool - Your reliable & trustworthy stakepool', + extended: 'http://localhost/extendedMetadata', + homepage: 'https://www.home-page.com', + name: 'Stakepool #1', + ticker: 'STKP' +}; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index be2e18d06e2..8c15a8b457b 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -8,7 +8,8 @@ export enum ProviderFailure { NotImplemented = 'NOT_IMPLEMENTED', Unhealthy = 'UNHEALTHY', ConnectionFailure = 'CONNECTION_FAILURE', - BadRequest = 'BAD_REQUEST' + BadRequest = 'BAD_REQUEST', + ServerUnavailable = 'SERVER_UNAVAILABLE' } export const providerFailureToStatusCodeMap: { [key in ProviderFailure]: number } = { @@ -18,7 +19,8 @@ export const providerFailureToStatusCodeMap: { [key in ProviderFailure]: number [ProviderFailure.Unknown]: 500, [ProviderFailure.InvalidResponse]: 500, [ProviderFailure.NotImplemented]: 500, - [ProviderFailure.ConnectionFailure]: 500 + [ProviderFailure.ConnectionFailure]: 500, + [ProviderFailure.ServerUnavailable]: 500 }; export class ProviderError extends ComposableError {