From 87954fdb4e1b5863956020751f6330154755848a Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 15 Oct 2026 21:27:40 +0100 Subject: [PATCH 1/3] feat: [SPIKE] INTER-2457 Event union from openapi#494 --- example/getEvent.mjs | 2 +- readme.md | 4 +- resources/fingerprint-server-api.yaml | 93 +++++++++++++++++-- src/generatedApiTypes.ts | 62 ++++++++++--- .../castWebhookEventTest.spec.ts | 17 +++- .../webhook/webhook_event.json | 57 ++++++++++-- tests/unit-tests/sealedResults.spec.ts | 7 ++ 7 files changed, 208 insertions(+), 34 deletions(-) diff --git a/example/getEvent.mjs b/example/getEvent.mjs index 0500d26b..b29b8ad1 100644 --- a/example/getEvent.mjs +++ b/example/getEvent.mjs @@ -32,7 +32,7 @@ try { const event = await client.getEvent(eventId, { ruleset_id: rulesetId }) console.log(JSON.stringify(event, null, 2)) - if (rulesetId && event.rule_action) { + if (rulesetId && event.source === 'device' && event.rule_action) { const { type, ruleset_id, rule_id, rule_expression } = event.rule_action console.log(`Rule action: ${type} (ruleset: ${ruleset_id}, rule: ${rule_id}, expression: ${rule_expression})`) diff --git a/readme.md b/readme.md index a1ee6888..5dd2a947 100644 --- a/readme.md +++ b/readme.md @@ -97,7 +97,9 @@ client.getEvent('').then((event) => { // Get an event with a ruleset evaluation client.getEvent('', { ruleset_id: '' }).then((event) => { - console.log(event.rule_action?.type) // 'allow' or 'block' + if (event.source === 'device') { + console.log(event.rule_action?.type) // 'allow' or 'block' + } }) // Search for identification events diff --git a/resources/fingerprint-server-api.yaml b/resources/fingerprint-server-api.yaml index 6b178b67..7f7d4380 100644 --- a/resources/fingerprint-server-api.yaml +++ b/resources/fingerprint-server-api.yaml @@ -1190,6 +1190,11 @@ components: enum: - device - edge + EnvironmentId: + type: string + examples: + - ae_47abaca3db2c7c43 + description: Environment ID of the event. Url: type: string examples: @@ -1597,6 +1602,52 @@ components: description: > `true` if the request came from a device running a VPN, `false` otherwise. + EventEdge: + type: object + description: >- + An event generated by the Automation Intelligence API (`source` is + `edge`), containing IP and bot analysis for a request intercepted at the + edge. No client-side agent is involved, so Identification (`visitor_id`) + and Smart Signals derived from device telemetry are not available. + `ip_info` is always present, but is empty when no IP intelligence is + available for the request. + required: + - event_id + - timestamp + - source + - ip_info + properties: + event_id: + $ref: '#/components/schemas/EventId' + timestamp: + $ref: '#/components/schemas/Timestamp' + source: + $ref: '#/components/schemas/EventSource' + const: edge + linked_id: + $ref: '#/components/schemas/LinkedId' + environment_id: + $ref: '#/components/schemas/EnvironmentId' + tags: + $ref: '#/components/schemas/Tags' + url: + $ref: '#/components/schemas/Url' + bot_info: + $ref: '#/components/schemas/BotInfo' + ip_info: + $ref: '#/components/schemas/IPInfo' + proxy: + $ref: '#/components/schemas/Proxy' + proxy_confidence: + $ref: '#/components/schemas/ProxyConfidence' + proxy_details: + $ref: '#/components/schemas/ProxyDetails' + vpn: + $ref: '#/components/schemas/Vpn' + vpn_confidence: + $ref: '#/components/schemas/VpnConfidence' + vpn_methods: + $ref: '#/components/schemas/VpnMethods' ErrorCode: type: string enum: @@ -1706,11 +1757,6 @@ components: enum: - partially_completed - completed - EnvironmentId: - type: string - examples: - - ae_47abaca3db2c7c43 - description: Environment ID of the event. Suspect: type: boolean description: >- @@ -3130,18 +3176,21 @@ components: case. This field is in the beta phase and only available to select customers. If you are interested, please [contact our support team](https://fingerprint.com/support/). - Event: + EventDevice: type: object description: >- - Contains results from Fingerprint Identification and all active Smart - Signals. Some Smart Signals are only supported for certain device types, - these fields will be omitted for events not generated from the supported - devices. Consult the [Smart Signals + An event generated by the JS agent or a mobile SDK running on an + end-user device (`source` is `device`). Contains results from + Fingerprint Identification and all active Smart Signals. Some Smart + Signals are only supported for certain device types, these fields will + be omitted for events not generated from the supported devices. Consult + the [Smart Signals reference](https://docs.fingerprint.com/docs/smart-signals-reference) for more details. required: - event_id - timestamp + - source properties: event_id: $ref: '#/components/schemas/EventId' @@ -3157,6 +3206,7 @@ components: - browser source: $ref: '#/components/schemas/EventSource' + const: device x-platforms: - android - ios @@ -3485,6 +3535,29 @@ components: - browser - ios - android + Event: + type: object + description: > + An event, discriminated by its `source` property. Check `source` before + reading other properties, because each source populates a different set + of them: + + - `device` - an identification event generated by the JS agent or a + mobile SDK running on an end-user device. + + - `edge` - an event generated by the Automation Intelligence API + (`/edge` endpoint), analyzing a request intercepted at the edge. It + contains only IP and bot analysis, and never contains Identification + (`visitor_id`) or Smart Signals derived from device telemetry. + required: [] + oneOf: + - $ref: '#/components/schemas/EventDevice' + - $ref: '#/components/schemas/EventEdge' + discriminator: + propertyName: source + mapping: + device: '#/components/schemas/EventDevice' + edge: '#/components/schemas/EventEdge' EventUpdate: type: object required: [] diff --git a/src/generatedApiTypes.ts b/src/generatedApiTypes.ts index af03669a..61d35c97 100644 --- a/src/generatedApiTypes.ts +++ b/src/generatedApiTypes.ts @@ -155,6 +155,11 @@ export interface components { * @enum {string} */ EventSource: 'device' | 'edge' + /** + * @description Environment ID of the event. + * @example ae_47abaca3db2c7c43 + */ + EnvironmentId: string /** * @description Page URL from which the request was sent. * @example https://www.example.com/login @@ -419,6 +424,41 @@ export interface components { /** @description `true` if the request came from a device running a VPN, `false` otherwise. */ ml_prediction?: boolean } + /** @description An event generated by the Automation Intelligence API (`source` is `edge`), containing IP and bot analysis for a request intercepted at the edge. No client-side agent is involved, so Identification (`visitor_id`) and Smart Signals derived from device telemetry are not available. `ip_info` is always present, but is empty when no IP intelligence is available for the request. */ + EventEdge: { + /** @description Unique identifier of the user's request. The first portion of the event_id is a unix epoch milliseconds timestamp. */ + event_id: components['schemas']['EventId'] + /** @description Timestamp of the event with millisecond precision in Unix time. */ + timestamp: components['schemas']['Timestamp'] + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + source: 'edge' + /** @description A customer-provided ID that was sent with the request. */ + linked_id?: components['schemas']['LinkedId'] + /** @description Environment ID of the event. */ + environment_id?: components['schemas']['EnvironmentId'] + /** @description A customer-provided value or an object that was sent with the identification request or updated later. */ + tags?: components['schemas']['Tags'] + /** @description Page URL from which the request was sent. */ + url?: components['schemas']['Url'] + /** @description Extended bot information. */ + bot_info?: components['schemas']['BotInfo'] + /** @description Details about the request IP address. Has separate fields for v4 and v6 IP address versions. */ + ip_info: components['schemas']['IPInfo'] + /** @description IP address was used by a public proxy provider or belonged to a known recent residential proxy */ + proxy?: components['schemas']['Proxy'] + /** @description Confidence level of the proxy detection. If a proxy is not detected, confidence is "high". If it's detected, can be "low", "medium", or "high". */ + proxy_confidence?: components['schemas']['ProxyConfidence'] + /** @description Proxy detection details (present if `proxy` is `true`) */ + proxy_details?: components['schemas']['ProxyDetails'] + /** @description VPN or other anonymizing service has been used when sending the request. */ + vpn?: components['schemas']['Vpn'] + /** @description A confidence rating for the VPN detection result — "low", "medium", or "high". Depends on the combination of results returned from all VPN detection methods. */ + vpn_confidence?: components['schemas']['VpnConfidence'] + vpn_methods?: components['schemas']['VpnMethods'] + } /** * @description Error code: * * `request_cannot_be_parsed` - The query parameters or JSON payload contains some errors @@ -506,11 +546,6 @@ export interface components { * @enum {string} */ IncrementalIdentificationStatus: 'partially_completed' | 'completed' - /** - * @description Environment ID of the event. - * @example ae_47abaca3db2c7c43 - */ - EnvironmentId: string /** @description Field is `true` if you have previously set the `suspect` flag for this event using the [Server API Update event endpoint](https://docs.fingerprint.com/reference/server-api-update-event). */ Suspect: boolean Integration: { @@ -1412,18 +1447,17 @@ export interface components { */ ml_score?: number }[] - /** @description Contains results from Fingerprint Identification and all active Smart Signals. Some Smart Signals are only supported for certain device types, these fields will be omitted for events not generated from the supported devices. Consult the [Smart Signals reference](https://docs.fingerprint.com/docs/smart-signals-reference) for more details. */ - Event: { + /** @description An event generated by the JS agent or a mobile SDK running on an end-user device (`source` is `device`). Contains results from Fingerprint Identification and all active Smart Signals. Some Smart Signals are only supported for certain device types, these fields will be omitted for events not generated from the supported devices. Consult the [Smart Signals reference](https://docs.fingerprint.com/docs/smart-signals-reference) for more details. */ + EventDevice: { /** @description Unique identifier of the user's request. The first portion of the event_id is a unix epoch milliseconds timestamp. */ event_id: components['schemas']['EventId'] /** @description Timestamp of the event with millisecond precision in Unix time. */ timestamp: components['schemas']['Timestamp'] /** - * @description Identifies how the event was generated. - * - `device` - the event was generated by the JS agent or a mobile SDK running on an end-user device. - * - `edge` - the event was generated by the Automation Intelligence API (`/edge` endpoint), analyzing a request intercepted at the edge. + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - source?: components['schemas']['EventSource'] + source: 'device' /** * @description Only included for requests using incremental identification. * - `partially_completed` - Indicates this event corresponds to a 'minimal' request. Smart Signals, even if included in your plan, are not computed; hence, their values must be ignored. @@ -1621,6 +1655,12 @@ export interface components { /** @description Each label returns a prediction (true or false) for a specific use case (label field) based on a machine learning score. The machine learning score is determined by a model trained on customer data for that use case. This field is in the beta phase and only available to select customers. If you are interested, please [contact our support team](https://fingerprint.com/support/). */ labels?: components['schemas']['Labels'] } + /** + * @description An event, discriminated by its `source` property. Check `source` before reading other properties, because each source populates a different set of them: + * - `device` - an identification event generated by the JS agent or a mobile SDK running on an end-user device. + * - `edge` - an event generated by the Automation Intelligence API (`/edge` endpoint), analyzing a request intercepted at the edge. It contains only IP and bot analysis, and never contains Identification (`visitor_id`) or Smart Signals derived from device telemetry. + */ + Event: components['schemas']['EventDevice'] | components['schemas']['EventEdge'] EventUpdate: { /** * @description Linked ID value to assign to the existing event diff --git a/tests/mocked-responses-tests/castWebhookEventTest.spec.ts b/tests/mocked-responses-tests/castWebhookEventTest.spec.ts index 51f786ff..318ceeb4 100644 --- a/tests/mocked-responses-tests/castWebhookEventTest.spec.ts +++ b/tests/mocked-responses-tests/castWebhookEventTest.spec.ts @@ -1,5 +1,6 @@ +import { createHmac } from 'crypto' import { describe, expect, it } from 'vitest' -import { Event } from '../../src' +import { Event, isValidWebhookSignature } from '../../src' import eventWebhookBody from './mocked-responses-data/webhook/webhook_event.json' describe('[Mocked body] Cast webhook event', () => { @@ -9,4 +10,18 @@ describe('[Mocked body] Cast webhook event', () => { // Assertion just to use the `event` variable. The goal of this test is to assume that Typescript won't throw an error here. expect(event).toBeTruthy() }) + + it('with a body that predates `source`', () => { + const oldBody: Record = { ...eventWebhookBody } + delete oldBody.source + const data = Buffer.from(JSON.stringify(oldBody)) + const secret = 'secret' + const header = `v1=${createHmac('sha256', secret).update(data).digest('hex')}` + + expect(isValidWebhookSignature({ header, data, secret })).toEqual(true) + + const event = JSON.parse(data.toString()) as Event + expect(event).not.toHaveProperty('source') + expect(event.event_id).toEqual(eventWebhookBody.event_id) + }) }) diff --git a/tests/mocked-responses-tests/mocked-responses-data/webhook/webhook_event.json b/tests/mocked-responses-tests/mocked-responses-data/webhook/webhook_event.json index 35567917..c50663be 100644 --- a/tests/mocked-responses-tests/mocked-responses-data/webhook/webhook_event.json +++ b/tests/mocked-responses-tests/mocked-responses-data/webhook/webhook_event.json @@ -3,9 +3,14 @@ "tags": {}, "timestamp": 1708102555327, "event_id": "1708102555327.NLOjmg", + "source": "device", + "incremental_identification_status": "completed", "url": "https://www.example.com/login?hope{this{works[!", "ip_address": "61.127.217.15", "user_agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) ....", + "device": "Other", + "os": "Windows", + "os_version": "7", "client_referrer": "https://example.com/blog/my-article", "browser_details": { "browser_name": "Chrome", @@ -26,14 +31,14 @@ "last_seen_at": 1708102555327 }, "supplementary_id_high_recall": { - "visitor_id": "3HNey93AkBW6CRbxV6xP", + "visitor_id": "0jnGMkPYXX37DqVa4ZIO3f_hr", "visitor_found": true, "confidence": { - "score": 0.97, - "version": "1.1" + "score": 0, + "version": "Not Supported" }, - "first_seen_at": 1708102555327, - "last_seen_at": 1708102555327 + "first_seen_at": 1778086556130, + "last_seen_at": 1778604975494 }, "proximity": { "id": "w1aTfd4MCvl", @@ -111,8 +116,10 @@ "last_seen_at": 1708102555327, "provider": "Massive" }, + "proxy_ml_score": 0.2, "vpn": false, "vpn_confidence": "high", + "vpn_ml_score": 0.2, "vpn_origin_timezone": "Europe/Berlin", "vpn_origin_country": "unknown", "vpn_methods": { @@ -120,10 +127,13 @@ "public_vpn": false, "auxiliary_mobile": false, "os_mismatch": false, - "relay": false + "relay": false, + "ml_prediction": false }, "incognito": false, "tampering": false, + "tampering_confidence": "high", + "tampering_ml_score": 0.3231, "tampering_details": { "anomaly_score": 0.1955, "anti_detect_browser": false @@ -143,6 +153,11 @@ "1_hour": 1, "24_hours": 1 }, + "distinct_linked_id": { + "5_minutes": 1, + "1_hour": 5, + "24_hours": 5 + }, "distinct_country": { "5_minutes": 1, "1_hour": 2, @@ -170,6 +185,11 @@ } }, "developer_tools": false, + "device_details": { + "device_manufacturer": "samsung", + "device_model": "SM-G991U", + "os_version": "13" + }, "mitm_attack": false, "sdk": { "platform": "js", @@ -187,8 +207,6 @@ }, "replayed": false, "high_activity_device": false, - "rare_device": false, - "rare_device_percentile_bucket": " { expect(result).toMatchSnapshot() }) + it('unseals a payload that predates `source`', async () => { + const result = await unsealEventsResponse(sealedData, [{ key: validKey, algorithm: DecryptionAlgorithm.Aes256Gcm }]) + + expect(result).not.toHaveProperty('source') + expect(result.event_id).toBeTruthy() + }) + it('unseals sealed data using aes256gcm, passed as a string parameter', async () => { const result = await unsealEventsResponse(sealedData, [ { From 5098155c1a2af49fc46a29eee12023506fa800f4 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 15 Oct 2026 22:00:54 +0100 Subject: [PATCH 2/3] feat: [SPIKE] INTER-2457 make source optional on device events Events stored before `source` was added (sealed results, webhook bodies) don't have it. Check `source === 'edge'`; anything else is a device event. --- example/getEvent.mjs | 3 ++- readme.md | 4 +++- src/types.ts | 6 +++++- tests/unit-tests/eventType.spec.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 tests/unit-tests/eventType.spec.ts diff --git a/example/getEvent.mjs b/example/getEvent.mjs index b29b8ad1..491c402f 100644 --- a/example/getEvent.mjs +++ b/example/getEvent.mjs @@ -32,7 +32,8 @@ try { const event = await client.getEvent(eventId, { ruleset_id: rulesetId }) console.log(JSON.stringify(event, null, 2)) - if (rulesetId && event.source === 'device' && event.rule_action) { + // Edge events have no rule action. Events without `source` are device events. + if (rulesetId && event.source !== 'edge' && event.rule_action) { const { type, ruleset_id, rule_id, rule_expression } = event.rule_action console.log(`Rule action: ${type} (ruleset: ${ruleset_id}, rule: ${rule_id}, expression: ${rule_expression})`) diff --git a/readme.md b/readme.md index 5dd2a947..02e76cbd 100644 --- a/readme.md +++ b/readme.md @@ -97,7 +97,9 @@ client.getEvent('').then((event) => { // Get an event with a ruleset evaluation client.getEvent('', { ruleset_id: '' }).then((event) => { - if (event.source === 'device') { + // Edge events (Automation Intelligence API) have no Identification or rule action. + // Events stored before `source` was added have no `source`; they are device events. + if (event.source !== 'edge') { console.log(event.rule_action?.type) // 'allow' or 'block' } }) diff --git a/src/types.ts b/src/types.ts index 41d97723..26f66376 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,7 +48,11 @@ export type SearchEventsResponse = components['schemas']['EventSearch'] /** * More info: https://dev.fingerprint.com/reference/server-api-v4-get-event */ -export type Event = components['schemas']['Event'] +export type Event = + // `source` is optional on device events because events stored before `source` was added (sealed results, + // webhook bodies) don't have it. Check `source === 'edge'`; everything else is a device event. + | (Omit & { source?: components['schemas']['EventDevice']['source'] }) + | components['schemas']['EventEdge'] export type GetEventOptions = paths['/events/{event_id}']['get']['parameters']['query'] diff --git a/tests/unit-tests/eventType.spec.ts b/tests/unit-tests/eventType.spec.ts new file mode 100644 index 00000000..f922e985 --- /dev/null +++ b/tests/unit-tests/eventType.spec.ts @@ -0,0 +1,29 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { Event } from '../../src' + +// Type-level tests: `pnpm typecheck:tests` fails if these stop compiling. +describe('Event type', () => { + it('accepts a legacy device event without `source`', () => { + const legacy: Event = { event_id: '1708102555327.NLOjmg', timestamp: 1708102555327 } + expectTypeOf(legacy).toExtend() + }) + + it("narrows to the edge variant on `source === 'edge'` and to the device variant otherwise", () => { + const event = {} as Event + + if (event.source === 'edge') { + expectTypeOf(event.ip_info).not.toBeUndefined() + // @ts-expect-error edge events never have Identification + void event.identification + } else { + expectTypeOf(event.source).toEqualTypeOf<'device' | undefined>() + expectTypeOf(event.identification?.visitor_id).toEqualTypeOf() + } + }) + + it("keeps the edge variant strict once `source` is 'edge'", () => { + // @ts-expect-error edge events require `ip_info` + const edge: Event = { event_id: 'x', timestamp: 1, source: 'edge' } + void edge + }) +}) From d7809924f68c50cfbe6f23f23e30d78d8c7da10c Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 15 Oct 2026 22:02:36 +0100 Subject: [PATCH 3/3] feat: [SPIKE] INTER-2457 keep Event strict, add Webhook type The API always returns `source`, so `Event` keeps it required on device events. `Webhook` makes it optional: webhook bodies stored before `source` existed don't have it, and resent webhooks deliver the stored body as-is. --- example/getEvent.mjs | 2 +- readme.md | 13 ++++-- src/index.ts | 1 + src/types.ts | 14 +++++-- .../castWebhookEventTest.spec.ts | 6 +-- tests/unit-tests/eventType.spec.ts | 40 +++++++++++++++---- 6 files changed, 57 insertions(+), 19 deletions(-) diff --git a/example/getEvent.mjs b/example/getEvent.mjs index 491c402f..d679fdd2 100644 --- a/example/getEvent.mjs +++ b/example/getEvent.mjs @@ -32,7 +32,7 @@ try { const event = await client.getEvent(eventId, { ruleset_id: rulesetId }) console.log(JSON.stringify(event, null, 2)) - // Edge events have no rule action. Events without `source` are device events. + // Edge events have no rule action. if (rulesetId && event.source !== 'edge' && event.rule_action) { const { type, ruleset_id, rule_id, rule_expression } = event.rule_action console.log(`Rule action: ${type} (ruleset: ${ruleset_id}, rule: ${rule_id}, expression: ${rule_expression})`) diff --git a/readme.md b/readme.md index 02e76cbd..9429b020 100644 --- a/readme.md +++ b/readme.md @@ -98,7 +98,6 @@ client.getEvent('').then((event) => { // Get an event with a ruleset evaluation client.getEvent('', { ruleset_id: '' }).then((event) => { // Edge events (Automation Intelligence API) have no Identification or rule action. - // Events stored before `source` was added have no `source`; they are device events. if (event.source !== 'edge') { console.log(event.rule_action?.type) // 'allow' or 'block' } @@ -155,12 +154,18 @@ Other `SdkError`s: #### Webhook types -When handling [Webhooks](https://dev.fingerprint.com/reference/posteventwebhook#/) coming from Fingerprint, you can cast the payload as the built-in `Event` type: +When handling [Webhooks](https://dev.fingerprint.com/reference/posteventwebhook#/) coming from Fingerprint, you can cast the payload as the built-in `Webhook` type: ```ts -import { Event } from '@fingerprint/node-sdk' +import { Webhook } from '@fingerprint/node-sdk' -const event = eventWebhookBody as unknown as Event +const webhook = eventWebhookBody as unknown as Webhook + +// Edge events (Automation Intelligence API) have no Identification. +// Bodies stored before `source` was added have no `source`; they are device events. +if (webhook.source !== 'edge') { + console.log(webhook.identification?.visitor_id) +} ``` #### Webhook signature validation diff --git a/src/index.ts b/src/index.ts index 2c9f2954..52dec31f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export { Region, SearchEventsFilter, SearchEventsResponse, + Webhook, } from './types' export * from './sealedResults' export * from './errors/unsealError' diff --git a/src/types.ts b/src/types.ts index 26f66376..6adc047a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,9 +48,17 @@ export type SearchEventsResponse = components['schemas']['EventSearch'] /** * More info: https://dev.fingerprint.com/reference/server-api-v4-get-event */ -export type Event = - // `source` is optional on device events because events stored before `source` was added (sealed results, - // webhook bodies) don't have it. Check `source === 'edge'`; everything else is a device event. +export type Event = components['schemas']['Event'] + +/** + * Body of a webhook sent by Fingerprint. + * Same as {@link Event}, except `source` is optional on device events: bodies stored before `source` was added + * don't have it, and resent webhooks deliver the stored body as-is. Check `source === 'edge'`; anything else is a + * device event. + * + * More info: https://dev.fingerprint.com/reference/posteventwebhook + */ +export type Webhook = | (Omit & { source?: components['schemas']['EventDevice']['source'] }) | components['schemas']['EventEdge'] diff --git a/tests/mocked-responses-tests/castWebhookEventTest.spec.ts b/tests/mocked-responses-tests/castWebhookEventTest.spec.ts index 318ceeb4..94a74ccd 100644 --- a/tests/mocked-responses-tests/castWebhookEventTest.spec.ts +++ b/tests/mocked-responses-tests/castWebhookEventTest.spec.ts @@ -1,11 +1,11 @@ import { createHmac } from 'crypto' import { describe, expect, it } from 'vitest' -import { Event, isValidWebhookSignature } from '../../src' +import { Webhook, isValidWebhookSignature } from '../../src' import eventWebhookBody from './mocked-responses-data/webhook/webhook_event.json' describe('[Mocked body] Cast webhook event', () => { it('with sample request body', () => { - const event = eventWebhookBody as Event + const event = eventWebhookBody as Webhook // Assertion just to use the `event` variable. The goal of this test is to assume that Typescript won't throw an error here. expect(event).toBeTruthy() @@ -20,7 +20,7 @@ describe('[Mocked body] Cast webhook event', () => { expect(isValidWebhookSignature({ header, data, secret })).toEqual(true) - const event = JSON.parse(data.toString()) as Event + const event = JSON.parse(data.toString()) as Webhook expect(event).not.toHaveProperty('source') expect(event.event_id).toEqual(eventWebhookBody.event_id) }) diff --git a/tests/unit-tests/eventType.spec.ts b/tests/unit-tests/eventType.spec.ts index f922e985..ba6eab1d 100644 --- a/tests/unit-tests/eventType.spec.ts +++ b/tests/unit-tests/eventType.spec.ts @@ -1,14 +1,15 @@ import { describe, expectTypeOf, it } from 'vitest' -import { Event } from '../../src' +import { Event, Webhook } from '../../src' // Type-level tests: `pnpm typecheck:tests` fails if these stop compiling. describe('Event type', () => { - it('accepts a legacy device event without `source`', () => { - const legacy: Event = { event_id: '1708102555327.NLOjmg', timestamp: 1708102555327 } - expectTypeOf(legacy).toExtend() + it('requires `source`, since the API always returns it', () => { + // @ts-expect-error `source` is required on API events + const event: Event = { event_id: '1708102555327.NLOjmg', timestamp: 1708102555327 } + void event }) - it("narrows to the edge variant on `source === 'edge'` and to the device variant otherwise", () => { + it("narrows on `source === 'edge'`", () => { const event = {} as Event if (event.source === 'edge') { @@ -16,14 +17,37 @@ describe('Event type', () => { // @ts-expect-error edge events never have Identification void event.identification } else { - expectTypeOf(event.source).toEqualTypeOf<'device' | undefined>() - expectTypeOf(event.identification?.visitor_id).toEqualTypeOf() + expectTypeOf(event.source).toEqualTypeOf<'device'>() + } + }) +}) + +describe('Webhook type', () => { + it('accepts a device body stored before `source` was added', () => { + const legacy: Webhook = { event_id: '1708102555327.NLOjmg', timestamp: 1708102555327 } + expectTypeOf(legacy).toExtend() + }) + + it("narrows to the edge variant on `source === 'edge'` and to the device variant otherwise", () => { + const webhook = {} as Webhook + + if (webhook.source === 'edge') { + expectTypeOf(webhook.ip_info).not.toBeUndefined() + // @ts-expect-error edge events never have Identification + void webhook.identification + } else { + expectTypeOf(webhook.source).toEqualTypeOf<'device' | undefined>() + expectTypeOf(webhook.identification?.visitor_id).toEqualTypeOf() } }) it("keeps the edge variant strict once `source` is 'edge'", () => { // @ts-expect-error edge events require `ip_info` - const edge: Event = { event_id: 'x', timestamp: 1, source: 'edge' } + const edge: Webhook = { event_id: 'x', timestamp: 1, source: 'edge' } void edge }) + + it('accepts every API event', () => { + expectTypeOf().toExtend() + }) })