import { type Db, Webhooks as Core } from 'tapimo' import * as Cursor from '../../../internal/Cursor.js' import * as TestApp from '../../../../test/App.js' import * as Mppx from '../../../../test/Mppx.js' import * as Tempo from '../../../../test/Tempo.js' import * as Webhooks from './webhooks.js' /** API key with both webhook scopes, owned by `org_test`. */ const writer = { id: 'key_writer', orgId: 'org_test', scopes: ['webhooks:read', 'webhooks:write'], token: 'secret_writer', } satisfies TestApp.kvStore.Key /** API key with read-only webhook scope, owned by `org_test`. */ const reader = { id: 'key_reader', orgId: 'org_test', scopes: ['webhooks:read'], token: 'secret_reader', } satisfies TestApp.kvStore.Key /** API key in a different org, used to prove owner isolation. */ const other = { id: 'key_other', orgId: 'org_other', scopes: ['webhooks:read', 'webhooks:write'], token: 'secret_other', } satisfies TestApp.kvStore.Key /** Creates a webhooks-enabled test client backed by an in-memory state db. */ function client(keys: readonly TestApp.kvStore.Key[] = [writer, reader, other]) { return TestApp.client({ auth: { keys }, }) } /** RequestInit carrying a bearer token for the given key. */ function as(key: { token: string }) { return { headers: { authorization: `Bearer ${key.token}` } } as const } /** Minimal valid creation payload. */ const validBody = { destination: { type: 'url', url: 'https://example.com/hook' }, eventType: 'token:transfer', } as const describe('log filter selectivity', () => { const address = '0x0000000000000000000000000000000000000001' const signature = 'event Transfer(address indexed from, address indexed to, uint256 value)' const topic0 = `0x${'11'.repeat(32)}` const topic1 = `0x${'22'.repeat(32)}` test('rejects a negated address as the only firehose guard', () => { const parsed = Webhooks.schema.LogFilters.safeParse({ address: { not: address }, }) expect(parsed.success).toBe(false) }) test('accepts pushdown-capable address filters', () => { expect(Webhooks.schema.LogFilters.safeParse({ address }).success).toBe(true) expect(Webhooks.schema.LogFilters.safeParse({ address: { eq: address } }).success).toBe(true) expect(Webhooks.schema.LogFilters.safeParse({ address: { in: [address] } }).success).toBe(true) }) test('requires pushdown-capable raw topics', () => { expect(Webhooks.schema.LogFilters.safeParse({ topic0, topic1 }).success).toBe(true) expect(Webhooks.schema.LogFilters.safeParse({ topic0, topic1: { not: topic1 } }).success).toBe( false, ) }) test('requires a pushdown-capable indexed argument', () => { expect( Webhooks.schema.LogFilters.safeParse({ args: { from: address }, signature }).success, ).toBe(true) expect(Webhooks.schema.LogFilters.safeParse({ args: {}, signature }).success).toBe(false) expect( Webhooks.schema.LogFilters.safeParse({ args: { value: '0x1' }, signature }).success, ).toBe(false) expect( Webhooks.schema.LogFilters.safeParse({ args: { from: { eq: { and: [{ eq: address }] } } }, signature, }).success, ).toBe(false) }) }) describe('feature gate', () => { test('returns 404 when webhooks are disabled', async () => { // Routes stay mounted for a stable schema, but the resource is disabled. // `event-types` requires `data:read`, so seed a key that holds it to reach // the feature gate rather than tripping the scope check first. const dataReader = { id: 'key_data_reader', orgId: 'org_test', scopes: ['data:read'], token: 'secret_data_reader', } satisfies TestApp.kvStore.Key const disabled = TestApp.client({ auth: { keys: [dataReader] }, webhook: false }) const response = await disabled.v1.webhooks['event-types'].$get({}, as(dataReader)) expect(response.status).toMatchInlineSnapshot(`404`) const body = (await response.json()) as { error?: { code?: string } } expect(body.error?.code).toMatchInlineSnapshot(`"webhooks_not_enabled"`) }) test('does not register funding events without a host producer', async () => { const app = TestApp.client({ auth: { keys: [writer] }, webhook: { supportedChainIds: [4217, 42431] }, }) const catalog = await app.v1.webhooks['event-types'].$get({}) const catalogBody = await TestApp.json(catalog, Webhooks.schema.getWebhookEventTypes.Response) const created = await app.v1.webhooks.$post( { json: { destination: { type: 'url', url: 'https://example.com/funding' }, eventType: 'funding:deposit.updated', }, }, as(writer), ) expect(catalogBody.data.map((eventType) => eventType.type)).not.toContain( 'funding:deposit.updated', ) expect(catalogBody.data.map((eventType) => eventType.type)).not.toContain( 'funding:transfer.updated', ) expect(created.status).toBe(400) }) }) describe('access matrix', () => { test('rejects anonymous reads with 401 even when MPP is enabled (API-key-only)', async () => { // Webhooks opt out of the paid (MPP) lane for now, so an anonymous caller on // a `public: false` route (`GET /webhooks`) is rejected outright rather than // offered the paid path — even though MPP is enabled app-wide by default. const response = await client().v1.webhooks.$get({ query: {} }) expect(response.status).toMatchInlineSnapshot(`401`) expect(response.headers.has('www-authenticate')).toMatchInlineSnapshot(`false`) }) test('rejects an invalid key', async () => { const response = await client().v1.webhooks.$get( { query: {} }, { headers: { authorization: 'Bearer nope' } }, ) expect(response.status).toMatchInlineSnapshot(`401`) }) test('rejects a key missing the read scope', async () => { const noScope = { id: 'key_noscope', orgId: 'org_test', scopes: ['data:read'], token: 'secret_noscope', } satisfies TestApp.kvStore.Key const response = await client([noScope]).v1.webhooks.$get({ query: {} }, as(noScope)) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toMatchInlineSnapshot(`403`) expect(body.error?.code).toMatchInlineSnapshot(`"api_key_forbidden"`) }) test('rejects a read-only key on writes', async () => { const response = await client().v1.webhooks.$post({ json: validBody }, as(reader)) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toMatchInlineSnapshot(`403`) expect(body.error?.code).toMatchInlineSnapshot(`"api_key_forbidden"`) }) test('rejects Zone webhook subscriptions', async () => { const zone = 421_700_001 const app = TestApp.client({ auth: { keys: [writer] }, zones: [TestApp.zone({ chainId: zone, rpcUrl: 'https://zone.rpc.test' })], }) const response = await app.v1.webhooks.$post( { json: { ...validBody, chainId: zone } }, as(writer), ) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toBe(400) expect(body.error?.code).toBe('chain_id_unsupported') }) test('lists event types without authentication (public catalog)', async () => { // `GET /webhooks/event-types` is intentionally public: anonymous callers are // served within the public quota, no key or scope required. const response = await client().v1.webhooks['event-types'].$get({}) const body = await TestApp.json(response, Webhooks.schema.getWebhookEventTypes.Response) expect(response.status).toMatchInlineSnapshot(`200`) // The catalog is static public data and must be cacheable (`stable`), // overriding the resource-wide `no-store` default for subscriptions — // without it every request pays the full auth/rate-limit floor. expect(response.headers.get('cache-control')).toMatchInlineSnapshot( `"private, max-age=300, stale-while-revalidate=3600"`, ) expect(body.data.map((entry) => entry.type)).toMatchInlineSnapshot(` [ "block:created", "funding:deposit.updated", "funding:transfer.updated", "log:emitted", "token:transfer", "transaction:included", ] `) }) test('lists event types for a key with the data:read scope', async () => { const dataReader = { id: 'key_data_reader', orgId: 'org_test', scopes: ['data:read'], token: 'secret_data_reader', } satisfies TestApp.kvStore.Key const response = await client([dataReader]).v1.webhooks['event-types'].$get({}, as(dataReader)) expect(response.status).toMatchInlineSnapshot(`200`) }) test('rejects a key lacking the data:read scope', async () => { const response = await client().v1.webhooks['event-types'].$get({}, as(reader)) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toMatchInlineSnapshot(`403`) expect(body.error?.code).toMatchInlineSnapshot(`"api_key_forbidden"`) }) }) describe('CRUD lifecycle', () => { test('requires funding read access to create a funding deposit subscription', async () => { const webhookWriter = { id: 'key_funding_webhook_only', orgId: 'org_funding_webhook_only', scopes: ['webhooks:write'], token: 'secret_funding_webhook_only', } satisfies TestApp.kvStore.Key const app = TestApp.client({ auth: { keys: [webhookWriter] } }) const response = await app.v1.webhooks.$post( { json: { destination: { type: 'url', url: 'https://example.com/funding' }, eventType: 'funding:deposit.updated', }, }, as(webhookWriter), ) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toBe(403) expect(body.error?.code).toBe('api_key_forbidden') }) test('captures funding transfer ownership scope without reading the RPC head', async () => { const db = TestApp.database() const projectWriter = { id: 'key_funding_webhook_project', orgId: 'org_funding_webhook', projectId: 'prj_funding_webhook', scopes: ['funding:read', 'webhooks:write'], token: 'secret_funding_webhook_project', } satisfies TestApp.kvStore.Key const app = TestApp.client({ auth: { keys: [projectWriter] }, db }) const response = await app.v1.webhooks.$post( { json: { destination: { type: 'url', url: 'https://example.com/funding' }, eventType: 'funding:transfer.updated', filters: { status: 'completed' }, }, }, as(projectWriter), ) const body = await TestApp.json(response, Webhooks.schema.createWebhook.Response) const stored = await Core.getSubscription( db, { orgId: projectWriter.orgId, type: 'api_key' }, body.id, ) expect(response.status).toBe(200) expect(stored).toMatchObject({ environment: 'production', projectId: projectWriter.projectId, }) expect(await Core.getCursor(db, body.id)).toBeNull() }) test('isolates funding subscriptions by API-key environment and project', async () => { const db = TestApp.database() const shared = { orgId: 'org_funding_access', scopes: ['funding:read', 'webhooks:read', 'webhooks:write'], } as const const projectA = { ...shared, id: 'key_funding_project_a', projectId: 'prj_funding_a', token: 'secret_funding_project_a', } satisfies TestApp.kvStore.Key const projectB = { ...shared, id: 'key_funding_project_b', projectId: 'prj_funding_b', token: 'secret_funding_project_b', } satisfies TestApp.kvStore.Key const sandboxProjectB = { ...projectB, environment: 'sandbox', id: 'key_funding_project_b_sandbox', token: 'secret_funding_project_b_sandbox', } satisfies TestApp.kvStore.Key const projectBWebhookReader = { ...projectB, id: 'key_funding_project_b_webhook_reader', scopes: ['webhooks:read'], token: 'secret_funding_project_b_webhook_reader', } satisfies TestApp.kvStore.Key const organization = { ...shared, id: 'key_funding_organization', token: 'secret_funding_organization', } satisfies TestApp.kvStore.Key const app = TestApp.client({ auth: { keys: [organization, projectA, projectB, projectBWebhookReader, sandboxProjectB], }, db, }) const subscription = await Core.createSubscription(db, { chainId: 4217, destination: { type: 'url', url: 'https://example.com/project-b' }, environment: 'production', eventType: 'funding:transfer.updated', owner: { orgId: shared.orgId, type: 'api_key' }, projectId: projectB.projectId, }) const listed = await app.v1.webhooks.$get({ query: { include: 'totalCount' } }, as(projectA)) const listedBody = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response) const hidden = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get( { param: { id: subscription.id } }, as(projectA), ) const sandboxHidden = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get( { param: { id: subscription.id } }, as(sandboxProjectB), ) const scopeHidden = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get( { param: { id: subscription.id } }, as(projectBWebhookReader), ) const patchHidden = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch( { json: { status: 'paused' }, param: { id: subscription.id } }, as(projectA), ) const deleteHidden = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$delete( { param: { id: subscription.id } }, as(projectA), ) const organizationRead = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get( { param: { id: subscription.id } }, as(organization), ) expect(listed.status).toBe(200) expect(listedBody).toMatchObject({ data: [], meta: { totalCount: 0 } }) expect(hidden.status).toBe(404) expect(sandboxHidden.status).toBe(404) expect(scopeHidden.status).toBe(404) expect(patchHidden.status).toBe(404) expect(deleteHidden.status).toBe(404) expect(organizationRead.status).toBe(200) }) test('creates, reads, lists, updates, and deletes a subscription', async () => { const db = TestApp.database() const app = TestApp.client({ auth: { keys: [writer, reader, other] }, db, }) // Create returns the one-time signing secret. const created = await app.v1.webhooks.$post({ json: validBody }, as(writer)) const createdBody = await TestApp.json(created, Webhooks.schema.createWebhook.Response) expect(created.status).toMatchInlineSnapshot(`200`) expect(createdBody.id.startsWith('wh_')).toBe(true) expect(createdBody.secret.startsWith('whsec_')).toBe(true) expect(createdBody.status).toMatchInlineSnapshot(`"active"`) expect(createdBody.destination.url).toMatchInlineSnapshot(`"https://example.com/hook"`) // API-key-owned subscriptions never expire. expect(createdBody.expiresAt).toMatchInlineSnapshot(`undefined`) expect(createdBody).not.toHaveProperty('owner') expect(Cursor.decode((await Core.getCursor(db, createdBody.id))!, ['int'])).toEqual([ expect.any(Number), ]) // Reads never expose the secret. const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get( { param: { id: createdBody.id } }, as(writer), ) const readBody = await TestApp.json(read, Webhooks.schema.getWebhook.Response) expect(read.status).toMatchInlineSnapshot(`200`) expect(readBody.id).toBe(createdBody.id) expect(readBody).not.toHaveProperty('secret') expect(read.headers.get('cache-control')).toMatchInlineSnapshot(`"no-store"`) // List shows the subscription. const listed = await app.v1.webhooks.$get({ query: {} }, as(writer)) const listedBody = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response) expect(listed.status).toMatchInlineSnapshot(`200`) expect(listedBody.data.map((entry) => entry.id)).toContain(createdBody.id) // Patch pauses it. const patched = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch( { json: { status: 'paused' }, param: { id: createdBody.id } }, as(writer), ) const patchedBody = await TestApp.json(patched, Webhooks.schema.updateWebhook.Response) expect(patched.status).toMatchInlineSnapshot(`200`) expect(patchedBody.status).toMatchInlineSnapshot(`"paused"`) // Delete removes it, after which reads 404. const deleted = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$delete( { param: { id: createdBody.id } }, as(writer), ) const deletedBody = await TestApp.json(deleted, Webhooks.schema.deleteWebhook.Response) expect(deleted.status).toMatchInlineSnapshot(`200`) expect(deletedBody.id).toBe(createdBody.id) const missing = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get( { param: { id: createdBody.id } }, as(writer), ) expect(missing.status).toMatchInlineSnapshot(`404`) }) test('embeds the exact subscription total via include=totalCount', async () => { const app = client() // Two subscriptions under the writer's owner. await app.v1.webhooks.$post({ json: validBody }, as(writer)) await app.v1.webhooks.$post({ json: validBody }, as(writer)) const listed = await app.v1.webhooks.$get( { query: { include: 'totalCount', limit: '5' } }, as(writer), ) const body = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response) expect(listed.status).toBe(200) // The count is the owner total, exact. expect(body.data.length).toBe(2) expect(body.meta).toEqual({ totalCountCapped: false, totalCount: 2 }) // Without the include, `meta` is omitted. const bare = await app.v1.webhooks.$get({ query: { limit: '5' } }, as(writer)) const bareBody = await TestApp.json(bare, Webhooks.schema.listWebhooks.Response) expect(bareBody.meta).toBeUndefined() }) test('a sibling key in the same org manages the org subscription', async () => { const app = client() const created = await app.v1.webhooks.$post({ json: validBody }, as(writer)) const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response) // `reader` shares `org_test`, so it can read but not mutate (scope-gated). const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get({ param: { id } }, as(reader)) expect(read.status).toMatchInlineSnapshot(`200`) }) }) describe('owner isolation', () => { test('a different org cannot see or mutate another org subscription', async () => { const app = client() const created = await app.v1.webhooks.$post({ json: validBody }, as(writer)) const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response) const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get({ param: { id } }, as(other)) expect(read.status).toMatchInlineSnapshot(`404`) const removed = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$delete( { param: { id } }, as(other), ) expect(removed.status).toMatchInlineSnapshot(`404`) const listed = await app.v1.webhooks.$get({ query: {} }, as(other)) const listedBody = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response) expect(listedBody.data).toMatchInlineSnapshot(`[]`) }) }) describe('subscription limits', () => { test('rejects creation past the configured per-owner cap with 403 limit_exceeded', async () => { const app = TestApp.client({ auth: { keys: [writer, other] }, webhook: { maxPerOwner: 2, supportedChainIds: [4217, 42431] }, }) const request = { chainId: 4217, ...validBody } // Fill the writer's quota. for (let i = 0; i < 2; i++) { const ok = await app.v1.webhooks.$post({ json: request }, as(writer)) expect(ok.status).toMatchInlineSnapshot(`200`) } // The third create is over the cap. const over = await app.v1.webhooks.$post({ json: request }, as(writer)) expect(over.status).toMatchInlineSnapshot(`403`) const body = (await over.json()) as { error?: { code?: string } } expect(body.error?.code).toMatchInlineSnapshot(`"limit_exceeded"`) // The cap is per-owner: a different org is unaffected. const otherOrg = await app.v1.webhooks.$post({ json: request }, as(other)) expect(otherOrg.status).toMatchInlineSnapshot(`200`) }) }) describe('delivery log', () => { /** A delivery row with a lexically-sortable id, mirroring the core test helper. */ function row(subscriptionId: string, n: number): Core.Delivery { return { attempt: n, createdAt: new Date(1_700_000_000_000 + n * 1_000).toISOString(), envelope: { chainId: 4217, createdAt: new Date(1_700_000_000_000 + n * 1_000).toISOString(), data: { amount: String(n) }, id: `evt_${n}`, subscriptionId, type: 'token:transfer', }, eventId: `evt_${n}`, id: `whd_${String(1_700_000_000_000 + n * 1_000).padStart(15, '0')}_${'0'.repeat(16)}`, requestUrl: 'https://example.com/hook', status: 'succeeded', subscriptionId, } } /** Seeds a subscription owned by `writer` plus `count` delivery rows. */ async function seed(db: Db.Db, count: number) { const app = TestApp.client({ auth: { keys: [writer, reader, other] }, db, }) const created = await app.v1.webhooks.$post({ json: validBody }, as(writer)) const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response) for (let n = 1; n <= count; n++) await Core.recordDelivery(db, row(id, n)) return { app, id } } test('lists a subscription deliveries newest-first', async () => { const db = TestApp.database() const { app, id } = await seed(db, 3) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: {} }, as(writer), ) expect(response.status).toMatchInlineSnapshot(`200`) const body = await TestApp.json(response, Webhooks.schema.listWebhookDeliveries.Response) expect(body.data.map((d) => d.eventId)).toMatchInlineSnapshot(` [ "evt_3", "evt_2", "evt_1", ] `) expect(body.nextCursor).toMatchInlineSnapshot(`null`) }) test('embeds the exact delivery total via include=totalCount', async () => { const db = TestApp.database() const { app, id } = await seed(db, 8) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: { include: 'totalCount', limit: '5' } }, as(writer), ) const body = await TestApp.json(response, Webhooks.schema.listWebhookDeliveries.Response) expect(response.status).toBe(200) // The count is the full delivery total, independent of the page `limit`. expect(body.data.length).toBe(5) expect(body.meta).toEqual({ totalCountCapped: false, totalCount: 8 }) // Without the include, `meta` is omitted. const bare = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: {} }, as(writer), ) const bareBody = await TestApp.json(bare, Webhooks.schema.listWebhookDeliveries.Response) expect(bareBody.meta).toBeUndefined() }) test('paginates with cursor and limit', async () => { const db = TestApp.database() const { app, id } = await seed(db, 6) const first = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: { limit: '5' } }, as(writer), ) const firstBody = await TestApp.json(first, Webhooks.schema.listWebhookDeliveries.Response) expect(firstBody.data.map((d) => d.eventId)).toEqual([ 'evt_6', 'evt_5', 'evt_4', 'evt_3', 'evt_2', ]) const second = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: { cursor: firstBody.nextCursor!, limit: '5' } }, as(writer), ) const secondBody = await TestApp.json(second, Webhooks.schema.listWebhookDeliveries.Response) expect(secondBody.data.map((d) => d.eventId)).toEqual(['evt_1']) }) test('serves a positional page via `page` (deterministic in-memory slice)', async () => { const db = TestApp.database() const { app, id } = await seed(db, 6) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: { limit: '5', page: '2' } }, as(writer), ) const body = await TestApp.json(response, Webhooks.schema.listWebhookDeliveries.Response) expect(response.status).toMatchInlineSnapshot(`200`) // Newest-first feed of evt_6..evt_1; page 2 at limit 5 holds the last row. expect(body.data.map((d) => d.eventId)).toEqual(['evt_1']) }) test('rejects `page` combined with `cursor`', async () => { const db = TestApp.database() const { app, id } = await seed(db, 6) const first = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: { limit: '5' } }, as(writer), ) const firstBody = await TestApp.json(first, Webhooks.schema.listWebhookDeliveries.Response) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: { cursor: firstBody.nextCursor!, page: '2' } }, as(writer), ) expect(response.status).toMatchInlineSnapshot(`400`) }) test('a read-only key can list deliveries', async () => { const db = TestApp.database() const { app, id } = await seed(db, 1) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: {} }, as(reader), ) expect(response.status).toMatchInlineSnapshot(`200`) }) test('a different org cannot read another org deliveries', async () => { const db = TestApp.database() const { app, id } = await seed(db, 1) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: {} }, as(other), ) expect(response.status).toMatchInlineSnapshot(`404`) }) test('404s for an unknown subscription', async () => { const app = client() const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id: 'wh_missing' }, query: {} }, as(writer), ) expect(response.status).toMatchInlineSnapshot(`404`) }) }) describe('SSRF protection', () => { test('rejects a non-https callback URL', async () => { const response = await client().v1.webhooks.$post( { json: { ...validBody, destination: { type: 'url', url: 'http://example.com/hook' } } }, as(writer), ) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toMatchInlineSnapshot(`400`) expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`) }) test('rejects the cloud metadata host', async () => { const response = await client().v1.webhooks.$post( { json: { ...validBody, destination: { type: 'url', url: 'https://169.254.169.254/latest/meta-data' }, }, }, as(writer), ) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toMatchInlineSnapshot(`400`) expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`) }) }) describe('subscription context', () => { test('round-trips context through create and read', async () => { const app = TestApp.client({ auth: { keys: [writer] }, }) const context = { description: 'Notify #ops when a large USDC transfer settles.', metadata: { env: 'prod', team: 'payments' }, title: 'Prod USDC large transfers', } const created = await app.v1.webhooks.$post( { json: { context, destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx' }, eventType: 'token:transfer', }, }, as(writer), ) expect(created.status).toBe(200) const body = await TestApp.json(created, Webhooks.schema.createWebhook.Response) expect(body.context).toEqual(context) const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get( { param: { id: body.id } }, as(writer), ) const readBody = await TestApp.json(read, Webhooks.schema.getWebhook.Response) expect(readBody.context).toEqual(context) }) test('clears context with an explicit null patch', async () => { const app = TestApp.client({ auth: { keys: [writer] }, }) const created = await app.v1.webhooks.$post( { json: { context: { title: 'Temporary' }, destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx' }, eventType: 'token:transfer', }, }, as(writer), ) const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response) const patched = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch( { json: { context: null }, param: { id } }, as(writer), ) const body = await TestApp.json(patched, Webhooks.schema.updateWebhook.Response) expect(body.context).toBeUndefined() }) test('rejects metadata that exceeds the entry limit', async () => { const app = TestApp.client({ auth: { keys: [writer] }, }) const metadata = Object.fromEntries(Array.from({ length: 21 }, (_, i) => [`key${i}`, 'value'])) const created = await app.v1.webhooks.$post( { json: { context: { metadata }, destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx' }, eventType: 'token:transfer', }, }, as(writer), ) expect(created.status).toBe(400) }) }) describe('slack destination', () => { test('creates a subscription with a slack incoming-webhook URL', async () => { const app = TestApp.client({ auth: { keys: [writer] }, }) const created = await app.v1.webhooks.$post( { json: { destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx', }, eventType: 'token:transfer', }, }, as(writer), ) expect(created.status).toMatchInlineSnapshot(`200`) const body = await TestApp.json(created, Webhooks.schema.createWebhook.Response) expect(body.destination).toMatchInlineSnapshot(` { "type": "slack", "url": "https://hooks.slack.com/services/T000/B000/xxxx", } `) }) test('rejects a slack destination whose host is not hooks.slack.com', async () => { const app = TestApp.client({ auth: { keys: [writer] }, }) const response = await app.v1.webhooks.$post( { json: { destination: { type: 'slack', url: 'https://evil.example.com/hook' }, eventType: 'token:transfer', }, }, as(writer), ) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toMatchInlineSnapshot(`400`) expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`) }) test('requires recreation before changing a provider destination to a URL', async () => { const app = client([writer]) const created = await app.v1.webhooks.$post( { json: { destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx', }, eventType: 'token:transfer', }, }, as(writer), ) const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch( { json: { destination: { type: 'url', url: 'https://example.com/webhooks' } }, param: { id }, }, as(writer), ) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'destination_transition_invalid' }, }) }) }) describe('betterstack destination', () => { test('separates redacted read destinations from write destinations', () => { const slack = { type: 'slack', url: 'https://hooks.slack.com/…' } as const const betterstack = { token: '[redacted]', type: 'betterstack', url: 'https://s1234567.eu-nbg-2.betterstackdata.com', } as const expect(Webhooks.schema.getWebhook.Response.shape.destination.safeParse(slack).success).toBe( true, ) expect( Webhooks.schema.getWebhook.Response.shape.destination.safeParse(betterstack).success, ).toBe(true) expect(Webhooks.schema.updateWebhook.Body.safeParse({ destination: slack }).success).toBe(false) expect(Webhooks.schema.updateWebhook.Body.safeParse({ destination: betterstack }).success).toBe( false, ) }) test('creates a subscription with a Better Stack source', async () => { const app = TestApp.client({ auth: { keys: [writer] }, }) const created = await app.v1.webhooks.$post( { json: { destination: { token: 'src_token', type: 'betterstack', url: 'https://s1234567.eu-nbg-2.betterstackdata.com', }, eventType: 'token:transfer', }, }, as(writer), ) expect(created.status).toMatchInlineSnapshot(`200`) const body = await TestApp.json(created, Webhooks.schema.createWebhook.Response) expect(body.destination).toMatchInlineSnapshot(` { "token": "src_token", "type": "betterstack", "url": "https://s1234567.eu-nbg-2.betterstackdata.com", } `) }) test('redacts destination bearer credentials from read responses', async () => { const app = TestApp.client({ auth: { keys: [writer, reader] } }) const slack = await app.v1.webhooks.$post( { json: { destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/secret', }, eventType: 'token:transfer', }, }, as(writer), ) const betterstack = await app.v1.webhooks.$post( { json: { destination: { token: 'src_secret', type: 'betterstack', url: 'https://s1234567.eu-nbg-2.betterstackdata.com', }, eventType: 'token:transfer', }, }, as(writer), ) const slackCreated = await TestApp.json(slack, Webhooks.schema.createWebhook.Response) const betterstackCreated = await TestApp.json( betterstack, Webhooks.schema.createWebhook.Response, ) expect(slackCreated.destination).toStrictEqual({ type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/secret', }) expect(betterstackCreated.destination).toStrictEqual({ token: 'src_secret', type: 'betterstack', url: 'https://s1234567.eu-nbg-2.betterstackdata.com', }) const slackRead = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get( { param: { id: slackCreated.id } }, as(reader), ) const betterstackRead = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get( { param: { id: betterstackCreated.id } }, as(reader), ) const slackBody = await TestApp.json(slackRead, Webhooks.schema.getWebhook.Response) const betterstackBody = await TestApp.json(betterstackRead, Webhooks.schema.getWebhook.Response) expect(slackBody.destination).toStrictEqual({ type: 'slack', url: 'https://hooks.slack.com/…', }) expect(betterstackBody.destination).toStrictEqual({ token: '[redacted]', type: 'betterstack', url: 'https://s1234567.eu-nbg-2.betterstackdata.com', }) const listed = await app.v1.webhooks.$get({ query: {} }, as(reader)) const listedBody = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response) expect( listedBody.data.find((subscription) => subscription.id === slackCreated.id)?.destination, ).toStrictEqual(slackBody.destination) expect( listedBody.data.find((subscription) => subscription.id === betterstackCreated.id) ?.destination, ).toStrictEqual(betterstackBody.destination) const slackUpdated = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch( { json: { status: 'paused' }, param: { id: slackCreated.id } }, as(writer), ) const betterstackUpdated = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch( { json: { status: 'paused' }, param: { id: betterstackCreated.id } }, as(writer), ) expect( (await TestApp.json(slackUpdated, Webhooks.schema.updateWebhook.Response)).destination, ).toStrictEqual(slackBody.destination) expect( (await TestApp.json(betterstackUpdated, Webhooks.schema.updateWebhook.Response)).destination, ).toStrictEqual(betterstackBody.destination) const slackRoundTrip = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch( { json: { destination: slackBody.destination }, param: { id: slackCreated.id } }, as(writer), ) const betterstackRoundTrip = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch( { json: { destination: betterstackBody.destination }, param: { id: betterstackCreated.id }, }, as(writer), ) expect(slackRoundTrip.status).toBe(400) expect(betterstackRoundTrip.status).toBe(400) }) test('rejects a Better Stack destination whose host is not betterstackdata.com', async () => { const app = TestApp.client({ auth: { keys: [writer] }, }) const response = await app.v1.webhooks.$post( { json: { destination: { token: 'src_token', type: 'betterstack', url: 'https://evil.example.com/ingest', }, eventType: 'token:transfer', }, }, as(writer), ) const body = (await response.json()) as { error?: { code?: string } } expect(response.status).toMatchInlineSnapshot(`400`) expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`) }) }) describe('ping', () => { afterEach(() => { vi.restoreAllMocks() }) /** Seeds a webhooks-enabled client with a subscription owned by `writer`. */ async function seed() { const app = TestApp.client({ auth: { keys: [writer, reader, other] }, }) const created = await app.v1.webhooks.$post({ json: validBody }, as(writer)) const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response) return { app, id } } test('delivers a synthetic ping and returns the result', async () => { const { app, id } = await seed() const calls: { signature: string | null }[] = [] vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { calls.push({ signature: new Headers(init?.headers).get('tempo-signature') }) return new Response(null, { status: 200 }) }) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post( { param: { id } }, as(writer), ) expect(response.status).toMatchInlineSnapshot(`200`) const body = await TestApp.json(response, Webhooks.schema.pingWebhook.Response) expect(body.delivered).toMatchInlineSnapshot(`true`) expect(body.responseStatus).toMatchInlineSnapshot(`200`) expect(body.eventId).toMatch(/^evt_[0-9a-f]{64}$/) // A single signed request reached the endpoint. expect(calls).toHaveLength(1) expect(calls[0]?.signature).toBeTruthy() // The ping is observable in the delivery log under the same event id. const deliveries = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get( { param: { id }, query: {} }, as(writer), ) const log = await TestApp.json(deliveries, Webhooks.schema.listWebhookDeliveries.Response) expect(log.data).toHaveLength(1) expect(log.data[0]?.eventId).toBe(body.eventId) }) test('reports a failed delivery without disabling the subscription', async () => { const { app, id } = await seed() vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 500 })) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post( { param: { id } }, as(writer), ) const body = await TestApp.json(response, Webhooks.schema.pingWebhook.Response) expect(body.delivered).toMatchInlineSnapshot(`false`) expect(body.error).toMatchInlineSnapshot(`"non-2xx response (500)"`) // A failed test ping must leave a healthy subscription active. const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get({ param: { id } }, as(writer)) const subscription = await TestApp.json(read, Webhooks.schema.getWebhook.Response) expect(subscription.status).toMatchInlineSnapshot(`"active"`) expect(subscription.failureCount).toMatchInlineSnapshot(`0`) }) test('a read-only key cannot ping', async () => { const { app, id } = await seed() const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post( { param: { id } }, as(reader), ) expect(response.status).toMatchInlineSnapshot(`403`) }) test('a different org cannot ping another org subscription', async () => { const { app, id } = await seed() const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post( { param: { id } }, as(other), ) expect(response.status).toMatchInlineSnapshot(`404`) }) test('404s for an unknown subscription', async () => { const app = client() const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post( { param: { id: 'wh_missing' } }, as(writer), ) expect(response.status).toMatchInlineSnapshot(`404`) }) }) describe('retry delivery', () => { afterEach(() => { vi.restoreAllMocks() }) const deliveryId = 'whd_001700000000000_0000000000000000' /** Seeds a webhooks client with a subscription plus one logged delivery to replay. */ async function seed() { const db = TestApp.database() const app = TestApp.client({ auth: { keys: [writer, reader, other] }, db, }) const { id } = await Core.createSubscription(db, { chainId: 4217, destination: validBody.destination, eventType: validBody.eventType, owner: { orgId: writer.orgId, type: 'api_key' }, }) await Core.recordDelivery(db, { attempt: 1, createdAt: new Date(1_700_000_000_000).toISOString(), envelope: { chainId: 4217, createdAt: new Date(1_700_000_000_000).toISOString(), data: { amount: '1' }, id: 'evt_seed', subscriptionId: id, type: 'token:transfer', }, eventId: 'evt_seed', id: deliveryId, requestUrl: 'https://example.com/hook', status: 'failed', subscriptionId: id, }) return { app, id, db } } test('replays the stored envelope and records a new attempt', async () => { const { app, id, db } = await seed() vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[ ':deliveryId' ].retry.$post({ param: { deliveryId, id } }, as(writer)) expect(response.status).toMatchInlineSnapshot(`200`) const body = await TestApp.json(response, Webhooks.schema.retryWebhookDelivery.Response) expect(body.delivered).toMatchInlineSnapshot(`true`) expect(body.eventId).toMatchInlineSnapshot(`"evt_seed"`) // A new attempt was appended alongside the seeded row. expect(await Core.listDeliveries(db, id)).toHaveLength(2) }) test('a failed replay reports the error', async () => { const { app, id } = await seed() vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 500 })) const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[ ':deliveryId' ].retry.$post({ param: { deliveryId, id } }, as(writer)) const body = await TestApp.json(response, Webhooks.schema.retryWebhookDelivery.Response) expect(body.delivered).toMatchInlineSnapshot(`false`) expect(body.error).toMatchInlineSnapshot(`"non-2xx response (500)"`) }) test('a read-only key cannot retry', async () => { const { app, id } = await seed() const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[ ':deliveryId' ].retry.$post({ param: { deliveryId, id } }, as(reader)) expect(response.status).toMatchInlineSnapshot(`403`) }) test('a different org cannot retry another org delivery', async () => { const { app, id } = await seed() const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[ ':deliveryId' ].retry.$post({ param: { deliveryId, id } }, as(other)) expect(response.status).toMatchInlineSnapshot(`404`) }) test('404s with delivery_not_found for an unknown delivery', async () => { const { app, id } = await seed() const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[ ':deliveryId' ].retry.$post({ param: { deliveryId: 'whd_missing', id } }, as(writer)) expect(response.status).toMatchInlineSnapshot(`404`) const body = (await response.json()) as { error?: { code?: string } } expect(body.error?.code).toMatchInlineSnapshot(`"delivery_not_found"`) }) test('404s for an unknown subscription', async () => { const app = client() const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[ ':deliveryId' ].retry.$post({ param: { deliveryId: 'whd_x', id: 'wh_missing' } }, as(writer)) expect(response.status).toMatchInlineSnapshot(`404`) }) }) describe('OpenAPI visibility', () => { test('hides webhook routes when the feature is disabled', async () => { const app = TestApp.create({ auth: { keys: [writer] }, webhook: false }) const body = (await (await app.request('/openapi.json')).json()) as { paths: object } // OpenAPI paths include App.create's mounted `/v1` prefix; the webhook // sub-app's local `/webhooks` paths are not emitted prefix-free. expect(Object.keys(body.paths).some((path) => path.startsWith('/v1/webhooks'))).toBe(false) }) test('documents webhook routes when the feature is enabled', async () => { const app = TestApp.create({ auth: { keys: [writer] }, }) const body = (await (await app.request('/openapi.json')).json()) as { paths: object } const paths = Object.keys(body.paths) .filter((path) => path.startsWith('/v1/webhooks')) .sort() expect(paths).toMatchInlineSnapshot(` [ "/v1/webhooks", "/v1/webhooks/event-types", "/v1/webhooks/{id}", "/v1/webhooks/{id}/deliveries", "/v1/webhooks/{id}/deliveries/{deliveryId}/retry", "/v1/webhooks/{id}/ping", ] `) }) test('omits the outbound `webhooks` section when the feature is disabled', async () => { const app = TestApp.create({ auth: { keys: [writer] }, webhook: false }) const body = (await (await app.request('/openapi.json')).json()) as { webhooks?: object } expect(body.webhooks).toMatchInlineSnapshot(`undefined`) }) test('documents the outbound delivery envelope under OAS `webhooks` when enabled', async () => { const app = TestApp.create({ auth: { keys: [writer] }, }) const body = (await (await app.request('/openapi.json')).json()) as { webhooks?: { event?: { post?: { parameters?: { name: string }[] requestBody?: { content?: { 'application/json'?: { schema?: { oneOf?: { title?: string }[] } } } } } } } } expect(body.webhooks?.event?.post?.parameters?.map((p) => p.name)).toMatchInlineSnapshot(` [ "tempo-signature", "tempo-event-id", "tempo-event-type", ] `) // Each envelope variant carries a `title` so Scalar renders a labelled // variant selector rather than collapsing the union to its first member. const variants = body.webhooks?.event?.post?.requestBody?.content?.['application/json']?.schema?.oneOf expect(variants?.map((v) => v.title)).toMatchInlineSnapshot(` [ "Transfer event", "Included transaction event", "Log event", "Block event", "Funding deposit updated event", "Funding transfer updated event", "Ping (test delivery)", ] `) }) test('documents per-event-type filters on the create body', async () => { const app = TestApp.create({ auth: { keys: [writer] }, }) const body = (await (await app.request('/openapi.json')).json()) as { paths: { '/v1/webhooks': { post: { requestBody: { content: { 'application/json': { schema: { oneOf?: { title?: string properties?: { filters?: { properties?: Record } } }[] } } } } } } } } const variants = body.paths['/v1/webhooks'].post.requestBody.content['application/json'].schema.oneOf // Each event type renders as a titled variant exposing only its own filters. expect( variants?.map((v) => ({ filters: Object.keys(v.properties?.filters?.properties ?? {}), title: v.title, })), ).toMatchInlineSnapshot(` [ { "filters": [ "address", "recipient", "sender", "token", ], "title": "Transfer subscription", }, { "filters": [ "hash", "address", "from", "to", "includeCalls", "value", "input", "calls", "callCount", "txType", "feeToken", "gasLimit", "maxFeePerGas", "maxPriorityFeePerGas", "nonce", "nonceKey", "validBefore", "validAfter", "blockNumber", "timestamp", ], "title": "Included-transaction subscription", }, { "filters": [ "address", "signature", "topic0", "topic1", "topic2", "topic3", "args", "blockNumber", ], "title": "Log subscription", }, { "filters": [ "number", "miner", "proposer", "gasUsed", "gasLimit", "timestamp", ], "title": "Block subscription", }, { "filters": [ "depositAddressId", "recipient", "status", ], "title": "Funding deposit subscription", }, { "filters": [ "id", "status", ], "title": "Funding transfer subscription", }, ] `) }) }) describe('envelope schema', () => { test('transaction events exclude receipt metadata', () => { expect(Object.keys(Webhooks.schema.TransactionEvent.shape.meta.shape)).toEqual(['rpc']) }) /** A `transfer` subscription used to build representative envelopes. */ async function transferSubscription() { const db = TestApp.database() return Core.buildEnvelope({ blockNumber: 100, createdAt: new Date('2026-01-01T00:00:00.000Z'), data: { address: '0x20c0000000000000000000008f5425160ebe5525', amount: '1000', blockNumber: 100, recipient: '0x2222222222222222222222222222222222222222', sender: '0x1111111111111111111111111111111111111111', timestamp: '2026-01-01T00:00:00.000Z', transactionHash: '0x33333333333333333333333333333333333333333333333333333333333333aa', }, logIndex: 0, subscription: await Core.createSubscription(db, { chainId: 42431, eventType: 'token:transfer', owner: { orgId: 'org_test', type: 'api_key' }, destination: { type: 'url', url: 'https://hooks.example.com/x' }, }), }) } test('round-trips a delivered transfer envelope', async () => { const parsed = Webhooks.schema.Envelope.safeParse(await transferSubscription()) expect(parsed.success).toMatchInlineSnapshot(`true`) }) test('round-trips a ping envelope', async () => { const db = TestApp.database() const subscription = await Core.createSubscription(db, { chainId: 42431, eventType: 'token:transfer', owner: { orgId: 'org_test', type: 'api_key' }, destination: { type: 'url', url: 'https://hooks.example.com/x' }, }) const parsed = Webhooks.schema.Envelope.safeParse(Core.buildPingEnvelope(subscription)) expect(parsed.success).toMatchInlineSnapshot(`true`) expect(parsed.success && parsed.data.type).toMatchInlineSnapshot(`"ping"`) }) test('rejects an unknown envelope type', () => { const parsed = Webhooks.schema.Envelope.safeParse({ chainId: 42431, createdAt: '2026-01-01T00:00:00.000Z', data: {}, id: 'evt_x', subscriptionId: 'wh_x', type: 'unknown', }) expect(parsed.success).toMatchInlineSnapshot(`false`) }) }) describe('MPP access (disabled for now — API-key-only)', () => { test('rejects an MPP payer with 401, offering no payment challenge', async () => { const app = TestApp.create({ auth: { keys: [], mpp: { secretKey: 'secret_test_key_0123456789abcdef', session: { chainId: Tempo.chain.id, currency: Tempo.currency, decimals: 6, getClient: () => Tempo.client, recipient: Tempo.accounts[2].address, }, }, }, }) // The paid lane is disabled on webhook routes, so no `402` challenge is // offered: the MPP client's request is rejected outright (`401`) and never // gets a chance to pay, so no payment receipt is issued. const response = await Mppx.createClient(app).fetch('http://tempo-api.test/v1/webhooks', { body: JSON.stringify(validBody), headers: { 'content-type': 'application/json' }, method: 'POST', }) expect(response.status).toMatchInlineSnapshot(`401`) expect(response.headers.has('payment-receipt')).toMatchInlineSnapshot(`false`) }) }) describe('matchesOperator', () => { const addr = '0x20c0000000000000000000008f5425160ebe5525' const addr2 = '0x1111111111111111111111111111111111111111' test('bare value matches case-insensitively (string kind)', () => { expect(Webhooks.matchesOperator(addr.toUpperCase(), addr)).toBe(true) expect(Webhooks.matchesOperator(addr2, addr)).toBe(false) }) test('a missing value never matches (fail-closed)', () => { expect(Webhooks.matchesOperator(undefined, addr)).toBe(false) }) test('{ eq } matches equality', () => { expect(Webhooks.matchesOperator(addr, { eq: addr })).toBe(true) }) test('bytes equality handles empty calldata without numeric conversion', () => { expect(Webhooks.matchesOperator('0x', { eq: '0x' })).toBe(true) expect(Webhooks.matchesOperator('0x', { eq: '0x00' })).toBe(false) }) test('{ in } matches membership', () => { expect(Webhooks.matchesOperator(addr, { in: [addr2, addr] })).toBe(true) expect(Webhooks.matchesOperator(addr, { in: [addr2] })).toBe(false) }) test('{ not } matches inequality', () => { expect(Webhooks.matchesOperator(addr, { not: addr2 })).toBe(true) expect(Webhooks.matchesOperator(addr, { not: addr })).toBe(false) }) test('compare bare value matches numeric equality', () => { expect(Webhooks.matchesOperator('0x1a', '0x1a', 'number')).toBe(true) expect(Webhooks.matchesOperator('0x1b', '0x1a', 'number')).toBe(false) }) test('compare range AND-combines bounds', () => { const op = { gte: '0x1', lte: '0x100' } expect(Webhooks.matchesOperator('0x80', op, 'number')).toBe(true) expect(Webhooks.matchesOperator('0x200', op, 'number')).toBe(false) }) test('bytes selector matches the first four bytes', () => { expect(Webhooks.matchesOperator('0xa9059cbbdeadbeef', { selector: '0xa9059cbb' })).toBe(true) expect(Webhooks.matchesOperator('0x12345678deadbeef', { selector: '0xa9059cbb' })).toBe(false) }) test('bytes startsWith matches a hex prefix', () => { expect(Webhooks.matchesOperator('0xa9059cbbdeadbeef', { startsWith: '0xa9059cbb' })).toBe(true) expect(Webhooks.matchesOperator('0xdead', { startsWith: '0xa9059cbb' })).toBe(false) }) }) describe('parseFilters', () => { test('returns validated filters for a known event type', () => { expect( Webhooks.parseFilters('token:transfer', { sender: '0x20c0000000000000000000008f5425160ebe5525', }), ).toMatchInlineSnapshot(` { "sender": "0x20c0000000000000000000008f5425160ebe5525", } `) }) test('treats undefined filters as the empty (match-all) set', () => { expect(Webhooks.parseFilters('token:transfer', undefined)).toMatchInlineSnapshot(`{}`) }) test('fails closed on an invalid filter shape', () => { let error: unknown try { Webhooks.parseFilters('token:transfer', { sender: 'not-an-address' }) } catch (cause) { error = cause } expect(error).toBeInstanceOf(Core.InvalidFilterError) expect((error as Core.InvalidFilterError).eventType).toBe('token:transfer') expect((error as Core.InvalidFilterError).details.length).toBeGreaterThan(0) }) })