import { Challenge } from 'mppx' import { sessionManager } from 'mppx/client' import { ZoneRpcAuthentication } from 'ox/tempo' import { Actions } from 'viem/tempo' import * as TestApp from '../../../../test/App.js' import * as Mppx from '../../../../test/Mppx.js' import * as Runtime from '../../../../test/runtime.js' import * as Tempo from '../../../../test/Tempo.js' import * as Auth from '../../../internal/Auth.js' import * as Tidx from '../../../internal/Tidx.js' import * as Viem from '../../../internal/Viem.js' import * as Indexer from './indexer.js' const runtime = Runtime.get() const chainId = String(runtime.chainId) describe('GET /indexer/query', () => { test('proxies a query to the upstream indexer', async () => { const client = TestApp.client() const response = await client.v1.indexer.query.$get( // testClient route inputs are query strings, not numbers. { query: { sql: 'select hash from txs limit 1', chainId } }, TestApp.auth, ) const body = await TestApp.json(response, Indexer.schema.Response) expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.get('content-type')).toMatchInlineSnapshot(`"application/json"`) // A successful response proves the proxy injected TIDX basic auth upstream. expect(body.ok).toMatchInlineSnapshot(`true`) expect(body.columns).toMatchInlineSnapshot(` [ "hash", ] `) expect(body.row_count).toMatchInlineSnapshot(`1`) expect(Array.isArray(body.rows)).toMatchInlineSnapshot(`true`) }) test('escaped string literals round-trip through the real indexer', async () => { // Pins the upstream's accepted escape form: TIDX's ANSI SQL parser only // accepts quote doubling (`''`) inside string literals and rejects // backslash escaping with a parse error, so this fails if `Tidx.escape` // ever regresses to a form the indexer cannot parse. const client = TestApp.client() const value = "a'b\\c" const response = await client.v1.indexer.query.$get( { query: { sql: `select '${Tidx.escape(value)}' as x`, chainId, }, }, TestApp.auth, ) const body = await TestApp.json(response, Indexer.schema.Response) expect(response.status).toMatchInlineSnapshot(`200`) expect(body.ok).toMatchInlineSnapshot(`true`) expect(body.rows[0]?.[0]).toBe(value) }) test('routes Zone queries to the Zone TIDX with its basic auth', async () => { const zone = runtime.zone.chainId const zoneReader = { id: 'key_zone_indexer', orgId: 'org_test', scopes: ['data:read', 'indexer:query', `zone:${zone}:read`], token: 'secret_zone_indexer', } satisfies TestApp.kvStore.Key const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ columns: [], ok: true, row_count: 0, rows: [] }), { headers: { 'content-type': 'application/json' }, }), ) try { const client = TestApp.client({ auth: { keys: [zoneReader] }, tidx: { auth: JSON.stringify({ [zone]: 'zone:secret' }) }, zones: [ TestApp.zone({ chainId: zone, rpcUrl: runtime.zone.publicZoneUrl, tidxUrl: 'https://zone.tidx.test', }), ], }) const response = await client.v1.indexer.query.$get( { query: { chainId: String(zone), sql: 'select 1' } }, { headers: { authorization: `Bearer ${zoneReader.token}`, [ZoneRpcAuthentication.headerName]: 'zone-rpc-secret', }, }, ) const upstream = fetch.mock.calls[0]![0] as Request expect(response.status).toBe(200) expect(new URL(upstream.url).origin).toBe('https://zone.tidx.test') expect(upstream.headers.get('authorization')).toBe('Basic em9uZTpzZWNyZXQ=') expect(upstream.headers.get(ZoneRpcAuthentication.headerName)).toBeNull() } finally { fetch.mockRestore() } }) test('routes Zone queries to the Zone TIDX with its bearer auth', async () => { const zone = runtime.zone.chainId const zoneReader = { id: 'key_zone_bearer_indexer', orgId: 'org_test', scopes: ['data:read', 'indexer:query', `zone:${zone}:read`], token: 'secret_zone_bearer_indexer', } satisfies TestApp.kvStore.Key const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ columns: [], ok: true, row_count: 0, rows: [] }), { headers: { 'content-type': 'application/json' }, }), ) try { const client = TestApp.client({ auth: { keys: [zoneReader] }, tidx: { auth: 'zone-secret', baseUrl: 'https://zone.tidx.test' }, zones: [TestApp.zone({ chainId: zone, rpcUrl: runtime.zone.publicZoneUrl })], }) const response = await client.v1.indexer.query.$get( { query: { chainId: String(zone), sql: 'select 1' } }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) const upstream = fetch.mock.calls[0]![0] as Request expect(response.status).toBe(200) expect(new URL(upstream.url).origin).toBe('https://zone.tidx.test') expect(upstream.headers.get('authorization')).toBe('Bearer zone-secret') } finally { fetch.mockRestore() } }) test('bounds and caches buffered queries', async () => { // Stub the upstream so the injected execution bounds are observable on the // outgoing request; the auth path is in-memory and never touches fetch. const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ columns: [], ok: true, row_count: 0, rows: [] }), { headers: { 'content-type': 'application/json' }, }), ) try { const client = TestApp.client() const response = await client.v1.indexer.query.$get( { query: { sql: 'select 1', chainId } }, { headers: { 'x-api-key': TestApp.key.token } }, ) expect(response.status).toMatchInlineSnapshot(`200`) // Buffered (non-`live`) queries are response-cached on the `feed` tier. expect(response.headers.get('cache-control')).toMatchInlineSnapshot( `"private, max-age=10, stale-while-revalidate=30"`, ) // Tempo API enforces the documented execution bounds upstream even when the // caller omits them. const upstream = new URL((fetch.mock.calls[0]![0] as Request).url) expect((fetch.mock.calls[0]![0] as Request).headers.get('x-api-key')).toBeNull() expect(upstream.searchParams.get('timeout_ms')).toMatchInlineSnapshot(`"5000"`) expect(upstream.searchParams.get('limit')).toMatchInlineSnapshot(`"10000"`) } finally { fetch.mockRestore() } }) test('forwards chain aliases to the upstream as numeric ids', async () => { const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ columns: [], ok: true, row_count: 0, rows: [] }), { headers: { 'content-type': 'application/json' }, }), ) try { const client = TestApp.client() const response = await client.v1.indexer.query.$get( { query: { chainId: 'mainnet', sql: 'select 2' } }, TestApp.auth, ) expect(response.status).toMatchInlineSnapshot(`200`) const upstream = new URL((fetch.mock.calls[0]![0] as Request).url) expect(upstream.searchParams.get('chainId')).toMatchInlineSnapshot(`"4217"`) } finally { fetch.mockRestore() } }) test('requires authentication', async () => { const client = TestApp.client() const response = await client.v1.indexer.query.$get( { query: { sql: 'select 1' } }, { headers: { 'tempo-api-key': 'wrong' } }, ) expect(response.status).toMatchInlineSnapshot(`401`) }) test('does not expose anonymous access', () => { expect(Auth.describeAccess(Indexer.indexer())['indexerQuery']).toMatchInlineSnapshot(` { "apiKey": true, "mpp": true, "public": false, "scopes": [ "indexer:query", ], "session": false, } `) }) test('proxies without auth middleware', async () => { const client = TestApp.client({ auth: false }) const response = await client.v1.indexer.query.$get({ query: { sql: 'select hash from txs limit 1', chainId }, }) const body = await TestApp.json(response, Indexer.schema.Response) expect(response.status).toMatchInlineSnapshot(`200`) expect(body.ok).toMatchInlineSnapshot(`true`) }) test('challenges anonymous callers for MPP payment', { retry: 1 }, async () => { const app = TestApp.create({ auth: { mpp: { secretKey: 'secret_test_key_0123456789abcdef', session: { chainId: Tempo.chain.id, currency: Tempo.currency, decimals: 6, getClient: () => Tempo.client, recipient: Tempo.accounts[2].address, }, }, }, }) // MPP challenges and receipts ride on real HTTP headers, so drive the // lifecycle through `app.fetch` rather than the typed route client. const challenge = await app.fetch( new Request('http://tempo-api.test/v1/indexer/query?sql=select%201'), ) expect(challenge.status).toMatchInlineSnapshot(`402`) expect(challenge.headers.get('www-authenticate')?.startsWith('Payment ')).toMatchInlineSnapshot( `true`, ) // Paying the challenge unlocks a real proxied query and returns a receipt. const sql = encodeURIComponent('select hash from txs limit 1') const paid = await Mppx.createClient(app).fetch( `http://tempo-api.test/v1/indexer/query?sql=${sql}&chainId=${chainId}`, ) const body = await TestApp.json(paid, Indexer.schema.Response) expect(paid.status).toMatchInlineSnapshot(`200`) expect(paid.headers.has('payment-receipt')).toMatchInlineSnapshot(`true`) expect(body.ok).toMatchInlineSnapshot(`true`) }) test('rejects an unsupported chain before issuing an MPP challenge', async () => { const app = TestApp.create({ auth: { mpp: { secretKey: 'secret_test_key_0123456789abcdef', session: { chainId: Tempo.chain.id, currency: Tempo.currency, decimals: 6, getClient: () => Tempo.client, recipient: Tempo.accounts[2].address, }, }, }, }) const response = await app.fetch( new Request('http://tempo-api.test/v1/indexer/query?sql=select%201&chainId=31318'), ) const body = (await response.json()) as { error: { code: string } } expect(response.status).toBe(400) expect(response.headers.has('www-authenticate')).toBe(false) expect(body.error.code).toBe('chain_id_unsupported') }) test('accepts session management POSTs', { timeout: 120_000 }, async () => { const app = TestApp.create({ auth: { mpp: { secretKey: 'secret_test_key_0123456789abcdef', session: { account: Tempo.accounts[2], chainId: Viem.chainId.mainnet, currency: Tempo.currency, decimals: 6, getClient: () => Tempo.client, suggestedDeposit: '0.01', }, }, }, }) await Actions.faucet.fundSync(Tempo.client, { account: Tempo.accounts[2], timeout: 60_000, }) const url = `http://tempo-api.test/v1/indexer/query?sql=select%201&chainId=${chainId}` const challenged = await app.fetch(new Request(url)) expect(challenged.status).toMatchInlineSnapshot(`402`) expect(Challenge.fromHeaders(challenged.headers).request).toMatchObject({ amount: '100', suggestedDeposit: '10000', }) const manager = sessionManager({ account: Tempo.accounts[1], client: Tempo.client, fetch: async (input, init) => app.fetch(new Request(input, init)), }) const paid = await manager.fetch(url) expect(paid.status).toMatchInlineSnapshot(`200`) const toppedUp = await manager.topUp('0.01') if (!toppedUp) throw new Error('expected top-up receipt') expect(toppedUp.status).toMatchInlineSnapshot(`"success"`) const closed = await manager.close() if (!closed) throw new Error('expected close receipt') expect(closed.status).toMatchInlineSnapshot(`"success"`) expect(closed.channelId).toBe(toppedUp.channelId) const state = await Actions.channel.getStates(Tempo.client, { channel: closed.channelId, }) expect(state.deposit).toMatchInlineSnapshot(`0n`) }) })