├── .changeset └── config.json ├── .eslintrc.json ├── .github └── workflows │ ├── analyze-commits.yml │ ├── build.yml │ ├── coverage-diff.yml │ ├── coverage-report.yml │ ├── e2e-tests.yml │ ├── functional_tests.yml │ ├── release.yml │ └── reset-prerelease-branch.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .npmignore ├── .npmrc ├── .prettierrc ├── .schema-version ├── .vscode └── launch.json ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE ├── commitlint.config.js ├── contributing.md ├── example ├── .env.example ├── deleteVisitor.mjs ├── getEvent.mjs ├── getVisitorHistory.mjs ├── package.json ├── relatedVisitors.mjs ├── searchEvents.mjs ├── unsealResult.mjs ├── updateEvent.mjs └── validateWebhookSignature.mjs ├── generate.mjs ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── readme.md ├── resources ├── fingerprint-server-api.yaml ├── license_banner.txt ├── logo_dark.svg └── logo_light.svg ├── rollup.config.js ├── src ├── errors │ ├── apiErrors.ts │ ├── getRetryAfter.ts │ ├── handleErrorResponse.ts │ └── unsealError.ts ├── generatedApiTypes.ts ├── index.ts ├── responseUtils.ts ├── sealedResults.ts ├── serverApiClient.ts ├── types.ts ├── urlUtils.ts ├── utils.ts └── webhook.ts ├── sync.sh ├── tests ├── functional-tests │ ├── .env.example │ ├── CHANGELOG.md │ ├── package.json │ └── smokeTests.mjs ├── mocked-responses-tests │ ├── __snapshots__ │ │ ├── castVisitorWebhookTest.spec.ts.snap │ │ ├── getEventTests.spec.ts.snap │ │ ├── getRelatedVisitorsTests.spec.ts.snap │ │ ├── getVisitorsTests.spec.ts.snap │ │ └── searchEventsTests.spec.ts.snap │ ├── castVisitorWebhookTest.spec.ts │ ├── deleteVisitorDataTests.spec.ts │ ├── getEventTests.spec.ts │ ├── getRelatedVisitorsTests.spec.ts │ ├── getVisitorsTests.spec.ts │ ├── mocked-responses-data │ │ ├── errors │ │ │ ├── 400_bot_type_invalid.json │ │ │ ├── 400_end_time_invalid.json │ │ │ ├── 400_ip_address_invalid.json │ │ │ ├── 400_limit_invalid.json │ │ │ ├── 400_linked_id_invalid.json │ │ │ ├── 400_pagination_key_invalid.json │ │ │ ├── 400_request_body_invalid.json │ │ │ ├── 400_reverse_invalid.json │ │ │ ├── 400_start_time_invalid.json │ │ │ ├── 400_visitor_id_invalid.json │ │ │ ├── 400_visitor_id_required.json │ │ │ ├── 403_feature_not_enabled.json │ │ │ ├── 403_subscription_not_active.json │ │ │ ├── 403_token_not_found.json │ │ │ ├── 403_token_required.json │ │ │ ├── 403_wrong_region.json │ │ │ ├── 404_request_not_found.json │ │ │ ├── 404_visitor_not_found.json │ │ │ ├── 409_state_not_ready.json │ │ │ └── 429_too_many_requests.json │ │ ├── get_event_200.json │ │ ├── get_event_200_all_errors.json │ │ ├── get_event_200_botd_failed_error.json │ │ ├── get_event_200_extra_fields.json │ │ ├── get_event_200_identification_failed_error.json │ │ ├── get_event_200_too_many_requests_error.json │ │ ├── get_event_200_with_broken_format.json │ │ ├── get_event_200_with_unknown_field.json │ │ ├── get_event_search_200.json │ │ ├── get_visitors_200_limit_1.json │ │ ├── get_visitors_200_limit_500.json │ │ ├── get_visitors_400_bad_request.json │ │ ├── get_visitors_403_forbidden.json │ │ ├── get_visitors_429_too_many_requests.json │ │ ├── related-visitors │ │ │ ├── get_related_visitors_200.json │ │ │ └── get_related_visitors_200_empty.json │ │ ├── update_event_multiple_fields_request.json │ │ ├── update_event_one_field_request.json │ │ └── webhook.json │ ├── searchEventsTests.spec.ts │ └── updateEventTests.spec.ts └── unit-tests │ ├── __snapshots__ │ └── sealedResults.spec.ts.snap │ ├── sealedResults.spec.ts │ ├── serverApiClientTests.spec.ts │ ├── urlUtilsTests.spec.ts │ └── webhookTests.spec.ts ├── tsconfig.json └── typedoc.js /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": [ 4 | "@fingerprintjs/changesets-changelog-format", 5 | { 6 | "repo": "fingerprintjs/fingerprintjs-pro-server-api-node-sdk" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "linked": [], 12 | "access": "public", 13 | "baseBranch": "main", 14 | "updateInternalDependencies": "patch", 15 | "ignore": ["fingerprintjs-pro-server-api-node-sdk-example"] 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@fingerprintjs/eslint-config-dx-team"] 3 | } -------------------------------------------------------------------------------- /.github/workflows/analyze-commits.yml: -------------------------------------------------------------------------------- 1 | name: Analyze Commit Messages 2 | on: 3 | pull_request: 4 | 5 | permissions: 6 | pull-requests: write 7 | contents: write 8 | jobs: 9 | analyze-commits: 10 | name: Generate docs and coverage report 11 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/analyze-commits.yml@v1 12 | with: 13 | previewNotes: false 14 | 15 | preview-changeset: 16 | name: Preview changeset 17 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/preview-changeset-release.yml@v1 18 | with: 19 | pr-title: ${{ github.event.pull_request.title }} 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build-and-check: 9 | name: Build project and run CI checks 10 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/build-typescript-project.yml@v1 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/coverage-diff.yml: -------------------------------------------------------------------------------- 1 | name: Check coverage for PR 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | run-tests-check-coverage: 8 | name: Run tests & check coverage 9 | permissions: 10 | checks: write 11 | pull-requests: write 12 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/coverage-diff.yml@v1 13 | -------------------------------------------------------------------------------- /.github/workflows/coverage-report.yml: -------------------------------------------------------------------------------- 1 | name: Generate docs and coverage report 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | coverage-report: 10 | name: Coverage report 11 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/docs-and-coverage.yml@v1 12 | with: 13 | prepare-gh-pages-commands: | 14 | mv docs/* ./gh-pages 15 | mv coverage/lcov-report ./gh-pages/coverage 16 | -------------------------------------------------------------------------------- /.github/workflows/e2e-tests.yml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | on: 3 | release: 4 | types: 5 | - published 6 | 7 | jobs: 8 | read-version: 9 | name: 'Read package version' 10 | runs-on: ubuntu-latest 11 | outputs: 12 | version: ${{ steps.version.outputs.version }} 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Read version from package.json 16 | id: version 17 | run: echo version=$(node -p "require('./package.json').version") >> $GITHUB_OUTPUT 18 | e2e-tests: 19 | name: 'Run E2E tests' 20 | needs: read-version 21 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/run-server-sdk-e2e-tests.yml@v1 22 | with: 23 | sdk: node 24 | sdkVersion: ${{ needs.read-version.outputs.version }} 25 | appId: ${{ vars.RUNNER_APP_ID }} 26 | commitSha: ${{ github.event.pull_request.head.sha || github.sha }} 27 | secrets: 28 | APP_PRIVATE_KEY: ${{ secrets.RUNNER_APP_PRIVATE_KEY }} 29 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 30 | -------------------------------------------------------------------------------- /.github/workflows/functional_tests.yml: -------------------------------------------------------------------------------- 1 | name: Functional tests 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | schedule: 6 | - cron: '0 5 * * *' 7 | jobs: 8 | build: 9 | name: 'Build sdk' 10 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/build-typescript-project.yml@v1 11 | with: 12 | artifactName: node-sdk-artifact 13 | artifactPath: ./dist 14 | 15 | functional_tests: 16 | name: 'Smoke test on node ${{ matrix.node-version }}' 17 | needs: build 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | max-parallel: 3 22 | fail-fast: false 23 | matrix: 24 | node-version: [18, 19, 20, 21, 22, 23] 25 | 26 | steps: 27 | - if: ${{ github.event_name == 'pull_request_target' }} 28 | uses: actions/checkout@v4 29 | with: 30 | ref: ${{ github.event.pull_request.head.sha }} 31 | 32 | - if: ${{ github.event_name != 'pull_request_target' }} 33 | uses: actions/checkout@v4 34 | 35 | - name: 'Install pnpm' 36 | uses: pnpm/action-setup@129abb77bf5884e578fcaf1f37628e41622cc371 37 | with: 38 | version: 9 39 | 40 | - uses: actions/setup-node@v4 41 | with: 42 | node-version: ${{ matrix.node-version }} 43 | cache: pnpm 44 | - uses: actions/download-artifact@v4 45 | with: 46 | name: node-sdk-artifact 47 | path: ./dist 48 | - name: Install Dependencies for SDK 49 | run: pnpm install 50 | env: 51 | CI: true 52 | - name: Install Dependencies for example 53 | run: pnpm install 54 | working-directory: ./example 55 | env: 56 | CI: true 57 | - name: Run test 58 | run: | 59 | node smokeTests.mjs 60 | working-directory: ./tests/functional-tests 61 | env: 62 | API_KEY: '${{ secrets.PRIVATE_KEY }}' 63 | 64 | report_status: 65 | needs: functional_tests 66 | if: always() 67 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/report-workflow-status.yml@v1 68 | with: 69 | notification_title: 'Node SDK Functional Tests has {status_message}' 70 | job_status: ${{ needs.functional_tests.result }} 71 | secrets: 72 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 73 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - test 7 | 8 | jobs: 9 | build-and-release: 10 | name: 'Build project, run CI checks and publish new release' 11 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/release-sdk-changesets.yml@v1 12 | with: 13 | appId: ${{ vars.APP_ID }} 14 | runnerAppId: ${{ vars.RUNNER_APP_ID }} 15 | version-command: pnpm exec changeset version 16 | publish-command: pnpm exec changeset publish 17 | language: node 18 | language-version: 21 19 | prepare-command: pnpm build 20 | secrets: 21 | APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} 22 | RUNNER_APP_PRIVATE_KEY: ${{ secrets.RUNNER_APP_PRIVATE_KEY }} 23 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/reset-prerelease-branch.yml: -------------------------------------------------------------------------------- 1 | name: Reset Prerelease Branch 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | reset-feature-branch: 7 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/reset-prerelease-branch.yml@v1 8 | with: 9 | branch_name: 'test' 10 | appId: ${{ vars.APP_ID }} 11 | secrets: 12 | APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | .env.production 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | out 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and not Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # MacOS filesystem 111 | .DS_Store 112 | 113 | # Stores VSCode versions used for testing VSCode extensions 114 | .vscode-test 115 | 116 | # Idea files 117 | .idea 118 | 119 | # VSCode 120 | .vscode 121 | 122 | # yarn v2 123 | .yarn/cache 124 | .yarn/unplugged 125 | .yarn/build-state.yml 126 | .yarn/install-state.gz 127 | .pnp.* 128 | 129 | docs 130 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | containsref() { if [[ $2 =~ $1 ]]; then echo 1; else echo 0; fi } 5 | 6 | push_command=$(ps -ocommand= -p $PPID | cut -d' ' -f 4) 7 | protected_branch='main' 8 | current_branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,') 9 | is_push_to_main_origin=$(containsref 'git@github.com:/?fingerprintjs/' "$push_command") 10 | 11 | # Block pushes only to protected branch in main repository 12 | if [ $is_push_to_main_origin = 1 ] && [ "$protected_branch" = "$current_branch" ]; then 13 | echo "You are on the $protected_branch branch, push blocked." 14 | exit 1 # push will not execute 15 | fi 16 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # Recursively allow files under subtree 5 | !dist/** 6 | !src/** 7 | !package.json 8 | !.npmignore 9 | !README.md -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | loglevel verbose -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | "@fingerprintjs/prettier-config-dx-team" -------------------------------------------------------------------------------- /.schema-version: -------------------------------------------------------------------------------- 1 | v2.7.0 -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}\\dist\\index.js", 15 | "outFiles": [ 16 | "${workspaceFolder}/**/*.js" 17 | ], 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Users referenced in this file will automatically be requested as reviewers for PRs that modify the given paths. 2 | # See https://help.github.com/articles/about-code-owners/ 3 | 4 | * @Orkuncakilkaya @JuroUhlar @ilfa @makma 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 FingerprintJS, Inc 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@fingerprintjs/commit-lint-dx-team'] } 2 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to FingerprintJS Server API Node.js SDK 2 | 3 | ## Working with code 4 | 5 | We prefer using [pnpm](https://pnpmpkg.com/) for installing dependencies and running scripts. 6 | 7 | The main branch is locked for the push action. For proposing changes, use the standard [pull request approach](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). It's recommended to discuss fixes or new functionality in the Issues, first. 8 | 9 | ### How to regenerate the types 10 | 11 | Run the following command that will regenerate types: 12 | 13 | ```shell 14 | pnpm generateTypes 15 | ``` 16 | 17 | It uses schema stored in [res/schema.json](resources/fingerprint-server-api.yaml). To fetch the latest schema run: 18 | 19 | ```shell 20 | ./sync.sh 21 | ``` 22 | 23 | ### How to build 24 | 25 | Just run: 26 | 27 | ```shell 28 | pnpm build 29 | ``` 30 | 31 | ### How to build API reference documentation 32 | 33 | Run: 34 | 35 | ```shell 36 | pnpm run docs 37 | ``` 38 | 39 | ### Code style 40 | 41 | The code style is controlled by [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/). Run to check that the code style is ok: 42 | 43 | ```shell 44 | pnpm lint 45 | ``` 46 | 47 | You aren't required to run the check manually, the CI will do it. Run the following command to fix style issues (not all issues can be fixed automatically): 48 | 49 | ```shell 50 | pnpm lint:fix 51 | ``` 52 | 53 | ### Running tests 54 | 55 | Tests are located in `tests` folder and run by [jest](https://jestjs.io/) in node environment. 56 | 57 | To run tests you can use IDE instruments or just run: 58 | 59 | ```shell 60 | pnpm test 61 | ``` 62 | 63 | The test might fail to due outdated snapshots. You can update snapshots by running: 64 | 65 | ```shell 66 | pnpm test -- --updateSnapshot 67 | ``` 68 | 69 | ### Testing the local source code of the SDK 70 | 71 | Use the `example` folder to make API requests using the local version of the SDK. The [example/package.json](./example/package.json) file reroutes the SDK import references to the project root folder. 72 | 73 | 1. Create an `.env` file inside the `example` folder according to [.env.example](/example/.env.example). 74 | 2. Install dependencies and build the SDK (inside the root folder): 75 | 76 | ```shell 77 | pnpm install 78 | pnpm build 79 | ``` 80 | 81 | 3. Install dependencies and run the examples (inside the `example` folder)): 82 | 83 | ```shell 84 | cd example 85 | pnpm install 86 | node getEvent.mjs 87 | node getVisitorHistory.mjs 88 | ``` 89 | 90 | Every time you change the SDK code, you need to rebuild it in the root folder using `pnpm build` and then run the example again. 91 | 92 | ### How to publish 93 | 94 | The library is automatically released and published to NPM on every push to the main branch if there are relevant changes using [semantic-release](https://github.com/semantic-release/semantic-release) with following plugins: 95 | 96 | - [@semantic-release/commit-analyzer](https://github.com/semantic-release/commit-analyzer) 97 | - [@semantic-release/release-notes-generator](https://github.com/semantic-release/release-notes-generator) 98 | - [@semantic-release/changelog](https://github.com/semantic-release/changelog) 99 | - [@semantic-release/npm](https://github.com/semantic-release/npm) 100 | - [@semantic-release/github](https://github.com/semantic-release/github) 101 | 102 | The workflow must be approved by one of the maintainers, first. 103 | -------------------------------------------------------------------------------- /example/.env.example: -------------------------------------------------------------------------------- 1 | API_KEY= 2 | VISITOR_ID= 3 | REQUEST_ID= 4 | # "eu" or "ap", "us" is the default 5 | REGION= 6 | WEBHOOK_SIGNATURE_SECRET= 7 | BASE64_KEY= 8 | BASE64_SEALED_RESULT= -------------------------------------------------------------------------------- /example/deleteVisitor.mjs: -------------------------------------------------------------------------------- 1 | import { FingerprintJsServerApiClient, Region, RequestError } from '@fingerprintjs/fingerprintjs-pro-server-api' 2 | import { config } from 'dotenv' 3 | config() 4 | 5 | const apiKey = process.env.API_KEY 6 | const visitorId = process.env.VISITOR_ID 7 | const envRegion = process.env.REGION 8 | 9 | if (!visitorId) { 10 | console.error('Visitor ID not defined') 11 | process.exit(1) 12 | } 13 | 14 | if (!apiKey) { 15 | console.error('API key not defined') 16 | process.exit(1) 17 | } 18 | 19 | let region = Region.Global 20 | if (envRegion === 'eu') { 21 | region = Region.EU 22 | } else if (envRegion === 'ap') { 23 | region = Region.AP 24 | } 25 | 26 | const client = new FingerprintJsServerApiClient({ region, apiKey }) 27 | 28 | try { 29 | await client.deleteVisitorData(visitorId) 30 | console.log(`All data associated with visitor ${visitorId} is scheduled to be deleted.`) 31 | } catch (error) { 32 | if (error instanceof RequestError) { 33 | console.log(error.statusCode, error.message) 34 | } else { 35 | console.error('unknown error: ', error) 36 | } 37 | process.exit(1) 38 | } 39 | -------------------------------------------------------------------------------- /example/getEvent.mjs: -------------------------------------------------------------------------------- 1 | import { FingerprintJsServerApiClient, Region, RequestError } from '@fingerprintjs/fingerprintjs-pro-server-api' 2 | import { config } from 'dotenv' 3 | config() 4 | 5 | const apiKey = process.env.API_KEY 6 | const requestId = process.env.REQUEST_ID 7 | const envRegion = process.env.REGION 8 | 9 | if (!requestId) { 10 | console.error('Request ID not defined') 11 | process.exit(1) 12 | } 13 | 14 | if (!apiKey) { 15 | console.error('API key not defined') 16 | process.exit(1) 17 | } 18 | 19 | let region = Region.Global 20 | if (envRegion === 'eu') { 21 | region = Region.EU 22 | } else if (envRegion === 'ap') { 23 | region = Region.AP 24 | } 25 | 26 | const client = new FingerprintJsServerApiClient({ region, apiKey }) 27 | 28 | try { 29 | const event = await client.getEvent(requestId) 30 | console.log(JSON.stringify(event, null, 2)) 31 | } catch (error) { 32 | if (error instanceof RequestError) { 33 | console.log(`error ${error.statusCode}: `, error.message) 34 | // You can also access the raw response 35 | console.log(error.response.statusText) 36 | } else { 37 | console.log('unknown error: ', error) 38 | } 39 | process.exit(1) 40 | } 41 | -------------------------------------------------------------------------------- /example/getVisitorHistory.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | FingerprintJsServerApiClient, 3 | Region, 4 | RequestError, 5 | TooManyRequestsError, 6 | } from '@fingerprintjs/fingerprintjs-pro-server-api' 7 | import { config } from 'dotenv' 8 | config() 9 | 10 | const apiKey = process.env.API_KEY 11 | const visitorId = process.env.VISITOR_ID 12 | const envRegion = process.env.REGION 13 | 14 | if (!visitorId) { 15 | console.error('Visitor ID not defined') 16 | process.exit(1) 17 | } 18 | 19 | if (!apiKey) { 20 | console.error('API key not defined') 21 | process.exit(1) 22 | } 23 | 24 | let region = Region.Global 25 | if (envRegion === 'eu') { 26 | region = Region.EU 27 | } else if (envRegion === 'ap') { 28 | region = Region.AP 29 | } 30 | 31 | const client = new FingerprintJsServerApiClient({ region, apiKey }) 32 | 33 | try { 34 | const visitorHistory = await client.getVisits(visitorId, { limit: 10 }) 35 | console.log(JSON.stringify(visitorHistory, null, 2)) 36 | } catch (error) { 37 | if (error instanceof RequestError) { 38 | console.log(error.statusCode, error.message) 39 | if (error instanceof TooManyRequestsError) { 40 | retryLater(error.retryAfter) // Needs to be implemented on your side 41 | } 42 | } else { 43 | console.error('unknown error: ', error) 44 | } 45 | process.exit(1) 46 | } 47 | 48 | /** 49 | * @param {number} delay - How many seconds to wait before retrying 50 | */ 51 | function retryLater(delay) { 52 | console.log(`Implement your own retry logic here and retry after ${delay} seconds`) 53 | } 54 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fingerprintjs-pro-server-api-node-sdk-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.mjs", 6 | "private": true, 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "MIT", 12 | "dependencies": { 13 | "@fingerprintjs/fingerprintjs-pro-server-api": "workspace:*", 14 | "dotenv": "^16.4.5" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /example/relatedVisitors.mjs: -------------------------------------------------------------------------------- 1 | import { FingerprintJsServerApiClient, Region, RequestError } from '@fingerprintjs/fingerprintjs-pro-server-api' 2 | import { config } from 'dotenv' 3 | config() 4 | 5 | const apiKey = process.env.API_KEY 6 | const visitorId = process.env.VISITOR_ID 7 | const envRegion = process.env.REGION 8 | 9 | if (!visitorId) { 10 | console.error('Visitor ID not defined') 11 | process.exit(1) 12 | } 13 | 14 | if (!apiKey) { 15 | console.error('API key not defined') 16 | process.exit(1) 17 | } 18 | 19 | let region = Region.Global 20 | if (envRegion === 'eu') { 21 | region = Region.EU 22 | } else if (envRegion === 'ap') { 23 | region = Region.AP 24 | } 25 | 26 | const client = new FingerprintJsServerApiClient({ region, apiKey }) 27 | 28 | try { 29 | const relatedVisitors = await client.getRelatedVisitors({ 30 | visitor_id: visitorId, 31 | }) 32 | 33 | console.log(JSON.stringify(relatedVisitors, null, 2)) 34 | } catch (error) { 35 | if (error instanceof RequestError) { 36 | console.log(`error ${error.statusCode}: `, error.message) 37 | // You can also access the raw response 38 | console.log(error.response.statusText) 39 | } else { 40 | console.log('unknown error: ', error) 41 | } 42 | process.exit(1) 43 | } 44 | -------------------------------------------------------------------------------- /example/searchEvents.mjs: -------------------------------------------------------------------------------- 1 | import { FingerprintJsServerApiClient, Region, RequestError } from '@fingerprintjs/fingerprintjs-pro-server-api' 2 | import { config } from 'dotenv' 3 | config() 4 | 5 | const apiKey = process.env.API_KEY 6 | const envRegion = process.env.REGION 7 | 8 | if (!apiKey) { 9 | console.error('API key not defined') 10 | process.exit(1) 11 | } 12 | 13 | let region = Region.Global 14 | if (envRegion === 'eu') { 15 | region = Region.EU 16 | } else if (envRegion === 'ap') { 17 | region = Region.AP 18 | } 19 | 20 | const client = new FingerprintJsServerApiClient({ region, apiKey }) 21 | 22 | const filter = { 23 | limit: 10, 24 | // pagination_key: '', 25 | // bot: 'all', 26 | // visitor_id: 'TaDnMBz9XCpZNuSzFUqP', 27 | // ip_address: '192.168.0.1/32', 28 | // linked_id: ', 29 | //start: 1620000000000, 30 | //end: 1630000000000, 31 | //reverse: true, 32 | //suspect: false, 33 | } 34 | 35 | try { 36 | const event = await client.searchEvents(filter) 37 | console.log(JSON.stringify(event, null, 2)) 38 | } catch (error) { 39 | if (error instanceof RequestError) { 40 | console.log(`error ${error.statusCode}: `, error.message) 41 | // You can also access the raw response 42 | console.log(error.response.statusText) 43 | } else { 44 | console.log('unknown error: ', error) 45 | } 46 | process.exit(1) 47 | } 48 | -------------------------------------------------------------------------------- /example/unsealResult.mjs: -------------------------------------------------------------------------------- 1 | import { unsealEventsResponse, DecryptionAlgorithm } from '@fingerprintjs/fingerprintjs-pro-server-api' 2 | import { config } from 'dotenv' 3 | config() 4 | 5 | const sealedData = process.env.BASE64_SEALED_RESULT 6 | const decryptionKey = process.env.BASE64_KEY 7 | 8 | if (!sealedData || !decryptionKey) { 9 | console.error('Please set BASE64_KEY and BASE64_SEALED_RESULT environment variables') 10 | process.exit(1) 11 | } 12 | 13 | try { 14 | const unsealedData = await unsealEventsResponse(Buffer.from(sealedData, 'base64'), [ 15 | { 16 | key: Buffer.from(decryptionKey, 'base64'), 17 | algorithm: DecryptionAlgorithm.Aes256Gcm, 18 | }, 19 | ]) 20 | console.log(JSON.stringify(unsealedData, null, 2)) 21 | } catch (e) { 22 | console.error(e) 23 | process.exit(1) 24 | } 25 | -------------------------------------------------------------------------------- /example/updateEvent.mjs: -------------------------------------------------------------------------------- 1 | import { FingerprintJsServerApiClient, RequestError, Region } from '@fingerprintjs/fingerprintjs-pro-server-api' 2 | import { config } from 'dotenv' 3 | 4 | config() 5 | 6 | const apiKey = process.env.API_KEY 7 | const requestId = process.env.REQUEST_ID 8 | const envRegion = process.env.REGION 9 | 10 | if (!requestId) { 11 | console.error('Request ID not defined') 12 | process.exit(1) 13 | } 14 | 15 | if (!apiKey) { 16 | console.error('API key not defined') 17 | process.exit(1) 18 | } 19 | 20 | let region = Region.Global 21 | if (envRegion === 'eu') { 22 | region = Region.EU 23 | } else if (envRegion === 'ap') { 24 | region = Region.AP 25 | } 26 | 27 | const client = new FingerprintJsServerApiClient({ region, apiKey }) 28 | 29 | try { 30 | await client.updateEvent( 31 | { 32 | tag: { 33 | key: 'value', 34 | }, 35 | linkedId: 'new_linked_id', 36 | suspect: false, 37 | }, 38 | requestId 39 | ) 40 | 41 | console.log('Event updated') 42 | } catch (error) { 43 | if (error instanceof RequestError) { 44 | console.log(`error ${error.statusCode}: `, error.message) 45 | // You can also access the raw response 46 | console.log(error.response.statusText) 47 | } else { 48 | console.log('unknown error: ', error) 49 | } 50 | process.exit(1) 51 | } 52 | -------------------------------------------------------------------------------- /example/validateWebhookSignature.mjs: -------------------------------------------------------------------------------- 1 | import { isValidWebhookSignature } from '@fingerprintjs/fingerprintjs-pro-server-api' 2 | 3 | /** 4 | * Webhook endpoint handler example 5 | * @param {Request} request 6 | */ 7 | export async function POST(request) { 8 | try { 9 | const secret = process.env.WEBHOOK_SIGNATURE_SECRET 10 | const header = request.headers.get('fpjs-event-signature') 11 | const data = Buffer.from(await request.arrayBuffer()) 12 | 13 | if (!secret) { 14 | return Response.json({ message: 'Secret is not set.' }, { status: 500 }) 15 | } 16 | 17 | if (!header) { 18 | return Response.json({ message: 'fpjs-event-signature header not found.' }, { status: 400 }) 19 | } 20 | 21 | if (!isValidWebhookSignature({ header, data, secret })) { 22 | return Response.json({ message: 'Webhook signature is invalid.' }, { status: 403 }) 23 | } 24 | 25 | return Response.json({ message: 'Webhook received.' }, { status: 200 }) 26 | } catch (error) { 27 | return Response.json({ error }, { status: 500 }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /generate.mjs: -------------------------------------------------------------------------------- 1 | import openapiTS, { astToString } from 'openapi-typescript' 2 | import * as fs from 'fs' 3 | import * as yaml from 'yaml' 4 | 5 | const schemaObject = yaml.parse(fs.readFileSync('resources/fingerprint-server-api.yaml', 'utf-8')) 6 | 7 | // Function to resolve $ref paths 8 | function getObjectByRef(refPath, schema) { 9 | if (!refPath.startsWith('#/')) { 10 | throw new Error('Only local $refs starting with "#/" are supported') 11 | } 12 | 13 | // Split the path into parts, e.g., "#/components/schemas/GeolocationCity" -> ["components", "schemas", "GeolocationCity"] 14 | const pathParts = refPath.slice(2).split('/') 15 | 16 | // Traverse the schema object based on path parts 17 | let current = schema 18 | for (const part of pathParts) { 19 | if (current[part] !== undefined) { 20 | current = current[part] 21 | } else { 22 | throw new Error(`Path not found: ${refPath}`) 23 | } 24 | } 25 | 26 | return current 27 | } 28 | 29 | try { 30 | const result = await openapiTS(schemaObject, { 31 | /** 32 | * Enhances generated documentation by propagating it from source object in schema to all properties that use it as $ref. 33 | * */ 34 | transform: (schema) => { 35 | if (schema.type === 'object' && Boolean(schema.properties)) { 36 | Object.entries(schema.properties).forEach(([key, value]) => { 37 | if (value.$ref && !value.description) { 38 | const source = getObjectByRef(value.$ref, schemaObject) 39 | 40 | schema.properties[key] = { 41 | ...value, 42 | description: source?.description, 43 | } 44 | } 45 | }) 46 | } 47 | }, 48 | }) 49 | 50 | fs.writeFileSync('./src/generatedApiTypes.ts', astToString(result)) 51 | } catch (e) { 52 | console.error(e) 53 | process.exit(1) 54 | } 55 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testRegex: '/tests/.+spec.ts?$', 6 | collectCoverageFrom: ['./src/**/**.{ts,tsx}'], 7 | coverageReporters: ['lcov', 'json-summary', ['text', { file: 'coverage.txt', path: './' }]], 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fingerprintjs/fingerprintjs-pro-server-api", 3 | "version": "6.6.0", 4 | "description": "Node.js wrapper for FingerprintJS Sever API", 5 | "main": "dist/index.cjs", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/index.d.ts", 11 | "import": "./dist/index.mjs", 12 | "require": "./dist/index.cjs", 13 | "node": "./dist/index.mjs" 14 | } 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/fingerprintjs/fingerprintjs-pro-server-api-node-sdk" 19 | }, 20 | "engines": { 21 | "node": ">=18.17.0" 22 | }, 23 | "scripts": { 24 | "prepare": "husky install", 25 | "build": "rimraf dist && rollup -c rollup.config.js --bundleConfigAsCjs", 26 | "lint": "eslint --ext .js,.ts --ignore-path .gitignore --max-warnings 0 .", 27 | "lint:fix": "pnpm lint --fix", 28 | "test": "jest", 29 | "test:coverage": "jest --coverage", 30 | "test:dts": "tsc --noEmit --isolatedModules dist/index.d.ts", 31 | "generateTypes": "node generate.mjs && pnpm lint:fix", 32 | "docs": "typedoc src/index.ts --out docs" 33 | }, 34 | "keywords": [], 35 | "author": "FingerprintJS, Inc (https://fingerprint.com)", 36 | "license": "MIT", 37 | "lint-staged": { 38 | "*.ts": "pnpm run lint:fix" 39 | }, 40 | "config": { 41 | "commitizen": { 42 | "path": "./node_modules/cz-conventional-changelog" 43 | } 44 | }, 45 | "devDependencies": { 46 | "@changesets/cli": "^2.27.8", 47 | "@commitlint/cli": "^19.2.1", 48 | "@fingerprintjs/changesets-changelog-format": "^0.2.0", 49 | "@fingerprintjs/commit-lint-dx-team": "^0.1.0", 50 | "@fingerprintjs/conventional-changelog-dx-team": "^0.1.0", 51 | "@fingerprintjs/eslint-config-dx-team": "^0.1.0", 52 | "@fingerprintjs/prettier-config-dx-team": "^0.2.0", 53 | "@fingerprintjs/tsconfig-dx-team": "^0.0.2", 54 | "@rollup/plugin-json": "^6.1.0", 55 | "@rollup/plugin-typescript": "^11.1.6", 56 | "@types/jest": "^29.5.12", 57 | "@types/node": "^20.11.30", 58 | "buffer": "^6.0.3", 59 | "commitizen": "^4.3.0", 60 | "cz-conventional-changelog": "^3.3.0", 61 | "husky": "^9.0.11", 62 | "jest": "^29.7.0", 63 | "lint-staged": "^15.2.2", 64 | "openapi-typescript": "^7.4.2", 65 | "rimraf": "^5.0.5", 66 | "rollup": "^4.34.9", 67 | "rollup-plugin-dts": "^6.1.0", 68 | "rollup-plugin-license": "^3.3.1", 69 | "rollup-plugin-peer-deps-external": "^2.2.4", 70 | "ts-jest": "^29.1.2", 71 | "tslib": "^2.6.2", 72 | "typedoc": "^0.27.9", 73 | "typescript": "^5.4.0", 74 | "yaml": "^2.6.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - './' 3 | - './example' 4 | - './tests/functional-tests' 5 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | 6 | Fingerprint logo 7 | 8 | 9 |

10 |

11 | Build status 12 | coverage 13 | Current NPM version 14 | Monthly downloads from NPM 15 | Discord server 16 |

17 | 18 | # Fingerprint Server API Node.js SDK 19 | 20 | [Fingerprint](https://fingerprint.com) is a device intelligence platform offering industry-leading accuracy. 21 | 22 | The Fingerprint Server Node SDK is an easy way to interact with the Fingerprint [Server API](https://dev.fingerprint.com/reference/pro-server-api) from your Node application. You can search, update, or delete identification events. 23 | 24 | ## Requirements 25 | 26 | TypeScript support: 27 | 28 | - TypeScript 4.5.5 or higher 29 | 30 | Supported runtimes: 31 | 32 | - Node.js 18 LTS or higher (we support all [Node LTS releases before end-of-life](https://nodejs.dev/en/about/releases/)). 33 | - Deno and Bun might work but are not actively tested. 34 | - "Edge" runtimes might work with some modifications but are not actively tested.
35 | See "edge" runtimes compatibility 36 | 37 | This SDK can be made compatible with JavaScript "edge" runtimes that do not support all Node APIs, for example, [Vercel Edge Runtime](https://edge-runtime.vercel.app/), or [Cloudflare Workers](https://developers.cloudflare.com/workers/). 38 | 39 | To make it work, replace the SDK's built-in `fetch` function (which relies on Node APIs) with the runtime's native `fetch` function. Pass the function into the constructor with proper binding: 40 | 41 | ```js 42 | const client = new FingerprintJsServerApiClient({ 43 | region: Region.EU, 44 | apiKey: apiKey, 45 | fetch: fetch.bind(globalThis), 46 | }) 47 | ``` 48 | 49 |
50 | 51 | ## How to install 52 | 53 | Install the package using your favorite package manager: 54 | 55 | - NPM: 56 | 57 | ```sh 58 | npm i @fingerprintjs/fingerprintjs-pro-server-api 59 | ``` 60 | 61 | - Yarn: 62 | ```sh 63 | yarn add @fingerprintjs/fingerprintjs-pro-server-api 64 | ``` 65 | - pnpm: 66 | ```sh 67 | pnpm i @fingerprintjs/fingerprintjs-pro-server-api 68 | ``` 69 | 70 | ## Getting started 71 | 72 | Initialize the client instance and use it to make API requests. You need to specify your Fingerprint [Secret API key](https://dev.fingerprint.com/docs/quick-start-guide#4-get-smart-signals-to-your-server) and the region of your Fingerprint workspace. 73 | 74 | ```ts 75 | import { 76 | FingerprintJsServerApiClient, 77 | Region, 78 | } from '@fingerprintjs/fingerprintjs-pro-server-api' 79 | 80 | const client = new FingerprintJsServerApiClient({ 81 | apiKey: '', 82 | region: Region.Global, 83 | }) 84 | 85 | // Get visit history of a specific visitor 86 | client.getVisits('').then((visitorHistory) => { 87 | console.log(visitorHistory) 88 | }) 89 | 90 | // Get a specific identification event 91 | client.getEvent('').then((event) => { 92 | console.log(event) 93 | }) 94 | 95 | // Search for identification events 96 | client 97 | .searchEvents({ 98 | limit: 10, 99 | // pagination_key: previousSearchResult.paginationKey, 100 | suspect: true, 101 | }) 102 | .then((events) => { 103 | console.log(events) 104 | }) 105 | ``` 106 | 107 | See the [Examples](https://github.com/fingerprintjs/fingerprintjs-pro-server-api-node-sdk/tree/main/example) folder for more detailed examples. 108 | 109 | ### Error handling 110 | 111 | The Server API methods can throw `RequestError`. 112 | When handling errors, you can check for it like this: 113 | 114 | ```typescript 115 | import { 116 | RequestError, 117 | FingerprintJsServerApiClient, 118 | TooManyRequestsError, 119 | } from '@fingerprintjs/fingerprintjs-pro-server-api' 120 | 121 | const client = new FingerprintJsServerApiClient({ 122 | apiKey: '', 123 | region: Region.Global, 124 | }) 125 | 126 | // Handling getEvent errors 127 | try { 128 | const event = await client.getEvent(requestId) 129 | console.log(JSON.stringify(event, null, 2)) 130 | } catch (error) { 131 | if (error instanceof RequestError) { 132 | console.log(error.responseBody) // Access parsed response body 133 | console.log(error.response) // You can also access the raw response 134 | console.log(`error ${error.statusCode}: `, error.message) 135 | } else { 136 | console.log('unknown error: ', error) 137 | } 138 | } 139 | 140 | // Handling getVisits errors 141 | try { 142 | const visitorHistory = await client.getVisits(visitorId, { 143 | limit: 10, 144 | }) 145 | console.log(JSON.stringify(visitorHistory, null, 2)) 146 | } catch (error) { 147 | if (error instanceof RequestError) { 148 | console.log(error.status, error.error) 149 | if (error instanceof TooManyRequestsError) { 150 | retryLater(error.retryAfter) // Needs to be implemented on your side 151 | } 152 | } else { 153 | console.error('unknown error: ', error) 154 | } 155 | 156 | // You can also check for specific error instance 157 | // if(error instanceof VisitorsError403) { 158 | // Handle 403 error... 159 | // } 160 | } 161 | ``` 162 | 163 | ### Webhooks 164 | 165 | #### Webhook types 166 | 167 | When handling [Webhooks](https://dev.fingerprint.com/docs/webhooks) coming from Fingerprint, you can cast the payload as the built-in `VisitWebhook` type: 168 | 169 | ```ts 170 | import { VisitWebhook } from '@fingerprintjs/fingerprintjs-pro-server-api' 171 | 172 | const visit = visitWebhookBody as unknown as VisitWebhook 173 | ``` 174 | 175 | #### Webhook signature validation 176 | 177 | Customers on the Enterprise plan can enable [Webhook signatures](https://dev.fingerprint.com/docs/webhooks-security) to cryptographically verify the authenticity of incoming webhooks. 178 | This SDK provides a utility method for verifying the HMAC signature of the incoming webhook request. 179 | 180 | To learn more, see [example/validateWebhookSignature.mjs](example/validateWebhookSignature.mjs) or read the [API Reference](https://fingerprintjs.github.io/fingerprintjs-pro-server-api-node-sdk/functions/isValidWebhookSignature.html). 181 | 182 | ### Sealed results 183 | 184 | Customers on the Enterprise plan can enable [Sealed results](https://dev.fingerprint.com/docs/sealed-client-results) to receive the full device intelligence result on the client and unseal it on the server. This SDK provides utility methods for decoding sealed results. 185 | 186 | To learn more, see [example/unsealResult.mjs](https://github.com/fingerprintjs/fingerprintjs-pro-server-api-node-sdk/tree/main/example/unsealResult.mjs) or the [API Reference](https://fingerprintjs.github.io/fingerprintjs-pro-server-api-node-sdk/functions/unsealEventsResponse.html). 187 | 188 | ### Deleting visitor data 189 | 190 | Customers on the Enterprise plan can [Delete all data associated with a specific visitor](https://dev.fingerprint.com/reference/deletevisitordata) to comply with privacy regulations. See [example/deleteVisitor.mjs](https://github.com/fingerprintjs/fingerprintjs-pro-server-api-node-sdk/tree/main/example/deleteVisitor.mjs) or the [API Reference](https://fingerprintjs.github.io/fingerprintjs-pro-server-api-node-sdk/classes/FingerprintJsServerApiClient.html#deleteVisitorData). 191 | 192 | ## API Reference 193 | 194 | See the full [API reference](https://fingerprintjs.github.io/fingerprintjs-pro-server-api-node-sdk/). 195 | 196 | ## Support and feedback 197 | 198 | To report problems, ask questions, or provide feedback, please use [Issues](https://github.com/fingerprintjs/fingerprintjs-pro-server-api-node-sdk/issues). If you need private support, you can email us at [oss-support@fingerprint.com](mailto:oss-support@fingerprint.com). 199 | 200 | ## License 201 | 202 | This project is licensed under the [MIT license](https://github.com/fingerprintjs/fingerprintjs-pro-server-api-node-sdk/tree/main/LICENSE). 203 | -------------------------------------------------------------------------------- /resources/license_banner.txt: -------------------------------------------------------------------------------- 1 | FingerprintJS Server API Node.js SDK v<%= pkg.version %> - Copyright (c) FingerprintJS, Inc, <%= new Date().getFullYear() %> (https://fingerprint.com) 2 | Licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) license. -------------------------------------------------------------------------------- /resources/logo_dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/logo_light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript' 2 | import jsonPlugin from '@rollup/plugin-json' 3 | import external from 'rollup-plugin-peer-deps-external' 4 | import { dts } from 'rollup-plugin-dts' 5 | import licensePlugin from 'rollup-plugin-license' 6 | import { join } from 'path' 7 | 8 | const { dependencies = {}, main, module, types } = require('./package.json') 9 | 10 | const inputFile = 'src/index.ts' 11 | 12 | const commonBanner = licensePlugin({ 13 | banner: { 14 | content: { 15 | file: join(__dirname, 'resources', 'license_banner.txt'), 16 | }, 17 | }, 18 | }) 19 | 20 | const commonInput = { 21 | input: inputFile, 22 | plugins: [jsonPlugin(), typescript(), external(), commonBanner], 23 | } 24 | 25 | const commonOutput = { 26 | exports: 'named', 27 | } 28 | 29 | export default [ 30 | { 31 | ...commonInput, 32 | external: Object.keys(dependencies), 33 | output: [ 34 | // CJS for usage with `require()` 35 | { 36 | ...commonOutput, 37 | file: main, 38 | format: 'cjs', 39 | }, 40 | 41 | // ESM for usage with `import` 42 | { 43 | ...commonOutput, 44 | file: module, 45 | format: 'es', 46 | }, 47 | ], 48 | }, 49 | 50 | // TypeScript definition 51 | { 52 | ...commonInput, 53 | plugins: [dts(), commonBanner], 54 | output: { 55 | file: types, 56 | format: 'es', 57 | }, 58 | }, 59 | ] 60 | -------------------------------------------------------------------------------- /src/errors/apiErrors.ts: -------------------------------------------------------------------------------- 1 | import { ErrorPlainResponse, ErrorResponse } from '../types' 2 | import { getRetryAfter } from './getRetryAfter' 3 | 4 | export class SdkError extends Error { 5 | constructor( 6 | message: string, 7 | readonly response?: Response, 8 | cause?: Error 9 | ) { 10 | super(message, { cause }) 11 | this.name = this.constructor.name 12 | } 13 | } 14 | 15 | export class RequestError extends SdkError { 16 | // HTTP Status code 17 | readonly statusCode: Code 18 | 19 | // API error code 20 | readonly errorCode: string 21 | 22 | // API error response 23 | readonly responseBody: Body 24 | 25 | // Raw HTTP response 26 | override readonly response: Response 27 | 28 | constructor(message: string, body: Body, statusCode: Code, errorCode: string, response: Response) { 29 | super(message, response) 30 | this.responseBody = body 31 | this.response = response 32 | this.errorCode = errorCode 33 | this.statusCode = statusCode 34 | } 35 | 36 | static unknown(response: Response) { 37 | return new RequestError('Unknown error', undefined, response.status, response.statusText, response) 38 | } 39 | 40 | static fromPlainError(body: ErrorPlainResponse, response: Response) { 41 | return new RequestError(body.error, body, response.status, response.statusText, response) 42 | } 43 | 44 | static fromErrorResponse(body: ErrorResponse, response: Response) { 45 | return new RequestError(body.error.message, body, response.status, body.error.code, response) 46 | } 47 | } 48 | 49 | /** 50 | * Error that indicate that the request was throttled. 51 | * */ 52 | export class TooManyRequestsError extends RequestError<429, ErrorResponse> { 53 | /** 54 | * Number of seconds to wait before retrying the request. 55 | * @remarks 56 | * The value is parsed from the `Retry-After` header of the response. 57 | */ 58 | readonly retryAfter: number = 0 59 | 60 | constructor(body: ErrorResponse, response: Response) { 61 | super(body.error.message, body, 429, body.error.code, response) 62 | this.retryAfter = getRetryAfter(response) 63 | } 64 | 65 | static fromPlain(error: ErrorPlainResponse, response: Response) { 66 | return new TooManyRequestsError( 67 | { 68 | error: { 69 | message: error.error, 70 | code: 'TooManyRequests', 71 | }, 72 | }, 73 | response 74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/errors/getRetryAfter.ts: -------------------------------------------------------------------------------- 1 | export function getRetryAfter(response: Response) { 2 | const retryAfter = parseInt(response.headers.get('retry-after') ?? '') 3 | return Number.isNaN(retryAfter) ? 0 : retryAfter 4 | } 5 | -------------------------------------------------------------------------------- /src/errors/handleErrorResponse.ts: -------------------------------------------------------------------------------- 1 | import { ErrorPlainResponse, ErrorResponse } from '../types' 2 | import { RequestError, TooManyRequestsError } from './apiErrors' 3 | 4 | function isErrorResponse(value: unknown): value is ErrorResponse { 5 | return Boolean( 6 | value && 7 | typeof value === 'object' && 8 | 'error' in value && 9 | typeof value.error === 'object' && 10 | value.error && 11 | 'code' in value.error && 12 | 'message' in value.error 13 | ) 14 | } 15 | 16 | function isPlainErrorResponse(value: unknown): value is ErrorPlainResponse { 17 | return Boolean(value && typeof value === 'object' && 'error' in value && typeof value.error === 'string') 18 | } 19 | 20 | export function handleErrorResponse(json: any, response: Response): never { 21 | if (isErrorResponse(json)) { 22 | if (response.status === 429) { 23 | throw new TooManyRequestsError(json, response) 24 | } 25 | 26 | throw RequestError.fromErrorResponse(json, response) 27 | } 28 | 29 | if (isPlainErrorResponse(json)) { 30 | if (response.status === 429) { 31 | throw TooManyRequestsError.fromPlain(json, response) 32 | } 33 | 34 | throw RequestError.fromPlainError(json, response) 35 | } 36 | 37 | throw RequestError.unknown(response) 38 | } 39 | -------------------------------------------------------------------------------- /src/errors/unsealError.ts: -------------------------------------------------------------------------------- 1 | import { DecryptionKey } from '../sealedResults' 2 | 3 | export class UnsealError extends Error { 4 | constructor( 5 | readonly key: DecryptionKey, 6 | readonly error?: Error 7 | ) { 8 | let msg = `Unable to decrypt sealed data` 9 | 10 | if (error) { 11 | msg = msg.concat(`: ${error.message}`) 12 | } 13 | 14 | super(msg) 15 | this.name = 'UnsealError' 16 | } 17 | } 18 | 19 | export class UnsealAggregateError extends Error { 20 | constructor(readonly errors: UnsealError[]) { 21 | super('Unable to decrypt sealed data') 22 | this.name = 'UnsealAggregateError' 23 | } 24 | 25 | addError(error: UnsealError) { 26 | this.errors.push(error) 27 | } 28 | 29 | toString() { 30 | return this.errors.map((e) => e.toString()).join('\n') 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './urlUtils' 2 | export * from './serverApiClient' 3 | export * from './types' 4 | export * from './sealedResults' 5 | export * from './errors/unsealError' 6 | export * from './webhook' 7 | export * from './errors/apiErrors' 8 | export * from './errors/unsealError' 9 | export * from './errors/getRetryAfter' 10 | -------------------------------------------------------------------------------- /src/responseUtils.ts: -------------------------------------------------------------------------------- 1 | import { SdkError } from './errors/apiErrors' 2 | import { toError } from './utils' 3 | 4 | export async function copyResponseJson(response: Response) { 5 | try { 6 | return await response.clone().json() 7 | } catch (e) { 8 | throw new SdkError('Failed to parse JSON response', response, toError(e)) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/sealedResults.ts: -------------------------------------------------------------------------------- 1 | import { createDecipheriv } from 'crypto' 2 | import { inflateRaw } from 'zlib' 3 | import { promisify } from 'util' 4 | import { EventsGetResponse } from './types' 5 | import { UnsealAggregateError, UnsealError } from './errors/unsealError' 6 | import { Buffer } from 'buffer' 7 | 8 | const asyncInflateRaw = promisify(inflateRaw) 9 | 10 | export enum DecryptionAlgorithm { 11 | Aes256Gcm = 'aes-256-gcm', 12 | } 13 | 14 | export interface DecryptionKey { 15 | key: Buffer 16 | algorithm: DecryptionAlgorithm 17 | } 18 | 19 | const SEALED_HEADER = Buffer.from([0x9e, 0x85, 0xdc, 0xed]) 20 | 21 | function isEventResponse(data: unknown): data is EventsGetResponse { 22 | return Boolean(data && typeof data === 'object' && 'products' in data) 23 | } 24 | 25 | /** 26 | * @private 27 | * */ 28 | export function parseEventsResponse(unsealed: string): EventsGetResponse { 29 | const json = JSON.parse(unsealed) 30 | 31 | if (!isEventResponse(json)) { 32 | throw new Error('Sealed data is not valid events response') 33 | } 34 | 35 | return json 36 | } 37 | 38 | /** 39 | * Decrypts the sealed response with the provided keys. 40 | * The SDK will try to decrypt the result with each key until it succeeds. 41 | * To learn more about sealed results visit: https://dev.fingerprint.com/docs/sealed-client-results 42 | */ 43 | export async function unsealEventsResponse( 44 | sealedData: Buffer, 45 | decryptionKeys: DecryptionKey[] 46 | ): Promise { 47 | const unsealed = await unseal(sealedData, decryptionKeys) 48 | 49 | return parseEventsResponse(unsealed) 50 | } 51 | 52 | /** 53 | * @private 54 | * */ 55 | export async function unseal(sealedData: Buffer, decryptionKeys: DecryptionKey[]) { 56 | if (sealedData.subarray(0, SEALED_HEADER.length).toString('hex') !== SEALED_HEADER.toString('hex')) { 57 | throw new Error('Invalid sealed data header') 58 | } 59 | 60 | const errors = new UnsealAggregateError([]) 61 | 62 | for (const decryptionKey of decryptionKeys) { 63 | switch (decryptionKey.algorithm) { 64 | case DecryptionAlgorithm.Aes256Gcm: 65 | try { 66 | return await unsealAes256Gcm(sealedData, decryptionKey.key) 67 | } catch (e) { 68 | errors.addError(new UnsealError(decryptionKey, e as Error)) 69 | continue 70 | } 71 | 72 | default: 73 | throw new Error(`Unsupported decryption algorithm: ${decryptionKey.algorithm}`) 74 | } 75 | } 76 | 77 | throw errors 78 | } 79 | 80 | async function unsealAes256Gcm(sealedData: Buffer, decryptionKey: Buffer) { 81 | const nonceLength = 12 82 | const nonce = sealedData.subarray(SEALED_HEADER.length, SEALED_HEADER.length + nonceLength) 83 | 84 | const authTagLength = 16 85 | const authTag = sealedData.subarray(-authTagLength) 86 | 87 | const ciphertext = sealedData.subarray(SEALED_HEADER.length + nonceLength, -authTagLength) 88 | 89 | const decipher = createDecipheriv('aes-256-gcm', decryptionKey, nonce).setAuthTag(authTag) 90 | const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]) 91 | 92 | const payload = await asyncInflateRaw(compressed) 93 | 94 | return payload.toString() 95 | } 96 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { components, operations, paths } from './generatedApiTypes' 2 | 3 | export enum Region { 4 | EU = 'EU', 5 | AP = 'AP', 6 | Global = 'Global', 7 | } 8 | 9 | export enum AuthenticationMode { 10 | AuthHeader = 'AuthHeader', 11 | QueryParameter = 'QueryParameter', 12 | } 13 | 14 | /** 15 | * Options for FingerprintJS server API client 16 | */ 17 | export interface Options { 18 | /** 19 | * Secret API key 20 | */ 21 | apiKey: string 22 | /** 23 | * Region of the FingerprintJS service server 24 | */ 25 | region?: Region 26 | /** 27 | * Authentication mode 28 | * Optional, default value is AuthHeader 29 | */ 30 | authenticationMode?: AuthenticationMode 31 | 32 | /** 33 | * Optional fetch implementation 34 | * */ 35 | fetch?: typeof fetch 36 | } 37 | 38 | /** 39 | * More info: https://dev.fingerprintjs.com/docs/server-api#query-parameters 40 | */ 41 | export type VisitorHistoryFilter = paths['/visitors/{visitor_id}']['get']['parameters']['query'] 42 | 43 | export type ErrorPlainResponse = components['schemas']['ErrorPlainResponse'] 44 | export type ErrorResponse = components['schemas']['ErrorResponse'] 45 | 46 | export type SearchEventsFilter = paths['/events/search']['get']['parameters']['query'] 47 | export type SearchEventsResponse = paths['/events/search']['get']['responses']['200']['content']['application/json'] 48 | 49 | /** 50 | * More info: https://dev.fingerprintjs.com/docs/server-api#response 51 | */ 52 | export type VisitorsResponse = paths['/visitors/{visitor_id}']['get']['responses']['200']['content']['application/json'] 53 | 54 | export type EventsGetResponse = paths['/events/{request_id}']['get']['responses']['200']['content']['application/json'] 55 | 56 | export type RelatedVisitorsResponse = 57 | paths['/related-visitors']['get']['responses']['200']['content']['application/json'] 58 | export type RelatedVisitorsFilter = paths['/related-visitors']['get']['parameters']['query'] 59 | 60 | /** 61 | * More info: https://dev.fingerprintjs.com/docs/webhooks#identification-webhook-object-format 62 | */ 63 | export type Webhook = components['schemas']['Webhook'] 64 | 65 | export type EventsUpdateRequest = components['schemas']['EventsUpdateRequest'] 66 | 67 | // Extract just the `path` parameters as a tuple of strings 68 | type ExtractPathParamStrings = Path extends { parameters: { path: infer P } } 69 | ? P extends Record 70 | ? [P[keyof P]] // We extract the path parameter values as a tuple of strings 71 | : [] 72 | : [] 73 | 74 | // Utility type to extract query parameters from an operation and differentiate required/optional 75 | export type ExtractQueryParams = Path extends { parameters: { query?: infer Q } } 76 | ? undefined extends Q // Check if Q can be undefined (meaning it's optional) 77 | ? Q | undefined // If so, it's optional 78 | : Q // Otherwise, it's required 79 | : never // If no query parameters, return never 80 | 81 | // Utility type to extract request body from an operation (for POST, PUT, etc.) 82 | type ExtractRequestBody = Path extends { requestBody: { content: { 'application/json': infer B } } } ? B : never 83 | 84 | // Utility type to extract the response type for 200 status code 85 | type ExtractResponse = Path extends { responses: { 200: { content: { 'application/json': infer R } } } } 86 | ? R 87 | : void 88 | 89 | // Extracts args to given API method 90 | type ApiMethodArgs = [ 91 | // If method has body, extract it as first parameter 92 | ...(ExtractRequestBody extends never ? [] : [body: ExtractRequestBody]), 93 | // Next are path params, e.g. for path "/events/{request_id}" it will be one string parameter, 94 | ...ExtractPathParamStrings, 95 | // Last parameter will be the query params, if any 96 | ...(ExtractQueryParams extends never ? [] : [params: ExtractQueryParams]), 97 | ] 98 | 99 | type ApiMethod = ( 100 | ...args: ApiMethodArgs 101 | ) => Promise> 102 | 103 | export type FingerprintApi = { 104 | [Path in keyof operations]: ApiMethod 105 | } 106 | -------------------------------------------------------------------------------- /src/urlUtils.ts: -------------------------------------------------------------------------------- 1 | import { ExtractQueryParams, Region } from './types' 2 | import { version } from '../package.json' 3 | import { paths } from './generatedApiTypes' 4 | 5 | const euRegionUrl = 'https://eu.api.fpjs.io/' 6 | const apRegionUrl = 'https://ap.api.fpjs.io/' 7 | const globalRegionUrl = 'https://api.fpjs.io/' 8 | 9 | type QueryStringParameters = Record & { 10 | api_key?: string 11 | ii: string 12 | } 13 | 14 | export function getIntegrationInfo() { 15 | return `fingerprint-pro-server-node-sdk/${version}` 16 | } 17 | 18 | function serializeQueryStringParams(params: QueryStringParameters): string { 19 | const filteredParams = Object.entries(params).filter(([, value]) => value !== undefined && value !== null) 20 | if (!filteredParams.length) { 21 | return '' 22 | } 23 | const urlSearchParams = new URLSearchParams(filteredParams as [string, string][]) 24 | 25 | return urlSearchParams.toString() 26 | } 27 | 28 | function getServerApiUrl(region: Region): string { 29 | switch (region) { 30 | case Region.EU: 31 | return euRegionUrl 32 | case Region.AP: 33 | return apRegionUrl 34 | case Region.Global: 35 | return globalRegionUrl 36 | default: 37 | throw new Error('Unsupported region') 38 | } 39 | } 40 | 41 | /** 42 | * Extracts parameter placeholders into a literal union type. 43 | * For example `extractPathParams<'/users/{userId}/posts/{postId}'>` resolves to `"userId" | "postId" 44 | */ 45 | type ExtractPathParams = T extends `${string}{${infer Param}}${infer Rest}` 46 | ? Param | ExtractPathParams 47 | : never 48 | 49 | type PathParams = 50 | ExtractPathParams extends never 51 | ? { pathParams?: never } 52 | : { 53 | pathParams: ExtractPathParams extends never ? never : string[] 54 | } 55 | 56 | type QueryParams = 57 | ExtractQueryParams extends never 58 | ? { queryParams?: any } // No query params 59 | : { 60 | queryParams?: ExtractQueryParams // Optional query params 61 | } 62 | 63 | type GetRequestPathOptions = { 64 | path: Path 65 | method: Method 66 | apiKey?: string 67 | region: Region 68 | } & PathParams & 69 | QueryParams 70 | 71 | /** 72 | * Formats a URL for the FingerprintJS server API by replacing placeholders and 73 | * appending query string parameters. 74 | * 75 | * @internal 76 | * 77 | * @param {GetRequestPathOptions} options 78 | * @param {Path} options.path - The path of the API endpoint 79 | * @param {string[]} [options.pathParams] - Path parameters to be replaced in the path 80 | * @param {string} [options.apiKey] - API key to be included in the query string 81 | * @param {QueryParams["queryParams"]} [options.queryParams] - Query string 82 | * parameters to be appended to the URL 83 | * @param {Region} options.region - The region of the API endpoint 84 | * @param {Method} options.method - The method of the API endpoint 85 | * 86 | * @returns {string} The formatted URL with parameters replaced and query string 87 | * parameters appended 88 | */ 89 | export function getRequestPath({ 90 | path, 91 | pathParams, 92 | apiKey, 93 | queryParams, 94 | region, 95 | // method mention here so that it can be referenced in JSDoc 96 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 97 | method: _, 98 | }: GetRequestPathOptions): string { 99 | // Step 1: Extract the path parameters (placeholders) from the path 100 | const placeholders = Array.from(path.matchAll(/{(.*?)}/g)).map((match) => match[1]) 101 | 102 | // Step 2: Replace the placeholders with provided pathParams 103 | let formattedPath: string = path 104 | placeholders.forEach((placeholder, index) => { 105 | if (pathParams?.[index]) { 106 | formattedPath = formattedPath.replace(`{${placeholder}}`, pathParams[index]) 107 | } else { 108 | throw new Error(`Missing path parameter for ${placeholder}`) 109 | } 110 | }) 111 | 112 | const queryStringParameters: QueryStringParameters = { 113 | ...(queryParams ?? {}), 114 | ii: getIntegrationInfo(), 115 | } 116 | if (apiKey) { 117 | queryStringParameters.api_key = apiKey 118 | } 119 | 120 | const url = new URL(getServerApiUrl(region)) 121 | url.pathname = formattedPath 122 | url.search = serializeQueryStringParams(queryStringParameters) 123 | 124 | return url.toString() 125 | } 126 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function toError(e: unknown): Error { 2 | if (e && typeof e === 'object' && 'message' in e) { 3 | return e as Error 4 | } 5 | 6 | return new Error(String(e)) 7 | } 8 | -------------------------------------------------------------------------------- /src/webhook.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | function isValidHmacSignature(signature: string, data: Buffer, secret: string) { 4 | return signature === crypto.createHmac('sha256', secret).update(data).digest('hex') 5 | } 6 | 7 | export interface IsValidWebhookSignatureParams { 8 | /** 9 | * The value of the "fpjs-event-signature" header. 10 | * */ 11 | header: string 12 | /** 13 | * The raw data of the incoming request 14 | * */ 15 | data: Buffer 16 | /** 17 | * The secret key used to sign the request. 18 | * */ 19 | secret: string 20 | } 21 | 22 | /** 23 | * Verifies the HMAC signature extracted from the "fpjs-event-signature" header of the incoming request. This is a part of the webhook signing process, which is available only for enterprise customers. 24 | * If you wish to enable it, please contact our support: https://fingerprint.com/support 25 | * 26 | * @param {IsValidWebhookSignatureParams} params 27 | * @param {string} params.header - The value of the "fpjs-event-signature" header. 28 | * @param {Buffer} params.data - The raw data of the incoming request. 29 | * @param {string} params.secret - The secret key used to sign the request. 30 | * 31 | * @return {boolean} true if the signature is valid, false otherwise. 32 | * 33 | * @example 34 | * ```javascript 35 | * // Webhook endpoint handler 36 | * export async function POST(request: Request) { 37 | * try { 38 | * const secret = process.env.WEBHOOK_SIGNATURE_SECRET; 39 | * const header = request.headers.get("fpjs-event-signature"); 40 | * const data = Buffer.from(await request.arrayBuffer()); 41 | * 42 | * if (!isValidWebhookSignature({ header, data, secret })) { 43 | * return Response.json( 44 | * { message: "Webhook signature is invalid." }, 45 | * { status: 403 }, 46 | * ); 47 | * } 48 | * 49 | * return Response.json({ message: "Webhook received." }); 50 | * } catch (error) { 51 | * return Response.json({ error }, { status: 500 }); 52 | * } 53 | * } 54 | * ``` 55 | */ 56 | export function isValidWebhookSignature(params: IsValidWebhookSignatureParams): boolean { 57 | const { header, data, secret } = params 58 | 59 | const signatures = header.split(',') 60 | for (const signature of signatures) { 61 | const [version, hash] = signature.split('=') 62 | if (version === 'v1' && isValidHmacSignature(hash, data, secret)) { 63 | return true 64 | } 65 | } 66 | return false 67 | } 68 | -------------------------------------------------------------------------------- /sync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | curl -o ./resources/fingerprint-server-api.yaml https://fingerprintjs.github.io/fingerprint-pro-server-api-openapi/schemas/fingerprint-server-api-compact.yaml 4 | 5 | examplesList=( 6 | 'webhook.json' 7 | 'get_event_200.json' 8 | 'get_event_200_all_errors.json' 9 | 'get_event_200_extra_fields.json' 10 | 'get_event_403_error.json' 11 | 'get_event_404_error.json' 12 | 'get_event_200_botd_failed_error.json' 13 | 'get_event_200_botd_too_many_requests_error.json' 14 | 'get_event_200_identification_failed_error.json' 15 | 'get_event_200_identification_too_many_requests_error.json' 16 | 'get_event_200_identification_too_many_requests_error_all_fields.json' 17 | 'get_visits_429_too_many_requests_error.json' 18 | 'shared/404_error_visitor_not_found.json' 19 | 'shared/400_error_incorrect_visitor_id.json' 20 | 'shared/403_error_feature_not_enabled.json' 21 | 'shared/429_error_too_many_requests.json' 22 | ) 23 | 24 | for example in ${examplesList[*]}; do 25 | curl -o ./tests/mocked-responses-tests/mocked-responses-data/external/"$example" https://fingerprintjs.github.io/fingerprint-pro-server-api-openapi/examples/"$example" 26 | done 27 | -------------------------------------------------------------------------------- /tests/functional-tests/.env.example: -------------------------------------------------------------------------------- 1 | API_KEY= 2 | # "eu" or "ap", "us" is the default 3 | REGION= 4 | -------------------------------------------------------------------------------- /tests/functional-tests/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # fingerprintjs-pro-server-api-node-sdk-smoke-tests 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [af62b15] 8 | - @fingerprintjs/fingerprintjs-pro-server-api@6.6.0 9 | 10 | ## 1.0.1 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [50be5ca] 15 | - Updated dependencies [50be5ca] 16 | - Updated dependencies [96911d9] 17 | - Updated dependencies [50be5ca] 18 | - @fingerprintjs/fingerprintjs-pro-server-api@6.5.0 19 | 20 | ## 1.0.1-test.1 21 | 22 | ### Patch Changes 23 | 24 | - Updated dependencies [96911d9] 25 | - @fingerprintjs/fingerprintjs-pro-server-api@6.5.0-test.1 26 | 27 | ## 1.0.1-test.0 28 | 29 | ### Patch Changes 30 | 31 | - Updated dependencies [50be5ca] 32 | - Updated dependencies [50be5ca] 33 | - Updated dependencies [50be5ca] 34 | - @fingerprintjs/fingerprintjs-pro-server-api@6.5.0-test.0 35 | -------------------------------------------------------------------------------- /tests/functional-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fingerprintjs-pro-server-api-node-sdk-smoke-tests", 3 | "version": "1.0.2", 4 | "description": "", 5 | "main": "index.mjs", 6 | "private": true, 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "MIT", 12 | "dependencies": { 13 | "@fingerprintjs/fingerprintjs-pro-server-api": "workspace:*", 14 | "dotenv": "^16.4.5" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/functional-tests/smokeTests.mjs: -------------------------------------------------------------------------------- 1 | import { FingerprintJsServerApiClient, Region, RequestError } from '@fingerprintjs/fingerprintjs-pro-server-api' 2 | import { config } from 'dotenv' 3 | config() 4 | 5 | const apiKey = process.env.API_KEY 6 | const envRegion = process.env.REGION 7 | 8 | if (!apiKey) { 9 | console.error('API key not defined') 10 | process.exit(1) 11 | } 12 | 13 | let region = Region.Global 14 | if (envRegion === 'eu') { 15 | region = Region.EU 16 | } else if (envRegion === 'ap') { 17 | region = Region.AP 18 | } 19 | 20 | const end = Date.now() 21 | const start = end - 90 * 24 * 60 * 60 * 1000 22 | 23 | const client = new FingerprintJsServerApiClient({ region, apiKey }) 24 | let visitorId, requestId 25 | 26 | // test search events 27 | try { 28 | const searchEventsResponse = await client.searchEvents({ limit: 2, start, end }) 29 | if (searchEventsResponse.events.length === 0) { 30 | console.log('FingerprintJsServerApiClient.searchEvents: is empty') 31 | process.exit(1) 32 | } 33 | const firstEvent = searchEventsResponse.events[0] 34 | visitorId = firstEvent.products.identification.data.visitorId 35 | requestId = firstEvent.products.identification.data.requestId 36 | 37 | console.log(JSON.stringify(searchEventsResponse, null, 2)) 38 | const searchEventsResponseSecondPage = await client.searchEvents({ 39 | limit: 2, 40 | start, 41 | end, 42 | pagination_key: firstEvent.pagination_key, 43 | }) 44 | if (searchEventsResponseSecondPage.events.length === 0) { 45 | console.log('Second page of FingerprintJsServerApiClient.searchEvents: is empty') 46 | process.exit(1) 47 | } 48 | } catch (error) { 49 | if (error instanceof RequestError) { 50 | console.log(`error ${error.statusCode}: `, error.message) 51 | // You can also access the raw response 52 | console.log(error.response.statusText) 53 | } else { 54 | console.log('unknown error: ', error) 55 | } 56 | process.exit(1) 57 | } 58 | 59 | // Test getEvent 60 | try { 61 | const event = await client.getEvent(requestId) 62 | console.log(JSON.stringify(event, null, 2)) 63 | } catch (error) { 64 | if (error instanceof RequestError) { 65 | console.log(`error ${error.statusCode}: `, error.message) 66 | // You can also access the raw response 67 | console.log(error.response.statusText) 68 | } else { 69 | console.log('unknown error: ', error) 70 | } 71 | process.exit(1) 72 | } 73 | 74 | // Test getVisits 75 | try { 76 | const visitorHistory = await client.getVisits(visitorId, { limit: 10 }) 77 | console.log(JSON.stringify(visitorHistory, null, 2)) 78 | } catch (error) { 79 | if (error instanceof RequestError) { 80 | console.log(error.statusCode, error.message) 81 | if (error instanceof TooManyRequestsError) { 82 | retryLater(error.retryAfter) // Needs to be implemented on your side 83 | } 84 | } else { 85 | console.error('unknown error: ', error) 86 | } 87 | process.exit(1) 88 | } 89 | 90 | // Check that old events are still match expected format 91 | try { 92 | const searchEventsResponseOld = await client.searchEvents({ limit: 2, start, end, reverse: true }) 93 | if (searchEventsResponseOld.events.length === 0) { 94 | console.log('FingerprintJsServerApiClient.searchEvents: is empty for old events') 95 | process.exit(1) 96 | } 97 | const oldEventIdentificationData = searchEventsResponseOld.events[0].products.identification.data 98 | const visitorIdOld = oldEventIdentificationData.visitorId 99 | const requestIdOld = oldEventIdentificationData.requestId 100 | 101 | if (visitorId === visitorIdOld || requestId === requestIdOld) { 102 | console.log('Old events are identical to new') 103 | process.exit(1) 104 | } 105 | await client.getEvent(requestIdOld) 106 | await client.getVisits(visitorIdOld) 107 | console.log('Old events are good') 108 | } catch (error) { 109 | if (error instanceof RequestError) { 110 | console.log(`error ${error.statusCode}: `, error.message) 111 | // You can also access the raw response 112 | console.log(error.response.statusText) 113 | } else { 114 | console.log('unknown error: ', error) 115 | } 116 | process.exit(1) 117 | } 118 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/__snapshots__/castVisitorWebhookTest.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`[Mocked body] Cast visitor webhook with sample request body 1`] = ` 4 | { 5 | "bot": { 6 | "result": "bad", 7 | "type": "selenium", 8 | }, 9 | "browserDetails": { 10 | "browserFullVersion": "73.0.3683.86", 11 | "browserMajorVersion": "73", 12 | "browserName": "Chrome", 13 | "device": "Other", 14 | "os": "Mac OS X", 15 | "osVersion": "10.14.3", 16 | "userAgent": "(Macintosh; Intel Mac OS X 10_14_3) Chrome/73.0.3683.86", 17 | }, 18 | "clientReferrer": "https://google.com?search=banking+services", 19 | "clonedApp": { 20 | "result": false, 21 | }, 22 | "confidence": { 23 | "score": 0.97, 24 | }, 25 | "developerTools": { 26 | "result": false, 27 | }, 28 | "emulator": { 29 | "result": false, 30 | }, 31 | "factoryReset": { 32 | "time": "1970-01-01T00:00:00Z", 33 | "timestamp": 0, 34 | }, 35 | "firstSeenAt": { 36 | "global": "2022-03-16T11:26:45.362Z", 37 | "subscription": "2022-03-16T11:31:01.101Z", 38 | }, 39 | "frida": { 40 | "result": false, 41 | }, 42 | "highActivity": { 43 | "result": false, 44 | }, 45 | "incognito": false, 46 | "ip": "216.3.128.12", 47 | "ipBlocklist": { 48 | "details": { 49 | "attackSource": false, 50 | "emailSpam": false, 51 | }, 52 | "result": false, 53 | }, 54 | "ipInfo": { 55 | "v4": { 56 | "address": "94.142.239.124", 57 | "asn": { 58 | "asn": "7922", 59 | "name": "COMCAST-7922", 60 | "network": "73.136.0.0/13", 61 | }, 62 | "datacenter": { 63 | "name": "DediPath", 64 | "result": true, 65 | }, 66 | "geolocation": { 67 | "accuracyRadius": 20, 68 | "city": { 69 | "name": "Prague", 70 | }, 71 | "continent": { 72 | "code": "EU", 73 | "name": "Europe", 74 | }, 75 | "country": { 76 | "code": "CZ", 77 | "name": "Czechia", 78 | }, 79 | "latitude": 50.05, 80 | "longitude": 14.4, 81 | "postalCode": "150 00", 82 | "subdivisions": [ 83 | { 84 | "isoCode": "10", 85 | "name": "Hlavni mesto Praha", 86 | }, 87 | ], 88 | "timezone": "Europe/Prague", 89 | }, 90 | }, 91 | }, 92 | "ipLocation": { 93 | "accuracyRadius": 1, 94 | "city": { 95 | "name": "Bolingbrook", 96 | }, 97 | "continent": { 98 | "code": "NA", 99 | "name": "North America", 100 | }, 101 | "country": { 102 | "code": "US", 103 | "name": "United States", 104 | }, 105 | "latitude": 41.12933, 106 | "longitude": -88.9954, 107 | "postalCode": "60547", 108 | "subdivisions": [ 109 | { 110 | "isoCode": "IL", 111 | "name": "Illinois", 112 | }, 113 | ], 114 | "timezone": "America/Chicago", 115 | }, 116 | "jailbroken": { 117 | "result": false, 118 | }, 119 | "lastSeenAt": { 120 | "global": "2022-03-16T11:28:34.023Z", 121 | "subscription": null, 122 | }, 123 | "linkedId": "any-string", 124 | "locationSpoofing": { 125 | "result": true, 126 | }, 127 | "mitmAttack": { 128 | "result": false, 129 | }, 130 | "privacySettings": { 131 | "result": false, 132 | }, 133 | "proxy": { 134 | "confidence": "high", 135 | "result": false, 136 | }, 137 | "rawDeviceAttributes": { 138 | "architecture": { 139 | "value": 127, 140 | }, 141 | "audio": { 142 | "value": 35.73832903057337, 143 | }, 144 | "canvas": { 145 | "value": { 146 | "Geometry": "4dce9d6017c3e0c052a77252f29f2b1c", 147 | "Text": "dd2474a56ff78c1de3e7a07070ba3b7d", 148 | "Winding": true, 149 | }, 150 | }, 151 | "colorDepth": { 152 | "value": 30, 153 | }, 154 | "colorGamut": { 155 | "value": "srgb", 156 | }, 157 | "contrast": { 158 | "value": 0, 159 | }, 160 | "cookiesEnabled": { 161 | "value": true, 162 | }, 163 | }, 164 | "remoteControl": { 165 | "result": false, 166 | }, 167 | "requestId": "Px6VxbRC6WBkA39yeNH3", 168 | "rootApps": { 169 | "result": false, 170 | }, 171 | "suspectScore": { 172 | "result": 0, 173 | }, 174 | "tag": { 175 | "requestType": "signup", 176 | "yourCustomId": 45321, 177 | }, 178 | "tampering": { 179 | "anomalyScore": 0, 180 | "antiDetectBrowser": false, 181 | "result": false, 182 | }, 183 | "time": "2019-10-12T07:20:50.52Z", 184 | "timestamp": 1554910997788, 185 | "tor": { 186 | "result": false, 187 | }, 188 | "url": "https://banking.example.com/signup", 189 | "userAgent": "(Macintosh; Intel Mac OS X 10_14_3) Chrome/73.0.3683.86", 190 | "velocity": { 191 | "distinctCountry": { 192 | "intervals": { 193 | "1h": 2, 194 | "24h": 2, 195 | "5m": 1, 196 | }, 197 | }, 198 | "distinctIp": { 199 | "intervals": { 200 | "1h": 1, 201 | "24h": 1, 202 | "5m": 1, 203 | }, 204 | }, 205 | "distinctIpByLinkedId": { 206 | "intervals": { 207 | "1h": 5, 208 | "24h": 5, 209 | "5m": 1, 210 | }, 211 | }, 212 | "distinctLinkedId": {}, 213 | "distinctVisitorIdByLinkedId": { 214 | "intervals": { 215 | "1h": 5, 216 | "24h": 5, 217 | "5m": 1, 218 | }, 219 | }, 220 | "events": { 221 | "intervals": { 222 | "1h": 5, 223 | "24h": 5, 224 | "5m": 1, 225 | }, 226 | }, 227 | "ipEvents": { 228 | "intervals": { 229 | "1h": 5, 230 | "24h": 5, 231 | "5m": 1, 232 | }, 233 | }, 234 | }, 235 | "virtualMachine": { 236 | "result": false, 237 | }, 238 | "visitorFound": true, 239 | "visitorId": "3HNey93AkBW6CRbxV6xP", 240 | "vpn": { 241 | "confidence": "high", 242 | "methods": { 243 | "auxiliaryMobile": false, 244 | "osMismatch": false, 245 | "publicVPN": false, 246 | "relay": false, 247 | "timezoneMismatch": false, 248 | }, 249 | "originCountry": "unknown", 250 | "originTimezone": "Europe/Berlin", 251 | "result": false, 252 | }, 253 | } 254 | `; 255 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/__snapshots__/getRelatedVisitorsTests.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`[Mocked response] Get related Visitors without filter 1`] = ` 4 | { 5 | "relatedVisitors": [ 6 | { 7 | "visitorId": "NtCUJGceWX9RpvSbhvOm", 8 | }, 9 | { 10 | "visitorId": "25ee02iZwGxeyT0jMNkZ", 11 | }, 12 | ], 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/__snapshots__/getVisitorsTests.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`[Mocked response] Get Visitors with limit and before 1`] = ` 4 | { 5 | "lastTimestamp": 1655373953086, 6 | "paginationKey": "1655373953086.DDlfmP", 7 | "visitorId": "AcxioeQKffpXF8iGQK3P", 8 | "visits": [ 9 | { 10 | "browserDetails": { 11 | "browserFullVersion": "102.0.5005", 12 | "browserMajorVersion": "102", 13 | "browserName": "Chrome", 14 | "device": "Other", 15 | "os": "Mac OS X", 16 | "osVersion": "10.15.7", 17 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36", 18 | }, 19 | "confidence": { 20 | "score": 1, 21 | }, 22 | "firstSeenAt": { 23 | "global": "2022-02-04T11:31:20Z", 24 | "subscription": "2022-02-04T11:31:20Z", 25 | }, 26 | "incognito": false, 27 | "ip": "82.118.30.68", 28 | "ipLocation": { 29 | "accuracyRadius": 1000, 30 | "city": { 31 | "name": "Prague", 32 | }, 33 | "continent": { 34 | "code": "EU", 35 | "name": "Europe", 36 | }, 37 | "country": { 38 | "code": "CZ", 39 | "name": "Czechia", 40 | }, 41 | "latitude": 50.0805, 42 | "longitude": 14.467, 43 | "postalCode": "130 00", 44 | "subdivisions": [ 45 | { 46 | "isoCode": "10", 47 | "name": "Hlavni mesto Praha", 48 | }, 49 | ], 50 | "timezone": "Europe/Prague", 51 | }, 52 | "lastSeenAt": { 53 | "global": "2022-06-16T10:03:00.912Z", 54 | "subscription": "2022-06-16T10:03:00.912Z", 55 | }, 56 | "requestId": "1655373953086.DDlfmP", 57 | "tag": {}, 58 | "time": "2022-06-16T10:05:53Z", 59 | "timestamp": 1655373953094, 60 | "url": "https://dashboard.fingerprint.com/", 61 | "visitorFound": true, 62 | }, 63 | ], 64 | } 65 | `; 66 | 67 | exports[`[Mocked response] Get Visitors with linked_id and limit filter 1`] = ` 68 | { 69 | "lastTimestamp": 1655373953086, 70 | "paginationKey": "1655373953086.DDlfmP", 71 | "visitorId": "AcxioeQKffpXF8iGQK3P", 72 | "visits": [ 73 | { 74 | "browserDetails": { 75 | "browserFullVersion": "102.0.5005", 76 | "browserMajorVersion": "102", 77 | "browserName": "Chrome", 78 | "device": "Other", 79 | "os": "Mac OS X", 80 | "osVersion": "10.15.7", 81 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36", 82 | }, 83 | "confidence": { 84 | "score": 1, 85 | }, 86 | "firstSeenAt": { 87 | "global": "2022-02-04T11:31:20Z", 88 | "subscription": "2022-02-04T11:31:20Z", 89 | }, 90 | "incognito": false, 91 | "ip": "82.118.30.68", 92 | "ipLocation": { 93 | "accuracyRadius": 1000, 94 | "city": { 95 | "name": "Prague", 96 | }, 97 | "continent": { 98 | "code": "EU", 99 | "name": "Europe", 100 | }, 101 | "country": { 102 | "code": "CZ", 103 | "name": "Czechia", 104 | }, 105 | "latitude": 50.0805, 106 | "longitude": 14.467, 107 | "postalCode": "130 00", 108 | "subdivisions": [ 109 | { 110 | "isoCode": "10", 111 | "name": "Hlavni mesto Praha", 112 | }, 113 | ], 114 | "timezone": "Europe/Prague", 115 | }, 116 | "lastSeenAt": { 117 | "global": "2022-06-16T10:03:00.912Z", 118 | "subscription": "2022-06-16T10:03:00.912Z", 119 | }, 120 | "requestId": "1655373953086.DDlfmP", 121 | "tag": {}, 122 | "time": "2022-06-16T10:05:53Z", 123 | "timestamp": 1655373953094, 124 | "url": "https://dashboard.fingerprint.com/", 125 | "visitorFound": true, 126 | }, 127 | ], 128 | } 129 | `; 130 | 131 | exports[`[Mocked response] Get Visitors with request_id and linked_id filter 1`] = ` 132 | { 133 | "lastTimestamp": 1655373953086, 134 | "paginationKey": "1655373953086.DDlfmP", 135 | "visitorId": "AcxioeQKffpXF8iGQK3P", 136 | "visits": [ 137 | { 138 | "browserDetails": { 139 | "browserFullVersion": "102.0.5005", 140 | "browserMajorVersion": "102", 141 | "browserName": "Chrome", 142 | "device": "Other", 143 | "os": "Mac OS X", 144 | "osVersion": "10.15.7", 145 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36", 146 | }, 147 | "confidence": { 148 | "score": 1, 149 | }, 150 | "firstSeenAt": { 151 | "global": "2022-02-04T11:31:20Z", 152 | "subscription": "2022-02-04T11:31:20Z", 153 | }, 154 | "incognito": false, 155 | "ip": "82.118.30.68", 156 | "ipLocation": { 157 | "accuracyRadius": 1000, 158 | "city": { 159 | "name": "Prague", 160 | }, 161 | "continent": { 162 | "code": "EU", 163 | "name": "Europe", 164 | }, 165 | "country": { 166 | "code": "CZ", 167 | "name": "Czechia", 168 | }, 169 | "latitude": 50.0805, 170 | "longitude": 14.467, 171 | "postalCode": "130 00", 172 | "subdivisions": [ 173 | { 174 | "isoCode": "10", 175 | "name": "Hlavni mesto Praha", 176 | }, 177 | ], 178 | "timezone": "Europe/Prague", 179 | }, 180 | "lastSeenAt": { 181 | "global": "2022-06-16T10:03:00.912Z", 182 | "subscription": "2022-06-16T10:03:00.912Z", 183 | }, 184 | "requestId": "1655373953086.DDlfmP", 185 | "tag": {}, 186 | "time": "2022-06-16T10:05:53Z", 187 | "timestamp": 1655373953094, 188 | "url": "https://dashboard.fingerprint.com/", 189 | "visitorFound": true, 190 | }, 191 | ], 192 | } 193 | `; 194 | 195 | exports[`[Mocked response] Get Visitors with request_id filter 1`] = ` 196 | { 197 | "lastTimestamp": 1655373953086, 198 | "paginationKey": "1655373953086.DDlfmP", 199 | "visitorId": "AcxioeQKffpXF8iGQK3P", 200 | "visits": [ 201 | { 202 | "browserDetails": { 203 | "browserFullVersion": "102.0.5005", 204 | "browserMajorVersion": "102", 205 | "browserName": "Chrome", 206 | "device": "Other", 207 | "os": "Mac OS X", 208 | "osVersion": "10.15.7", 209 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36", 210 | }, 211 | "confidence": { 212 | "score": 1, 213 | }, 214 | "firstSeenAt": { 215 | "global": "2022-02-04T11:31:20Z", 216 | "subscription": "2022-02-04T11:31:20Z", 217 | }, 218 | "incognito": false, 219 | "ip": "82.118.30.68", 220 | "ipLocation": { 221 | "accuracyRadius": 1000, 222 | "city": { 223 | "name": "Prague", 224 | }, 225 | "continent": { 226 | "code": "EU", 227 | "name": "Europe", 228 | }, 229 | "country": { 230 | "code": "CZ", 231 | "name": "Czechia", 232 | }, 233 | "latitude": 50.0805, 234 | "longitude": 14.467, 235 | "postalCode": "130 00", 236 | "subdivisions": [ 237 | { 238 | "isoCode": "10", 239 | "name": "Hlavni mesto Praha", 240 | }, 241 | ], 242 | "timezone": "Europe/Prague", 243 | }, 244 | "lastSeenAt": { 245 | "global": "2022-06-16T10:03:00.912Z", 246 | "subscription": "2022-06-16T10:03:00.912Z", 247 | }, 248 | "requestId": "1655373953086.DDlfmP", 249 | "tag": {}, 250 | "time": "2022-06-16T10:05:53Z", 251 | "timestamp": 1655373953094, 252 | "url": "https://dashboard.fingerprint.com/", 253 | "visitorFound": true, 254 | }, 255 | ], 256 | } 257 | `; 258 | 259 | exports[`[Mocked response] Get Visitors without filter 1`] = ` 260 | { 261 | "lastTimestamp": 1655373953086, 262 | "paginationKey": "1655373953086.DDlfmP", 263 | "visitorId": "AcxioeQKffpXF8iGQK3P", 264 | "visits": [ 265 | { 266 | "browserDetails": { 267 | "browserFullVersion": "102.0.5005", 268 | "browserMajorVersion": "102", 269 | "browserName": "Chrome", 270 | "device": "Other", 271 | "os": "Mac OS X", 272 | "osVersion": "10.15.7", 273 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36", 274 | }, 275 | "confidence": { 276 | "score": 1, 277 | }, 278 | "firstSeenAt": { 279 | "global": "2022-02-04T11:31:20Z", 280 | "subscription": "2022-02-04T11:31:20Z", 281 | }, 282 | "incognito": false, 283 | "ip": "82.118.30.68", 284 | "ipLocation": { 285 | "accuracyRadius": 1000, 286 | "city": { 287 | "name": "Prague", 288 | }, 289 | "continent": { 290 | "code": "EU", 291 | "name": "Europe", 292 | }, 293 | "country": { 294 | "code": "CZ", 295 | "name": "Czechia", 296 | }, 297 | "latitude": 50.0805, 298 | "longitude": 14.467, 299 | "postalCode": "130 00", 300 | "subdivisions": [ 301 | { 302 | "isoCode": "10", 303 | "name": "Hlavni mesto Praha", 304 | }, 305 | ], 306 | "timezone": "Europe/Prague", 307 | }, 308 | "lastSeenAt": { 309 | "global": "2022-06-16T10:03:00.912Z", 310 | "subscription": "2022-06-16T10:03:00.912Z", 311 | }, 312 | "requestId": "1655373953086.DDlfmP", 313 | "tag": {}, 314 | "time": "2022-06-16T10:05:53Z", 315 | "timestamp": 1655373953094, 316 | "url": "https://dashboard.fingerprint.com/", 317 | "visitorFound": true, 318 | }, 319 | ], 320 | } 321 | `; 322 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/castVisitorWebhookTest.spec.ts: -------------------------------------------------------------------------------- 1 | import { Webhook } from '../../src/types' 2 | import visitWebhookBody from './mocked-responses-data/webhook.json' 3 | 4 | describe('[Mocked body] Cast visitor webhook', () => { 5 | test('with sample request body', async () => { 6 | const visit = visitWebhookBody as Webhook 7 | 8 | expect(visit).toMatchSnapshot() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/deleteVisitorDataTests.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ErrorResponse, 3 | FingerprintJsServerApiClient, 4 | getIntegrationInfo, 5 | Region, 6 | RequestError, 7 | SdkError, 8 | TooManyRequestsError, 9 | } from '../../src' 10 | import Error404 from './mocked-responses-data/errors/404_request_not_found.json' 11 | import Error403 from './mocked-responses-data/errors/403_feature_not_enabled.json' 12 | import Error400 from './mocked-responses-data/errors/400_visitor_id_invalid.json' 13 | import Error429 from './mocked-responses-data/errors/429_too_many_requests.json' 14 | 15 | jest.spyOn(global, 'fetch') 16 | 17 | const mockFetch = fetch as unknown as jest.Mock 18 | 19 | describe('[Mocked response] Delete visitor data', () => { 20 | const apiKey = 'dummy_api_key' 21 | 22 | const existingVisitorId = 'TaDnMBz9XCpZNuSzFUqP' 23 | 24 | const client = new FingerprintJsServerApiClient({ region: Region.EU, apiKey }) 25 | 26 | test('with visitorId', async () => { 27 | mockFetch.mockReturnValue(Promise.resolve(new Response())) 28 | 29 | const response = await client.deleteVisitorData(existingVisitorId) 30 | 31 | expect(response).toBeUndefined() 32 | expect(mockFetch).toHaveBeenCalledWith( 33 | `https://eu.api.fpjs.io/visitors/${existingVisitorId}?ii=${encodeURIComponent(getIntegrationInfo())}`, 34 | { 35 | headers: { 'Auth-API-Key': 'dummy_api_key' }, 36 | method: 'DELETE', 37 | } 38 | ) 39 | }) 40 | 41 | test('404 error', async () => { 42 | const mockResponse = new Response(JSON.stringify(Error404), { 43 | status: 404, 44 | }) 45 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 46 | 47 | await expect(client.deleteVisitorData(existingVisitorId)).rejects.toThrow( 48 | RequestError.fromErrorResponse(Error404 as ErrorResponse, mockResponse) 49 | ) 50 | }) 51 | 52 | test('403 error', async () => { 53 | const mockResponse = new Response(JSON.stringify(Error403), { 54 | status: 403, 55 | }) 56 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 57 | 58 | await expect(client.deleteVisitorData(existingVisitorId)).rejects.toThrow( 59 | RequestError.fromErrorResponse(Error403 as ErrorResponse, mockResponse) 60 | ) 61 | }) 62 | 63 | test('400 error', async () => { 64 | const mockResponse = new Response(JSON.stringify(Error400), { 65 | status: 400, 66 | }) 67 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 68 | 69 | await expect(client.deleteVisitorData(existingVisitorId)).rejects.toThrow( 70 | RequestError.fromErrorResponse(Error400 as ErrorResponse, mockResponse) 71 | ) 72 | }) 73 | 74 | test('429 error', async () => { 75 | const mockResponse = new Response(JSON.stringify(Error429), { 76 | status: 429, 77 | headers: { 78 | 'retry-after': '5', 79 | }, 80 | }) 81 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 82 | 83 | const expectedError = new TooManyRequestsError(Error429 as ErrorResponse, mockResponse) 84 | await expect(client.deleteVisitorData(existingVisitorId)).rejects.toThrow(expectedError) 85 | expect(expectedError.retryAfter).toEqual(5) 86 | }) 87 | 88 | test('Error with bad JSON', async () => { 89 | const mockResponse = new Response('(Some bad JSON)', { 90 | status: 404, 91 | }) 92 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 93 | 94 | await expect(client.deleteVisitorData(existingVisitorId)).rejects.toMatchObject( 95 | new SdkError( 96 | 'Failed to parse JSON response', 97 | mockResponse, 98 | new SyntaxError('Unexpected token \'(\', "(Some bad JSON)" is not valid JSON') 99 | ) 100 | ) 101 | }) 102 | 103 | test('Error with bad shape', async () => { 104 | const errorInfo = 'Some text instead of shaped object' 105 | const mockResponse = new Response( 106 | JSON.stringify({ 107 | _error: errorInfo, 108 | }), 109 | { 110 | status: 404, 111 | } 112 | ) 113 | 114 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 115 | 116 | await expect(client.deleteVisitorData(existingVisitorId)).rejects.toThrow(RequestError as any) 117 | await expect(client.deleteVisitorData(existingVisitorId)).rejects.toThrow('Unknown error') 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/getEventTests.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorResponse, Region } from '../../src/types' 2 | import { FingerprintJsServerApiClient } from '../../src/serverApiClient' 3 | import getEventResponse from './mocked-responses-data/get_event_200.json' 4 | import getEventWithExtraFieldsResponse from './mocked-responses-data/get_event_200_extra_fields.json' 5 | import getEventAllErrorsResponse from './mocked-responses-data/get_event_200_all_errors.json' 6 | import { RequestError, SdkError } from '../../src/errors/apiErrors' 7 | import { getIntegrationInfo } from '../../src' 8 | 9 | jest.spyOn(global, 'fetch') 10 | 11 | const mockFetch = fetch as unknown as jest.Mock 12 | describe('[Mocked response] Get Event', () => { 13 | const apiKey = 'dummy_api_key' 14 | const existingRequestId = '1626550679751.cVc5Pm' 15 | 16 | const client = new FingerprintJsServerApiClient({ region: Region.EU, apiKey }) 17 | 18 | test('with request_id', async () => { 19 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getEventResponse)))) 20 | 21 | const response = await client.getEvent(existingRequestId) 22 | 23 | expect(mockFetch).toHaveBeenCalledWith( 24 | `https://eu.api.fpjs.io/events/${existingRequestId}?ii=${encodeURIComponent(getIntegrationInfo())}`, 25 | { 26 | headers: { 'Auth-API-Key': 'dummy_api_key' }, 27 | method: 'GET', 28 | } 29 | ) 30 | expect(response).toMatchSnapshot() 31 | }) 32 | 33 | test('with additional signals', async () => { 34 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getEventWithExtraFieldsResponse)))) 35 | 36 | const response = await client.getEvent(existingRequestId) 37 | expect(response).toMatchSnapshot() 38 | }) 39 | 40 | test('with all signals with failed error', async () => { 41 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getEventAllErrorsResponse)))) 42 | 43 | const response = await client.getEvent(existingRequestId) 44 | 45 | expect(response).toMatchSnapshot() 46 | }) 47 | 48 | test('403 error', async () => { 49 | const errorInfo = { 50 | error: { 51 | code: 'TokenRequired', 52 | message: 'secret key is required', 53 | }, 54 | } satisfies ErrorResponse 55 | const mockResponse = new Response(JSON.stringify(errorInfo), { 56 | status: 403, 57 | }) 58 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 59 | await expect(client.getEvent(existingRequestId)).rejects.toThrow( 60 | RequestError.fromErrorResponse(errorInfo, mockResponse) 61 | ) 62 | }) 63 | 64 | test('404 error', async () => { 65 | const errorInfo = { 66 | error: { 67 | code: 'RequestNotFound', 68 | message: 'request id is not found', 69 | }, 70 | } satisfies ErrorResponse 71 | const mockResponse = new Response(JSON.stringify(errorInfo), { 72 | status: 404, 73 | }) 74 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 75 | await expect(client.getEvent(existingRequestId)).rejects.toThrow( 76 | RequestError.fromErrorResponse(errorInfo, mockResponse) 77 | ) 78 | }) 79 | 80 | test('Error with bad shape', async () => { 81 | const errorInfo = 'Some text instead of shaped object' 82 | const mockResponse = new Response( 83 | JSON.stringify({ 84 | error: errorInfo, 85 | }), 86 | { 87 | status: 404, 88 | } 89 | ) 90 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 91 | await expect(client.getEvent(existingRequestId)).rejects.toThrow(RequestError) 92 | await expect(client.getEvent(existingRequestId)).rejects.toThrow('Some text instead of shaped object') 93 | }) 94 | 95 | test('Error with bad JSON', async () => { 96 | const mockResponse = new Response('(Some bad JSON)', { 97 | status: 404, 98 | }) 99 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 100 | 101 | await expect(client.getEvent(existingRequestId)).rejects.toMatchObject( 102 | new SdkError( 103 | 'Failed to parse JSON response', 104 | mockResponse, 105 | new SyntaxError('Unexpected token \'(\', \\"(Some bad JSON)\\" is not valid JSON') 106 | ) 107 | ) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/getRelatedVisitorsTests.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorResponse, Region } from '../../src/types' 2 | import { FingerprintJsServerApiClient } from '../../src/serverApiClient' 3 | import getRelatedVisitors from './mocked-responses-data/related-visitors/get_related_visitors_200.json' 4 | import { RequestError, SdkError, TooManyRequestsError } from '../../src/errors/apiErrors' 5 | import { getIntegrationInfo } from '../../src' 6 | 7 | jest.spyOn(global, 'fetch') 8 | 9 | const mockFetch = fetch as unknown as jest.Mock 10 | 11 | describe('[Mocked response] Get related Visitors', () => { 12 | const apiKey = 'dummy_api_key' 13 | const existingVisitorId = 'TaDnMBz9XCpZNuSzFUqP' 14 | 15 | const client = new FingerprintJsServerApiClient({ region: Region.EU, apiKey: apiKey }) 16 | 17 | test('without filter', async () => { 18 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getRelatedVisitors)))) 19 | 20 | const response = await client.getRelatedVisitors({ 21 | visitor_id: existingVisitorId, 22 | }) 23 | expect(response).toMatchSnapshot() 24 | expect(mockFetch).toHaveBeenCalledWith( 25 | `https://eu.api.fpjs.io/related-visitors?visitor_id=${existingVisitorId}&ii=${encodeURIComponent(getIntegrationInfo())}`, 26 | { 27 | headers: { 'Auth-API-Key': 'dummy_api_key' }, 28 | method: 'GET', 29 | } 30 | ) 31 | }) 32 | 33 | test('400 error', async () => { 34 | const error = { 35 | error: { 36 | message: 'Forbidden', 37 | code: 'RequestCannotBeParsed', 38 | }, 39 | } satisfies ErrorResponse 40 | const mockResponse = new Response(JSON.stringify(error), { 41 | status: 400, 42 | }) 43 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 44 | await expect( 45 | client.getRelatedVisitors({ 46 | visitor_id: existingVisitorId, 47 | }) 48 | ).rejects.toThrow(RequestError.fromErrorResponse(error, mockResponse)) 49 | }) 50 | 51 | test('403 error', async () => { 52 | const error = { 53 | error: { 54 | message: 'secret key is required', 55 | code: 'TokenRequired', 56 | }, 57 | } satisfies ErrorResponse 58 | const mockResponse = new Response(JSON.stringify(error), { 59 | status: 403, 60 | }) 61 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 62 | await expect( 63 | client.getRelatedVisitors({ 64 | visitor_id: existingVisitorId, 65 | }) 66 | ).rejects.toThrow(RequestError.fromErrorResponse(error, mockResponse)) 67 | }) 68 | 69 | test('404 error', async () => { 70 | const error = { 71 | error: { 72 | message: 'request id is not found', 73 | code: 'RequestNotFound', 74 | }, 75 | } satisfies ErrorResponse 76 | const mockResponse = new Response(JSON.stringify(error), { 77 | status: 404, 78 | }) 79 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 80 | await expect( 81 | client.getRelatedVisitors({ 82 | visitor_id: existingVisitorId, 83 | }) 84 | ).rejects.toThrow(RequestError.fromErrorResponse(error, mockResponse)) 85 | }) 86 | 87 | test('429 error', async () => { 88 | const error = { 89 | error: { 90 | message: 'Too Many Requests', 91 | code: 'TooManyRequests', 92 | }, 93 | } satisfies ErrorResponse 94 | const mockResponse = new Response(JSON.stringify(error), { 95 | status: 429, 96 | headers: { 'Retry-after': '10' }, 97 | }) 98 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 99 | 100 | const expectedError = new TooManyRequestsError(error, mockResponse) 101 | await expect( 102 | client.getRelatedVisitors({ 103 | visitor_id: existingVisitorId, 104 | }) 105 | ).rejects.toThrow(expectedError) 106 | expect(expectedError.retryAfter).toEqual(10) 107 | }) 108 | 109 | test('429 error with empty retry-after header', async () => { 110 | const error = { 111 | error: { 112 | message: 'Too Many Requests', 113 | code: 'TooManyRequests', 114 | }, 115 | } satisfies ErrorResponse 116 | const mockResponse = new Response(JSON.stringify(error), { 117 | status: 429, 118 | }) 119 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 120 | const expectedError = new TooManyRequestsError(error, mockResponse) 121 | await expect( 122 | client.getRelatedVisitors({ 123 | visitor_id: existingVisitorId, 124 | }) 125 | ).rejects.toThrow(expectedError) 126 | expect(expectedError.retryAfter).toEqual(0) 127 | }) 128 | 129 | test('Error with bad JSON', async () => { 130 | const mockResponse = new Response('(Some bad JSON)', { 131 | status: 404, 132 | }) 133 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 134 | await expect( 135 | client.getRelatedVisitors({ 136 | visitor_id: existingVisitorId, 137 | }) 138 | ).rejects.toMatchObject( 139 | new SdkError( 140 | 'Failed to parse JSON response', 141 | mockResponse, 142 | new SyntaxError('Unexpected token \'(\', "(Some bad JSON)" is not valid JSON') 143 | ) 144 | ) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/getVisitorsTests.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorPlainResponse, Region, VisitorHistoryFilter } from '../../src/types' 2 | import { FingerprintJsServerApiClient } from '../../src/serverApiClient' 3 | import getVisits from './mocked-responses-data/get_visitors_200_limit_1.json' 4 | import { RequestError, SdkError, TooManyRequestsError } from '../../src/errors/apiErrors' 5 | import { getIntegrationInfo } from '../../src' 6 | 7 | jest.spyOn(global, 'fetch') 8 | 9 | const mockFetch = fetch as unknown as jest.Mock 10 | 11 | describe('[Mocked response] Get Visitors', () => { 12 | const apiKey = 'dummy_api_key' 13 | const existingVisitorId = 'TaDnMBz9XCpZNuSzFUqP' 14 | const existingRequestId = '1626550679751.cVc5Pm' 15 | const existingLinkedId = 'makma' 16 | 17 | const client = new FingerprintJsServerApiClient({ region: Region.EU, apiKey: apiKey }) 18 | 19 | test('without filter', async () => { 20 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getVisits)))) 21 | 22 | const response = await client.getVisits(existingVisitorId) 23 | expect(response).toMatchSnapshot() 24 | expect(mockFetch).toHaveBeenCalledWith( 25 | `https://eu.api.fpjs.io/visitors/${existingVisitorId}?ii=${encodeURIComponent(getIntegrationInfo())}`, 26 | { 27 | headers: { 'Auth-API-Key': 'dummy_api_key' }, 28 | method: 'GET', 29 | } 30 | ) 31 | }) 32 | 33 | test('with request_id filter', async () => { 34 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getVisits)))) 35 | 36 | const filter: VisitorHistoryFilter = { request_id: existingRequestId } 37 | const response = await client.getVisits(existingVisitorId, filter) 38 | expect(response).toMatchSnapshot() 39 | }) 40 | 41 | test('with request_id and linked_id filter', async () => { 42 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getVisits)))) 43 | 44 | const filter: VisitorHistoryFilter = { 45 | request_id: existingRequestId, 46 | linked_id: existingLinkedId, 47 | } 48 | const response = await client.getVisits(existingVisitorId, filter) 49 | expect(response).toMatchSnapshot() 50 | }) 51 | 52 | test('with linked_id and limit filter', async () => { 53 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getVisits)))) 54 | 55 | const filter: VisitorHistoryFilter = { linked_id: existingLinkedId, limit: 5 } 56 | const response = await client.getVisits(existingVisitorId, filter) 57 | expect(response).toMatchSnapshot() 58 | }) 59 | 60 | test('with limit and before', async () => { 61 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getVisits)))) 62 | 63 | const filter: VisitorHistoryFilter = { limit: 4, before: 1626538505244 } 64 | const response = await client.getVisits(existingVisitorId, filter) 65 | expect(response).toMatchSnapshot() 66 | }) 67 | 68 | test('403 error', async () => { 69 | const error = { 70 | error: 'Forbidden', 71 | } satisfies ErrorPlainResponse 72 | const mockResponse = new Response(JSON.stringify(error), { 73 | status: 403, 74 | }) 75 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 76 | await expect(client.getVisits(existingVisitorId)).rejects.toThrow(RequestError.fromPlainError(error, mockResponse)) 77 | }) 78 | 79 | test('429 error', async () => { 80 | const error = { 81 | error: 'Too Many Requests', 82 | } 83 | const mockResponse = new Response(JSON.stringify(error), { 84 | status: 429, 85 | headers: { 'Retry-after': '10' }, 86 | }) 87 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 88 | 89 | const expectedError = TooManyRequestsError.fromPlain(error, mockResponse) 90 | await expect(client.getVisits(existingVisitorId)).rejects.toThrow(expectedError) 91 | expect(expectedError.retryAfter).toEqual(10) 92 | }) 93 | 94 | test('429 error with empty retry-after header', async () => { 95 | const error = { 96 | error: 'Too Many Requests', 97 | } satisfies ErrorPlainResponse 98 | const mockResponse = new Response(JSON.stringify(error), { 99 | status: 429, 100 | }) 101 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 102 | const expectedError = TooManyRequestsError.fromPlain(error, mockResponse) 103 | await expect(client.getVisits(existingVisitorId)).rejects.toThrow(expectedError) 104 | expect(expectedError.retryAfter).toEqual(0) 105 | }) 106 | 107 | test('Error with bad JSON', async () => { 108 | const mockResponse = new Response('(Some bad JSON)', { 109 | status: 404, 110 | }) 111 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 112 | await expect(client.getVisits(existingVisitorId)).rejects.toMatchObject( 113 | new SdkError( 114 | 'Failed to parse JSON response', 115 | mockResponse, 116 | new SyntaxError('Unexpected token \'(\', "(Some bad JSON)" is not valid JSON') 117 | ) 118 | ) 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_bot_type_invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "invalid bot type" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_end_time_invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "invalid end time" 5 | } 6 | } -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_ip_address_invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "invalid ip address" 5 | } 6 | } -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_limit_invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "invalid limit" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_linked_id_invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "linked_id can't be greater than 256 characters long" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_pagination_key_invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "invalid pagination key" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_request_body_invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "request body is not valid" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_reverse_invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "invalid reverse param" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_start_time_invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "invalid start time" 5 | } 6 | } -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_visitor_id_invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "invalid visitor id" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/400_visitor_id_required.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestCannotBeParsed", 4 | "message": "visitor id is required" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/403_feature_not_enabled.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "FeatureNotEnabled", 4 | "message": "feature not enabled" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/403_subscription_not_active.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "SubscriptionNotActive", 4 | "message": "forbidden" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/403_token_not_found.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "TokenNotFound", 4 | "message": "secret key is not found" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/403_token_required.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "TokenRequired", 4 | "message": "secret key is required" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/403_wrong_region.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "WrongRegion", 4 | "message": "wrong region" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/404_request_not_found.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "RequestNotFound", 4 | "message": "request id is not found" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/404_visitor_not_found.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "VisitorNotFound", 4 | "message": "visitor not found" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/409_state_not_ready.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "StateNotReady", 4 | "message": "resource is not mutable yet, try again" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/errors/429_too_many_requests.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": "TooManyRequests", 4 | "message": "too many requests" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_event_200.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": { 3 | "identification": { 4 | "data": { 5 | "visitorId": "Ibk1527CUFmcnjLwIs4A9", 6 | "requestId": "1708102555327.NLOjmg", 7 | "incognito": true, 8 | "linkedId": "somelinkedId", 9 | "tag": {}, 10 | "time": "2019-05-21T16:40:13Z", 11 | "timestamp": 1582299576512, 12 | "url": "https://www.example.com/login?hope{this{works[!", 13 | "ip": "61.127.217.15", 14 | "ipLocation": { 15 | "accuracyRadius": 10, 16 | "latitude": 49.982, 17 | "longitude": 36.2566, 18 | "postalCode": "61202", 19 | "timezone": "Europe/Dusseldorf", 20 | "city": { 21 | "name": "Dusseldorf" 22 | }, 23 | "country": { 24 | "code": "DE", 25 | "name": "Germany" 26 | }, 27 | "continent": { 28 | "code": "EU", 29 | "name": "Europe" 30 | }, 31 | "subdivisions": [ 32 | { 33 | "isoCode": "63", 34 | "name": "North Rhine-Westphalia" 35 | } 36 | ] 37 | }, 38 | "browserDetails": { 39 | "browserName": "Chrome", 40 | "browserMajorVersion": "74", 41 | "browserFullVersion": "74.0.3729", 42 | "os": "Windows", 43 | "osVersion": "7", 44 | "device": "Other", 45 | "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) ...." 46 | }, 47 | "confidence": { 48 | "score": 0.97 49 | }, 50 | "visitorFound": false, 51 | "firstSeenAt": { 52 | "global": "2022-03-16T11:26:45.362Z", 53 | "subscription": "2022-03-16T11:31:01.101Z" 54 | }, 55 | "lastSeenAt": { 56 | "global": null, 57 | "subscription": null 58 | } 59 | } 60 | }, 61 | "botd": { 62 | "data": { 63 | "bot": { 64 | "result": "notDetected" 65 | }, 66 | "url": "https://www.example.com/login?hope{this{works}[!", 67 | "ip": "61.127.217.15", 68 | "time": "2019-05-21T16:40:13Z", 69 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 YaBrowser/24.1.0.0 Safari/537.36", 70 | "requestId": "1708102555327.NLOjmg" 71 | } 72 | }, 73 | "rootApps": { 74 | "data": { 75 | "result": false 76 | } 77 | }, 78 | "emulator": { 79 | "data": { 80 | "result": false 81 | } 82 | }, 83 | "ipInfo": { 84 | "data": { 85 | "v4": { 86 | "address": "94.142.239.124", 87 | "geolocation": { 88 | "accuracyRadius": 20, 89 | "latitude": 50.05, 90 | "longitude": 14.4, 91 | "postalCode": "150 00", 92 | "timezone": "Europe/Prague", 93 | "city": { 94 | "name": "Prague" 95 | }, 96 | "country": { 97 | "code": "CZ", 98 | "name": "Czechia" 99 | }, 100 | "continent": { 101 | "code": "EU", 102 | "name": "Europe" 103 | }, 104 | "subdivisions": [ 105 | { 106 | "isoCode": "10", 107 | "name": "Hlavni mesto Praha" 108 | } 109 | ] 110 | }, 111 | "asn": { 112 | "asn": "7922", 113 | "name": "COMCAST-7922", 114 | "network": "73.136.0.0/13" 115 | }, 116 | "datacenter": { 117 | "result": true, 118 | "name": "DediPath" 119 | } 120 | }, 121 | "v6": { 122 | "address": "2001:db8:3333:4444:5555:6666:7777:8888", 123 | "geolocation": { 124 | "accuracyRadius": 5, 125 | "latitude": 49.982, 126 | "longitude": 36.2566, 127 | "postalCode": "10112", 128 | "timezone": "Europe/Berlin", 129 | "city": { 130 | "name": "Berlin" 131 | }, 132 | "country": { 133 | "code": "DE", 134 | "name": "Germany" 135 | }, 136 | "continent": { 137 | "code": "EU", 138 | "name": "Europe" 139 | }, 140 | "subdivisions": [ 141 | { 142 | "isoCode": "BE", 143 | "name": "Land Berlin" 144 | } 145 | ] 146 | }, 147 | "asn": { 148 | "asn": "6805", 149 | "name": "Telefonica Germany", 150 | "network": "2a02:3100::/24" 151 | }, 152 | "datacenter": { 153 | "result": false, 154 | "name": "" 155 | } 156 | } 157 | } 158 | }, 159 | "ipBlocklist": { 160 | "data": { 161 | "result": false, 162 | "details": { 163 | "emailSpam": false, 164 | "attackSource": false 165 | } 166 | } 167 | }, 168 | "tor": { 169 | "data": { 170 | "result": false 171 | } 172 | }, 173 | "vpn": { 174 | "data": { 175 | "result": false, 176 | "confidence": "high", 177 | "originTimezone": "Europe/Berlin", 178 | "originCountry": "unknown", 179 | "methods": { 180 | "timezoneMismatch": false, 181 | "publicVPN": false, 182 | "auxiliaryMobile": false, 183 | "osMismatch": false, 184 | "relay": false 185 | } 186 | } 187 | }, 188 | "proxy": { 189 | "data": { 190 | "result": false, 191 | "confidence": "high" 192 | } 193 | }, 194 | "incognito": { 195 | "data": { 196 | "result": false 197 | } 198 | }, 199 | "tampering": { 200 | "data": { 201 | "result": false, 202 | "anomalyScore": 0.1955, 203 | "antiDetectBrowser": false 204 | } 205 | }, 206 | "clonedApp": { 207 | "data": { 208 | "result": false 209 | } 210 | }, 211 | "factoryReset": { 212 | "data": { 213 | "time": "1970-01-01T00:00:00Z", 214 | "timestamp": 0 215 | } 216 | }, 217 | "jailbroken": { 218 | "data": { 219 | "result": false 220 | } 221 | }, 222 | "frida": { 223 | "data": { 224 | "result": false 225 | } 226 | }, 227 | "privacySettings": { 228 | "data": { 229 | "result": false 230 | } 231 | }, 232 | "virtualMachine": { 233 | "data": { 234 | "result": false 235 | } 236 | }, 237 | "rawDeviceAttributes": { 238 | "data": { 239 | "architecture": { 240 | "value": 127 241 | }, 242 | "audio": { 243 | "value": 35.73832903057337 244 | }, 245 | "canvas": { 246 | "value": { 247 | "Winding": true, 248 | "Geometry": "4dce9d6017c3e0c052a77252f29f2b1c", 249 | "Text": "dd2474a56ff78c1de3e7a07070ba3b7d" 250 | } 251 | }, 252 | "colorDepth": { 253 | "value": 30 254 | }, 255 | "colorGamut": { 256 | "value": "p3" 257 | }, 258 | "contrast": { 259 | "value": 0 260 | }, 261 | "cookiesEnabled": { 262 | "value": true 263 | }, 264 | "cpuClass": {}, 265 | "fonts": { 266 | "value": ["Arial Unicode MS", "Gill Sans", "Helvetica Neue", "Menlo"] 267 | } 268 | } 269 | }, 270 | "highActivity": { 271 | "data": { 272 | "result": false 273 | } 274 | }, 275 | "locationSpoofing": { 276 | "data": { 277 | "result": false 278 | } 279 | }, 280 | "remoteControl": { 281 | "data": { 282 | "result": false 283 | } 284 | }, 285 | "velocity": { 286 | "data": { 287 | "distinctIp": { 288 | "intervals": { 289 | "5m": 1, 290 | "1h": 1, 291 | "24h": 1 292 | } 293 | }, 294 | "distinctLinkedId": {}, 295 | "distinctCountry": { 296 | "intervals": { 297 | "5m": 1, 298 | "1h": 2, 299 | "24h": 2 300 | } 301 | }, 302 | "events": { 303 | "intervals": { 304 | "5m": 1, 305 | "1h": 5, 306 | "24h": 5 307 | } 308 | }, 309 | "ipEvents": { 310 | "intervals": { 311 | "5m": 1, 312 | "1h": 5, 313 | "24h": 5 314 | } 315 | }, 316 | "distinctIpByLinkedId": { 317 | "intervals": { 318 | "5m": 1, 319 | "1h": 5, 320 | "24h": 5 321 | } 322 | }, 323 | "distinctVisitorIdByLinkedId": { 324 | "intervals": { 325 | "5m": 1, 326 | "1h": 5, 327 | "24h": 5 328 | } 329 | } 330 | } 331 | }, 332 | "developerTools": { 333 | "data": { 334 | "result": false 335 | } 336 | }, 337 | "mitmAttack": { 338 | "data": { 339 | "result": false 340 | } 341 | } 342 | } 343 | } -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_event_200_all_errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": { 3 | "identification": { 4 | "error": { 5 | "code": "Failed", 6 | "message": "internal server error" 7 | } 8 | }, 9 | "botd": { 10 | "error": { 11 | "code": "Failed", 12 | "message": "internal server error" 13 | } 14 | }, 15 | "ipInfo": { 16 | "error": { 17 | "code": "Failed", 18 | "message": "internal server error" 19 | } 20 | }, 21 | "incognito": { 22 | "error": { 23 | "code": "Failed", 24 | "message": "internal server error" 25 | } 26 | }, 27 | "rootApps": { 28 | "error": { 29 | "code": "Failed", 30 | "message": "internal server error" 31 | } 32 | }, 33 | "clonedApp": { 34 | "error": { 35 | "code": "Failed", 36 | "message": "internal server error" 37 | } 38 | }, 39 | "factoryReset": { 40 | "error": { 41 | "code": "Failed", 42 | "message": "internal server error" 43 | } 44 | }, 45 | "jailbroken": { 46 | "error": { 47 | "code": "Failed", 48 | "message": "internal server error" 49 | } 50 | }, 51 | "frida": { 52 | "error": { 53 | "code": "Failed", 54 | "message": "internal server error" 55 | } 56 | }, 57 | "emulator": { 58 | "error": { 59 | "code": "Failed", 60 | "message": "internal server error" 61 | } 62 | }, 63 | "ipBlocklist": { 64 | "error": { 65 | "code": "Failed", 66 | "message": "internal server error" 67 | } 68 | }, 69 | "tor": { 70 | "error": { 71 | "code": "Failed", 72 | "message": "internal server error" 73 | } 74 | }, 75 | "vpn": { 76 | "error": { 77 | "code": "Failed", 78 | "message": "internal server error" 79 | } 80 | }, 81 | "proxy": { 82 | "error": { 83 | "code": "Failed", 84 | "message": "internal server error" 85 | } 86 | }, 87 | "privacySettings": { 88 | "error": { 89 | "code": "Failed", 90 | "message": "internal server error" 91 | } 92 | }, 93 | "virtualMachine": { 94 | "error": { 95 | "code": "Failed", 96 | "message": "internal server error" 97 | } 98 | }, 99 | "tampering": { 100 | "error": { 101 | "code": "Failed", 102 | "message": "internal server error" 103 | } 104 | }, 105 | "rawDeviceAttributes": { 106 | "data": { 107 | "audio": { 108 | "error": { 109 | "name": "Error", 110 | "message": "internal server error" 111 | } 112 | }, 113 | "canvas": { 114 | "error": { 115 | "name": "Error", 116 | "message": "internal server error" 117 | } 118 | } 119 | } 120 | }, 121 | "locationSpoofing": { 122 | "error": { 123 | "code": "Failed", 124 | "message": "internal server error" 125 | } 126 | }, 127 | "highActivity": { 128 | "error": { 129 | "code": "Failed", 130 | "message": "internal server error" 131 | } 132 | }, 133 | "suspectScore": { 134 | "error": { 135 | "code": "Failed", 136 | "message": "internal server error" 137 | } 138 | }, 139 | "remoteControl": { 140 | "error": { 141 | "code": "Failed", 142 | "message": "internal server error" 143 | } 144 | }, 145 | "velocity": { 146 | "error": { 147 | "code": "Failed", 148 | "message": "internal server error" 149 | } 150 | }, 151 | "developerTools": { 152 | "error": { 153 | "code": "Failed", 154 | "message": "internal server error" 155 | } 156 | }, 157 | "mitmAttack": { 158 | "error": { 159 | "code": "Failed", 160 | "message": "internal server error" 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_event_200_botd_failed_error.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": { 3 | "identification": { 4 | "data": { 5 | "visitorId": "Ibk1527CUFmcnjLwIs4A9", 6 | "requestId": "0KSh65EnVoB85JBmloQK", 7 | "incognito": true, 8 | "linkedId": "somelinkedId", 9 | "time": "2019-05-21T16:40:13Z", 10 | "tag": {}, 11 | "timestamp": 1582299576512, 12 | "url": "https://www.example.com/login", 13 | "ip": "61.127.217.15", 14 | "ipLocation": { 15 | "accuracyRadius": 10, 16 | "latitude": 49.982, 17 | "longitude": 36.2566, 18 | "postalCode": "61202", 19 | "timezone": "Europe/Dusseldorf", 20 | "city": { 21 | "name": "Dusseldorf" 22 | }, 23 | "continent": { 24 | "code": "EU", 25 | "name": "Europe" 26 | }, 27 | "country": { 28 | "code": "DE", 29 | "name": "Germany" 30 | }, 31 | "subdivisions": [ 32 | { 33 | "isoCode": "63", 34 | "name": "North Rhine-Westphalia" 35 | } 36 | ] 37 | }, 38 | "browserDetails": { 39 | "browserName": "Chrome", 40 | "browserMajorVersion": "74", 41 | "browserFullVersion": "74.0.3729", 42 | "os": "Windows", 43 | "osVersion": "7", 44 | "device": "Other", 45 | "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) ...." 46 | }, 47 | "confidence": { 48 | "score": 0.97 49 | }, 50 | "visitorFound": true, 51 | "firstSeenAt": { 52 | "global": "2022-03-16T11:26:45.362Z", 53 | "subscription": "2022-03-16T11:31:01.101Z" 54 | }, 55 | "lastSeenAt": { 56 | "global": "2022-03-16T11:28:34.023Z", 57 | "subscription": null 58 | } 59 | } 60 | }, 61 | "botd": { 62 | "error": { 63 | "code": "Failed", 64 | "message": "internal server error" 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_event_200_extra_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": { 3 | "identification": { 4 | "data": { 5 | "visitorId": "Ibk1527CUFmcnjLwIs4A9", 6 | "requestId": "0KSh65EnVoB85JBmloQK", 7 | "incognito": true, 8 | "linkedId": "somelinkedId", 9 | "tag": {}, 10 | "time": "2019-05-21T16:40:13Z", 11 | "timestamp": 1582299576512, 12 | "url": "https://www.example.com/login", 13 | "ip": "61.127.217.15", 14 | "ipLocation": { 15 | "accuracyRadius": 10, 16 | "latitude": 49.982, 17 | "longitude": 36.2566, 18 | "postalCode": "61202", 19 | "timezone": "Europe/Dusseldorf", 20 | "city": { 21 | "name": "Dusseldorf" 22 | }, 23 | "continent": { 24 | "code": "EU", 25 | "name": "Europe" 26 | }, 27 | "country": { 28 | "code": "DE", 29 | "name": "Germany" 30 | }, 31 | "subdivisions": [ 32 | { 33 | "isoCode": "63", 34 | "name": "North Rhine-Westphalia" 35 | } 36 | ] 37 | }, 38 | "browserDetails": { 39 | "browserName": "Chrome", 40 | "browserMajorVersion": "74", 41 | "browserFullVersion": "74.0.3729", 42 | "os": "Windows", 43 | "osVersion": "7", 44 | "device": "Other", 45 | "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) ...." 46 | }, 47 | "confidence": { 48 | "score": 0.97, 49 | "revision": "v1.1" 50 | }, 51 | "visitorFound": true, 52 | "firstSeenAt": { 53 | "global": "2022-03-16T11:26:45.362Z", 54 | "subscription": "2022-03-16T11:31:01.101Z" 55 | }, 56 | "lastSeenAt": { 57 | "global": "2022-03-16T11:28:34.023Z", 58 | "subscription": null 59 | } 60 | } 61 | }, 62 | "botd": { 63 | "data": { 64 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 YaBrowser/24.1.0.0 Safari/537.36", 65 | "requestId": "1708102555327.NLOjmg", 66 | "bot": { 67 | "result": "notDetected" 68 | }, 69 | "url": "https://www.example.com/login", 70 | "ip": "61.127.217.15", 71 | "time": "2019-05-21T16:40:13Z" 72 | } 73 | }, 74 | "product3": { 75 | "data": { 76 | "result": false 77 | } 78 | }, 79 | "product4": { 80 | "data": { 81 | "result": true, 82 | "details": { 83 | "detail1": true, 84 | "detail2": "detail description", 85 | "detail3": 42 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_event_200_identification_failed_error.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": { 3 | "identification": { 4 | "error": { 5 | "code": "Failed", 6 | "message": "internal server error" 7 | } 8 | }, 9 | "botd": { 10 | "data": { 11 | "bot": { 12 | "result": "bad", 13 | "type": "headlessChrome" 14 | }, 15 | "url": "https://example.com/login", 16 | "ip": "94.60.143.223", 17 | "time": "2024-02-23T10:20:25.287Z", 18 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/121.0.6167.57 Safari/537.36", 19 | "requestId": "1708683625245.tuJ4nD" 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_event_200_too_many_requests_error.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": { 3 | "identification": { 4 | "error": { 5 | "code": "429 Too Many Requests", 6 | "message": "too many requests" 7 | } 8 | }, 9 | "botd": { 10 | "error": { 11 | "code": "TooManyRequests", 12 | "message": "too many requests" 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_event_200_with_broken_format.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": { 3 | "identification": { 4 | "data": { 5 | "visitorId": "Ibk1527CUFmcnjLwIs4A9", 6 | "requestId": "1708102555327.NLOjmg", 7 | "incognito": true, 8 | "linkedId": { 9 | "broken": "format" 10 | }, 11 | "tag": {}, 12 | "time": "2019-05-21T16:40:13Z", 13 | "timestamp": 1582299576512, 14 | "url": "https://www.example.com/login?hope{this{works[!", 15 | "ip": "61.127.217.15", 16 | "ipLocation": { 17 | "accuracyRadius": 10, 18 | "latitude": 49.982, 19 | "longitude": 36.2566, 20 | "postalCode": "61202", 21 | "timezone": "Europe/Dusseldorf", 22 | "city": { 23 | "name": "Dusseldorf" 24 | }, 25 | "country": { 26 | "code": "DE", 27 | "name": "Germany" 28 | }, 29 | "continent": { 30 | "code": "EU", 31 | "name": "Europe" 32 | }, 33 | "subdivisions": [ 34 | { 35 | "isoCode": "63", 36 | "name": "North Rhine-Westphalia" 37 | } 38 | ] 39 | }, 40 | "browserDetails": { 41 | "browserName": "Chrome", 42 | "browserMajorVersion": "74", 43 | "browserFullVersion": "74.0.3729", 44 | "os": "Windows", 45 | "osVersion": "7", 46 | "device": "Other", 47 | "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) ...." 48 | }, 49 | "confidence": { 50 | "score": 0.97 51 | }, 52 | "visitorFound": false, 53 | "firstSeenAt": { 54 | "global": "2022-03-16T11:26:45.362Z", 55 | "subscription": "2022-03-16T11:31:01.101Z" 56 | }, 57 | "lastSeenAt": { 58 | "global": null, 59 | "subscription": null 60 | } 61 | } 62 | }, 63 | "botd": { 64 | "data": { 65 | "bot": { 66 | "result": "notDetected" 67 | }, 68 | "url": "https://www.example.com/login?hope{this{works}[!", 69 | "ip": "61.127.217.15", 70 | "time": "2019-05-21T16:40:13Z", 71 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 YaBrowser/24.1.0.0 Safari/537.36", 72 | "requestId": "1708102555327.NLOjmg" 73 | } 74 | }, 75 | "rootApps": { 76 | "data": { 77 | "result": false 78 | } 79 | }, 80 | "emulator": { 81 | "data": { 82 | "result": false 83 | } 84 | }, 85 | "ipInfo": { 86 | "data": { 87 | "v4": { 88 | "address": "94.142.239.124", 89 | "geolocation": { 90 | "accuracyRadius": 20, 91 | "latitude": 50.05, 92 | "longitude": 14.4, 93 | "postalCode": "150 00", 94 | "timezone": "Europe/Prague", 95 | "city": { 96 | "name": "Prague" 97 | }, 98 | "country": { 99 | "code": "CZ", 100 | "name": "Czechia" 101 | }, 102 | "continent": { 103 | "code": "EU", 104 | "name": "Europe" 105 | }, 106 | "subdivisions": [ 107 | { 108 | "isoCode": "10", 109 | "name": "Hlavni mesto Praha" 110 | } 111 | ] 112 | }, 113 | "asn": { 114 | "asn": "7922", 115 | "name": "COMCAST-7922", 116 | "network": "73.136.0.0/13" 117 | }, 118 | "datacenter": { 119 | "result": true, 120 | "name": "DediPath" 121 | } 122 | }, 123 | "v6": { 124 | "address": "2001:db8:3333:4444:5555:6666:7777:8888", 125 | "geolocation": { 126 | "accuracyRadius": 5, 127 | "latitude": 49.982, 128 | "longitude": 36.2566, 129 | "postalCode": "10112", 130 | "timezone": "Europe/Berlin", 131 | "city": { 132 | "name": "Berlin" 133 | }, 134 | "country": { 135 | "code": "DE", 136 | "name": "Germany" 137 | }, 138 | "continent": { 139 | "code": "EU", 140 | "name": "Europe" 141 | }, 142 | "subdivisions": [ 143 | { 144 | "isoCode": "BE", 145 | "name": "Land Berlin" 146 | } 147 | ] 148 | }, 149 | "asn": { 150 | "asn": "6805", 151 | "name": "Telefonica Germany", 152 | "network": "2a02:3100::/24" 153 | }, 154 | "datacenter": { 155 | "result": false, 156 | "name": "" 157 | } 158 | } 159 | } 160 | }, 161 | "ipBlocklist": { 162 | "data": { 163 | "result": false, 164 | "details": { 165 | "emailSpam": false, 166 | "attackSource": false 167 | } 168 | } 169 | }, 170 | "tor": { 171 | "data": { 172 | "result": false 173 | } 174 | }, 175 | "vpn": { 176 | "data": { 177 | "result": false, 178 | "originTimezone": "Europe/Berlin", 179 | "originCountry": "unknown", 180 | "methods": { 181 | "timezoneMismatch": false, 182 | "publicVPN": false, 183 | "auxiliaryMobile": false 184 | } 185 | } 186 | }, 187 | "proxy": { 188 | "data": { 189 | "result": false, 190 | "confidence": "high" 191 | } 192 | }, 193 | "incognito": { 194 | "data": { 195 | "result": false 196 | } 197 | }, 198 | "tampering": { 199 | "data": { 200 | "result": false, 201 | "anomalyScore": 0.1955 202 | } 203 | }, 204 | "clonedApp": { 205 | "data": { 206 | "result": false 207 | } 208 | }, 209 | "factoryReset": { 210 | "data": { 211 | "time": "1970-01-01T00:00:00Z", 212 | "timestamp": 0 213 | } 214 | }, 215 | "jailbroken": { 216 | "data": { 217 | "result": false 218 | } 219 | }, 220 | "frida": { 221 | "data": { 222 | "result": false 223 | } 224 | }, 225 | "privacySettings": { 226 | "data": { 227 | "result": false 228 | } 229 | }, 230 | "virtualMachine": { 231 | "data": { 232 | "result": false 233 | } 234 | }, 235 | "rawDeviceAttributes": { 236 | "data": { 237 | "architecture": { 238 | "value": 127 239 | }, 240 | "audio": { 241 | "value": 35.73832903057337 242 | }, 243 | "canvas": { 244 | "value": { 245 | "Winding": true, 246 | "Geometry": "4dce9d6017c3e0c052a77252f29f2b1c", 247 | "Text": "dd2474a56ff78c1de3e7a07070ba3b7d" 248 | } 249 | }, 250 | "colorDepth": { 251 | "value": 30 252 | }, 253 | "colorGamut": { 254 | "value": "p3" 255 | }, 256 | "contrast": { 257 | "value": 0 258 | }, 259 | "cookiesEnabled": { 260 | "value": true 261 | }, 262 | "cpuClass": {}, 263 | "fonts": { 264 | "value": [ 265 | "Arial Unicode MS", 266 | "Gill Sans", 267 | "Helvetica Neue", 268 | "Menlo" 269 | ] 270 | } 271 | } 272 | }, 273 | "highActivity": { 274 | "data": { 275 | "result": false 276 | } 277 | }, 278 | "locationSpoofing": { 279 | "data": { 280 | "result": false 281 | } 282 | }, 283 | "mitmAttack": { 284 | "data": { 285 | "result": false 286 | } 287 | } 288 | } 289 | } -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_event_200_with_unknown_field.json: -------------------------------------------------------------------------------- 1 | { 2 | "unknown": "field", 3 | "products": { 4 | "unknown": "field", 5 | "identification": { 6 | "unknown": "field", 7 | "data": { 8 | "unknown": "field", 9 | "visitorId": "Ibk1527CUFmcnjLwIs4A9", 10 | "requestId": "1708102555327.NLOjmg", 11 | "incognito": true, 12 | "linkedId": "somelinkedId", 13 | "tag": {}, 14 | "time": "2019-05-21T16:40:13Z", 15 | "timestamp": 1582299576512, 16 | "url": "https://www.example.com/login?hope{this{works[!", 17 | "ip": "61.127.217.15", 18 | "ipLocation": { 19 | "accuracyRadius": 10, 20 | "latitude": 49.982, 21 | "longitude": 36.2566, 22 | "postalCode": "61202", 23 | "timezone": "Europe/Dusseldorf", 24 | "city": { 25 | "name": "Dusseldorf" 26 | }, 27 | "country": { 28 | "code": "DE", 29 | "name": "Germany" 30 | }, 31 | "continent": { 32 | "code": "EU", 33 | "name": "Europe" 34 | }, 35 | "subdivisions": [ 36 | { 37 | "isoCode": "63", 38 | "name": "North Rhine-Westphalia" 39 | } 40 | ] 41 | }, 42 | "browserDetails": { 43 | "browserName": "Chrome", 44 | "browserMajorVersion": "74", 45 | "browserFullVersion": "74.0.3729", 46 | "os": "Windows", 47 | "osVersion": "7", 48 | "device": "Other", 49 | "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) ...." 50 | }, 51 | "confidence": { 52 | "score": 0.97 53 | }, 54 | "visitorFound": false, 55 | "firstSeenAt": { 56 | "global": "2022-03-16T11:26:45.362Z", 57 | "subscription": "2022-03-16T11:31:01.101Z" 58 | }, 59 | "lastSeenAt": { 60 | "global": null, 61 | "subscription": null 62 | } 63 | } 64 | }, 65 | "botd": { 66 | "data": { 67 | "bot": { 68 | "result": "notDetected" 69 | }, 70 | "url": "https://www.example.com/login?hope{this{works}[!", 71 | "ip": "61.127.217.15", 72 | "time": "2019-05-21T16:40:13Z", 73 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 YaBrowser/24.1.0.0 Safari/537.36", 74 | "requestId": "1708102555327.NLOjmg" 75 | } 76 | }, 77 | "rootApps": { 78 | "data": { 79 | "result": false 80 | } 81 | }, 82 | "emulator": { 83 | "data": { 84 | "result": false 85 | } 86 | }, 87 | "ipInfo": { 88 | "data": { 89 | "v4": { 90 | "address": "94.142.239.124", 91 | "geolocation": { 92 | "accuracyRadius": 20, 93 | "latitude": 50.05, 94 | "longitude": 14.4, 95 | "postalCode": "150 00", 96 | "timezone": "Europe/Prague", 97 | "city": { 98 | "name": "Prague" 99 | }, 100 | "country": { 101 | "code": "CZ", 102 | "name": "Czechia" 103 | }, 104 | "continent": { 105 | "code": "EU", 106 | "name": "Europe" 107 | }, 108 | "subdivisions": [ 109 | { 110 | "isoCode": "10", 111 | "name": "Hlavni mesto Praha" 112 | } 113 | ] 114 | }, 115 | "asn": { 116 | "asn": "7922", 117 | "name": "COMCAST-7922", 118 | "network": "73.136.0.0/13" 119 | }, 120 | "datacenter": { 121 | "result": true, 122 | "name": "DediPath" 123 | } 124 | }, 125 | "v6": { 126 | "address": "2001:db8:3333:4444:5555:6666:7777:8888", 127 | "geolocation": { 128 | "accuracyRadius": 5, 129 | "latitude": 49.982, 130 | "longitude": 36.2566, 131 | "postalCode": "10112", 132 | "timezone": "Europe/Berlin", 133 | "city": { 134 | "name": "Berlin" 135 | }, 136 | "country": { 137 | "code": "DE", 138 | "name": "Germany" 139 | }, 140 | "continent": { 141 | "code": "EU", 142 | "name": "Europe" 143 | }, 144 | "subdivisions": [ 145 | { 146 | "isoCode": "BE", 147 | "name": "Land Berlin" 148 | } 149 | ] 150 | }, 151 | "asn": { 152 | "asn": "6805", 153 | "name": "Telefonica Germany", 154 | "network": "2a02:3100::/24" 155 | }, 156 | "datacenter": { 157 | "result": false, 158 | "name": "" 159 | } 160 | } 161 | } 162 | }, 163 | "ipBlocklist": { 164 | "data": { 165 | "result": false, 166 | "details": { 167 | "emailSpam": false, 168 | "attackSource": false 169 | } 170 | } 171 | }, 172 | "tor": { 173 | "data": { 174 | "result": false 175 | } 176 | }, 177 | "vpn": { 178 | "data": { 179 | "result": false, 180 | "originTimezone": "Europe/Berlin", 181 | "originCountry": "unknown", 182 | "methods": { 183 | "timezoneMismatch": false, 184 | "publicVPN": false, 185 | "auxiliaryMobile": false 186 | } 187 | } 188 | }, 189 | "proxy": { 190 | "data": { 191 | "result": false, 192 | "confidence": "high" 193 | } 194 | }, 195 | "incognito": { 196 | "data": { 197 | "result": false 198 | } 199 | }, 200 | "tampering": { 201 | "data": { 202 | "result": false, 203 | "anomalyScore": 0.1955 204 | } 205 | }, 206 | "clonedApp": { 207 | "data": { 208 | "result": false 209 | } 210 | }, 211 | "factoryReset": { 212 | "data": { 213 | "time": "1970-01-01T00:00:00Z", 214 | "timestamp": 0 215 | } 216 | }, 217 | "jailbroken": { 218 | "data": { 219 | "result": false 220 | } 221 | }, 222 | "frida": { 223 | "data": { 224 | "result": false 225 | } 226 | }, 227 | "privacySettings": { 228 | "data": { 229 | "result": false 230 | } 231 | }, 232 | "virtualMachine": { 233 | "data": { 234 | "result": false 235 | } 236 | }, 237 | "rawDeviceAttributes": { 238 | "data": { 239 | "architecture": { 240 | "value": 127 241 | }, 242 | "audio": { 243 | "value": 35.73832903057337 244 | }, 245 | "canvas": { 246 | "value": { 247 | "Winding": true, 248 | "Geometry": "4dce9d6017c3e0c052a77252f29f2b1c", 249 | "Text": "dd2474a56ff78c1de3e7a07070ba3b7d" 250 | } 251 | }, 252 | "colorDepth": { 253 | "value": 30 254 | }, 255 | "colorGamut": { 256 | "value": "p3" 257 | }, 258 | "contrast": { 259 | "value": 0 260 | }, 261 | "cookiesEnabled": { 262 | "value": true 263 | }, 264 | "cpuClass": {}, 265 | "fonts": { 266 | "value": [ 267 | "Arial Unicode MS", 268 | "Gill Sans", 269 | "Helvetica Neue", 270 | "Menlo" 271 | ] 272 | } 273 | } 274 | }, 275 | "highActivity": { 276 | "data": { 277 | "result": false 278 | } 279 | }, 280 | "locationSpoofing": { 281 | "data": { 282 | "result": false 283 | } 284 | }, 285 | "mitmAttack": { 286 | "data": { 287 | "result": false 288 | } 289 | } 290 | } 291 | } -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_event_search_200.json: -------------------------------------------------------------------------------- 1 | { 2 | "events": [ 3 | { 4 | "products": { 5 | "identification": { 6 | "data": { 7 | "visitorId": "Ibk1527CUFmcnjLwIs4A9", 8 | "requestId": "1708102555327.NLOjmg", 9 | "incognito": true, 10 | "linkedId": "somelinkedId", 11 | "tag": {}, 12 | "time": "2019-05-21T16:40:13Z", 13 | "timestamp": 1582299576512, 14 | "url": "https://www.example.com/login?hope{this{works[!", 15 | "ip": "61.127.217.15", 16 | "ipLocation": { 17 | "accuracyRadius": 10, 18 | "latitude": 49.982, 19 | "longitude": 36.2566, 20 | "postalCode": "61202", 21 | "timezone": "Europe/Dusseldorf", 22 | "city": { 23 | "name": "Dusseldorf" 24 | }, 25 | "country": { 26 | "code": "DE", 27 | "name": "Germany" 28 | }, 29 | "continent": { 30 | "code": "EU", 31 | "name": "Europe" 32 | }, 33 | "subdivisions": [ 34 | { 35 | "isoCode": "63", 36 | "name": "North Rhine-Westphalia" 37 | } 38 | ] 39 | }, 40 | "browserDetails": { 41 | "browserName": "Chrome", 42 | "browserMajorVersion": "74", 43 | "browserFullVersion": "74.0.3729", 44 | "os": "Windows", 45 | "osVersion": "7", 46 | "device": "Other", 47 | "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) ...." 48 | }, 49 | "confidence": { 50 | "score": 0.97 51 | }, 52 | "visitorFound": false, 53 | "firstSeenAt": { 54 | "global": "2022-03-16T11:26:45.362Z", 55 | "subscription": "2022-03-16T11:31:01.101Z" 56 | }, 57 | "lastSeenAt": { 58 | "global": null, 59 | "subscription": null 60 | } 61 | } 62 | }, 63 | "botd": { 64 | "data": { 65 | "bot": { 66 | "result": "notDetected" 67 | }, 68 | "url": "https://www.example.com/login?hope{this{works}[!", 69 | "ip": "61.127.217.15", 70 | "time": "2019-05-21T16:40:13Z", 71 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 YaBrowser/24.1.0.0 Safari/537.36", 72 | "requestId": "1708102555327.NLOjmg" 73 | } 74 | }, 75 | "rootApps": { 76 | "data": { 77 | "result": false 78 | } 79 | }, 80 | "emulator": { 81 | "data": { 82 | "result": false 83 | } 84 | }, 85 | "ipInfo": { 86 | "data": { 87 | "v4": { 88 | "address": "94.142.239.124", 89 | "geolocation": { 90 | "accuracyRadius": 20, 91 | "latitude": 50.05, 92 | "longitude": 14.4, 93 | "postalCode": "150 00", 94 | "timezone": "Europe/Prague", 95 | "city": { 96 | "name": "Prague" 97 | }, 98 | "country": { 99 | "code": "CZ", 100 | "name": "Czechia" 101 | }, 102 | "continent": { 103 | "code": "EU", 104 | "name": "Europe" 105 | }, 106 | "subdivisions": [ 107 | { 108 | "isoCode": "10", 109 | "name": "Hlavni mesto Praha" 110 | } 111 | ] 112 | }, 113 | "asn": { 114 | "asn": "7922", 115 | "name": "COMCAST-7922", 116 | "network": "73.136.0.0/13" 117 | }, 118 | "datacenter": { 119 | "result": true, 120 | "name": "DediPath" 121 | } 122 | }, 123 | "v6": { 124 | "address": "2001:db8:3333:4444:5555:6666:7777:8888", 125 | "geolocation": { 126 | "accuracyRadius": 5, 127 | "latitude": 49.982, 128 | "longitude": 36.2566, 129 | "postalCode": "10112", 130 | "timezone": "Europe/Berlin", 131 | "city": { 132 | "name": "Berlin" 133 | }, 134 | "country": { 135 | "code": "DE", 136 | "name": "Germany" 137 | }, 138 | "continent": { 139 | "code": "EU", 140 | "name": "Europe" 141 | }, 142 | "subdivisions": [ 143 | { 144 | "isoCode": "BE", 145 | "name": "Land Berlin" 146 | } 147 | ] 148 | }, 149 | "asn": { 150 | "asn": "6805", 151 | "name": "Telefonica Germany", 152 | "network": "2a02:3100::/24" 153 | }, 154 | "datacenter": { 155 | "result": false, 156 | "name": "" 157 | } 158 | } 159 | } 160 | }, 161 | "ipBlocklist": { 162 | "data": { 163 | "result": false, 164 | "details": { 165 | "emailSpam": false, 166 | "attackSource": false 167 | } 168 | } 169 | }, 170 | "tor": { 171 | "data": { 172 | "result": false 173 | } 174 | }, 175 | "vpn": { 176 | "data": { 177 | "result": false, 178 | "confidence": "high", 179 | "originTimezone": "Europe/Berlin", 180 | "originCountry": "unknown", 181 | "methods": { 182 | "timezoneMismatch": false, 183 | "publicVPN": false, 184 | "auxiliaryMobile": false, 185 | "osMismatch": false, 186 | "relay": false 187 | } 188 | } 189 | }, 190 | "proxy": { 191 | "data": { 192 | "result": false, 193 | "confidence": "high" 194 | } 195 | }, 196 | "incognito": { 197 | "data": { 198 | "result": false 199 | } 200 | }, 201 | "tampering": { 202 | "data": { 203 | "result": false, 204 | "anomalyScore": 0.1955, 205 | "antiDetectBrowser": false 206 | } 207 | }, 208 | "clonedApp": { 209 | "data": { 210 | "result": false 211 | } 212 | }, 213 | "factoryReset": { 214 | "data": { 215 | "time": "1970-01-01T00:00:00Z", 216 | "timestamp": 0 217 | } 218 | }, 219 | "jailbroken": { 220 | "data": { 221 | "result": false 222 | } 223 | }, 224 | "frida": { 225 | "data": { 226 | "result": false 227 | } 228 | }, 229 | "privacySettings": { 230 | "data": { 231 | "result": false 232 | } 233 | }, 234 | "virtualMachine": { 235 | "data": { 236 | "result": false 237 | } 238 | }, 239 | "rawDeviceAttributes": { 240 | "data": { 241 | "architecture": { 242 | "value": 127 243 | }, 244 | "audio": { 245 | "value": 35.73832903057337 246 | }, 247 | "canvas": { 248 | "value": { 249 | "Winding": true, 250 | "Geometry": "4dce9d6017c3e0c052a77252f29f2b1c", 251 | "Text": "dd2474a56ff78c1de3e7a07070ba3b7d" 252 | } 253 | }, 254 | "colorDepth": { 255 | "value": 30 256 | }, 257 | "colorGamut": { 258 | "value": "p3" 259 | }, 260 | "contrast": { 261 | "value": 0 262 | }, 263 | "cookiesEnabled": { 264 | "value": true 265 | }, 266 | "cpuClass": {}, 267 | "fonts": { 268 | "value": ["Arial Unicode MS", "Gill Sans", "Helvetica Neue", "Menlo"] 269 | } 270 | } 271 | }, 272 | "highActivity": { 273 | "data": { 274 | "result": false 275 | } 276 | }, 277 | "locationSpoofing": { 278 | "data": { 279 | "result": false 280 | } 281 | }, 282 | "remoteControl": { 283 | "data": { 284 | "result": false 285 | } 286 | }, 287 | "velocity": { 288 | "data": { 289 | "distinctIp": { 290 | "intervals": { 291 | "5m": 1, 292 | "1h": 1, 293 | "24h": 1 294 | } 295 | }, 296 | "distinctLinkedId": {}, 297 | "distinctCountry": { 298 | "intervals": { 299 | "5m": 1, 300 | "1h": 2, 301 | "24h": 2 302 | } 303 | }, 304 | "events": { 305 | "intervals": { 306 | "5m": 1, 307 | "1h": 5, 308 | "24h": 5 309 | } 310 | }, 311 | "ipEvents": { 312 | "intervals": { 313 | "5m": 1, 314 | "1h": 5, 315 | "24h": 5 316 | } 317 | }, 318 | "distinctIpByLinkedId": { 319 | "intervals": { 320 | "5m": 1, 321 | "1h": 5, 322 | "24h": 5 323 | } 324 | }, 325 | "distinctVisitorIdByLinkedId": { 326 | "intervals": { 327 | "5m": 1, 328 | "1h": 5, 329 | "24h": 5 330 | } 331 | } 332 | } 333 | }, 334 | "developerTools": { 335 | "data": { 336 | "result": false 337 | } 338 | }, 339 | "mitmAttack": { 340 | "data": { 341 | "result": false 342 | } 343 | } 344 | }} 345 | ], 346 | "paginationKey": "1655373953086" 347 | } 348 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_visitors_200_limit_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "visitorId": "AcxioeQKffpXF8iGQK3P", 3 | "visits": [ 4 | { 5 | "requestId": "1655373953086.DDlfmP", 6 | "browserDetails": { 7 | "browserName": "Chrome", 8 | "browserMajorVersion": "102", 9 | "browserFullVersion": "102.0.5005", 10 | "os": "Mac OS X", 11 | "osVersion": "10.15.7", 12 | "device": "Other", 13 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36" 14 | }, 15 | "incognito": false, 16 | "ip": "82.118.30.68", 17 | "ipLocation": { 18 | "accuracyRadius": 1000, 19 | "latitude": 50.0805, 20 | "longitude": 14.467, 21 | "postalCode": "130 00", 22 | "timezone": "Europe/Prague", 23 | "city": { 24 | "name": "Prague" 25 | }, 26 | "country": { 27 | "code": "CZ", 28 | "name": "Czechia" 29 | }, 30 | "continent": { 31 | "code": "EU", 32 | "name": "Europe" 33 | }, 34 | "subdivisions": [ 35 | { 36 | "isoCode": "10", 37 | "name": "Hlavni mesto Praha" 38 | } 39 | ] 40 | }, 41 | "timestamp": 1655373953094, 42 | "time": "2022-06-16T10:05:53Z", 43 | "url": "https://dashboard.fingerprint.com/", 44 | "tag": {}, 45 | "confidence": { 46 | "score": 1 47 | }, 48 | "visitorFound": true, 49 | "firstSeenAt": { 50 | "global": "2022-02-04T11:31:20Z", 51 | "subscription": "2022-02-04T11:31:20Z" 52 | }, 53 | "lastSeenAt": { 54 | "global": "2022-06-16T10:03:00.912Z", 55 | "subscription": "2022-06-16T10:03:00.912Z" 56 | } 57 | } 58 | ], 59 | "lastTimestamp": 1655373953086, 60 | "paginationKey": "1655373953086.DDlfmP" 61 | } 62 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_visitors_400_bad_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "bad request" 3 | } 4 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_visitors_403_forbidden.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "Forbidden (HTTP 403)" 3 | } 4 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/get_visitors_429_too_many_requests.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "too many requests" 3 | } 4 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/related-visitors/get_related_visitors_200.json: -------------------------------------------------------------------------------- 1 | { 2 | "relatedVisitors": [ 3 | { 4 | "visitorId": "NtCUJGceWX9RpvSbhvOm" 5 | }, 6 | { 7 | "visitorId": "25ee02iZwGxeyT0jMNkZ" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/related-visitors/get_related_visitors_200_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "relatedVisitors": [] 3 | } 4 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/update_event_multiple_fields_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "linkedId": "myNewLinkedId", 3 | "tag": { 4 | "myTag": "myNewValue" 5 | }, 6 | "suspect": true 7 | } 8 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/update_event_one_field_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "linkedId": "myNewLinkedId" 3 | } 4 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/mocked-responses-data/webhook.json: -------------------------------------------------------------------------------- 1 | { 2 | "requestId": "Px6VxbRC6WBkA39yeNH3", 3 | "url": "https://banking.example.com/signup", 4 | "ip": "216.3.128.12", 5 | "tag": { 6 | "requestType": "signup", 7 | "yourCustomId": 45321 8 | }, 9 | "time": "2019-10-12T07:20:50.52Z", 10 | "timestamp": 1554910997788, 11 | "ipLocation": { 12 | "accuracyRadius": 1, 13 | "city": { 14 | "name": "Bolingbrook" 15 | }, 16 | "continent": { 17 | "code": "NA", 18 | "name": "North America" 19 | }, 20 | "country": { 21 | "code": "US", 22 | "name": "United States" 23 | }, 24 | "latitude": 41.12933, 25 | "longitude": -88.9954, 26 | "postalCode": "60547", 27 | "subdivisions": [ 28 | { 29 | "isoCode": "IL", 30 | "name": "Illinois" 31 | } 32 | ], 33 | "timezone": "America/Chicago" 34 | }, 35 | "linkedId": "any-string", 36 | "visitorId": "3HNey93AkBW6CRbxV6xP", 37 | "visitorFound": true, 38 | "confidence": { 39 | "score": 0.97 40 | }, 41 | "firstSeenAt": { 42 | "global": "2022-03-16T11:26:45.362Z", 43 | "subscription": "2022-03-16T11:31:01.101Z" 44 | }, 45 | "lastSeenAt": { 46 | "global": "2022-03-16T11:28:34.023Z", 47 | "subscription": null 48 | }, 49 | "browserDetails": { 50 | "browserName": "Chrome", 51 | "browserFullVersion": "73.0.3683.86", 52 | "browserMajorVersion": "73", 53 | "os": "Mac OS X", 54 | "osVersion": "10.14.3", 55 | "device": "Other", 56 | "userAgent": "(Macintosh; Intel Mac OS X 10_14_3) Chrome/73.0.3683.86" 57 | }, 58 | "incognito": false, 59 | "clientReferrer": "https://google.com?search=banking+services", 60 | "bot": { 61 | "result": "bad", 62 | "type": "selenium" 63 | }, 64 | "userAgent": "(Macintosh; Intel Mac OS X 10_14_3) Chrome/73.0.3683.86", 65 | "rootApps": { 66 | "result": false 67 | }, 68 | "emulator": { 69 | "result": false 70 | }, 71 | "ipInfo": { 72 | "v4": { 73 | "address": "94.142.239.124", 74 | "geolocation": { 75 | "accuracyRadius": 20, 76 | "latitude": 50.05, 77 | "longitude": 14.4, 78 | "postalCode": "150 00", 79 | "timezone": "Europe/Prague", 80 | "city": { 81 | "name": "Prague" 82 | }, 83 | "country": { 84 | "code": "CZ", 85 | "name": "Czechia" 86 | }, 87 | "continent": { 88 | "code": "EU", 89 | "name": "Europe" 90 | }, 91 | "subdivisions": [ 92 | { 93 | "isoCode": "10", 94 | "name": "Hlavni mesto Praha" 95 | } 96 | ] 97 | }, 98 | "asn": { 99 | "asn": "7922", 100 | "name": "COMCAST-7922", 101 | "network": "73.136.0.0/13" 102 | }, 103 | "datacenter": { 104 | "result": true, 105 | "name": "DediPath" 106 | } 107 | } 108 | }, 109 | "ipBlocklist": { 110 | "result": false, 111 | "details": { 112 | "emailSpam": false, 113 | "attackSource": false 114 | } 115 | }, 116 | "tor": { 117 | "result": false 118 | }, 119 | "vpn": { 120 | "result": false, 121 | "confidence": "high", 122 | "originTimezone": "Europe/Berlin", 123 | "originCountry": "unknown", 124 | "methods": { 125 | "timezoneMismatch": false, 126 | "publicVPN": false, 127 | "auxiliaryMobile": false, 128 | "osMismatch": false, 129 | "relay": false 130 | } 131 | }, 132 | "proxy": { 133 | "result": false, 134 | "confidence": "high" 135 | }, 136 | "tampering": { 137 | "result": false, 138 | "anomalyScore": 0, 139 | "antiDetectBrowser": false 140 | }, 141 | "clonedApp": { 142 | "result": false 143 | }, 144 | "factoryReset": { 145 | "time": "1970-01-01T00:00:00Z", 146 | "timestamp": 0 147 | }, 148 | "jailbroken": { 149 | "result": false 150 | }, 151 | "frida": { 152 | "result": false 153 | }, 154 | "privacySettings": { 155 | "result": false 156 | }, 157 | "virtualMachine": { 158 | "result": false 159 | }, 160 | "rawDeviceAttributes": { 161 | "architecture": { 162 | "value": 127 163 | }, 164 | "audio": { 165 | "value": 35.73832903057337 166 | }, 167 | "canvas": { 168 | "value": { 169 | "Winding": true, 170 | "Geometry": "4dce9d6017c3e0c052a77252f29f2b1c", 171 | "Text": "dd2474a56ff78c1de3e7a07070ba3b7d" 172 | } 173 | }, 174 | "colorDepth": { 175 | "value": 30 176 | }, 177 | "colorGamut": { 178 | "value": "srgb" 179 | }, 180 | "contrast": { 181 | "value": 0 182 | }, 183 | "cookiesEnabled": { 184 | "value": true 185 | } 186 | }, 187 | "highActivity": { 188 | "result": false 189 | }, 190 | "locationSpoofing": { 191 | "result": true 192 | }, 193 | "suspectScore": { 194 | "result": 0 195 | }, 196 | "remoteControl": { 197 | "result": false 198 | }, 199 | "velocity": { 200 | "distinctIp": { 201 | "intervals": { 202 | "5m": 1, 203 | "1h": 1, 204 | "24h": 1 205 | } 206 | }, 207 | "distinctLinkedId": {}, 208 | "distinctCountry": { 209 | "intervals": { 210 | "5m": 1, 211 | "1h": 2, 212 | "24h": 2 213 | } 214 | }, 215 | "events": { 216 | "intervals": { 217 | "5m": 1, 218 | "1h": 5, 219 | "24h": 5 220 | } 221 | }, 222 | "ipEvents": { 223 | "intervals": { 224 | "5m": 1, 225 | "1h": 5, 226 | "24h": 5 227 | } 228 | }, 229 | "distinctIpByLinkedId": { 230 | "intervals": { 231 | "5m": 1, 232 | "1h": 5, 233 | "24h": 5 234 | } 235 | }, 236 | "distinctVisitorIdByLinkedId": { 237 | "intervals": { 238 | "5m": 1, 239 | "1h": 5, 240 | "24h": 5 241 | } 242 | } 243 | }, 244 | "developerTools": { 245 | "result": false 246 | }, 247 | "mitmAttack": { 248 | "result": false 249 | } 250 | } -------------------------------------------------------------------------------- /tests/mocked-responses-tests/searchEventsTests.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorResponse, FingerprintJsServerApiClient, getIntegrationInfo, RequestError } from '../../src' 2 | import getEventsSearch from './mocked-responses-data/get_event_search_200.json' 3 | 4 | jest.spyOn(global, 'fetch') 5 | 6 | const mockFetch = fetch as unknown as jest.Mock 7 | 8 | describe('[Mocked response] Search Events', () => { 9 | const apiKey = 'dummy_api_key' 10 | const client = new FingerprintJsServerApiClient({ apiKey }) 11 | 12 | test('without filter', async () => { 13 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getEventsSearch)))) 14 | 15 | const response = await client.searchEvents({ 16 | limit: 10, 17 | }) 18 | expect(response).toMatchSnapshot() 19 | expect(mockFetch).toHaveBeenCalledWith( 20 | `https://api.fpjs.io/events/search?limit=10&ii=${encodeURIComponent(getIntegrationInfo())}`, 21 | { 22 | headers: { 'Auth-API-Key': apiKey }, 23 | method: 'GET', 24 | } 25 | ) 26 | }) 27 | 28 | test('with filter params passed as undefined', async () => { 29 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getEventsSearch)))) 30 | 31 | const response = await client.searchEvents({ 32 | limit: 10, 33 | ip_address: undefined, 34 | visitor_id: undefined, 35 | }) 36 | expect(response).toMatchSnapshot() 37 | expect(mockFetch).toHaveBeenCalledWith( 38 | `https://api.fpjs.io/events/search?limit=10&ii=${encodeURIComponent(getIntegrationInfo())}`, 39 | { 40 | headers: { 'Auth-API-Key': apiKey }, 41 | method: 'GET', 42 | } 43 | ) 44 | }) 45 | 46 | test('with partial filter', async () => { 47 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getEventsSearch)))) 48 | 49 | const response = await client.searchEvents({ 50 | limit: 10, 51 | bot: 'good', 52 | visitor_id: 'visitor_id', 53 | }) 54 | expect(response).toMatchSnapshot() 55 | expect(mockFetch).toHaveBeenCalledWith( 56 | `https://api.fpjs.io/events/search?limit=10&bot=good&visitor_id=visitor_id&ii=${encodeURIComponent(getIntegrationInfo())}`, 57 | { 58 | headers: { 'Auth-API-Key': apiKey }, 59 | method: 'GET', 60 | } 61 | ) 62 | }) 63 | 64 | test('with all possible filters', async () => { 65 | mockFetch.mockReturnValue(Promise.resolve(new Response(JSON.stringify(getEventsSearch)))) 66 | 67 | const response = await client.searchEvents({ 68 | limit: 10, 69 | bot: 'all', 70 | visitor_id: 'visitor_id', 71 | ip_address: '192.168.0.1/32', 72 | linked_id: 'linked_id', 73 | start: 1620000000000, 74 | end: 1630000000000, 75 | reverse: true, 76 | suspect: false, 77 | anti_detect_browser: true, 78 | cloned_app: true, 79 | factory_reset: true, 80 | frida: true, 81 | jailbroken: true, 82 | min_suspect_score: 0.5, 83 | privacy_settings: true, 84 | root_apps: true, 85 | tampering: true, 86 | virtual_machine: true, 87 | vpn: true, 88 | vpn_confidence: 'medium', 89 | emulator: true, 90 | incognito: true, 91 | ip_blocklist: true, 92 | datacenter: true, 93 | }) 94 | 95 | expect(response).toMatchSnapshot() 96 | 97 | expect(mockFetch).toHaveBeenCalledWith( 98 | `https://api.fpjs.io/events/search?limit=10&bot=all&visitor_id=visitor_id&ip_address=${encodeURIComponent( 99 | '192.168.0.1/32' 100 | )}&linked_id=linked_id&start=1620000000000&end=1630000000000&reverse=true&suspect=false&anti_detect_browser=true&cloned_app=true&factory_reset=true&frida=true&jailbroken=true&min_suspect_score=0.5&privacy_settings=true&root_apps=true&tampering=true&virtual_machine=true&vpn=true&vpn_confidence=medium&emulator=true&incognito=true&ip_blocklist=true&datacenter=true&ii=${encodeURIComponent( 101 | getIntegrationInfo() 102 | )}`, 103 | { 104 | headers: { 'Auth-API-Key': apiKey }, 105 | method: 'GET', 106 | } 107 | ) 108 | }) 109 | 110 | test('400 error', async () => { 111 | const error = { 112 | error: { 113 | message: 'Forbidden', 114 | code: 'RequestCannotBeParsed', 115 | }, 116 | } satisfies ErrorResponse 117 | const mockResponse = new Response(JSON.stringify(error), { 118 | status: 400, 119 | }) 120 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 121 | await expect( 122 | client.searchEvents({ 123 | limit: 10, 124 | }) 125 | ).rejects.toThrow(RequestError.fromErrorResponse(error, mockResponse)) 126 | }) 127 | 128 | test('403 error', async () => { 129 | const error = { 130 | error: { 131 | message: 'secret key is required', 132 | code: 'TokenRequired', 133 | }, 134 | } satisfies ErrorResponse 135 | const mockResponse = new Response(JSON.stringify(error), { 136 | status: 403, 137 | }) 138 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 139 | await expect( 140 | client.searchEvents({ 141 | limit: 10, 142 | }) 143 | ).rejects.toThrow(RequestError.fromErrorResponse(error, mockResponse)) 144 | }) 145 | }) 146 | -------------------------------------------------------------------------------- /tests/mocked-responses-tests/updateEventTests.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorResponse, FingerprintJsServerApiClient, getIntegrationInfo, Region, RequestError } from '../../src' 2 | import Error404 from './mocked-responses-data/errors/404_request_not_found.json' 3 | import Error403 from './mocked-responses-data/errors/403_feature_not_enabled.json' 4 | import Error400 from './mocked-responses-data/errors/400_request_body_invalid.json' 5 | import Error409 from './mocked-responses-data/errors/409_state_not_ready.json' 6 | import { SdkError } from '../../src/errors/apiErrors' 7 | 8 | jest.spyOn(global, 'fetch') 9 | 10 | const mockFetch = fetch as unknown as jest.Mock 11 | 12 | describe('[Mocked response] Update event', () => { 13 | const apiKey = 'dummy_api_key' 14 | 15 | const existingVisitorId = 'TaDnMBz9XCpZNuSzFUqP' 16 | 17 | const client = new FingerprintJsServerApiClient({ region: Region.EU, apiKey }) 18 | 19 | test('with visitorId', async () => { 20 | mockFetch.mockReturnValue(Promise.resolve(new Response())) 21 | 22 | const body = { 23 | linkedId: 'linked_id', 24 | suspect: true, 25 | } 26 | const response = await client.updateEvent(body, existingVisitorId) 27 | 28 | expect(response).toBeUndefined() 29 | 30 | const call = mockFetch.mock.calls[0] 31 | const bodyFromCall = call[1]?.body 32 | expect(JSON.parse(bodyFromCall)).toEqual(body) 33 | 34 | expect(mockFetch).toHaveBeenCalledWith( 35 | `https://eu.api.fpjs.io/events/${existingVisitorId}?ii=${encodeURIComponent(getIntegrationInfo())}`, 36 | { 37 | headers: { 'Auth-API-Key': 'dummy_api_key' }, 38 | method: 'PUT', 39 | body: JSON.stringify(body), 40 | } 41 | ) 42 | }) 43 | 44 | test('404 error', async () => { 45 | const mockResponse = new Response(JSON.stringify(Error404), { 46 | status: 404, 47 | }) 48 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 49 | 50 | const body = { 51 | linkedId: 'linked_id', 52 | suspect: true, 53 | } 54 | await expect(client.updateEvent(body, existingVisitorId)).rejects.toThrow( 55 | RequestError.fromErrorResponse(Error404 as ErrorResponse, mockResponse) 56 | ) 57 | }) 58 | 59 | test('403 error', async () => { 60 | const mockResponse = new Response(JSON.stringify(Error403), { 61 | status: 403, 62 | }) 63 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 64 | 65 | const body = { 66 | linkedId: 'linked_id', 67 | suspect: true, 68 | } 69 | await expect(client.updateEvent(body, existingVisitorId)).rejects.toThrow( 70 | RequestError.fromErrorResponse(Error403 as ErrorResponse, mockResponse) 71 | ) 72 | }) 73 | 74 | test('400 error', async () => { 75 | const mockResponse = new Response(JSON.stringify(Error400), { 76 | status: 400, 77 | }) 78 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 79 | 80 | const body = { 81 | linkedId: 'linked_id', 82 | suspect: true, 83 | } 84 | await expect(client.updateEvent(body, existingVisitorId)).rejects.toThrow( 85 | RequestError.fromErrorResponse(Error400 as ErrorResponse, mockResponse) 86 | ) 87 | }) 88 | 89 | test('409 error', async () => { 90 | const mockResponse = new Response(JSON.stringify(Error409), { 91 | status: 409, 92 | }) 93 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 94 | 95 | const body = { 96 | linkedId: 'linked_id', 97 | suspect: true, 98 | } 99 | await expect(client.updateEvent(body, existingVisitorId)).rejects.toThrow( 100 | RequestError.fromErrorResponse(Error409 as ErrorResponse, mockResponse) 101 | ) 102 | }) 103 | 104 | test('Error with bad JSON', async () => { 105 | const mockResponse = new Response('(Some bad JSON)', { 106 | status: 404, 107 | }) 108 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 109 | 110 | const body = { 111 | linkedId: 'linked_id', 112 | suspect: true, 113 | } 114 | await expect(client.updateEvent(body, existingVisitorId)).rejects.toMatchObject( 115 | new SdkError( 116 | 'Failed to parse JSON response', 117 | mockResponse, 118 | new SyntaxError('Unexpected token \'(\', "(Some bad JSON)" is not valid JSON') 119 | ) 120 | ) 121 | }) 122 | 123 | test('Error with bad shape', async () => { 124 | const errorInfo = 'Some text instead of shaped object' 125 | const mockResponse = new Response( 126 | JSON.stringify({ 127 | error: errorInfo, 128 | }), 129 | { 130 | status: 404, 131 | } 132 | ) 133 | 134 | mockFetch.mockReturnValue(Promise.resolve(mockResponse)) 135 | 136 | const body = { 137 | linkedId: 'linked_id', 138 | suspect: true, 139 | } 140 | await expect(client.updateEvent(body, existingVisitorId)).rejects.toThrow(RequestError) 141 | await expect(client.updateEvent(body, existingVisitorId)).rejects.toThrow('Some text instead of shaped object') 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /tests/unit-tests/__snapshots__/sealedResults.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Unseal event response unseals sealed data using aes256gcm 1`] = ` 4 | { 5 | "products": { 6 | "botd": { 7 | "data": { 8 | "bot": { 9 | "result": "notDetected", 10 | }, 11 | "ip": "::1", 12 | "meta": { 13 | "foo": "bar", 14 | }, 15 | "requestId": "1703067132750.Z5hutJ", 16 | "time": "2023-12-20T10:12:13.894Z", 17 | "url": "http://localhost:8080/", 18 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15", 19 | }, 20 | }, 21 | "identification": { 22 | "data": { 23 | "browserDetails": { 24 | "browserFullVersion": "17.3", 25 | "browserMajorVersion": "17", 26 | "browserName": "Safari", 27 | "device": "Other", 28 | "os": "Mac OS X", 29 | "osVersion": "10.15.7", 30 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15", 31 | }, 32 | "confidence": { 33 | "score": 1, 34 | }, 35 | "firstSeenAt": { 36 | "global": "2023-12-15T12:13:55.103Z", 37 | "subscription": "2023-12-15T12:13:55.103Z", 38 | }, 39 | "incognito": false, 40 | "ip": "::1", 41 | "ipLocation": { 42 | "accuracyRadius": 1000, 43 | "city": { 44 | "name": "Stockholm", 45 | }, 46 | "continent": { 47 | "code": "EU", 48 | "name": "Europe", 49 | }, 50 | "country": { 51 | "code": "SE", 52 | "name": "Sweden", 53 | }, 54 | "latitude": 59.3241, 55 | "longitude": 18.0517, 56 | "postalCode": "100 05", 57 | "subdivisions": [ 58 | { 59 | "isoCode": "AB", 60 | "name": "Stockholm County", 61 | }, 62 | ], 63 | "timezone": "Europe/Stockholm", 64 | }, 65 | "lastSeenAt": { 66 | "global": "2023-12-19T11:39:51.52Z", 67 | "subscription": "2023-12-19T11:39:51.52Z", 68 | }, 69 | "requestId": "1703067132750.Z5hutJ", 70 | "tag": { 71 | "foo": "bar", 72 | }, 73 | "time": "2023-12-20T10:12:16Z", 74 | "timestamp": 1703067136286, 75 | "url": "http://localhost:8080/", 76 | "visitorFound": true, 77 | "visitorId": "2ZEDCZEfOfXjEmMuE3tq", 78 | }, 79 | }, 80 | }, 81 | } 82 | `; 83 | -------------------------------------------------------------------------------- /tests/unit-tests/sealedResults.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DecryptionAlgorithm, 3 | parseEventsResponse, 4 | UnsealAggregateError, 5 | UnsealError, 6 | unsealEventsResponse, 7 | } from '../../src' 8 | 9 | describe('Parse events response', () => { 10 | it('throws if response is not valid events response', () => { 11 | expect(() => { 12 | parseEventsResponse('{}') 13 | }).toThrowError('Sealed data is not valid events response') 14 | }) 15 | }) 16 | 17 | describe('Unseal event response', () => { 18 | const sealedData = Buffer.from( 19 | 'noXc7SXO+mqeAGrvBMgObi/S0fXTpP3zupk8qFqsO/1zdtWCD169iLA3VkkZh9ICHpZ0oWRzqG0M9/TnCeKFohgBLqDp6O0zEfXOv6i5q++aucItznQdLwrKLP+O0blfb4dWVI8/aSbd4ELAZuJJxj9bCoVZ1vk+ShbUXCRZTD30OIEAr3eiG9aw00y1UZIqMgX6CkFlU9L9OnKLsNsyomPIaRHTmgVTI5kNhrnVNyNsnzt9rY7fUD52DQxJILVPrUJ1Q+qW7VyNslzGYBPG0DyYlKbRAomKJDQIkdj/Uwa6bhSTq4XYNVvbk5AJ/dGwvsVdOnkMT2Ipd67KwbKfw5bqQj/cw6bj8Cp2FD4Dy4Ud4daBpPRsCyxBM2jOjVz1B/lAyrOp8BweXOXYugwdPyEn38MBZ5oL4D38jIwR/QiVnMHpERh93jtgwh9Abza6i4/zZaDAbPhtZLXSM5ztdctv8bAb63CppLU541Kf4OaLO3QLvfLRXK2n8bwEwzVAqQ22dyzt6/vPiRbZ5akh8JB6QFXG0QJF9DejsIspKF3JvOKjG2edmC9o+GfL3hwDBiihYXCGY9lElZICAdt+7rZm5UxMx7STrVKy81xcvfaIp1BwGh/HyMsJnkE8IczzRFpLlHGYuNDxdLoBjiifrmHvOCUDcV8UvhSV+UAZtAVejdNGo5G/bz0NF21HUO4pVRPu6RqZIs/aX4hlm6iO/0Ru00ct8pfadUIgRcephTuFC2fHyZxNBC6NApRtLSNLfzYTTo/uSjgcu6rLWiNo5G7yfrM45RXjalFEFzk75Z/fu9lCJJa5uLFgDNKlU+IaFjArfXJCll3apbZp4/LNKiU35ZlB7ZmjDTrji1wLep8iRVVEGht/DW00MTok7Zn7Fv+MlxgWmbZB3BuezwTmXb/fNw==', 20 | 'base64' 21 | ) 22 | const validKey = Buffer.from('p2PA7MGy5tx56cnyJaFZMr96BCFwZeHjZV2EqMvTq53=', 'base64') 23 | const invalidKey = Buffer.from('a2PA7MGy5tx56cnyJaFZMr96BCFwZeHjZV2EqMvTq53=', 'base64') 24 | 25 | it('unseals sealed data using aes256gcm', async () => { 26 | const result = await unsealEventsResponse(sealedData, [ 27 | { 28 | key: invalidKey, 29 | algorithm: DecryptionAlgorithm.Aes256Gcm, 30 | }, 31 | { 32 | key: validKey, 33 | algorithm: DecryptionAlgorithm.Aes256Gcm, 34 | }, 35 | ]) 36 | 37 | expect(result).toBeTruthy() 38 | expect(result).toMatchSnapshot() 39 | }) 40 | 41 | it('throws error if header is not correct', async () => { 42 | const invalidData = Buffer.from( 43 | 'xzXc7SXO+mqeAGrvBMgObi/S0fXTpP3zupk8qFqsO/1zdtWCD169iLA3VkkZh9ICHpZ0oWRzqG0M9/TnCeKFohgBLqDp6O0zEfXOv6i5q++aucItznQdLwrKLP+O0blfb4dWVI8/aSbd4ELAZuJJxj9bCoVZ1vk+ShbUXCRZTD30OIEAr3eiG9aw00y1UZIqMgX6CkFlU9L9OnKLsNsyomPIaRHTmgVTI5kNhrnVNyNsnzt9rY7fUD52DQxJILVPrUJ1Q+qW7VyNslzGYBPG0DyYlKbRAomKJDQIkdj/Uwa6bhSTq4XYNVvbk5AJ/dGwvsVdOnkMT2Ipd67KwbKfw5bqQj/cw6bj8Cp2FD4Dy4Ud4daBpPRsCyxBM2jOjVz1B/lAyrOp8BweXOXYugwdPyEn38MBZ5oL4D38jIwR/QiVnMHpERh93jtgwh9Abza6i4/zZaDAbPhtZLXSM5ztdctv8bAb63CppLU541Kf4OaLO3QLvfLRXK2n8bwEwzVAqQ22dyzt6/vPiRbZ5akh8JB6QFXG0QJF9DejsIspKF3JvOKjG2edmC9o+GfL3hwDBiihYXCGY9lElZICAdt+7rZm5UxMx7STrVKy81xcvfaIp1BwGh/HyMsJnkE8IczzRFpLlHGYuNDxdLoBjiifrmHvOCUDcV8UvhSV+UAZtAVejdNGo5G/bz0NF21HUO4pVRPu6RqZIs/aX4hlm6iO/0Ru00ct8pfadUIgRcephTuFC2fHyZxNBC6NApRtLSNLfzYTTo/uSjgcu6rLWiNo5G7yfrM45RXjalFEFzk75Z/fu9lCJJa5uLFgDNKlU+IaFjArfXJCll3apbZp4/LNKiU35ZlB7ZmjDTrji1wLep8iRVVEGht/DW00MTok7Zn7Fv+MlxgWmbZB3BuezwTmXb/fNw==', 44 | 'base64' 45 | ) 46 | 47 | await expect( 48 | unsealEventsResponse(invalidData, [ 49 | { 50 | key: invalidKey, 51 | algorithm: DecryptionAlgorithm.Aes256Gcm, 52 | }, 53 | { 54 | key: validKey, 55 | algorithm: DecryptionAlgorithm.Aes256Gcm, 56 | }, 57 | ]) 58 | ).rejects.toThrowError('Invalid sealed data header') 59 | }) 60 | 61 | it('throws error if invalid algorithm is provided', async () => { 62 | await expect( 63 | unsealEventsResponse(sealedData, [ 64 | { 65 | key: invalidKey, 66 | algorithm: 'invalid-algorithm' as DecryptionAlgorithm, 67 | }, 68 | ]) 69 | ).rejects.toThrowError('Unsupported decryption algorithm: invalid-algorithm') 70 | }) 71 | 72 | it('throws error if sealed result is not valid event response', async () => { 73 | await expect( 74 | unsealEventsResponse( 75 | Buffer.from( 76 | // "{\"invalid\":true}" 77 | 'noXc7VOpBstjjcavDKSKr4HTavt4mdq8h6NC32T0hUtw9S0jXT8lPjZiWL8SyHxmrF3uTGqO+g==', 78 | 'base64' 79 | ), 80 | [ 81 | { 82 | key: validKey, 83 | algorithm: DecryptionAlgorithm.Aes256Gcm, 84 | }, 85 | ] 86 | ) 87 | ).rejects.toThrowError('Sealed data is not valid events response') 88 | }) 89 | 90 | it('throws error if sealed result was not compressed', async () => { 91 | try { 92 | await unsealEventsResponse( 93 | Buffer.from( 94 | 'noXc7dtuk0smGE+ZbaoXzrp6Rq8ySxLepejTsu7+jUXlPhV1w+WuHx9gbPhaENJnOQo8BcGmsaRhL5k2NVj+DRNzYO9cQD7wHxmXKCyTbl/dvSYOMoHziUZ2VbQ7tmaorFny26v8jROr/UBGfvPE0dLKC36IN9ZlJ3X0NZJO8SY+8bCr4mTrkVZsv/hpvZp+OjC4h7e5vxcpmnBWXzxfaO79Lq3aMRIEf9XfK7/bVIptHaEqtPKCTwl9rz1KUpUUNQSHTPM0NlqJe9bjYf5mr1uYvWHhcJoXSyRyVMxIv/quRiw3SKJzAMOTBiAvFICpWuRFa+T/xIMHK0g96w/IMQo0jdY1E067ZEvBUOBmsJnGJg1LllS3rbJVe+E2ClFNL8SzFphyvtlcfvYB+SVSD4bzI0w/YCldv5Sq42BFt5bn4n4aE5A6658DYsfSRYWqP6OpqPJx96cY34W7H1t/ZG0ulez6zF5NvWhc1HDQ1gMtXd+K/ogt1n+FyFtn8xzvtSGkmrc2jJgYNI5Pd0Z0ent73z0MKbJx9v2ta/emPEzPr3cndN5amdr6TmRkDU4bq0vyhAh87DJrAnJQLdrvYLddnrr8xTdeXxj1i1Yug6SGncPh9sbTYkdOfuamPAYOuiJVBAMcfYsYEiQndZe8mOQ4bpCr+hxAAqixhZ16pQ8CeUwa247+D2scRymLB8qJXlaERuFZtWGVAZ8VP/GS/9EXjrzpjGX9vlrIPeJP8fh2S5QPzw55cGNJ7JfAdOyManXnoEw2/QzDhSZQARVl+akFgSO0Y13YmbiL7H6HcKWGcJ2ipDKIaj2fJ7GE0Vzyt+CBEezSQR99Igd8x3p2JtvsVKp35iLPksjS1VqtSCTbuIRUlINlfQHNjeQiE/B/61jo3Mf7SmjYjqtvXt5e9RKb+CQku2qH4ZU8xN3DSg+4mLom3BgKBkm/MoyGBpMK41c96d2tRp3tp4hV0F6ac02Crg7P2lw8IUct+i2VJ8VUjcbRfTIPQs0HjNjM6/gLfLCkWOHYrlFjwusXWQCJz91Kq+hVxj7M9LtplPO4AUq6RUMNhlPGUmyOI2tcUMrjq9vMLXGlfdkH185zM4Mk+O7DRLC8683lXZFZvcBEmxr855PqLLH/9SpYKHBoGRatDRdQe3oRp6gHS0jpQ1SW/si4kvLKiUNjiBExvbQVOUV7/VFXvG1RpM9wbzSoOd40gg7ZzD/72QshUC/25DkM/Pm7RBzwtjgmnRKjT+mROeC/7VQLoz3amv09O8Mvbt+h/lX5+51Q834F7NgIGagbB20WtWcMtrmKrvCEZlaoiZrmYVSbi1RfknRK7CTPJkopw9IjO7Ut2EhKZ+jL4rwk6TlVm6EC6Kuj7KNqp6wB/UNe9eM2Eym/aiHAcja8XN4YQhSIuJD2Wxb0n3LkKnAjK1/GY65c8K6rZsVYQ0MQL1j4lMl0UZPjG/vzKyetIsVDyXc4J9ZhOEMYnt/LaxEeSt4EMJGBA9wpTmz33X4h3ij0Y3DY/rH7lrEScUknw20swTZRm5T6q1bnimj7M1OiOkebdI09MZ0nyaTWRHdB7B52C/moh89Q7qa2Fulp5h8Us1FYRkWBLt37a5rGI1IfVeP38KaPbagND+XzWpNqX4HVrAVPLQVK5EwUvGamED3ooJ0FMieTc0IH0N+IeUYG7Q8XmrRVBcw32W8pEfYLO9L71An/J0jQZCIP8DuQnUG0mOvunOuloBGvP/9LvkBlkamh68F0a5f5ny1jloyIFJhRh5dt2SBlbsXS9AKqUwARYSSsA9Ao4WJWOZMyjp8A+qIBAfW65MdhhUDKYMBgIAbMCc3uiptzElQQopE5TT5xIhwfYxa503jVzQbz1Q==', 95 | 'base64' 96 | ), 97 | 98 | [ 99 | { 100 | key: validKey, 101 | algorithm: DecryptionAlgorithm.Aes256Gcm, 102 | }, 103 | ] 104 | ) 105 | } catch (e) { 106 | expect(e).toBeInstanceOf(UnsealAggregateError) 107 | 108 | expect((e as Error).toString()).toMatchInlineSnapshot( 109 | `"UnsealError: Unable to decrypt sealed data: invalid distance too far back"` 110 | ) 111 | 112 | return 113 | } 114 | 115 | throw new Error('Expected error to be thrown') 116 | }) 117 | 118 | it('throws error if all decryption keys are invalid', async () => { 119 | const keys = [ 120 | { 121 | key: invalidKey, 122 | algorithm: DecryptionAlgorithm.Aes256Gcm, 123 | }, 124 | { 125 | key: Buffer.from('aW52YWxpZA==', 'base64'), 126 | algorithm: DecryptionAlgorithm.Aes256Gcm, 127 | }, 128 | ] 129 | 130 | await expect(unsealEventsResponse(sealedData, keys)).rejects.toThrow( 131 | new UnsealAggregateError(keys.map((k) => new UnsealError(k))) 132 | ) 133 | }) 134 | 135 | it('throws if data is empty', async () => { 136 | const invalidData = Buffer.from('', 'utf-8') 137 | 138 | await expect( 139 | unsealEventsResponse(invalidData, [ 140 | { 141 | key: invalidKey, 142 | algorithm: DecryptionAlgorithm.Aes256Gcm, 143 | }, 144 | { 145 | key: validKey, 146 | algorithm: DecryptionAlgorithm.Aes256Gcm, 147 | }, 148 | ]) 149 | ).rejects.toThrowError('Invalid sealed data header') 150 | }) 151 | 152 | it('throws if nonce is not correct', async () => { 153 | const invalidData = Buffer.from([0x9e, 0x85, 0xdc, 0xed, 0xaa, 0xbb, 0xcc]) 154 | 155 | try { 156 | await unsealEventsResponse( 157 | invalidData, 158 | 159 | [ 160 | { 161 | key: validKey, 162 | algorithm: DecryptionAlgorithm.Aes256Gcm, 163 | }, 164 | ] 165 | ) 166 | } catch (e) { 167 | expect(e).toBeInstanceOf(UnsealAggregateError) 168 | 169 | expect((e as Error).toString()).toMatchInlineSnapshot( 170 | `"UnsealError: Unable to decrypt sealed data: Invalid authentication tag length: 7"` 171 | ) 172 | 173 | return 174 | } 175 | 176 | throw new Error('Expected error to be thrown') 177 | }) 178 | }) 179 | -------------------------------------------------------------------------------- /tests/unit-tests/serverApiClientTests.spec.ts: -------------------------------------------------------------------------------- 1 | import { RequestError, FingerprintJsServerApiClient, Region } from '../../src' 2 | 3 | describe('ServerApiClient', () => { 4 | it('should support passing custom fetch implementation', async () => { 5 | const mockFetch = jest.fn().mockResolvedValue(new Response(JSON.stringify({}))) 6 | 7 | const client = new FingerprintJsServerApiClient({ 8 | fetch: mockFetch, 9 | apiKey: 'test', 10 | region: Region.Global, 11 | }) 12 | 13 | await client.getVisits('visitorId') 14 | 15 | expect(mockFetch).toHaveBeenCalledTimes(1) 16 | }) 17 | 18 | it('errors should return response that supports body related methods', async () => { 19 | const responseBody = { 20 | error: { 21 | code: 'FeatureNotEnabled', 22 | message: 'feature not enabled', 23 | }, 24 | } 25 | const mockFetch = jest.fn().mockResolvedValue(new Response(JSON.stringify(responseBody), { status: 403 })) 26 | 27 | const client = new FingerprintJsServerApiClient({ 28 | fetch: mockFetch, 29 | apiKey: 'test', 30 | region: Region.Global, 31 | }) 32 | 33 | try { 34 | await client.getEvent('test') 35 | } catch (e) { 36 | if (e instanceof RequestError) { 37 | expect(e.response.status).toBe(403) 38 | expect(e.responseBody).toEqual(responseBody) 39 | 40 | await expect(e.response.json()).resolves.not.toThrow() 41 | 42 | return 43 | } 44 | } 45 | 46 | throw new Error('Expected EventError to be thrown') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /tests/unit-tests/urlUtilsTests.spec.ts: -------------------------------------------------------------------------------- 1 | import { Region, VisitorHistoryFilter } from '../../src/types' 2 | import { getRequestPath } from '../../src/urlUtils' 3 | import { version } from '../../package.json' 4 | 5 | const visitorId = 'TaDnMBz9XCpZNuSzFUqP' 6 | const requestId = '1626550679751.cVc5Pm' 7 | const ii = `ii=fingerprint-pro-server-node-sdk%2F${version}` 8 | 9 | describe('Get Event path', () => { 10 | it('returns correct path without api key', () => { 11 | const url = getRequestPath({ 12 | path: '/events/{request_id}', 13 | method: 'get', 14 | pathParams: [requestId], 15 | region: Region.Global, 16 | }) 17 | const expectedPath = `https://api.fpjs.io/events/${requestId}?${ii}` 18 | 19 | expect(url).toEqual(expectedPath) 20 | }) 21 | 22 | it('returns correct path with api key', () => { 23 | const apiKey = 'test-api-key' 24 | const url = getRequestPath({ 25 | path: '/events/{request_id}', 26 | method: 'get', 27 | pathParams: [requestId], 28 | apiKey, 29 | region: Region.Global, 30 | }) 31 | const expectedPath = `https://api.fpjs.io/events/${requestId}?${ii}&api_key=${apiKey}` 32 | 33 | expect(url).toEqual(expectedPath) 34 | }) 35 | }) 36 | 37 | describe('Get Visitors path', () => { 38 | const linkedId = 'makma' 39 | const limit = 10 40 | const before = 1626538505244 41 | const paginationKey = '1683900801733.Ogvu1j' 42 | 43 | test('eu region without filter', async () => { 44 | const actualPath = getRequestPath({ 45 | path: '/visitors/{visitor_id}', 46 | method: 'get', 47 | pathParams: [visitorId], 48 | region: Region.EU, 49 | }) 50 | const expectedPath = `https://eu.api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?${ii}` 51 | expect(actualPath).toEqual(expectedPath) 52 | }) 53 | 54 | test('ap region without filter', async () => { 55 | const actualPath = getRequestPath({ 56 | path: '/visitors/{visitor_id}', 57 | method: 'get', 58 | pathParams: [visitorId], 59 | region: Region.AP, 60 | }) 61 | const expectedPath = `https://ap.api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?${ii}` 62 | expect(actualPath).toEqual(expectedPath) 63 | }) 64 | 65 | test('without path param', async () => { 66 | expect(() => 67 | getRequestPath({ 68 | path: '/visitors/{visitor_id}', 69 | method: 'get', 70 | pathParams: [], 71 | region: Region.AP, 72 | }) 73 | ).toThrowError('Missing path parameter for visitor_id') 74 | }) 75 | 76 | test('unsupported region', async () => { 77 | expect(() => 78 | getRequestPath({ 79 | path: '/visitors/{visitor_id}', 80 | method: 'get', 81 | pathParams: [visitorId], 82 | // @ts-expect-error 83 | region: 'INVALID', 84 | }) 85 | ).toThrowError('Unsupported region') 86 | }) 87 | 88 | test('eu region with request_id filter', async () => { 89 | const filter: VisitorHistoryFilter = { request_id: requestId } 90 | const actualPath = getRequestPath({ 91 | path: '/visitors/{visitor_id}', 92 | method: 'get', 93 | queryParams: filter, 94 | pathParams: [visitorId], 95 | region: Region.EU, 96 | }) 97 | const expectedPath = `https://eu.api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?request_id=1626550679751.cVc5Pm&${ii}` 98 | expect(actualPath).toEqual(expectedPath) 99 | }) 100 | 101 | test('eu region with request_id linked_id filters', async () => { 102 | const filter: VisitorHistoryFilter = { request_id: requestId, linked_id: linkedId } 103 | const actualPath = getRequestPath({ 104 | path: '/visitors/{visitor_id}', 105 | method: 'get', 106 | queryParams: filter, 107 | pathParams: [visitorId], 108 | region: Region.EU, 109 | }) 110 | const expectedPath = `https://eu.api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?request_id=1626550679751.cVc5Pm&linked_id=makma&${ii}` 111 | expect(actualPath).toEqual(expectedPath) 112 | }) 113 | 114 | test('eu region with request_id, linked_id, limit, before filters', async () => { 115 | const filter: VisitorHistoryFilter = { 116 | request_id: requestId, 117 | linked_id: linkedId, 118 | limit, 119 | before, 120 | } 121 | const actualPath = getRequestPath({ 122 | path: '/visitors/{visitor_id}', 123 | method: 'get', 124 | queryParams: filter, 125 | pathParams: [visitorId], 126 | region: Region.EU, 127 | }) 128 | const expectedPath = `https://eu.api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?request_id=1626550679751.cVc5Pm&linked_id=makma&limit=10&before=1626538505244&${ii}` 129 | expect(actualPath).toEqual(expectedPath) 130 | }) 131 | 132 | test('eu region with request_id, linked_id, limit, paginationKey filters', async () => { 133 | const filter: VisitorHistoryFilter = { 134 | request_id: requestId, 135 | linked_id: linkedId, 136 | limit, 137 | paginationKey, 138 | } 139 | const actualPath = getRequestPath({ 140 | path: '/visitors/{visitor_id}', 141 | method: 'get', 142 | queryParams: filter, 143 | pathParams: [visitorId], 144 | region: Region.EU, 145 | }) 146 | const expectedPath = `https://eu.api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?request_id=1626550679751.cVc5Pm&linked_id=makma&limit=10&paginationKey=1683900801733.Ogvu1j&${ii}` 147 | expect(actualPath).toEqual(expectedPath) 148 | }) 149 | 150 | test('global region without filter', async () => { 151 | const actualPath = getRequestPath({ 152 | path: '/visitors/{visitor_id}', 153 | method: 'get', 154 | pathParams: [visitorId], 155 | region: Region.Global, 156 | }) 157 | const expectedPath = `https://api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?${ii}` 158 | expect(actualPath).toEqual(expectedPath) 159 | }) 160 | 161 | test('global region with request_id filter', async () => { 162 | const filter: VisitorHistoryFilter = { request_id: requestId } 163 | const actualPath = getRequestPath({ 164 | path: '/visitors/{visitor_id}', 165 | method: 'get', 166 | pathParams: [visitorId], 167 | region: Region.Global, 168 | queryParams: filter, 169 | }) 170 | const expectedPath = `https://api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?request_id=1626550679751.cVc5Pm&${ii}` 171 | expect(actualPath).toEqual(expectedPath) 172 | }) 173 | 174 | test('global region with request_id linked_id filters', async () => { 175 | const filter: VisitorHistoryFilter = { request_id: requestId, linked_id: linkedId } 176 | const actualPath = getRequestPath({ 177 | path: '/visitors/{visitor_id}', 178 | method: 'get', 179 | pathParams: [visitorId], 180 | queryParams: filter, 181 | region: Region.Global, 182 | }) 183 | const expectedPath = `https://api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?request_id=1626550679751.cVc5Pm&linked_id=makma&${ii}` 184 | expect(actualPath).toEqual(expectedPath) 185 | }) 186 | 187 | test('global region with request_id, linked_id, limit, paginationKey filters', async () => { 188 | const filter: VisitorHistoryFilter = { 189 | request_id: requestId, 190 | linked_id: linkedId, 191 | limit, 192 | paginationKey, 193 | } 194 | const actualPath = getRequestPath({ 195 | path: '/visitors/{visitor_id}', 196 | method: 'get', 197 | pathParams: [visitorId], 198 | region: Region.Global, 199 | queryParams: filter, 200 | }) 201 | const expectedPath = `https://api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?request_id=1626550679751.cVc5Pm&linked_id=makma&limit=10&paginationKey=1683900801733.Ogvu1j&${ii}` 202 | expect(actualPath).toEqual(expectedPath) 203 | }) 204 | }) 205 | 206 | describe('Delete visitor path', () => { 207 | test('eu region', async () => { 208 | const actualPath = getRequestPath({ 209 | path: '/visitors/{visitor_id}', 210 | method: 'delete', 211 | pathParams: [visitorId], 212 | region: Region.EU, 213 | }) 214 | const expectedPath = `https://eu.api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?${ii}` 215 | expect(actualPath).toEqual(expectedPath) 216 | }) 217 | 218 | test('ap region', async () => { 219 | const actualPath = getRequestPath({ 220 | path: '/visitors/{visitor_id}', 221 | method: 'delete', 222 | pathParams: [visitorId], 223 | region: Region.AP, 224 | }) 225 | const expectedPath = `https://ap.api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?${ii}` 226 | expect(actualPath).toEqual(expectedPath) 227 | }) 228 | 229 | test('global region', async () => { 230 | const actualPath = getRequestPath({ 231 | path: '/visitors/{visitor_id}', 232 | method: 'delete', 233 | pathParams: [visitorId], 234 | region: Region.Global, 235 | }) 236 | const expectedPath = `https://api.fpjs.io/visitors/TaDnMBz9XCpZNuSzFUqP?${ii}` 237 | expect(actualPath).toEqual(expectedPath) 238 | }) 239 | }) 240 | -------------------------------------------------------------------------------- /tests/unit-tests/webhookTests.spec.ts: -------------------------------------------------------------------------------- 1 | import { isValidWebhookSignature } from '../../src' 2 | 3 | const secret = 'secret' 4 | const data = Buffer.from('data') 5 | 6 | const validHeader = 'v1=1b2c16b75bd2a870c114153ccda5bcfca63314bc722fa160d690de133ccbb9db' 7 | 8 | describe('Is valid webhook signature', () => { 9 | it('with valid signature', () => { 10 | expect(isValidWebhookSignature({ header: validHeader, data: data, secret: secret })).toEqual(true) 11 | }) 12 | 13 | it('with invalid header', () => { 14 | expect(isValidWebhookSignature({ header: 'v2=invalid', data: data, secret: secret })).toEqual(false) 15 | }) 16 | 17 | it('with header without version', () => { 18 | expect(isValidWebhookSignature({ header: 'invalid', data: data, secret: secret })).toEqual(false) 19 | }) 20 | 21 | it('with empty header', () => { 22 | expect(isValidWebhookSignature({ header: '', data: data, secret: secret })).toEqual(false) 23 | }) 24 | 25 | it('with empty secret', () => { 26 | expect(isValidWebhookSignature({ header: validHeader, data: data, secret: '' })).toEqual(false) 27 | }) 28 | 29 | it('with empty data', () => { 30 | expect(isValidWebhookSignature({ header: validHeader, data: Buffer.from(''), secret: secret })).toEqual(false) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@fingerprintjs/tsconfig-dx-team/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2022", 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "sourceMap": true, 12 | "resolveJsonModule": true, 13 | "exactOptionalPropertyTypes": false 14 | }, 15 | "include": [ 16 | "src/**/*" 17 | ], 18 | "exclude": [ 19 | "node_modules", 20 | "dist", 21 | "**/*.spec.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | excludePrivate: true, 3 | excludeProtected: true, 4 | out: { 5 | sort: ['source-order', 'static-first'], 6 | }, 7 | } 8 | --------------------------------------------------------------------------------