From 8d707f6025ed73eb9bb9895477a1cc0e02097f4e Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:28:34 +0100 Subject: [PATCH] fix: charge RPC batches by operation cost Bound expensive RPC parameters and account for method and request complexity before forwarding to the upstream provider. --- server/api/internal/rpc/[chainId].ts | 260 ++++++++++++++++++++++++- tests/server/rpc-proxy-route.test.ts | 271 +++++++++++++++++++++++++++ 2 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 tests/server/rpc-proxy-route.test.ts diff --git a/server/api/internal/rpc/[chainId].ts b/server/api/internal/rpc/[chainId].ts index 57e00c5dc..010723c3f 100644 --- a/server/api/internal/rpc/[chainId].ts +++ b/server/api/internal/rpc/[chainId].ts @@ -27,6 +27,25 @@ const ALLOWED_METHODS = new Set([ ]) const MAX_BATCH_SIZE = 100 +const MAX_PARAMS_BYTES = 256 * 1024 +const MAX_CALLDATA_BYTES = 256 * 1024 +const MAX_EXPLICIT_GAS = 50_000_000n +const MAX_STATE_OVERRIDE_ACCOUNTS = 128 +const MAX_STATE_OVERRIDE_SLOTS = 2_048 +const MAX_STATE_OVERRIDE_CODE_BYTES = 64 * 1024 +const MAX_FEE_HISTORY_BLOCKS = 128n +const MAX_REWARD_PERCENTILES = 20 +const MAX_LOG_BLOCK_RANGE = 10_000n +const MAX_LOG_ADDRESSES = 20 +const MAX_LOG_TOPIC_POSITIONS = 4 +const MAX_LOG_TOPIC_OR_VALUES = 20 + +const CALLDATA_COST_CHUNK_BYTES = 16 * 1024 +const GAS_COST_CHUNK = 5_000_000n +const STATE_OVERRIDE_SLOT_COST_CHUNK = 16 +const STATE_OVERRIDE_CODE_COST_CHUNK_BYTES = 16 * 1024 +const LOG_RANGE_COST_CHUNK = 1_000n +const FEE_HISTORY_COST_CHUNK = 16n const UPSTREAM_TIMEOUT_MS = 30_000 const rateLimiter = createRateLimiter({ @@ -42,6 +61,242 @@ interface JsonRpcRequest { id?: unknown } +type JsonObject = Record + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function invalidParams(statusMessage: string, statusCode = 400): never { + throw createError({ statusCode, statusMessage }) +} + +function jsonByteLength(value: unknown): number { + try { + return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8') + } + catch { + return invalidParams('Invalid RPC params') + } +} + +function hexDataByteLength(value: unknown, label: string): number { + if (typeof value !== 'string' || !/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) { + return invalidParams(`Invalid ${label}`) + } + return (value.length - 2) / 2 +} + +function parseHexQuantity(value: unknown, label: string): bigint { + if (typeof value !== 'string' || !/^0x[0-9a-fA-F]+$/.test(value) || value.length > 66) { + return invalidParams(`Invalid ${label}`) + } + return BigInt(value) +} + +function steppedModifier(value: number, chunk: number): number { + return Math.max(0, Math.ceil(value / chunk) - 1) +} + +function steppedBigIntModifier(value: bigint, chunk: bigint): number { + if (value <= chunk) return 0 + return Number((value + chunk - 1n) / chunk - 1n) +} + +function assessStateOverride(value: unknown): number { + if (!isJsonObject(value)) return invalidParams('Invalid RPC state override') + + const accounts = Object.values(value) + if (accounts.length > MAX_STATE_OVERRIDE_ACCOUNTS) { + return invalidParams(`RPC state override exceeds ${MAX_STATE_OVERRIDE_ACCOUNTS} accounts`) + } + + let slotCount = 0 + let codeBytes = 0 + + for (const account of accounts) { + if (!isJsonObject(account)) return invalidParams('Invalid RPC state override account') + + for (const key of ['state', 'stateDiff'] as const) { + const mapping = account[key] + if (mapping === undefined) continue + if (!isJsonObject(mapping)) return invalidParams(`Invalid RPC state override ${key}`) + slotCount += Object.keys(mapping).length + if (slotCount > MAX_STATE_OVERRIDE_SLOTS) { + return invalidParams(`RPC state override exceeds ${MAX_STATE_OVERRIDE_SLOTS} storage slots`) + } + } + + if (account.code !== undefined) { + codeBytes += hexDataByteLength(account.code, 'RPC state override code') + if (codeBytes > MAX_STATE_OVERRIDE_CODE_BYTES) { + return invalidParams(`RPC state override code exceeds ${MAX_STATE_OVERRIDE_CODE_BYTES} bytes`) + } + } + } + + return 5 + + accounts.length + + Math.ceil(slotCount / STATE_OVERRIDE_SLOT_COST_CHUNK) + + Math.ceil(codeBytes / STATE_OVERRIDE_CODE_COST_CHUNK_BYTES) +} + +function assessTransactionParams(params: unknown): number { + if (!Array.isArray(params)) return 0 + const transaction = params[0] + if (!isJsonObject(transaction)) return 0 + + let calldataBytes = 0 + for (const key of ['data', 'input'] as const) { + if (transaction[key] === undefined) continue + calldataBytes += hexDataByteLength(transaction[key], 'RPC calldata') + } + if (calldataBytes > MAX_CALLDATA_BYTES) { + return invalidParams(`RPC calldata exceeds ${MAX_CALLDATA_BYTES} bytes`, 413) + } + + let cost = steppedModifier(calldataBytes, CALLDATA_COST_CHUNK_BYTES) + if (transaction.gas !== undefined) { + const gas = parseHexQuantity(transaction.gas, 'RPC gas limit') + if (gas > MAX_EXPLICIT_GAS) { + return invalidParams(`RPC gas limit exceeds ${MAX_EXPLICIT_GAS}`) + } + cost += steppedBigIntModifier(gas, GAS_COST_CHUNK) + } + + const stateOverride = params[2] + if (stateOverride !== undefined && stateOverride !== null) { + cost += assessStateOverride(stateOverride) + } + + return cost +} + +function validateLogAddress(value: unknown): boolean { + return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value) +} + +function validateLogTopic(value: unknown): boolean { + return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value) +} + +function assessLogFilter(params: unknown): number { + if (!Array.isArray(params) || !isJsonObject(params[0])) { + return invalidParams('Invalid eth_getLogs filter') + } + const filter = params[0] + const hasBlockHash = filter.blockHash !== undefined + const hasFromBlock = filter.fromBlock !== undefined + const hasToBlock = filter.toBlock !== undefined + + let range = 0n + if (hasBlockHash) { + if (!validateLogTopic(filter.blockHash) || hasFromBlock || hasToBlock) { + return invalidParams('eth_getLogs requires blockHash or an explicit block range') + } + } + else { + if (!hasFromBlock || !hasToBlock) { + return invalidParams('eth_getLogs requires blockHash or an explicit block range') + } + if (filter.fromBlock === 'latest' || filter.toBlock === 'latest') { + if (filter.fromBlock !== 'latest' || filter.toBlock !== 'latest') { + return invalidParams('Invalid eth_getLogs block range') + } + range = 1n + } + else { + const fromBlock = parseHexQuantity(filter.fromBlock, 'eth_getLogs fromBlock') + const toBlock = parseHexQuantity(filter.toBlock, 'eth_getLogs toBlock') + if (toBlock < fromBlock) return invalidParams('Invalid eth_getLogs block range') + range = toBlock - fromBlock + 1n + } + if (range > MAX_LOG_BLOCK_RANGE) { + return invalidParams(`eth_getLogs block range exceeds ${MAX_LOG_BLOCK_RANGE}`) + } + } + + let hasRestrictiveFilter = false + if (filter.address !== undefined) { + const addresses = Array.isArray(filter.address) ? filter.address : [filter.address] + if (addresses.length === 0 || addresses.length > MAX_LOG_ADDRESSES || !addresses.every(validateLogAddress)) { + return invalidParams(`Invalid eth_getLogs address filter (maximum ${MAX_LOG_ADDRESSES})`) + } + hasRestrictiveFilter = true + } + + if (filter.topics !== undefined) { + if (!Array.isArray(filter.topics) || filter.topics.length > MAX_LOG_TOPIC_POSITIONS) { + return invalidParams(`Invalid eth_getLogs topics filter (maximum ${MAX_LOG_TOPIC_POSITIONS} positions)`) + } + + for (const topic of filter.topics) { + if (topic === null) continue + if (Array.isArray(topic)) { + if (topic.length === 0 || topic.length > MAX_LOG_TOPIC_OR_VALUES || !topic.every(validateLogTopic)) { + return invalidParams('Invalid eth_getLogs topic values') + } + } + else if (!validateLogTopic(topic)) { + return invalidParams('Invalid eth_getLogs topic') + } + hasRestrictiveFilter = true + } + } + + if (!hasRestrictiveFilter) { + return invalidParams('eth_getLogs requires an address or topic filter') + } + + return 5 + steppedBigIntModifier(range, LOG_RANGE_COST_CHUNK) +} + +function assessFeeHistory(params: unknown): number { + if (!Array.isArray(params)) return invalidParams('Invalid eth_feeHistory params') + const blockCount = parseHexQuantity(params[0], 'eth_feeHistory block count') + if (blockCount === 0n || blockCount > MAX_FEE_HISTORY_BLOCKS) { + return invalidParams(`eth_feeHistory block count must be between 1 and ${MAX_FEE_HISTORY_BLOCKS}`) + } + + const percentiles = params[2] + if (percentiles !== undefined && percentiles !== null) { + if (!Array.isArray(percentiles) || percentiles.length > MAX_REWARD_PERCENTILES) { + return invalidParams(`Invalid eth_feeHistory reward percentiles (maximum ${MAX_REWARD_PERCENTILES})`) + } + let previous = -1 + for (const percentile of percentiles) { + if (typeof percentile !== 'number' || !Number.isFinite(percentile) || percentile < 0 || percentile > 100 || percentile <= previous) { + return invalidParams('eth_feeHistory reward percentiles must be sorted unique numbers from 0 to 100') + } + previous = percentile + } + } + + return 2 + steppedBigIntModifier(blockCount, FEE_HISTORY_COST_CHUNK) +} + +function assessRpcRequestCost(request: JsonRpcRequest): number { + if (jsonByteLength(request.params) > MAX_PARAMS_BYTES) { + return invalidParams(`RPC params exceed ${MAX_PARAMS_BYTES} bytes`, 413) + } + + switch (request.method) { + case 'eth_call': + return 2 + assessTransactionParams(request.params) + case 'eth_estimateGas': + case 'eth_createAccessList': + return 10 + assessTransactionParams(request.params) + case 'eth_getLogs': + return assessLogFilter(request.params) + case 'eth_feeHistory': + return assessFeeHistory(request.params) + case 'eth_getBlockByNumber': + return Array.isArray(request.params) && request.params[1] === true ? 5 : 1 + default: + return 1 + } +} + // Validates a JSON-RPC 2.0 request object. Requires `id` to be present, // which means JSON-RPC 2.0 *notifications* (requests without `id`) are // intentionally rejected — the proxy only handles request/response patterns. @@ -93,6 +348,7 @@ export default defineEventHandler(async (event) => { } const isBatch = Array.isArray(body) + let requestCost = 0 if (isBatch) { if (body.length === 0) { @@ -111,6 +367,7 @@ export default defineEventHandler(async (event) => { ) throw createError({ statusCode: 403, statusMessage: `Method not allowed: ${(req as JsonRpcRequest)?.method ?? 'unknown'}` }) } + requestCost += assessRpcRequestCost(req) } } else { @@ -121,9 +378,10 @@ export default defineEventHandler(async (event) => { ) throw createError({ statusCode: 403, statusMessage: `Method not allowed: ${body?.method ?? 'unknown'}` }) } + requestCost = assessRpcRequestCost(body) } - rateLimiter.consume(event) + rateLimiter.consume(event, requestCost) const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS) diff --git a/tests/server/rpc-proxy-route.test.ts b/tests/server/rpc-proxy-route.test.ts new file mode 100644 index 000000000..4d013eacf --- /dev/null +++ b/tests/server/rpc-proxy-route.test.ts @@ -0,0 +1,271 @@ +import type { H3Event } from 'h3' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + consume: vi.fn(), + fetch: vi.fn(), + resolveRpcUrl: vi.fn(), + warn: vi.fn(), + rateLimiterConfigs: [] as Array<{ max: number, windowMs: number, label: string }>, +})) + +vi.mock('h3', () => ({ + createError: (error: unknown) => error, + getMethod: (event: TestEvent) => event.method, + readBody: (event: TestEvent) => event.body, + setResponseHeader: (event: TestEvent, name: string, value: string) => { + event.context.responseHeaders = { + ...event.context.responseHeaders, + [name]: value, + } + }, + setResponseStatus: (event: TestEvent, status: number) => { + event.context.status = status + }, +})) + +vi.mock('~/server/utils/logger', () => ({ + logger: { warn: mocks.warn }, +})) + +vi.mock('~/server/utils/rate-limit', () => ({ + createRateLimiter: (config: { max: number, windowMs: number, label: string }) => { + mocks.rateLimiterConfigs.push(config) + return { consume: mocks.consume } + }, +})) + +vi.mock('~/server/utils/rpc', () => ({ + resolveRpcUrl: mocks.resolveRpcUrl, +})) + +vi.mock('~/utils/errorHandling', () => ({ + isAbortError: () => false, +})) + +type TestEvent = H3Event & { + method: string + body?: unknown + context: { + params?: { chainId?: string } + responseHeaders?: Record + status?: number + } +} + +type RpcRequest = { + jsonrpc: '2.0' + id: number + method: string + params?: unknown +} + +const ADDRESS = '0x0000000000000000000000000000000000000001' +const OTHER_ADDRESS = '0x0000000000000000000000000000000000000002' +const TOPIC = `0x${'11'.repeat(32)}` +const BLOCK_HASH = `0x${'22'.repeat(32)}` + +const handler = (await import('~/server/api/internal/rpc/[chainId]')).default + +const rpcRequest = (method: string, params?: unknown, id = 1): RpcRequest => ({ + jsonrpc: '2.0', + id, + method, + ...(params === undefined ? {} : { params }), +}) + +const quantity = (value: bigint | number) => `0x${BigInt(value).toString(16)}` + +const makeEvent = (body?: unknown, overrides: { chainId?: string, method?: string } = {}): TestEvent => ({ + method: overrides.method ?? 'POST', + body, + context: { params: { chainId: overrides.chainId ?? '1' } }, + node: { + req: { + headers: { 'cf-connecting-ip': '127.0.0.1' }, + socket: {}, + }, + res: {}, + }, +} as unknown as TestEvent) + +const storage = (count: number) => Object.fromEntries( + Array.from({ length: count }, (_, index) => [ + `0x${index.toString(16).padStart(64, '0')}`, + `0x${(index + 1).toString(16).padStart(64, '0')}`, + ]), +) + +const compactStorage = (count: number) => Object.fromEntries( + Array.from({ length: count }, (_, index) => [`slot-${index}`, '0x00']), +) + +const accounts = (count: number) => Object.fromEntries( + Array.from({ length: count }, (_, index) => [ + `0x${index.toString(16).padStart(40, '0')}`, + {}, + ]), +) + +describe('/api/internal/rpc route', () => { + beforeEach(() => { + mocks.resolveRpcUrl.mockReturnValue('https://rpc.example') + mocks.fetch.mockResolvedValue(new Response('{"jsonrpc":"2.0","id":1,"result":"0x1"}', { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + vi.stubGlobal('fetch', mocks.fetch) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('uses the shared 10,000-unit app-server budget', () => { + expect(mocks.rateLimiterConfigs).toContainEqual({ + max: 10_000, + windowMs: 60_000, + label: 'rpc', + }) + }) + + it.each([ + ['cheap RPC method', rpcRequest('eth_blockNumber'), 1], + ['small eth_call', rpcRequest('eth_call', [{ to: ADDRESS, data: '0x12345678' }, 'latest']), 2], + ['eth_estimateGas', rpcRequest('eth_estimateGas', [{ to: ADDRESS, data: '0x12345678' }]), 10], + ['eth_createAccessList', rpcRequest('eth_createAccessList', [{ to: ADDRESS, data: '0x12345678' }, 'latest']), 10], + ['block headers only', rpcRequest('eth_getBlockByNumber', ['latest', false]), 1], + ['full block response', rpcRequest('eth_getBlockByNumber', ['latest', true]), 5], + [ + '10,000-block filtered log range', + rpcRequest('eth_getLogs', [{ fromBlock: '0x1', toBlock: quantity(10_000), address: ADDRESS }]), + 14, + ], + ['128-block fee history', rpcRequest('eth_feeHistory', ['0x80', 'latest', [10, 50, 90]]), 9], + ])('charges the exact cost for %s', async (_label, request, expectedCost) => { + const event = makeEvent(request) + + await expect(handler(event)).resolves.toContain('"result":"0x1"') + + expect(mocks.consume).toHaveBeenCalledWith(event, expectedCost) + expect(mocks.fetch).toHaveBeenCalledTimes(1) + }) + + it('adds calldata, gas, and state-override complexity to an SDK-shaped eth_call', async () => { + const request = rpcRequest('eth_call', [ + { + from: ADDRESS, + to: OTHER_ADDRESS, + data: `0x${'12'.repeat(16 * 1024 + 1)}`, + gas: quantity(5_000_001), + }, + 'latest', + { + [ADDRESS]: { + stateDiff: storage(17), + code: `0x${'34'.repeat(16 * 1024 + 1)}`, + }, + [OTHER_ADDRESS]: {}, + }, + ]) + const event = makeEvent(request) + + await handler(event) + + // base 2 + calldata 1 + gas 1 + override (5 + 2 accounts + 2 slot chunks + 2 code chunks) + expect(mocks.consume).toHaveBeenCalledWith(event, 15) + }) + + it('charges every operation in a maximum-size batch', async () => { + const batch = Array.from({ length: 100 }, (_, id) => rpcRequest('eth_blockNumber', undefined, id)) + const event = makeEvent(batch) + + await handler(event) + + expect(mocks.consume).toHaveBeenCalledOnce() + expect(mocks.consume).toHaveBeenCalledWith(event, 100) + expect(mocks.fetch).toHaveBeenCalledOnce() + }) + + it('accepts representative Viem log and fee-history request shapes', async () => { + const event = makeEvent([ + rpcRequest('eth_getLogs', [{ blockHash: BLOCK_HASH, topics: [TOPIC, null] }], 1), + rpcRequest('eth_getLogs', [{ fromBlock: '0x100', toBlock: '0x120', address: [ADDRESS, OTHER_ADDRESS], topics: [[TOPIC]] }], 2), + rpcRequest('eth_feeHistory', ['0x10', 'latest', null], 3), + rpcRequest('eth_getLogs', [{ fromBlock: 'latest', toBlock: 'latest', topics: [Array(20).fill(TOPIC), Array(20).fill(TOPIC)] }], 4), + ]) + + await handler(event) + + expect(mocks.consume).toHaveBeenCalledWith(event, 17) + expect(mocks.fetch).toHaveBeenCalledOnce() + }) + + it.each([ + ['oversized params JSON', rpcRequest('eth_blockNumber', ['x'.repeat(256 * 1024 + 1)]), 413], + ['oversized calldata', rpcRequest('eth_call', [{ data: `0x${'11'.repeat(256 * 1024 + 1)}` }, 'latest']), 413], + ['excessive explicit gas', rpcRequest('eth_call', [{ gas: quantity(50_000_001) }, 'latest']), 400], + ['too many override accounts', rpcRequest('eth_call', [{}, 'latest', accounts(129)]), 400], + ['too many override slots', rpcRequest('eth_call', [{}, 'latest', { [ADDRESS]: { stateDiff: compactStorage(2_049) } }]), 400], + ['too much override code', rpcRequest('eth_call', [{}, 'latest', { [ADDRESS]: { code: `0x${'11'.repeat(64 * 1024 + 1)}` } }]), 400], + ['too many fee-history blocks', rpcRequest('eth_feeHistory', ['0x81', 'latest', []]), 400], + ['too many fee-history percentiles', rpcRequest('eth_feeHistory', ['0x1', 'latest', Array.from({ length: 21 }, (_, index) => index)]), 400], + ['unsorted fee-history percentiles', rpcRequest('eth_feeHistory', ['0x1', 'latest', [50, 10]]), 400], + ['unbounded logs', rpcRequest('eth_getLogs', [{ address: ADDRESS }]), 400], + ['oversized log range', rpcRequest('eth_getLogs', [{ fromBlock: '0x1', toBlock: quantity(10_001), address: ADDRESS }]), 400], + ['mixed numeric/latest log range', rpcRequest('eth_getLogs', [{ fromBlock: '0x1', toBlock: 'latest', address: ADDRESS }]), 400], + ['unfiltered logs', rpcRequest('eth_getLogs', [{ fromBlock: '0x1', toBlock: '0x2' }]), 400], + ['too many log addresses', rpcRequest('eth_getLogs', [{ blockHash: BLOCK_HASH, address: Array.from({ length: 21 }, () => ADDRESS) }]), 400], + ['too many topic positions', rpcRequest('eth_getLogs', [{ blockHash: BLOCK_HASH, topics: [TOPIC, TOPIC, TOPIC, TOPIC, TOPIC] }]), 400], + ['too many topic alternatives', rpcRequest('eth_getLogs', [{ blockHash: BLOCK_HASH, topics: [Array.from({ length: 21 }, () => TOPIC)] }]), 400], + ])('rejects %s before consuming budget or contacting upstream', async (_label, request, statusCode) => { + await expect(handler(makeEvent(request))).rejects.toMatchObject({ statusCode }) + + expect(mocks.consume).not.toHaveBeenCalled() + expect(mocks.fetch).not.toHaveBeenCalled() + }) + + it('stops before the upstream fetch when the weighted limiter rejects a request', async () => { + mocks.consume.mockImplementationOnce(() => { + throw { statusCode: 429, statusMessage: 'Too Many Requests' } + }) + + await expect(handler(makeEvent(rpcRequest('eth_estimateGas', [{ to: ADDRESS }])))).rejects.toMatchObject({ + statusCode: 429, + statusMessage: 'Too Many Requests', + }) + + expect(mocks.consume).toHaveBeenCalledWith(expect.anything(), 10) + expect(mocks.fetch).not.toHaveBeenCalled() + }) + + it.each([ + ['missing body', makeEvent(), 400], + ['empty batch', makeEvent([]), 400], + ['oversized batch', makeEvent(Array.from({ length: 101 }, (_, id) => rpcRequest('eth_blockNumber', undefined, id))), 400], + ['invalid JSON-RPC request', makeEvent({ jsonrpc: '1.0', id: 1, method: 'eth_call' }), 403], + ['disallowed method', makeEvent(rpcRequest('eth_sendRawTransaction', ['0x00'])), 403], + ['invalid chain', makeEvent(rpcRequest('eth_blockNumber'), { chainId: 'invalid' }), 400], + ['invalid HTTP method', makeEvent(rpcRequest('eth_blockNumber'), { method: 'GET' }), 405], + ])('preserves %s validation before rate limiting', async (_label, event, statusCode) => { + await expect(handler(event)).rejects.toMatchObject({ statusCode }) + + expect(mocks.consume).not.toHaveBeenCalled() + expect(mocks.fetch).not.toHaveBeenCalled() + }) + + it('preserves the validated request body and upstream response metadata', async () => { + const request = rpcRequest('eth_getBalance', [ADDRESS, 'latest']) + const event = makeEvent(request) + + await expect(handler(event)).resolves.toContain('"result":"0x1"') + + expect(mocks.fetch).toHaveBeenCalledWith('https://rpc.example', expect.objectContaining({ + method: 'POST', + body: JSON.stringify(request), + headers: { 'content-type': 'application/json' }, + })) + expect(event.context.status).toBe(200) + expect(event.context.responseHeaders?.['content-type']).toBe('application/json') + }) +})