diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8d3f206 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{yml,yaml,json,neon,xml,xml.dist}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[tests/data/**] +insert_final_newline = unset +trim_trailing_whitespace = unset +indent_size = unset diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ef70083 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +* text=auto eol=lf + +# Exact request bodies used by the signature tests: never touch their bytes. +/tests/data/*.raw.txt -text + +# Keep the Packagist archive small. +/.github export-ignore +/examples export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/CONTRIBUTING.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3643c8b..2837a78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,18 +5,60 @@ on: branches: [main] pull_request: +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: - test: + tests: + name: Tests (PHP ${{ matrix.php }}, ${{ matrix.dependencies }} dependencies) runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - php-version: ["8.1", "8.2", "8.3"] + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + dependencies: [highest] + include: + - php: '8.1' + dependencies: lowest steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: - php-version: ${{ matrix.php-version }} - extensions: curl, json + persist-credentials: false + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php }} + extensions: curl, json, mbstring coverage: none - - run: composer install --no-interaction --prefer-dist - - run: vendor/bin/phpunit + tools: composer:v2 + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist ${{ matrix.dependencies == 'lowest' && '--prefer-lowest --prefer-stable' || '' }} + - name: Run the test suite + run: vendor/bin/phpunit + + quality: + name: Static analysis, code style and coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.4' + extensions: curl, json, mbstring + coverage: pcov + tools: composer:v2 + - name: Validate composer.json + run: composer validate --strict + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + - name: PHPStan + run: vendor/bin/phpstan analyse --no-progress + - name: php-cs-fixer + run: vendor/bin/php-cs-fixer check --diff + - name: Coverage + run: vendor/bin/phpunit --coverage-text --only-summary-for-coverage-text diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index b1f0436..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Publish - -on: - push: - tags: - - "v*" - -jobs: - release: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: - php-version: "8.3" - extensions: curl, json - coverage: none - - run: composer install --no-interaction --prefer-dist - - run: vendor/bin/phpunit - # Packagist auto-updates from GitHub tags once shieldlabs/shieldlabs is submitted. - # Optional: set PACKAGIST_USERNAME + PACKAGIST_TOKEN to force a sync hook. - - name: Notify Packagist - env: - PACKAGIST_USERNAME: ${{ secrets.PACKAGIST_USERNAME }} - PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }} - run: | - if [ -z "$PACKAGIST_TOKEN" ]; then - echo "PACKAGIST_TOKEN not set; skipping Packagist hook (auto-update via GitHub integration is enough)." - exit 0 - fi - curl -X POST "https://packagist.org/api/update-package?username=${PACKAGIST_USERNAME}&apiToken=${PACKAGIST_TOKEN}" \ - -H 'Content-Type: application/json' \ - -d '{"repository":{"url":"https://github.com/ShieldLabs-ai/shieldlabs-php"}}' - - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c460ec5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + test: + name: Test ${{ github.ref_name }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.4' + extensions: curl, json, mbstring + coverage: none + tools: composer:v2 + - name: Check that the tag matches ShieldLabs::VERSION + run: | + version="$(sed -n "s/.*public const VERSION = '\(.*\)';/\1/p" src/ShieldLabs.php)" + if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} does not match ShieldLabs::VERSION (${version})." + exit 1 + fi + - name: Validate composer.json + run: composer validate --strict + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + - name: Run the test suite + run: vendor/bin/phpunit + + # Runs no Composer and no PHP code: only this job can write to the repository. + publish: + name: Publish ${{ github.ref_name }} + needs: test + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - name: Create or update the GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + version="${GITHUB_REF_NAME#v}" + awk -v v="$version" 'index($0, "## [" v "]") == 1 {found = 1; next} found && /^## \[/ {exit} found {print}' CHANGELOG.md > release-notes.md + # Safe to re-run: when a later step failed, the release already exists and only its notes are refreshed. + if gh release view "${GITHUB_REF_NAME}" > /dev/null 2>&1; then + gh release edit "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --notes-file release-notes.md + else + gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --notes-file release-notes.md --verify-tag + fi + - name: Update the package on Packagist + env: + PACKAGIST_USERNAME: ${{ secrets.PACKAGIST_USERNAME }} + PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }} + run: | + if [ -z "${PACKAGIST_USERNAME}" ] || [ -z "${PACKAGIST_TOKEN}" ]; then + echo "::error::Set the PACKAGIST_USERNAME and PACKAGIST_TOKEN secrets, then re-run this job." + exit 1 + fi + # The token goes in a header, never in the URL. + curl --fail --silent --show-error -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer ${PACKAGIST_USERNAME}:${PACKAGIST_TOKEN}" \ + https://packagist.org/api/update-package \ + -d '{"repository":{"url":"https://github.com/ShieldLabs-ai/shieldlabs-php"}}' diff --git a/.gitignore b/.gitignore index 11770b7..c3a4f86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,12 @@ -vendor/ -composer.lock +/vendor/ +/composer.lock +/.phpunit.cache/ +/.phpunit.result.cache +/.php-cs-fixer.cache +/.phpstan.cache/ +/coverage/ +/build/ .env .env.* .DS_Store -.phpunit.result.cache .history diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..b9b8038 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,23 @@ +in([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/examples']) + ->append([__FILE__]); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache') + ->setRules([ + '@PER-CS' => true, + 'declare_strict_types' => true, + 'no_unused_imports' => true, + 'ordered_imports' => ['imports_order' => ['class', 'function', 'const'], 'sort_algorithm' => 'alpha'], + 'single_quote' => true, + 'native_function_invocation' => ['include' => ['@compiler_optimized'], 'scope' => 'namespaced', 'strict' => true], + 'native_constant_invocation' => ['scope' => 'namespaced', 'strict' => true], + 'no_empty_phpdoc' => true, + 'phpdoc_trim' => true, + ]) + ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md index ca512e6..4a5ac2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog -## 2026-09-06 +All notable changes to this package are documented in this file. The format is +based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package +follows [Semantic Versioning](https://semver.org/). -- Minor improvements and bug fixes +## [1.0.0] - 2026-09-30 + +First stable release, published as `shieldlabs/shieldlabs-php`. It replaces the +earlier preview package and its API. + +### Added + +- `ShieldLabs\ShieldLabs`: History API client for the Private API Key. + - `identifications->get($requestId)` waits for the verdict within a total time + budget (`timeout`, 10 s by default). It polls at once, then after waits of + 250 ms, 500 ms, 1 s, 1.5 s and 2 s (1, 2, 4, 6 and 8 times `poll_interval`, + each at most 2 s, or at most `poll_interval` when that is longer, so 3 s polls + every 3 s), and one last time when the budget ends. Each poll is one HTTP + request without retries, limited to the time left but at least 1 s. A 429, a + 5xx response, a connection error or a timeout keeps the polling going, and + such an error is thrown only when the last poll failed. After a 429 the next + poll waits at least 1 s: the longest of the next scheduled wait, 1 s and + `Retry-After` (up to 10 s; `Retry-After: 0` or a past date counts as 0), cut + short when the budget ends; when `Retry-After` is longer than the time left, + the 429 is thrown at once. 400, 401, 403, 404 and other errors end the wait at + once. + - `history->search($type, $value)` and the lazy `history->iterate()` generator, + which de-duplicates rows on `request_id` and stops after `max_items` rows + (`0` yields nothing and sends no request). + - Client-side validation of lookup types, UUIDs, IPv4 addresses, `limit` and + `offset`: invalid input throws `ValidationException` and sends nothing. + - User HID lookups are sent in canonical path form (`@`, `+`, `=`, `:` and + similar characters stay as they are), so such values match. A User HID that + contains `/`, or is `.` or `..`, throws `ValidationException`, because the + History API cannot search it. +- `ShieldLabs\ShieldLabsManagement::getProfile()`: domain profile from the + Management API, with domain normalization. It never retries a 429. +- `ShieldLabs\Webhook::verifySignature()` and `Webhook::constructEvent()`: signature + checks over the raw body with one secret or a list (for rotation), and typed + events (`IdentificationScoredEvent`, `WebhookPingEvent`, `UnknownWebhookEvent`). +- One `Identification` model for webhook payloads and History rows, with typed + `IpInfo`, `TrafficSource`, `Signal` and `DetectionFlags` (19 flags; missing flags + are false). +- `Risk::band()`, `Risk::isRateLimited()`, `Risk::evaluate()` (freshness, replay, + rate-limit marker, device signals, flags and bands) and `UserHid::fromUserId()`. +- Exception hierarchy under `ShieldLabs\Exception`, including + `QuotaExceededException` (402) and `RateLimitException::getRetryAfter()`. +- Keys, secrets and domains are checked when a client is created: a value with + characters that cannot be sent in an HTTP header (line breaks, NUL, spaces, + non-ASCII) throws `ValidationException`, and no error message repeats a key or + secret. The built-in cURL client refuses header lines with line breaks. +- Retries with exponential backoff and jitter for connection errors, timeouts, 429 + (History API) and 5xx, following `Retry-After` as sent (up to 10 s). A 429 + without `Retry-After` waits at least 1 s. A timeout or a dropped connection while + the response body is read (Symfony HttpClient streams it) counts as a timeout or + connection error too. +- Base URLs must use https. Plain http is accepted for `localhost`, `127.0.0.1` + and `[::1]`, and for other hosts only with the `allow_insecure_http` option, so a + mistyped URL never sends a key unencrypted. +- HTTP client selection that applies the `timeout` option by default: a Guzzle 7 or + Symfony HttpClient found through PSR-18 discovery is created with the timeout, + otherwise the built-in cURL client is used. A client passed as `http_client` is used + as is. The built-in `Http\CurlClient` has `getTimeout()` and `withTimeout()`; with + it, and with the Guzzle or Symfony client the SDK creates, a poll of + `identifications->get()` gets the shorter timeout. +- SDK warnings, such as an API key in an unexpected format, go to the PHP error log + through `error_log()`, once per process, and never into a response. +- Runnable example: `examples/server.php` (signup guard and webhook receiver). + +### Fixed + +- Requests go to `https://account.shieldlabs.ai/api/v1/history/...`; the preview + built `/api/api/...` paths. A base URL ending in `/api` is now accepted and + normalized. + +### Removed + +- The preview `ShieldLabs\Client` class and the `shieldlabs/shieldlabs` package name. + +[1.0.0]: https://github.com/ShieldLabs-ai/shieldlabs-php/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7d33d46 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing + +Thanks for helping improve the ShieldLabs PHP SDK. Bug reports and pull requests are +welcome at https://github.com/ShieldLabs-ai/shieldlabs-php. For questions about your +account, write to contact@shieldlabs.ai. + +## Setup + +You need PHP 8.1 or newer with ext-curl, and Composer 2. + +```bash +composer install +composer check # tests, static analysis and code style +``` + +Without a local PHP, run the same inside Docker: + +```bash +docker run --rm -v "$PWD":/app -w /app composer:2 sh -c "composer install && composer check" +``` + +## Rules for changes + +- Tests come with every change. Unit tests use the mock HTTP client and the virtual clock + in `tests/Support/`; integration tests start `php -S` servers. +- `tests/data/` holds the shared test fixtures that every ShieldLabs server SDK passes. Do + not edit them in this repository: when the API changes, they change in every SDK + together. +- Keep the public API small and typed. PHPStan runs at the max level and php-cs-fixer + applies the PER coding style (`composer cs:fix`). +- The SDK must not log keys, secrets or request bodies, and must not make network calls + outside the methods a caller invokes. +- Write commit messages in the Conventional Commits style (`feat:`, `fix:`, `docs:`, + `test:`, `ci:`) and add a line to `CHANGELOG.md` under an "Unreleased" heading. + +## Releasing (maintainers) + +1. Update `ShieldLabs::VERSION` in `src/ShieldLabs.php` and move the changelog entries under + a new version heading with the release date. +2. Merge to `main`, then push a tag such as `v1.0.1`. +3. The `Release` workflow runs in two jobs. `test` checks that the tag matches + `ShieldLabs::VERSION` and runs the tests with a read-only token. `publish` runs no + Composer or PHP code: it creates the GitHub release and asks Packagist to update + `shieldlabs/shieldlabs-php`. It runs in the `release` environment (GitHub creates it on + the first run; add required reviewers there to approve each release) and needs the + secrets `PACKAGIST_USERNAME` and `PACKAGIST_TOKEN` (an API token of a maintainer of the + package on Packagist), best stored as secrets of that environment. When a step fails, + for example because the secrets are missing, fix the cause and re-run the failed jobs: + the workflow updates the existing release instead of creating it again. + +Workflows pin every action to a full commit SHA, with the version in a comment. Update both +together. diff --git a/LICENSE b/LICENSE index 39bb7e4..597e96b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 ShieldLabs +Copyright (c) 2026 ShieldLabs Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PUBLISHING.md b/PUBLISHING.md deleted file mode 100644 index 7f4b863..0000000 --- a/PUBLISHING.md +++ /dev/null @@ -1,21 +0,0 @@ -# Publishing - -Release packages by pushing a semver tag (`v0.1.0`, `v1.0.0`, …). Workflows live in `.github/workflows/publish.yml`. - -## Prerequisites (founder / org admin) - -| Registry | Package | Setup | -|---|---|---| -| npm | `@shieldlabs/node`, `@shieldlabs/js`, … | Create npm org `@shieldlabs`. Add GitHub Actions secret `NPM_TOKEN` **or** configure [trusted publishing / OIDC](https://docs.npmjs.com/trusted-publishers) for each package. Provenance is enabled (`id-token: write`). | -| PyPI | `shieldlabs` | Claim project name. Add a [trusted publisher](https://docs.pypi.org/trusted-publishers/) for `ShieldLabs-ai/shieldlabs-python` → workflow `publish.yml`. | -| Packagist | `shieldlabs/shieldlabs` | Submit https://github.com/ShieldLabs-ai/shieldlabs-php. Optional secrets `PACKAGIST_USERNAME` + `PACKAGIST_TOKEN` for update hooks. | -| Go | `github.com/ShieldLabs-ai/shieldlabs-go` | Consumers `go get` by git tag; publish workflow creates a GitHub Release. | - -## Publish - -```bash -git tag v0.1.0 -git push origin v0.1.0 -``` - -Do not publish until namespaces are reserved and secrets/OIDC are configured — the workflows will fail otherwise. diff --git a/README.md b/README.md index fc5ea37..5b529ea 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,455 @@ -# shieldlabs/shieldlabs (PHP) +# ShieldLabs PHP SDK + +Read ShieldLabs identification verdicts on your PHP backend, verify webhooks and turn Risk Scores into decisions. + +[![CI](https://github.com/ShieldLabs-ai/shieldlabs-php/actions/workflows/ci.yml/badge.svg)](https://github.com/ShieldLabs-ai/shieldlabs-php/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Packagist version](https://img.shields.io/packagist/v/shieldlabs/shieldlabs-php.svg)](https://packagist.org/packages/shieldlabs/shieldlabs-php) + +ShieldLabs identifies visitors with device intelligence and scores every identification from 300+ risk signals. New to ShieldLabs? [Start free](https://app.shieldlabs.ai) and read the [documentation](https://docs.shieldlabs.ai). + +## How it fits + +1. **Browser.** The ShieldLabs agent (loaded with `@shieldlabs-ai/js` or one of the framework packages) runs an identification and hands your page a `requestId`. The browser never sees a Risk Score or a device ID. +2. **Your backend (this SDK).** The page sends the `requestId` with the protected action (signup, login, checkout). Your backend reads the verdict for it from the History API, or receives it in a signed `identification.scored` webhook. +3. **Decision.** Your backend acts on the Risk Score, the risk band, the detection flags and the identifiers (for example: how many accounts one device ID has used). + +``` +browser --requestId--> your PHP backend --History API--> Identification --> allow / review / refuse +``` + +## Install + +```bash +composer require shieldlabs/shieldlabs-php +``` + +Requirements: PHP 8.1 or newer, and `ext-curl` unless your project already has Guzzle 7 or Symfony HttpClient (see [HTTP client and timeouts](#http-client-and-timeouts)). + +| Dependency | Why it is there | +|---|---| +| `psr/http-client`, `psr/http-factory`, `psr/http-message` | The standard HTTP interfaces, so any PSR-18 client can carry the requests | +| `php-http/discovery` | Finds a PSR-18 client and PSR-17 factories that your project already has. It ships an optional Composer plugin: Composer may ask whether to allow it, and the SDK works either way | +| `nyholm/psr7` | A small PSR-7 and PSR-17 implementation with no dependencies of its own, so the built-in cURL client works in a project without an HTTP stack | + +Keys come from the ShieldLabs analytics dashboard at [app.shieldlabs.ai](https://app.shieldlabs.ai). Keep all three on the server (the browser only ever uses the Public Key): + +| Key | Used by | Environment variable | +|---|---|---| +| Private API Key (`sec_...`), one per domain | `ShieldLabs` (History API) | `SHIELDLABS_API_KEY` | +| Secret Key and the registered domain | `ShieldLabsManagement` (Management API) | `SHIELDLABS_SECRET_KEY`, `SHIELDLABS_DOMAIN` | +| Webhook signing secret (`whsec_...`), one per endpoint | `Webhook::constructEvent()` | `SHIELDLABS_WEBHOOK_SECRET` | + +## Quick start + +Save this as `signup.php` next to your `vendor/` folder. It reads the verdict for the `requestId` your frontend sent with the form, then decides: + +```php + 'sec_your_private_key']); // or new ShieldLabs() to read SHIELDLABS_API_KEY + +try { + // Polls the History API until the verdict is stored (usually 1-3 seconds after + // the browser call), for up to 10 seconds by default. + $identification = $shieldlabs->identifications->get(is_string($requestId) ? $requestId : ''); +} catch (ValidationException) { + respond(400, ['error' => 'requestId must be a UUID']); +} catch (ShieldLabsException) { + respond(503, ['error' => 'verification unavailable']); // unverified is never "clean" +} + +$evaluation = Risk::evaluate($identification, [ + 'is_replay' => fn(string $id): bool => !markRequestIdUsed($id), +]); + +if (!$evaluation->ok) { + respond(403, ['error' => 'signup refused', 'reason' => $evaluation->reason?->value]); +} + +// Create the account here. +respond(200, ['ok' => true]); +``` + +Try it with `php -S localhost:8000 signup.php` and a request ID from your browser integration: `curl -X POST localhost:8000 -d requestId=`. With the placeholder key the SDK logs a warning that the key does not look like a Private API Key; use your own key. + +Verify a webhook delivery (`webhook.php`): + +```php +data; // the same Identification model the History API returns + // Store it keyed by $identification->request_id so a repeated delivery is harmless. +} + +http_response_code(200); // answer fast: the sender waits at most 1 second +``` + +A complete runnable app is in [`examples/`](examples/). + +## Guide + +### Waiting for the verdict + +Scoring is asynchronous. The History row of an identification appears about 1-3 seconds after the browser call and can be refined for up to about 10 seconds while follow-up checks finish. Start the identification in the browser when the user begins the action (for example with `identifyOnInteraction()` from `@shieldlabs-ai/js`, which starts it when the signup form gets focus), not when the form is submitted, so the verdict is usually stored by the time your backend asks for it. `identifications->get()` polls for the row and returns the first version it sees. Its `timeout` option (10 seconds by default) is the total time budget of the call: + +- **Schedule.** The first poll runs at once. The SDK then waits 250 ms, 500 ms, 1 s, 1.5 s and then 2 s between polls, and the last poll runs when the budget ends. With another `poll_interval` p the waits are p, 2p, 4p, 6p and then 8p, each at most 2 seconds, or at most p when p is longer: a 1-second interval waits 1, 2, 2, 2 seconds, and a 3-second interval polls every 3 seconds. +- **One request per poll.** Each poll is a single HTTP request without retries of its own. It may take the time left in the budget, but at least 1 second and never longer than the client's `timeout`. +- **Temporary errors keep the wait going.** After a 429, a 5xx response, a connection error or a timeout, the SDK polls again on the same schedule. If the last poll fails, its exception is thrown when the budget ends: `RateLimitException`, `ServerException`, `ApiConnectionException` or `ApiTimeoutException`. +- **Rate limits.** Inside the wait, a 429 is always followed by at least 1 second before the next poll: the SDK takes the longest of the next wait of the schedule, 1 second and `Retry-After` (up to 10 seconds). `Retry-After: 0` or a date in the past counts as 0, so it still waits 1 second. A wait that would pass the end of the budget is cut short, and the last poll runs then. When `Retry-After` (up to 10 seconds) asks for more than the time left, the `RateLimitException` is thrown at once. Calls outside the wait (`history->search()`, `history->iterate()` and `'wait' => false`) follow `Retry-After` as sent (see [Errors and retries](#errors-and-retries)). +- **Errors that stop at once.** Any other error, such as a 400, 401, 403 or 404, ends the wait at once and is thrown: a wrong request, key or base URL does not heal. +- **`null`** means the last poll found no identification for that request ID. Treat it as unverified, never as clean. + +The limit per poll applies to the built-in `CurlClient`, whether the SDK created it or you passed it, and to the Guzzle or Symfony HttpClient that the SDK creates. Another PSR-18 client that you pass as `http_client` keeps its own timeout (PSR-18 has no way to change it per request), so with such a client the call can end up to one request timeout after the budget. + +`null` also covers an identification that was never stored: when a visitor's IP goes over the per-IP limit of identifications, the browser still receives a request ID, but no row is written for it. + +With `'wait' => false` the SDK makes one lookup, with the client's `timeout` and the usual retries. To read the refined version of a row, for example to log the final risk signals, make such a lookup again after about 10 seconds. + +```php +$identification = $shieldlabs->identifications->get($requestId); // wait up to 10 s in total +$identification = $shieldlabs->identifications->get($requestId, ['timeout' => 3]); // a 3-second budget +$identification = $shieldlabs->identifications->get($requestId, ['wait' => false]); // one lookup, no polling +``` + +The agent also runs background identifications on its own (on clicks and navigation). Their request IDs never reach your page, so the History API can show extra rows for the same visitor. Always look up the request ID that arrived with the action. + +### Deciding + +An `Identification` carries: + +- `risk_score`: 0-100. A value above 100 (999) is the rate-limit marker, never a score. +- the risk band from `$identification->riskBand()` or `Risk::band($score)`: trusted 0-29, suspicious 30-59, dangerous 60-100, plus `RiskBand::RateLimited` for the marker. +- `detection_flags`: 19 stable booleans such as `vpn`, `proxy`, `tor`, `anti_detect_browser` and `browser_automation`. +- `signals`: the weighted risk signals behind the score, for display and logging. + +Branch on the score, the band and the flags. Signal names are an open set and weights can be negative, so never add them up yourself. + +`Risk::evaluate()` runs the guard checks from our integration guides. The first failing check gives the reason: + +| Order | Check | Reason | +|---|---|---| +| 1 | No identification | `missing` | +| 2 | `is_replay($requestId)` returns true (the SDK stores nothing itself) | `replayed` | +| 3 | Older than `max_age` seconds, by `observed_at` | `stale` | +| 4 | Score above 100 | `rate_limited` | +| 5 | Device ID is the all-zero UUID (no usable device signals) | `no_device_signals` | +| 6 | A flag from `block_flags` is set | `blocked_flag` (the flag is in `$evaluation->flag`) | +| 7 | The band is in `block_bands` | `blocked_band` | + +Defaults: `max_age` 300, `block_bands` `[RiskBand::Dangerous]`, `block_flags` `['browser_automation', 'javascript_disabled']`. They are a starting point; tune them per protected action. For example, a login could ask for a second factor on the suspicious band: + +```php +use ShieldLabs\Risk; +use ShieldLabs\RiskBand; + +$evaluation = Risk::evaluate($identification, [ + 'max_age' => 120, + 'block_flags' => ['browser_automation', 'javascript_disabled', 'tor'], + 'is_replay' => $isReplay, +]); + +if (!$evaluation->ok) { + // refuse, and log $evaluation->reason->value +} elseif ($evaluation->band === RiskBand::Suspicious) { + // ask for a second factor +} +``` + +### Storing used request IDs + +Pass `is_replay` so that one identification authorizes one action. The SDK stores nothing itself: record each request ID in your database with a unique key, and treat a duplicate-key error as "already used". Keep the rows a little longer than `max_age`, for example one day: older identifications are refused as `stale` anyway. ```php -use ShieldLabs\Client; +// CREATE TABLE used_request_ids (request_id CHAR(36) PRIMARY KEY, used_at TIMESTAMP NOT NULL) +function markRequestIdUsed(PDO $pdo, string $requestId): bool +{ + try { + $pdo->prepare('INSERT INTO used_request_ids (request_id, used_at) VALUES (?, CURRENT_TIMESTAMP)') + ->execute([$requestId]); -$ok = Client::verifyWebhook($rawBody, $_SERVER['HTTP_X_SHIELD_SIGNATURE'] ?? '', $secret); + return true; + } catch (PDOException $exception) { + if (str_starts_with((string) $exception->getCode(), '23')) { + return false; // integrity constraint violation: the request ID was used before + } + + throw $exception; + } +} + +$evaluation = Risk::evaluate($identification, [ + 'is_replay' => fn(string $id): bool => !markRequestIdUsed($pdo, $id), +]); +``` + +PDO throws exceptions by default since PHP 8.0. With Redis, `SET shieldlabs:used: 1 NX EX 86400` does the same in one command: a null reply means the request ID was used before. + +### Searching history for account-abuse checks + +```php +use ShieldLabs\LookupType; + +$page = $shieldlabs->history->search(LookupType::UserHid, $userHid, ['limit' => 50]); +$page->data; // list of Identification, newest first +$page->total; // rows for this User HID across all pages +``` + +Lookup types: `ip` (dotted IPv4), `user_hid`, `visitor_id`, `request_id`, `device_id`, `session_id` and `cookie_id`. The SDK checks every lookup before sending it: an unknown type, a malformed UUID, an IPv6 address, an empty User HID or a `limit` outside 1-100 throws `ValidationException` and sends nothing. + +A User HID is sent as one URL path segment in canonical form, so values with `@`, `+`, `=` or spaces (an email address, a base64 string) match exactly. A value that contains `/`, and the values `.` and `..`, cannot be searched and throw `ValidationException`. User HIDs from `UserHid::fromUserId()` are 64 hex characters and always work. + +`history->iterate()` returns a lazy generator over every row. It pages with `offset`, skips rows it already returned (paging while new rows arrive can repeat a row) and stops at the total, at an empty page or after `max_items` rows (with `'max_items' => 0` it yields nothing and sends no request): + +```php +// How many accounts has this device been used with? +$accounts = []; +if ($identification->hasDeviceSignals()) { + $rows = $shieldlabs->history->iterate(LookupType::DeviceId, $identification->device_id, ['max_items' => 500]); + foreach ($rows as $row) { + if ($row->user_hid !== null && !in_array($row->user_hid, ['anonymous', 'fail', '-1', 'unknown'], true)) { + $accounts[$row->user_hid] = true; + } + } +} + +if (count($accounts) >= 3) { + // send the signup to review +} +``` + +Look up `LookupType::UserHid` to count the devices behind one account, or `LookupType::Ip` for one address. Each page is one request, so keep the rate limit below in mind. + +### Linking identifications to your users + +Pass a User HID to the browser agent instead of a raw email or database ID, and compute it on the server: + +```php +use ShieldLabs\UserHid; + +$userHid = UserHid::fromUserId((string) $user->id, $userHidSecret); +// Render $userHid into the page and pass it to the browser SDK as the userId. +``` + +The result is HMAC-SHA256 as 64 lowercase hex characters: stable for a user and irreversible. Keep the secret on the server; changing it changes every User HID. + +### Webhooks + +- Verify the raw body: `file_get_contents('php://input')` in plain PHP, `$request->getContent()` in Laravel and Symfony. Never verify JSON that you decoded and encoded again: the server escapes `&` as `\u0026` and the bytes would differ. +- The header is `X-Shield-Signature: sha256=`, the HMAC-SHA256 of the raw body keyed with the full signing secret, `whsec_` prefix included. +- To rotate a secret without downtime, pass both: `Webhook::constructEvent($payload, $header, [$newSecret, $oldSecret])`. A delivery is valid when any secret matches. +- Events: `IdentificationScoredEvent` (`$event->data` is an `Identification`), `WebhookPingEvent` (sent when you verify an endpoint) and `UnknownWebhookEvent` for event types newer than this SDK (acknowledge them with a 2xx). `SignatureVerificationException` means the delivery is not authentic; `WebhookParseException` means a correctly signed body is not a valid event. +- Today each identification is delivered once per endpoint: one attempt with a 1-second timeout and no retries. Respond with a 2xx quickly and do slow work afterwards. Future retries will resend identical bytes, so make handlers idempotent on `data.request_id` (`$event->data->request_id`) now. +- For guaranteed reads, use the History API: a missed delivery is not sent again, and a History row can be refined after its webhook was sent. +- The "Test" button in the analytics dashboard sends a sample with 17 of the 19 flags and second-precision timestamps. It parses normally; missing flags are false. +- `Webhook::verifySignature()` returns a boolean without parsing, for custom flows. + +### Domain profile (Management API) + +```php +use ShieldLabs\ShieldLabsManagement; + +$management = new ShieldLabsManagement([ + 'secret_key' => 'your_secret_key', // or SHIELDLABS_SECRET_KEY + 'domain' => 'example.com', // or SHIELDLABS_DOMAIN; scheme, path and "www." are removed +]); + +$profile = $management->getProfile(); +$profile->remaining_identifications; // negative when the account is over its included volume +$profile->public_key_masked; // "****************************a3f8" +$profile->created_at; // DateTimeImmutable in UTC +``` + +The Management API allows about 15 requests per minute per client IP and then blocks that IP for 10 minutes. The client never retries a 429: cache the profile rather than reading it on every request. + +### Errors and retries + +Every exception extends `ShieldLabs\Exception\ShieldLabsException`: + +| Exception | When | Retried | +|---|---|---| +| `ValidationException` | Invalid arguments or options, before any request | No | +| `BadRequestException` | HTTP 400 | No | +| `AuthenticationException` | HTTP 401 or 403 (wrong, disabled or mismatched key) | No | +| `QuotaExceededException` | HTTP 402 (no identifications left) | No | +| `NotFoundException` | HTTP 404 (often a wrong base URL) | No | +| `RateLimitException` | HTTP 429 | History API: yes. Management API: never | +| `ServerException` | HTTP 5xx, including edge proxy pages | Yes | +| `ApiException` | Any other status (the parent of the classes above) | No | +| `ApiConnectionException` | The request could not be completed | Yes | +| `ApiTimeoutException` | An attempt ran past the timeout | Yes | +| `SignatureVerificationException` | A webhook signature is missing, malformed or wrong | Not applicable | +| `WebhookParseException` | A verified webhook body is not a valid event | Not applicable | + +`ApiException` has `getStatusCode()`, `getBody()` (decoded JSON, or the raw text), `getRawBody()`, `getErrorMessage()` and `getHeaders()`; `RateLimitException::getRetryAfter()` gives the `Retry-After` value in seconds. Error bodies differ between endpoints (empty, a bare JSON string, an object, plain text), and the SDK parses all of them without failing. + +Error messages never include keys or secrets. A key, secret or domain with characters that cannot be sent in an HTTP header (line breaks, NUL, spaces, non-ASCII) throws `ValidationException` when the client is created, without repeating the value. + +Retries use exponential backoff with jitter (0.5 s base, doubling, at most 8 s), for at most `max_retries` retries (2 by default). When a 429 or 5xx response carries `Retry-After`, the retry follows it as sent, up to 10 seconds: `Retry-After: 0` or a date in the past retries at once. A 429 without `Retry-After` is retried after at least 1 second, because the History API counts requests per second. `identifications->get()` sends no retries while it waits: the next poll takes their place, and after a 429 it waits at least 1 second whatever `Retry-After` says (see [Waiting for the verdict](#waiting-for-the-verdict)). + +### Rate limits + +| API | Limit | What the SDK does | +|---|---|---| +| History API | About 15 requests per second per domain, shared by everything that uses the domain's key | Retries a 429 after `Retry-After` as sent (up to 10 seconds), or after at least 1 second when it has none. While `identifications->get()` waits, a 429 waits at least 1 second, or longer when the schedule or `Retry-After` asks for more | +| Management API | About 15 requests per minute per client IP, then a 10-minute block | Never retries a 429; cache the results | + +### HTTP client and timeouts + +`timeout` (10 seconds by default) limits every HTTP attempt the SDK sends. While `identifications->get()` waits, a poll is also limited to the time left in its budget (at least 1 second) when the SDK can change the client's timeout. With no `http_client` option, the SDK picks the client that applies it: + +1. Guzzle 7 or Symfony HttpClient, when `php-http/discovery` finds one of them in your project. The SDK creates its own instance with the timeout set and redirects turned off. +2. Otherwise its built-in client on ext-curl, `ShieldLabs\Http\CurlClient`, which never follows redirects and only speaks HTTP and HTTPS. +3. Without ext-curl, any other PSR-18 client that discovery finds. The SDK cannot pass the timeout to it, so it logs a warning; pass a configured client instead. + +A client that you pass as `http_client` is used as is, with its own settings, so give it a timeout. With the built-in `CurlClient` a poll still gets the shorter timeout described above; with another client each poll can take that client's full timeout: + +```php +$shieldlabs = new ShieldLabs([ + 'api_key' => 'sec_your_private_key', + 'http_client' => new GuzzleHttp\Client(['timeout' => 10, 'connect_timeout' => 3]), +]); + +// Or always use the built-in client, whatever else is installed: +$shieldlabs = new ShieldLabs([ + 'api_key' => 'sec_your_private_key', + 'http_client' => new ShieldLabs\Http\CurlClient(timeout: 10.0), +]); +``` + +The SDK writes its warnings (an API key in an unexpected format, a timeout it cannot apply, a plain http base URL allowed with `allow_insecure_http`) to the PHP error log with `error_log()`, so they never end up in a response. Each warning is logged once per process; under PHP-FPM or `php -S` that is once per request that creates a client. + +A client keeps no mutable state after construction: share one instance, for example as a singleton in your service container, including in long-running PHP workers. + +### Configuration + +`new ShieldLabs(array $options)`: + +| Option | Default | Notes | +|---|---|---| +| `api_key` | `SHIELDLABS_API_KEY` | Private API Key, visible ASCII characters only. Required. The SDK logs a warning (and never fails) when it does not look like `sec_xxxxxxxx-xxxxxxxx-xxxxxxxx`; the key itself is never logged | +| `base_url` | `SHIELDLABS_API_BASE_URL`, else `https://account.shieldlabs.ai` | Origin of the History API; a trailing `/api` is removed, so requests never go to `/api/api/...`. It must use https; plain http is accepted for `localhost`, `127.0.0.1` and `[::1]` | +| `allow_insecure_http` | `false` | Accept a plain http `base_url` on another host, for a test server. The key then travels unencrypted, and the SDK logs a warning. Without it such a URL throws `ValidationException` | +| `timeout` | `10` | Seconds per HTTP attempt. Applied to the client the SDK creates; an `http_client` you pass keeps its own settings. While `identifications->get()` waits, a poll is limited to the time left (at least 1 second) where the client allows it | +| `max_retries` | `2` | Retries for connection errors, timeouts, 429 and 5xx | +| `http_client` | Guzzle or Symfony HttpClient when installed, else `CurlClient` | Any `Psr\Http\Client\ClientInterface`, used as is | +| `request_factory` | discovered | Any `Psr\Http\Message\RequestFactoryInterface` | + +`new ShieldLabsManagement(array $options)` takes `secret_key` (`SHIELDLABS_SECRET_KEY`, visible ASCII characters only), `domain` (`SHIELDLABS_DOMAIN`; an international domain in its punycode form, `xn--...`), `base_url` (`SHIELDLABS_MANAGEMENT_BASE_URL`, else `https://api.shieldlabs.ai`; a trailing `/v1` is removed; https, or plain http on a loopback host) and the same `allow_insecure_http`, `timeout`, `max_retries`, `http_client` and `request_factory`. Unknown option names throw `ValidationException`. A key, domain or URL option that is missing, `null` or `false` (what `getenv()` returns for an unset variable) falls back to its environment variable. + +## Reference + +| API | Returns | +|---|---| +| `new ShieldLabs(array $options = [])` | History API client | +| `$client->identifications->get(string $requestId, array $options = [])` | `?Model\Identification`. Options: `wait` (true), `timeout` (10, the total budget in seconds), `poll_interval` (0.25, the first wait; the later waits are 2, 4, 6 and 8 times it, each at most 2 s, or at most `poll_interval` when that is longer) | +| `$client->history->search(LookupType\|string $type, string $value, array $options = [])` | `Model\HistoryPage`. Options: `limit` (20, 1-100), `offset` (0). A `user_hid` value cannot contain `/` | +| `$client->history->iterate(LookupType\|string $type, string $value, array $options = [])` | `Generator` of `Model\Identification`. Options: `page_size` (100), `max_items` (no limit; 0 yields nothing) | +| `new ShieldLabsManagement(array $options = [])` | Management API client | +| `$management->getProfile()` | `Model\DomainProfile` | +| `$management->getDomain()`, `ShieldLabsManagement::normalizeDomain(string $domain)` | The normalized domain | +| `Webhook::verifySignature(string $payload, ?string $header, string\|array $secret)` | `bool` | +| `Webhook::constructEvent(string $payload, ?string $header, string\|array $secret)` | `Event\IdentificationScoredEvent\|Event\WebhookPingEvent\|Event\UnknownWebhookEvent` | +| `Risk::band(int $score)`, `RiskBand::fromScore(int $score)` | `RiskBand` | +| `Risk::isRateLimited(int $score)` | `bool` | +| `Risk::evaluate(?Model\Identification $identification, array $options = [])` | `Evaluation` (`ok`, `reason`, `band`, `flag`) | +| `UserHid::fromUserId(string $userId, string $secret)` | `string` (64 hex characters) | +| `Http\CurlClient(float $timeout = 10.0, ?float $connectTimeout = null)` | The built-in PSR-18 client. `getTimeout()`; `withTimeout(float $timeout)` returns a copy with another timeout | + +| Model | Properties | +|---|---| +| `Model\Identification` | `request_id`, `visitor_id`, `device_id`, `session_id`, `cookie_id`, `user_hid` (`?string`; `"anonymous"` for anonymous checks), `domain`, `public_ip` and `local_ip` (`IpInfo`), `connection_type`, `os`, `browser`, `device_type`, `traffic_source` (`TrafficSource`), `risk_score` (`int`), `signals` (list of `Signal`), `detection_flags` (`DetectionFlags`), `observed_at` (`?DateTimeImmutable`, UTC), `source` (`"webhook"` or `"history"`), `raw` (the payload as received). Methods: `riskBand()`, `isRateLimited()`, `hasDeviceSignals()`, `toArray()` | +| `Model\IpInfo` | `ip` (`""` when unknown), `country` (English country name such as `"Germany"`, `""` when unknown) | +| `Model\TrafficSource` | `channel`, `referrer_domain`, `landing_url`, `click_id_type`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term` | +| `Model\Signal` | `name`, `weight` (`int`, can be negative), `description` (History API rows only) | +| `Model\DetectionFlags` | 19 booleans; `get(string $name)`, `active()`, `toArray()` | +| `Model\HistoryPage` | `data` (list of `Identification`), `total` | +| `Model\DomainProfile` | `domain`, `remaining_identifications`, `public_key_masked`, `secret_key_masked`, `created_at`, `raw` | +| `Event\IdentificationScoredEvent` | `event_type`, `schema_version`, `created_at`, `data` (`Identification`), `raw` | +| `Event\WebhookPingEvent`, `Event\UnknownWebhookEvent` | `event_type`, `schema_version`, `created_at`, `raw` | + +Enums: `LookupType`, `RiskBand` (`Trusted`, `Suspicious`, `Dangerous`, `RateLimited`) and `EvaluationReason`. Constants: `SignalName` (known signal names), `ConnectionType` (known connection types), `ShieldLabs::VERSION`. All classes live in the `ShieldLabs\` namespace. + +## Compatibility + +- PHP 8.1, 8.2, 8.3, 8.4 and 8.5. +- Any PSR-18 client (Guzzle 7, Symfony HttpClient and others), or the built-in cURL client. +- Webhook schema version `2026-06-01`. Unknown fields, event types and schema versions are accepted; new signal names arrive as plain strings. +- Semantic Versioning: breaking changes only in a new major version. + +## Development + +```bash +composer install +composer test # PHPUnit: unit, shared fixture and integration tests +composer analyse # PHPStan at the max level +composer cs # php-cs-fixer (PER coding style); composer cs:fix applies it +``` + +Without a local PHP: + +```bash +docker run --rm -v "$PWD":/app -w /app composer:2 sh -c "composer install && composer check" ``` -Signature: `X-Shield-Signature: sha256=` + hash_hmac('sha256', $raw, $secret). Schema `2026-06-01`. +`tests/data/` holds the shared test fixtures that every ShieldLabs server SDK passes. See [CONTRIBUTING.md](CONTRIBUTING.md). + +## Support + +- Documentation: [docs.shieldlabs.ai](https://docs.shieldlabs.ai) +- Analytics dashboard: [app.shieldlabs.ai](https://app.shieldlabs.ai) +- Email: [contact@shieldlabs.ai](mailto:contact@shieldlabs.ai) ## License -[MIT](./LICENSE) +[MIT](LICENSE) diff --git a/composer.json b/composer.json index 3f4c54b..bb339e1 100644 --- a/composer.json +++ b/composer.json @@ -1,19 +1,73 @@ { - "name": "shieldlabs/shieldlabs", - "description": "ShieldLabs server SDK for PHP: API client, webhook verification, and types.", - "type": "library", - "license": "MIT", - "homepage": "https://shieldlabs.ai", - "keywords": ["shieldlabs", "server-sdk", "webhook-verification", "risk-score", "fraud-prevention"], - "require": { "php": ">=8.1", "ext-curl": "*", "ext-json": "*" }, - "require-dev": { "phpunit/phpunit": "^10.0" }, - "autoload": { "psr-4": { "ShieldLabs\\": "src/" } }, - "autoload-dev": { "psr-4": { "ShieldLabs\\Tests\\": "tests/" } }, - "scripts": { - "test": "phpunit" - }, - "support": { - "issues": "https://github.com/ShieldLabs-ai/shieldlabs-php/issues", - "source": "https://github.com/ShieldLabs-ai/shieldlabs-php" - } + "name": "shieldlabs/shieldlabs-php", + "description": "ShieldLabs server SDK for PHP: read identification verdicts from the History API, verify webhooks and turn Risk Scores into decisions.", + "type": "library", + "license": "MIT", + "keywords": [ + "shieldlabs", + "device intelligence", + "fraud detection", + "risk score", + "webhooks", + "psr-18" + ], + "homepage": "https://docs.shieldlabs.ai", + "authors": [ + { + "name": "ShieldLabs Inc.", + "email": "contact@shieldlabs.ai", + "homepage": "https://shieldlabs.ai" + } + ], + "support": { + "email": "contact@shieldlabs.ai", + "docs": "https://docs.shieldlabs.ai", + "issues": "https://github.com/ShieldLabs-ai/shieldlabs-php/issues", + "source": "https://github.com/ShieldLabs-ai/shieldlabs-php" + }, + "require": { + "php": "^8.1", + "ext-json": "*", + "nyholm/psr7": "^1.8", + "php-http/discovery": "^1.19", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.64", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5 || ^11.5 || ^12.0" + }, + "suggest": { + "ext-curl": "Used by the built-in HTTP client, the default unless your project has Guzzle 7 or Symfony HttpClient", + "ext-mbstring": "Unicode lowercasing when signal names are derived from unfamiliar History descriptions" + }, + "autoload": { + "psr-4": { + "ShieldLabs\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ShieldLabs\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "php-http/discovery": false + }, + "sort-packages": true + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan analyse --no-progress", + "cs": "php-cs-fixer check --diff", + "cs:fix": "php-cs-fixer fix", + "check": [ + "@test", + "@analyse", + "@cs" + ] + } } diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..edef0be --- /dev/null +++ b/examples/README.md @@ -0,0 +1,62 @@ +# Example: signup guard and webhook receiver + +`server.php` is a small app for PHP's built-in web server that does both halves of a +ShieldLabs integration: + +- `POST /signup` reads the `requestId` your frontend sent (JSON `{"requestId": "..."}` or a + form field), waits for the verdict with `identifications->get()` and applies + `Risk::evaluate()`: a missing, reused, stale or rate-limited identification is refused, and + so are browser automation, disabled JavaScript and the dangerous band. +- `POST /webhooks/shieldlabs` verifies the signature, parses the event and logs it once per + `request_id`. + +## Run it + +From the repository root: + +```bash +composer install +export SHIELDLABS_API_KEY=sec_your_private_key +export SHIELDLABS_WEBHOOK_SECRET=whsec_your_signing_secret +php -S localhost:8000 examples/server.php +``` + +Use your own Private API Key and signing secret. With the placeholder key the SDK logs a +warning in the server output that the key does not look like a Private API Key; the +responses are not affected. + +`SHIELDLABS_WEBHOOK_SECRET` can hold several comma-separated secrets while you rotate one. +`SHIELDLABS_API_BASE_URL` points the History API calls somewhere else, for example a local +mock (https, or plain http on `localhost` or `127.0.0.1`). Used request IDs are kept in a +JSON file in the system temp directory; use your database or cache in production. + +To use the code in your own project, copy it and replace the `require` line with your +Composer autoloader. + +## Try it + +In a second terminal, send a request ID from a real identification in your browser +integration: + +```bash +curl -i -X POST http://localhost:8000/signup \ + -H 'Content-Type: application/json' \ + -d '{"requestId": "3b241101-e2bb-4255-8caf-4136c566a962"}' +``` + +Send a signed `webhook.ping` the way ShieldLabs does. The signature is computed from +`SHIELDLABS_WEBHOOK_SECRET`, so export the same value in this terminal first: + +```bash +export SHIELDLABS_WEBHOOK_SECRET=whsec_your_signing_secret +BODY='{"created_at":"2026-09-30T12:34:56Z","event_type":"webhook.ping","schema_version":"2026-06-01"}' +SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SHIELDLABS_WEBHOOK_SECRET" | sed 's/^.* //') +curl -i -X POST http://localhost:8000/webhooks/shieldlabs \ + -H 'Content-Type: application/json' \ + -H "X-Shield-Signature: sha256=$SIGNATURE" \ + --data-binary "$BODY" +``` + +The server log shows each event. To receive real deliveries, expose the server over HTTPS +(for example with a tunnel), add the endpoint in the ShieldLabs analytics dashboard and +press "Verify" or "Test". diff --git a/examples/server.php b/examples/server.php new file mode 100644 index 0000000..669c027 --- /dev/null +++ b/examples/server.php @@ -0,0 +1,220 @@ +file, 'c+'); + if ($handle === false) { + throw new RuntimeException('Cannot open ' . $this->file); + } + try { + flock($handle, LOCK_EX); + $decoded = json_decode((string) stream_get_contents($handle), true); + $keys = []; + foreach (is_array($decoded) ? $decoded : [] as $name => $time) { + if (is_int($time) && $time > time() - self::RETENTION_SECONDS) { + $keys[(string) $name] = $time; + } + } + if (isset($keys[$key])) { + return false; + } + $keys[$key] = time(); + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, (string) json_encode($keys)); + + return true; + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } +} + +/** + * @param array $body + */ +function respond(int $status, array $body): void +{ + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode($body), "\n"; +} + +function requestIdFromBody(): ?string +{ + if (isset($_POST['requestId']) && is_string($_POST['requestId'])) { + return $_POST['requestId']; + } + $json = json_decode((string) file_get_contents('php://input'), true); + $requestId = is_array($json) ? ($json['requestId'] ?? null) : null; + + return is_string($requestId) && $requestId !== '' ? $requestId : null; +} + +/** + * The History API client, created once per process and reused: it keeps no + * per-request state (in a framework, register it as a shared service). + * + * @throws ShieldLabsException when SHIELDLABS_API_KEY is missing + */ +function shieldlabs(): ShieldLabs +{ + /** @var ShieldLabs|null $client */ + static $client = null; + + return $client ??= new ShieldLabs(); // reads SHIELDLABS_API_KEY (and SHIELDLABS_API_BASE_URL) +} + +function handleSignup(UsedKeys $usedKeys): void +{ + $requestId = requestIdFromBody(); + if ($requestId === null) { + respond(400, ['ok' => false, 'error' => 'requestId is required']); + + return; + } + + try { + $client = shieldlabs(); + } catch (ShieldLabsException $exception) { + error_log('ShieldLabs client setup failed: ' . $exception->getMessage()); + respond(500, ['ok' => false, 'error' => 'server misconfigured']); + + return; + } + + try { + // Waits up to 10 s for the verdict: the History row appears about 1-3 s after the browser call. + $identification = $client->identifications->get($requestId); + } catch (ValidationException) { + respond(400, ['ok' => false, 'error' => 'requestId must be a UUID']); + + return; + } catch (ShieldLabsException $exception) { + error_log('ShieldLabs lookup failed: ' . $exception->getMessage()); + // Unverified is never "clean": refuse, or send the signup to review. + respond(503, ['ok' => false, 'reason' => 'verification_unavailable']); + + return; + } + + // Missing, reused, older than 5 minutes, rate-limit marker, no device signals, + // browser automation or JavaScript disabled, or the dangerous band: refuse. + $evaluation = Risk::evaluate($identification, [ + 'is_replay' => static fn(string $id): bool => !$usedKeys->add('request:' . $id), + ]); + if (!$evaluation->ok) { + respond(403, ['ok' => false] + $evaluation->toArray()); + + return; + } + + // Create the account here. + respond(200, ['ok' => true, 'band' => $evaluation->band?->value]); +} + +function handleWebhook(UsedKeys $usedKeys): void +{ + $payload = (string) file_get_contents('php://input'); + $secrets = array_values(array_filter(array_map('trim', explode(',', (string) getenv('SHIELDLABS_WEBHOOK_SECRET'))))); + $header = $_SERVER['HTTP_X_SHIELD_SIGNATURE'] ?? null; + + try { + $event = Webhook::constructEvent($payload, is_string($header) ? $header : null, $secrets); + } catch (SignatureVerificationException $exception) { + error_log('Rejected webhook: ' . $exception->getMessage()); + respond(401, ['error' => 'invalid signature']); + + return; + } catch (WebhookParseException) { + respond(400, ['error' => 'invalid payload']); + + return; + } + + if ($event instanceof IdentificationScoredEvent) { + $identification = $event->data; + // One delivery per identification today, without retries. Future retries resend + // identical bytes, so stay idempotent on request_id. + if ($usedKeys->add('webhook:' . $identification->request_id)) { + error_log(sprintf( + 'identification.scored request_id=%s risk_score=%d band=%s flags=%s', + $identification->request_id, + $identification->risk_score, + $identification->riskBand()->value, + implode(',', $identification->detection_flags->active()) ?: 'none', + )); + } else { + error_log('Duplicate delivery ignored for request_id=' . $identification->request_id); + } + } elseif ($event instanceof WebhookPingEvent) { + error_log('webhook.ping received'); + } else { + error_log('Unhandled event type ' . $event->event_type); + } + + // Answer fast (well under 1 s); do slow work after responding or in a queue. + respond(200, ['received' => true]); +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$uri = $_SERVER['REQUEST_URI'] ?? '/'; +$path = parse_url(is_string($uri) ? $uri : '/', PHP_URL_PATH); +$usedKeys = new UsedKeys(sys_get_temp_dir() . '/shieldlabs-example-keys.json'); + +if ($method === 'POST' && $path === '/signup') { + handleSignup($usedKeys); +} elseif ($method === 'POST' && $path === '/webhooks/shieldlabs') { + handleWebhook($usedKeys); +} else { + respond(404, ['error' => 'not found']); +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..36148a3 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,8 @@ +parameters: + level: max + paths: + - src + - examples + tmpDir: .phpstan.cache + # Option arrays come from callers: validate them at runtime even when PHPDoc says the type. + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index da9dce6..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - tests - - - diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..b597a84 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + tests/Unit + + + tests/Integration + + + + + src + + + diff --git a/src/Client.php b/src/Client.php deleted file mode 100644 index c2c36c8..0000000 --- a/src/Client.php +++ /dev/null @@ -1,73 +0,0 @@ -apiKey = $apiKey; - $this->baseUrl = rtrim($baseUrl, '/'); - } - - /** - * Verify X-Shield-Signature against HMAC-SHA256(secret, raw body). - * Comparison is constant-time via hash_equals. - */ - public static function verifyWebhook(string $payload, string $signature, string $secret): bool - { - if ($signature === '' || $secret === '') { - return false; - } - $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret); - return hash_equals($expected, $signature); - } - - /** - * GET /api/v1/history/{search_type}/{value} → ['data' => ..., 'total' => ...] - * - * @return array{data: list, total: int|float} - */ - public function getHistory(string $searchType, string $value, ?int $limit = null, ?int $offset = null): array - { - $url = $this->baseUrl . '/api/v1/history/' . rawurlencode($searchType) . '/' . rawurlencode($value); - $query = []; - if ($limit !== null) { - $query['limit'] = (string) $limit; - } - if ($offset !== null) { - $query['offset'] = (string) $offset; - } - if ($query !== []) { - $url .= '?' . http_build_query($query); - } - - $ch = curl_init($url); - if ($ch === false) { - throw new \RuntimeException('curl_init failed'); - } - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - 'Authorization: Bearer ' . $this->apiKey, - ], - ]); - $body = curl_exec($ch); - $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - if ($body === false || $status !== 200) { - throw new \RuntimeException('ShieldLabs History API error: ' . $status); - } - /** @var array{data: list, total: int|float} $decoded */ - $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - return $decoded; - } -} diff --git a/src/ConnectionType.php b/src/ConnectionType.php new file mode 100644 index 0000000..34b62a2 --- /dev/null +++ b/src/ConnectionType.php @@ -0,0 +1,22 @@ + $this->ok, + 'reason' => $this->reason?->value, + 'band' => $this->band?->value, + 'flag' => $this->flag, + ]; + } + + /** + * @return array{ok: bool, reason: string|null, band: string|null, flag: string|null} + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/EvaluationReason.php b/src/EvaluationReason.php new file mode 100644 index 0000000..7d933a7 --- /dev/null +++ b/src/EvaluationReason.php @@ -0,0 +1,26 @@ +data->request_id`, and use the History API for guaranteed reads. + */ +final class IdentificationScoredEvent extends WebhookEvent +{ + public const TYPE = 'identification.scored'; + + /** + * @param array $raw + */ + public function __construct( + string $event_type, + string $schema_version, + ?\DateTimeImmutable $created_at, + public readonly Identification $data, + array $raw = [], + ) { + parent::__construct($event_type, $schema_version, $created_at, $raw); + } +} diff --git a/src/Event/UnknownWebhookEvent.php b/src/Event/UnknownWebhookEvent.php new file mode 100644 index 0000000..855934f --- /dev/null +++ b/src/Event/UnknownWebhookEvent.php @@ -0,0 +1,11 @@ + $raw the decoded body as received + */ + public function __construct( + public readonly string $event_type, + public readonly string $schema_version, + public readonly ?\DateTimeImmutable $created_at, + public readonly array $raw = [], + ) {} +} diff --git a/src/Event/WebhookPingEvent.php b/src/Event/WebhookPingEvent.php new file mode 100644 index 0000000..c770960 --- /dev/null +++ b/src/Event/WebhookPingEvent.php @@ -0,0 +1,14 @@ +> $headers response headers, names in lowercase + * @param string|null $errorMessage message the server put in the body, when there is one + */ + public function __construct( + string $message, + private readonly int $statusCode, + private readonly mixed $body = null, + private readonly string $rawBody = '', + private readonly array $headers = [], + private readonly ?string $errorMessage = null, + ?\Throwable $previous = null, + ) { + parent::__construct($message, $statusCode, $previous); + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * The response body: decoded JSON when it parses (an array, a string or null), + * otherwise the raw text. Bodies differ per endpoint and status (empty, a bare + * JSON string, an object with an "error" key, plain text or an HTML proxy page). + */ + public function getBody(): mixed + { + return $this->body; + } + + public function getRawBody(): string + { + return $this->rawBody; + } + + /** + * The error text the server sent (for example "too many requests"), or null. + */ + public function getErrorMessage(): ?string + { + return $this->errorMessage; + } + + /** + * @return array> response headers with lowercase names + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * First value of a response header (case-insensitive name), or null. + */ + public function getHeader(string $name): ?string + { + return $this->headers[strtolower($name)][0] ?? null; + } +} diff --git a/src/Exception/ApiTimeoutException.php b/src/Exception/ApiTimeoutException.php new file mode 100644 index 0000000..3f81d43 --- /dev/null +++ b/src/Exception/ApiTimeoutException.php @@ -0,0 +1,10 @@ +> $headers + * @param float|null $retryAfter seconds from the Retry-After header, when present + */ + public function __construct( + string $message, + int $statusCode, + mixed $body = null, + string $rawBody = '', + array $headers = [], + ?string $errorMessage = null, + private readonly ?float $retryAfter = null, + ?\Throwable $previous = null, + ) { + parent::__construct($message, $statusCode, $body, $rawBody, $headers, $errorMessage, $previous); + } + + /** + * Seconds to wait before the next request, from the Retry-After header, or null + * when the server did not send one. + */ + public function getRetryAfter(): ?float + { + return $this->retryAfter; + } +} diff --git a/src/Exception/ServerException.php b/src/Exception/ServerException.php new file mode 100644 index 0000000..9ff5b00 --- /dev/null +++ b/src/Exception/ServerException.php @@ -0,0 +1,10 @@ +get()` waits, the SDK + * uses {@see withTimeout()} to limit a poll to the time left. + */ +final class CurlClient implements ClientInterface +{ + private readonly ResponseFactoryInterface $responseFactory; + private readonly StreamFactoryInterface $streamFactory; + + /** + * @param float $timeout total time allowed for one request, in seconds + * @param float|null $connectTimeout time allowed to connect, in seconds (defaults to $timeout) + * + * @throws ShieldLabsException when ext-curl is not loaded or a timeout is not positive + */ + public function __construct( + private readonly float $timeout = 10.0, + private readonly ?float $connectTimeout = null, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + if (!\extension_loaded('curl')) { + throw new ShieldLabsException('CurlClient needs the curl PHP extension.'); + } + if ($timeout <= 0 || ($connectTimeout !== null && $connectTimeout <= 0)) { + throw new ValidationException('CurlClient timeouts must be greater than zero.'); + } + $factory = new Psr17Factory(); + $this->responseFactory = $responseFactory ?? $factory; + $this->streamFactory = $streamFactory ?? $factory; + } + + /** + * Total time allowed for one request, in seconds. + */ + public function getTimeout(): float + { + return $this->timeout; + } + + /** + * A copy of this client with another total time per request. The connect timeout + * stays as configured and never exceeds the total time. + * + * @param float $timeout total time allowed for one request, in seconds + * + * @throws ValidationException when the timeout is not greater than zero + */ + public function withTimeout(float $timeout): self + { + if ($timeout === $this->timeout) { + return $this; + } + + return new self($timeout, $this->connectTimeout, $this->responseFactory, $this->streamFactory); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $url = (string) $request->getUri(); + $method = $request->getMethod(); + if ($url === '' || $method === '') { + throw new RequestException('The request has no URI or no method.', $request); + } + $headerLines = []; + foreach ($request->getHeaders() as $name => $values) { + $line = $name . ': ' . implode(', ', $values); + if (strpbrk($line, "\r\n\0") !== false) { + // cURL would send the line as is and split it into several headers. + throw new RequestException('A request header contains a line break or a NUL byte.', $request); + } + $headerLines[] = $line; + } + $headerLines[] = 'Expect:'; + + $handle = curl_init(); + if ($handle === false) { + throw new NetworkException('Could not initialize a cURL handle.', $request); + } + + /** @var list $responseHeaders */ + $responseHeaders = []; + $reasonPhrase = ''; + $options = [ + \CURLOPT_URL => $url, + \CURLOPT_CUSTOMREQUEST => $method, + \CURLOPT_HTTPHEADER => $headerLines, + \CURLOPT_RETURNTRANSFER => true, + \CURLOPT_HEADER => false, + \CURLOPT_FOLLOWLOCATION => false, + \CURLOPT_TIMEOUT_MS => (int) ceil($this->timeout * 1000), + \CURLOPT_CONNECTTIMEOUT_MS => (int) ceil(min($this->connectTimeout ?? $this->timeout, $this->timeout) * 1000), + \CURLOPT_NOSIGNAL => true, + \CURLOPT_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS, + \CURLOPT_ENCODING => '', + \CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$responseHeaders, &$reasonPhrase): int { + $trimmed = trim($line); + if (str_starts_with($trimmed, 'HTTP/')) { + // A new response block (for example after "100 Continue"): start over. + $responseHeaders = []; + $statusLine = explode(' ', $trimmed, 3); + $reasonPhrase = $statusLine[2] ?? ''; + } elseif (str_contains($trimmed, ':')) { + [$name, $value] = explode(':', $trimmed, 2); + $responseHeaders[] = [trim($name), trim($value)]; + } + + return \strlen($line); + }, + ]; + $body = (string) $request->getBody(); + if ($body !== '') { + $options[\CURLOPT_POSTFIELDS] = $body; + } + if ($method === 'HEAD') { + $options[\CURLOPT_NOBODY] = true; + } + curl_setopt_array($handle, $options); + + $result = curl_exec($handle); + if (!\is_string($result)) { + $errno = curl_errno($handle); + + throw new NetworkException( + \sprintf('cURL error %d: %s', $errno, curl_error($handle)), + $request, + $errno === \CURLE_OPERATION_TIMEDOUT, + $errno, + ); + } + + $status = (int) curl_getinfo($handle, \CURLINFO_RESPONSE_CODE); + $response = $this->responseFactory + ->createResponse($status, $reasonPhrase) + ->withBody($this->streamFactory->createStream($result)); + foreach ($responseHeaders as [$name, $value]) { + try { + $response = $response->withAddedHeader($name, $value); + } catch (\InvalidArgumentException) { + // Skip a header line that is not valid HTTP. + } + } + + return $response; + } +} diff --git a/src/Http/NetworkException.php b/src/Http/NetworkException.php new file mode 100644 index 0000000..26d9d43 --- /dev/null +++ b/src/Http/NetworkException.php @@ -0,0 +1,39 @@ +request; + } + + /** + * True when the transfer was stopped by the client timeout. + */ + public function isTimeout(): bool + { + return $this->timeout; + } +} diff --git a/src/Http/RequestException.php b/src/Http/RequestException.php new file mode 100644 index 0000000..8249c62 --- /dev/null +++ b/src/Http/RequestException.php @@ -0,0 +1,27 @@ +request; + } +} diff --git a/src/Internal/BaseUrl.php b/src/Internal/BaseUrl.php new file mode 100644 index 0000000..8e84b68 --- /dev/null +++ b/src/Internal/BaseUrl.php @@ -0,0 +1,94 @@ + true.', + $optionName, + )); + } + Warning::once(\sprintf( + 'option "%s" uses plain http, so the key travels unencrypted. Use https outside local testing.', + $optionName, + )); + } + $path = rtrim($parts['path'] ?? '', '/'); + if ($stripSuffix !== '' && str_ends_with($path, $stripSuffix)) { + $path = rtrim(substr($path, 0, -\strlen($stripSuffix)), '/'); + } + + return $scheme . '://' . $parts['host'] + . (isset($parts['port']) ? ':' . $parts['port'] : '') + . $path; + } + + /** + * True for localhost and its subdomains, 127.0.0.0/8 and [::1]. + */ + public static function isLoopback(string $host): bool + { + $host = strtolower(rtrim($host, '.')); + if ($host === 'localhost' || str_ends_with($host, '.localhost')) { + return true; + } + if (filter_var($host, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4) !== false) { + return str_starts_with($host, '127.'); + } + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $address = substr($host, 1, -1); + + return filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) !== false + && inet_pton($address) === inet_pton('::1'); + } + + return false; + } +} diff --git a/src/Internal/Clock.php b/src/Internal/Clock.php new file mode 100644 index 0000000..076c44b --- /dev/null +++ b/src/Internal/Clock.php @@ -0,0 +1,20 @@ +get()` never runs much longer than the time left. + * + * @internal + */ +final class ConfiguredClient implements ClientInterface +{ + /** The client configured with {@see getTimeout()}. */ + public readonly ClientInterface $client; + + /** + * @param \Closure(float): ClientInterface $create builds the client for a total time per request, in seconds + * @param float $timeout total time allowed for one request, in seconds + */ + public function __construct( + private readonly \Closure $create, + private readonly float $timeout, + ) { + $this->client = ($this->create)($timeout); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->client->sendRequest($request); + } + + public function getTimeout(): float + { + return $this->timeout; + } + + /** + * The same kind of client with another total time per request. + */ + public function withTimeout(float $timeout): self + { + if ($timeout === $this->timeout) { + return $this; + } + + return new self($this->create, $timeout); + } +} diff --git a/src/Internal/Env.php b/src/Internal/Env.php new file mode 100644 index 0000000..afcbe17 --- /dev/null +++ b/src/Internal/Env.php @@ -0,0 +1,31 @@ + $createStack()] : []; + + return new ConfiguredClient( + static fn(float $seconds): ClientInterface => new $class($shared + ['timeout' => $seconds, 'connect_timeout' => $seconds]), + $timeout, + ); + } + if (self::is($class, self::SYMFONY_PSR18_CLIENT) && \is_callable($createSymfonyClient)) { + // "timeout" limits the idle time, "max_duration" the whole request. Like the + // built-in client, never follow redirects. A client with another timeout is + // derived with withOptions() when the installed version has it. + $createSymfony = \Closure::fromCallable($createSymfonyClient); + $httpClient = $createSymfony(['timeout' => $timeout, 'max_duration' => $timeout, 'max_redirects' => 0]); + $factory = new Psr17Factory(); + + return new ConfiguredClient( + static function (float $seconds) use ($class, $httpClient, $factory, $timeout, $createSymfony): ClientInterface { + $client = $httpClient; + if ($seconds !== $timeout) { + $withOptions = [$httpClient, 'withOptions']; + $client = \is_callable($withOptions) + ? $withOptions(['timeout' => $seconds, 'max_duration' => $seconds]) + : $createSymfony(['timeout' => $seconds, 'max_duration' => $seconds, 'max_redirects' => 0]); + } + + return new $class($client, $factory, $factory); + }, + $timeout, + ); + } + } catch (\Throwable) { + // An incompatible major version: let the caller fall back to the built-in client. + return null; + } + + return null; + } + + private static function is(string $class, string $expected): bool + { + return strcasecmp($class, $expected) === 0; + } +} diff --git a/src/Internal/Normalizer.php b/src/Internal/Normalizer.php new file mode 100644 index 0000000..1805fd2 --- /dev/null +++ b/src/Internal/Normalizer.php @@ -0,0 +1,473 @@ + 'is_vpn', + 'privacy_relay' => 'is_privacy_relay', + 'tor' => 'is_tor', + 'proxy' => 'is_proxy', + 'datacenter_ip' => 'is_datacenter', + 'abuser' => 'is_abuser', + 'os_mismatch' => 'is_os_mismatch', + 'os_not_detected' => 'is_os_not_detected', + 'timezone_mismatch' => 'is_timezone_mismatch', + 'anti_detect_browser' => 'is_antidetect', + 'browser_automation' => 'is_browser_automation', + 'incognito' => 'is_incognito', + 'search_bot' => 'is_search_bot', + 'suspicious_paid_click' => 'is_suspicious_paid_click', + 'javascript_disabled' => 'is_js_disabled', + 'stun_not_checked' => 'is_stun_not_checked', + 'check_incomplete' => 'check_incomplete', + ]; + + private const EXACT_SLUGS = [ + 'Is tor' => 'tor', + 'Is VPN' => 'vpn', + 'Is privacy relay' => 'privacy_relay', + 'Is proxy' => 'proxy', + 'Is datacenter' => 'datacenter_ip', + 'Is abuser' => 'abuser', + 'Stun is not checked' => 'stun_not_checked', + 'Stun passed (late arrival, corrected)' => 'stun_late_correction', + 'UA OS is not detected' => 'os_not_detected', + 'Network OS is not detected' => 'os_not_detected', + 'Browser timezone ≠ IP-timezone' => 'timezone_mismatch', + 'Browser VPN/Proxy' => 'browser_vpn_proxy', + 'Browser Automation' => 'browser_automation', + 'Port scan routed via proxy (antidetect browser pattern)' => 'proxy_routed_antidetect', + 'User has been banned 1H, to many requests' => 'rate_limited', + ]; + + private const PREFIX_SLUGS = [ + ['Antidetect browser', 'antidetect_browser'], + ['Os_mismatch', 'os_mismatch'], + ['OS mismatch2', 'os_mismatch2'], + ['TCP handshake', 'tcp_handshake_v2'], + ['Latency test', 'ws_tcp_latency'], + ['JavaScript disabled', 'javascript_disabled'], + ]; + + private const STICKY_PREFIX = 'Sticky verdict: '; + private const IP_LEAK_PREFIX = 'IP ≠ leakIP'; + + /** Characters treated as whitespace when trimming (the Unicode whitespace set). */ + private const WHITESPACE = '[\x{09}-\x{0D}\x{1C}-\x{20}\x{85}\x{A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}]'; + + private function __construct() {} + + /** + * Signal slug for a History `score_details` description (the name the webhook + * would carry): exact map, then known prefixes, then sticky verdicts, then the + * generic slugger. + */ + public static function signalSlug(string $description): string + { + if (isset(self::EXACT_SLUGS[$description])) { + return self::EXACT_SLUGS[$description]; + } + foreach (self::PREFIX_SLUGS as [$prefix, $slug]) { + if (str_starts_with($description, $prefix)) { + return $slug; + } + } + if (str_starts_with($description, self::STICKY_PREFIX)) { + $index = strpos($description, ':'); + $rest = $index !== false && $index + 1 < \strlen($description) + ? self::strip(substr($description, $index + 1)) + : $description; + + return self::fallbackSlug($rest); + } + + return self::fallbackSlug($description); + } + + /** + * Generic slugger: text before the first "(", lowercased letters and digits, + * runs of spaces, "-" and "/" collapsed to "_", "≠" written as "_neq_", other + * characters dropped; "unknown" when nothing remains. + */ + public static function fallbackSlug(string $description): string + { + $text = self::strip($description); + $paren = strpos($text, '('); + if ($paren !== false) { + $text = self::strip(substr($text, 0, $paren)); + } + $out = ''; + $previousWasSeparator = false; + foreach (self::characters($text) as $char) { + if ($char === ' ' || $char === '-' || $char === '/') { + if (!$previousWasSeparator && $out !== '') { + $out .= '_'; + $previousWasSeparator = true; + } + } elseif ($char === '≠') { + $out .= '_neq_'; + $previousWasSeparator = false; + } elseif (preg_match('/^[\p{L}\p{Nd}]$/u', $char) === 1) { + $out .= self::lower($char); + $previousWasSeparator = false; + } + } + $slug = trim($out, '_'); + + return $slug !== '' ? $slug : 'unknown'; + } + + /** + * History `created_at` ("YYYY-MM-DD HH:MM:SS[.fff]", UTC, no zone designator). + * A zone designator, when present, is ignored: History times are always UTC. + */ + public static function parseHistoryTime(mixed $value): ?\DateTimeImmutable + { + $text = self::strip(self::orEmpty($value)); + if ($text === '') { + return null; + } + if (preg_match('/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})?$/', $text, $m) !== 1) { + return null; + } + + return Time::build($m[1], $m[2], $m[3] ?? '', '+00:00'); + } + + /** + * RFC 3339 timestamp with up to 9 fractional digits (kept to microseconds by + * truncation), converted to UTC. + */ + public static function parseRfc3339(mixed $value): ?\DateTimeImmutable + { + if (!\is_string($value)) { + return null; + } + if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/', $value, $m) !== 1) { + return null; + } + + return Time::build($m[1], $m[2], $m[3], $m[4] === 'Z' ? '+00:00' : $m[4]); + } + + /** + * @param array $row one element of a History API response `data` array + * + * @return array{ + * request_id: string, visitor_id: string, device_id: string, session_id: string, cookie_id: string, + * user_hid: string|null, domain: string, + * public_ip: array{ip: string, country: string}, local_ip: array{ip: string, country: string}, + * connection_type: string, os: string, browser: string, device_type: string, + * traffic_source: array, risk_score: int, + * signals: list, + * detection_flags: array, observed_at: \DateTimeImmutable|null, source: string + * } + */ + public static function fromHistoryRow(array $row): array + { + $leakSource = self::strip(self::orEmpty($row['webrtc_leak_source'] ?? null)); + if ($leakSource !== '' && $leakSource !== 'none') { + $localIp = self::ip($row['webrtc_leak_ip'] ?? null); + $localCountry = self::orEmpty($row['webrtc_leak_country'] ?? null); + } else { + $localIp = self::ip($row['web_rtc_ip'] ?? null); + $localCountry = self::orEmpty($row['web_rtc_country'] ?? null); + } + $publicIp = self::ip($row['ip'] ?? null); + + $signals = []; + $ipLeakDetail = false; + foreach (self::scoreDetails($row['score_details'] ?? null) as $detail) { + if (!self::isObject($detail)) { + continue; + } + $description = self::orEmpty($detail['Description'] ?? null); + if (str_starts_with($description, self::IP_LEAK_PREFIX)) { + $ipLeakDetail = true; + } + $weight = $detail['Value'] ?? 0; + if (!\is_int($weight) || $weight === 0) { + continue; + } + $signals[] = [ + 'name' => self::signalSlug($description), + 'weight' => $weight, + 'description' => $description, + ]; + } + + $searchBot = self::truthy($row['is_search_bot'] ?? null); + $flags = []; + foreach (self::FLAG_KEYS as $key) { + if ($key === 'browser_vpn_proxy') { + $flags[$key] = ($row['connection_type'] ?? null) === 'browser_vpn_proxy'; + } elseif ($key === 'ip_mismatch') { + $flags[$key] = !$searchBot + && ($ipLeakDetail || ($publicIp !== '' && $localIp !== '' && $publicIp !== $localIp)); + } else { + $flags[$key] = self::truthy($row[self::HISTORY_FLAG_MAP[$key]] ?? null); + } + } + + $siteDomain = $row['site_domain'] ?? null; + + return [ + 'request_id' => self::str($row['request_id'] ?? null), + 'visitor_id' => self::str($row['visitor_id'] ?? null), + 'device_id' => self::str($row['device_id'] ?? null), + 'session_id' => self::str($row['session_id'] ?? null), + 'cookie_id' => self::str($row['cookie_id'] ?? null), + 'user_hid' => self::userHid($row['user_hid'] ?? null), + 'domain' => self::truthy($siteDomain) ? self::str($siteDomain) : self::str($row['domain'] ?? null), + 'public_ip' => ['ip' => $publicIp, 'country' => self::orEmpty($row['country'] ?? null)], + 'local_ip' => ['ip' => $localIp, 'country' => $localCountry], + 'connection_type' => self::str($row['connection_type'] ?? null), + 'os' => self::str($row['os'] ?? null), + 'browser' => self::str($row['browser'] ?? null), + 'device_type' => self::str($row['device_type'] ?? null), + 'traffic_source' => [ + 'channel' => self::orEmpty($row['traffic_channel'] ?? null), + 'referrer_domain' => self::orEmpty($row['referrer_domain'] ?? null), + 'landing_url' => self::orEmpty($row['entry_url'] ?? null), + 'click_id_type' => self::orEmpty($row['click_id_type'] ?? null), + 'utm_source' => self::orEmpty($row['utm_source'] ?? null), + 'utm_medium' => self::orEmpty($row['utm_medium'] ?? null), + 'utm_campaign' => self::orEmpty($row['utm_campaign'] ?? null), + 'utm_content' => self::orEmpty($row['utm_content'] ?? null), + 'utm_term' => self::orEmpty($row['utm_term'] ?? null), + ], + 'risk_score' => self::int($row['score'] ?? null), + 'signals' => $signals, + 'detection_flags' => $flags, + 'observed_at' => self::parseHistoryTime($row['created_at'] ?? null), + 'source' => 'history', + ]; + } + + /** + * @param array $data the `data` object of an identification.scored webhook + * + * @return array{ + * request_id: string, visitor_id: string, device_id: string, session_id: string, cookie_id: string, + * user_hid: string|null, domain: string, + * public_ip: array{ip: string, country: string}, local_ip: array{ip: string, country: string}, + * connection_type: string, os: string, browser: string, device_type: string, + * traffic_source: array, risk_score: int, + * signals: list, + * detection_flags: array, observed_at: \DateTimeImmutable|null, source: string + * } + */ + public static function fromWebhookData(array $data): array + { + $rawFlags = $data['detection_flags'] ?? null; + $rawFlags = \is_array($rawFlags) ? $rawFlags : []; + $flags = []; + foreach (self::FLAG_KEYS as $key) { + $flags[$key] = self::truthy($rawFlags[$key] ?? null); + } + + $traffic = $data['traffic_source'] ?? null; + $traffic = \is_array($traffic) ? $traffic : []; + $trafficSource = []; + foreach (self::TRAFFIC_KEYS as $key) { + $trafficSource[$key] = self::orEmpty($traffic[$key] ?? null); + } + + $signals = []; + $rawSignals = $data['signals'] ?? null; + if (\is_array($rawSignals) && array_is_list($rawSignals)) { + foreach ($rawSignals as $signal) { + if (!self::isObject($signal)) { + continue; + } + $signals[] = [ + 'name' => self::str($signal['name'] ?? null), + 'weight' => self::int($signal['weight'] ?? null), + 'description' => null, + ]; + } + } + + return [ + 'request_id' => self::str($data['request_id'] ?? null), + 'visitor_id' => self::str($data['visitor_id'] ?? null), + 'device_id' => self::str($data['device_id'] ?? null), + 'session_id' => self::str($data['session_id'] ?? null), + 'cookie_id' => self::str($data['cookie_id'] ?? null), + 'user_hid' => self::userHid($data['user_hid'] ?? null), + 'domain' => self::str($data['domain'] ?? null), + 'public_ip' => self::ipObject($data['public_ip'] ?? null), + 'local_ip' => self::ipObject($data['local_ip'] ?? null), + 'connection_type' => self::str($data['connection_type'] ?? null), + 'os' => self::str($data['os'] ?? null), + 'browser' => self::str($data['browser'] ?? null), + 'device_type' => self::str($data['device_type'] ?? null), + 'traffic_source' => $trafficSource, + 'risk_score' => self::int($data['risk_score'] ?? null), + 'signals' => $signals, + 'detection_flags' => $flags, + 'observed_at' => self::parseRfc3339($data['observed_at'] ?? null), + 'source' => 'webhook', + ]; + } + + /** + * Trims Unicode whitespace from both ends. + */ + public static function strip(string $value): string + { + $result = preg_replace('/^' . self::WHITESPACE . '+|' . self::WHITESPACE . '+$/u', '', $value); + + return $result ?? trim($value); + } + + /** + * @return array{ip: string, country: string} + */ + private static function ipObject(mixed $value): array + { + $object = \is_array($value) ? $value : []; + + return [ + 'ip' => self::ip($object['ip'] ?? null), + 'country' => self::orEmpty($object['country'] ?? null), + ]; + } + + /** + * "0.0.0.0" and blank values mean "no address" and become "". + */ + private static function ip(mixed $value): string + { + $ip = self::strip(self::orEmpty($value)); + + return $ip === '' || $ip === '0.0.0.0' ? '' : $ip; + } + + /** + * `score_details` is a JSON-encoded string; anything else (or invalid JSON, or a + * value that is not a list) yields no details. + * + * @return list + */ + private static function scoreDetails(mixed $value): array + { + if (!\is_string($value) || $value === '') { + return []; + } + try { + $decoded = json_decode($value, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return []; + } + + return \is_array($decoded) && array_is_list($decoded) ? $decoded : []; + } + + private static function userHid(mixed $value): ?string + { + $userHid = self::str($value); + + return $userHid === '' ? null : $userHid; + } + + /** + * True for decoded JSON objects (an empty array can be either; it carries no data). + * + * @phpstan-assert-if-true array $value + */ + private static function isObject(mixed $value): bool + { + return \is_array($value) && ($value === [] || !array_is_list($value)); + } + + /** + * JSON truthiness: false, null, 0, "" and empty containers are false. + */ + private static function truthy(mixed $value): bool + { + return \is_string($value) ? $value !== '' : (bool) $value; + } + + /** + * The value when it is truthy, otherwise "" (converted to a string). + */ + private static function orEmpty(mixed $value): string + { + return self::truthy($value) ? self::str($value) : ''; + } + + private static function str(mixed $value): string + { + if (\is_string($value)) { + return $value; + } + if (\is_int($value) || \is_float($value)) { + return (string) $value; + } + + return ''; + } + + private static function int(mixed $value): int + { + if (\is_int($value)) { + return $value; + } + if (\is_float($value) && is_finite($value)) { + return (int) $value; + } + + return 0; + } + + /** + * @return list + */ + private static function characters(string $text): array + { + if ($text === '') { + return []; + } + $characters = preg_split('//u', $text, -1, \PREG_SPLIT_NO_EMPTY); + + return $characters !== false ? $characters : str_split($text); + } + + private static function lower(string $char): string + { + if (\function_exists('mb_strtolower')) { + return mb_strtolower($char, 'UTF-8'); + } + + return strtr($char, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + } +} diff --git a/src/Internal/Options.php b/src/Internal/Options.php new file mode 100644 index 0000000..3c36ef0 --- /dev/null +++ b/src/Internal/Options.php @@ -0,0 +1,156 @@ + $options + * @param list $allowed + * + * @throws ValidationException when an option name is not in $allowed + */ + public static function assertKnown(array $options, array $allowed, string $context): void + { + foreach (array_keys($options) as $key) { + if (!\is_string($key) || !\in_array($key, $allowed, true)) { + throw new ValidationException(\sprintf( + 'Unknown option "%s" for %s. Allowed options: %s.', + (string) $key, + $context, + implode(', ', $allowed), + )); + } + } + } + + /** + * Raw value of an option, or null. + * + * @param array $options + */ + public static function get(array $options, string $key): mixed + { + return $options[$key] ?? null; + } + + /** + * A number of seconds (int or float). Null or a missing key gives the default. + * + * @param array $options + * + * @throws ValidationException + */ + public static function seconds(array $options, string $key, float $default, bool $allowZero): float + { + $value = $options[$key] ?? null; + if ($value === null) { + return $default; + } + if ((!\is_int($value) && !\is_float($value)) || !is_finite((float) $value)) { + throw new ValidationException(\sprintf('Option "%s" must be a number of seconds.', $key)); + } + $value = (float) $value; + if ($value < 0 || (!$allowZero && $value == 0.0)) { + throw new ValidationException(\sprintf( + 'Option "%s" must be %s.', + $key, + $allowZero ? 'zero or greater' : 'greater than zero', + )); + } + + return $value; + } + + /** + * An integer within [$min, $max]. Null or a missing key gives the default. + * + * @param array $options + * + * @throws ValidationException + */ + public static function integer(array $options, string $key, int $default, int $min, ?int $max = null): int + { + $value = $options[$key] ?? null; + if ($value === null) { + return $default; + } + if (!\is_int($value) || $value < $min || ($max !== null && $value > $max)) { + throw new ValidationException(\sprintf( + 'Option "%s" must be an integer %s.', + $key, + $max === null ? \sprintf('greater than or equal to %d', $min) : \sprintf('from %d to %d', $min, $max), + )); + } + + return $value; + } + + /** + * @param array $options + * + * @throws ValidationException + */ + public static function boolean(array $options, string $key, bool $default): bool + { + $value = $options[$key] ?? null; + if ($value === null) { + return $default; + } + if (!\is_bool($value)) { + throw new ValidationException(\sprintf('Option "%s" must be a boolean.', $key)); + } + + return $value; + } + + /** + * A string option, falling back to an environment variable when the option is + * missing, null or false (what getenv() returns for an unset variable). An + * explicit string, even an empty one, is returned as given. + * + * @param array $options + * + * @throws ValidationException + */ + public static function stringOrEnv(array $options, string $key, string $envName): ?string + { + $value = $options[$key] ?? null; + if ($value === null || $value === false) { + return Env::get($envName); + } + + return self::string($options, $key); + } + + /** + * A string option, or null when it is missing or null. + * + * @param array $options + * + * @throws ValidationException + */ + public static function string(array $options, string $key): ?string + { + $value = $options[$key] ?? null; + if ($value === null) { + return null; + } + if (!\is_string($value)) { + throw new ValidationException(\sprintf('Option "%s" must be a string.', $key)); + } + + return $value; + } +} diff --git a/src/Internal/ResponseErrors.php b/src/Internal/ResponseErrors.php new file mode 100644 index 0000000..8da7e2b --- /dev/null +++ b/src/Internal/ResponseErrors.php @@ -0,0 +1,140 @@ +getStatusCode(); + $raw = $rawBody ?? self::body($response); + $headers = self::headers($response); + [$body, $serverMessage] = self::parse($raw); + $message = \sprintf('ShieldLabs API error (HTTP %d)', $status) + . ($serverMessage !== null ? ': ' . $serverMessage : ''); + + return match (true) { + $status === 400 => new BadRequestException($message, $status, $body, $raw, $headers, $serverMessage), + $status === 401, $status === 403 => new AuthenticationException($message, $status, $body, $raw, $headers, $serverMessage), + $status === 402 => new QuotaExceededException($message, $status, $body, $raw, $headers, $serverMessage), + $status === 404 => new NotFoundException($message, $status, $body, $raw, $headers, $serverMessage), + $status === 429 => new RateLimitException( + $message, + $status, + $body, + $raw, + $headers, + $serverMessage, + self::retryAfter($headers['retry-after'][0] ?? null), + ), + $status >= 500 && $status <= 599 => new ServerException($message, $status, $body, $raw, $headers, $serverMessage), + default => new ApiException($message, $status, $body, $raw, $headers, $serverMessage), + }; + } + + /** + * Seconds from a Retry-After header (delta-seconds or an HTTP date), or null. + */ + public static function retryAfter(?string $value, ?int $now = null): ?float + { + if ($value === null) { + return null; + } + $value = trim($value); + if (preg_match('/^\d+(?:\.\d+)?$/D', $value) === 1) { + return (float) $value; + } + $date = \DateTimeImmutable::createFromFormat('D, d M Y H:i:s \\G\\M\\T', $value, new \DateTimeZone('UTC')); + if ($date === false) { + return null; + } + + return (float) max(0, $date->getTimestamp() - ($now ?? time())); + } + + public static function body(ResponseInterface $response): string + { + try { + return (string) $response->getBody(); + } catch (\Throwable) { + return ''; + } + } + + /** + * @return array> + */ + public static function headers(ResponseInterface $response): array + { + $headers = []; + foreach ($response->getHeaders() as $name => $values) { + $key = strtolower((string) $name); + foreach ($values as $value) { + $headers[$key][] = $value; + } + } + + return $headers; + } + + /** + * @return array{0: mixed, 1: string|null} decoded body and the server's error text + */ + private static function parse(string $raw): array + { + if (trim($raw) === '') { + return [$raw, null]; + } + try { + $decoded = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $text = trim($raw); + $isShortText = !str_contains($text, '<') && \strlen($text) <= self::MAX_MESSAGE_LENGTH; + + return [$raw, $isShortText ? $text : null]; + } + if (\is_string($decoded)) { + return [$decoded, $decoded !== '' ? self::truncate($decoded) : null]; + } + if (\is_array($decoded)) { + foreach (['error', 'message'] as $key) { + if (isset($decoded[$key]) && \is_string($decoded[$key]) && $decoded[$key] !== '') { + return [$decoded, self::truncate($decoded[$key])]; + } + } + } + + return [$decoded, null]; + } + + private static function truncate(string $text): string + { + return \strlen($text) > self::MAX_MESSAGE_LENGTH ? substr($text, 0, self::MAX_MESSAGE_LENGTH) . '...' : $text; + } +} diff --git a/src/Internal/SystemClock.php b/src/Internal/SystemClock.php new file mode 100644 index 0000000..b160e9f --- /dev/null +++ b/src/Internal/SystemClock.php @@ -0,0 +1,31 @@ + 0) { + sleep($whole); + } + if ($micro > 0) { + usleep($micro); + } + } +} diff --git a/src/Internal/Time.php b/src/Internal/Time.php new file mode 100644 index 0000000..40bde99 --- /dev/null +++ b/src/Internal/Time.php @@ -0,0 +1,62 @@ + 0 || $errors['error_count'] > 0)) { + return null; + } + + return $value->setTimezone(new \DateTimeZone('UTC')); + } + + /** + * RFC 3339 in UTC with millisecond precision (truncated), for example + * 2026-09-30T12:34:56.123Z. + */ + public static function format(?\DateTimeImmutable $value): ?string + { + if ($value === null) { + return null; + } + $utc = $value->setTimezone(new \DateTimeZone('UTC')); + + return \sprintf('%04d-%s', (int) $utc->format('Y'), $utc->format('m-d\TH:i:s.v\Z')); + } + + /** + * Unix time in seconds (with microseconds) of a date. + */ + public static function unix(\DateTimeInterface $value): float + { + return (float) $value->format('U.u'); + } +} diff --git a/src/Internal/Transport.php b/src/Internal/Transport.php new file mode 100644 index 0000000..31ebe73 --- /dev/null +++ b/src/Internal/Transport.php @@ -0,0 +1,233 @@ +get() waits: the History API counts requests per second, so + * an earlier request would land in the same window. + */ + public const RATE_LIMIT_MIN_WAIT = 1.0; + + /** + * @param array $headers sent with every request + */ + public function __construct( + private readonly ClientInterface $client, + private readonly RequestFactoryInterface $requestFactory, + private readonly string $baseUrl, + private readonly array $headers, + private readonly int $maxRetries, + private readonly bool $retryRateLimited, + private readonly Clock $clock, + ) {} + + /** + * GET a path under the base URL and return the decoded JSON body. + * + * @param array $query + * @param int|null $maxRetries retries for this call instead of the configured number + * @param float|null $attemptTimeout seconds each attempt may take at most, where the HTTP client allows it + * + * @throws ShieldLabsException + */ + public function getJson(string $path, array $query = [], ?int $maxRetries = null, ?float $attemptTimeout = null): mixed + { + $url = $this->baseUrl . $path; + if ($query !== []) { + $url .= '?' . http_build_query($query, '', '&', \PHP_QUERY_RFC3986); + } + $client = $this->clientFor($attemptTimeout); + $maxRetries ??= $this->maxRetries; + + for ($attempt = 0; ; ++$attempt) { + try { + [$response, $raw] = $this->send($client, $url); + $status = $response->getStatusCode(); + if ($status >= 200 && $status < 300) { + return $this->decode($response, $raw); + } + $error = ResponseErrors::fromResponse($response, $raw); + } catch (ApiConnectionException|ApiTimeoutException $exception) { + $error = $exception; + } + + if ($attempt >= $maxRetries || !$this->isRetryable($error)) { + throw $error; + } + $this->clock->sleep($this->retryDelay($attempt, $error)); + } + } + + /** + * The HTTP client for one call. With an attempt timeout shorter than the client's + * own, the built-in {@see CurlClient} and the clients the SDK created itself are + * replaced by a copy limited to it. Any other client passed as `http_client` + * keeps its own timeout, because PSR-18 has no way to change it per request. + */ + private function clientFor(?float $attemptTimeout): ClientInterface + { + $client = $this->client; + if ( + $attemptTimeout !== null + && ($client instanceof CurlClient || $client instanceof ConfiguredClient) + && $attemptTimeout < $client->getTimeout() + ) { + return $client->withTimeout($attemptTimeout); + } + + return $client; + } + + /** + * Delay before retry number $attempt + 1. + */ + public function retryDelay(int $attempt, ShieldLabsException $error): float + { + $retryAfter = null; + if ($error instanceof RateLimitException) { + $retryAfter = $error->getRetryAfter(); + } elseif ($error instanceof ServerException) { + $retryAfter = ResponseErrors::retryAfter($error->getHeader('retry-after')); + } + if ($retryAfter !== null) { + return min(max($retryAfter, 0.0), self::RETRY_AFTER_CAP); + } + $backoff = min(self::BACKOFF_CAP, self::BACKOFF_BASE * self::BACKOFF_FACTOR ** $attempt); + $delay = $backoff * (0.5 + 0.5 * (random_int(0, 1_000_000) / 1_000_000)); + + return $error instanceof RateLimitException ? max($delay, self::RATE_LIMIT_MIN_WAIT) : $delay; + } + + private function isRetryable(ShieldLabsException $error): bool + { + return match (true) { + $error instanceof ApiTimeoutException, $error instanceof ServerException => true, + $error instanceof ApiConnectionException => !$error->getPrevious() instanceof RequestExceptionInterface, + $error instanceof RateLimitException => $this->retryRateLimited, + default => false, + }; + } + + /** + * Sends one attempt and reads the whole response body. + * + * @return array{0: ResponseInterface, 1: string} the response and its body + * + * @throws ApiConnectionException + * @throws ApiTimeoutException + * @throws ValidationException when the PSR-7 layer refuses the URL or a header + */ + private function send(ClientInterface $client, string $url): array + { + try { + $request = $this->requestFactory->createRequest('GET', $url); + foreach ($this->headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + } catch (\InvalidArgumentException) { + // Some PSR-7 implementations quote the refused header value, the API key + // included, so their message is neither repeated nor chained. + throw new ValidationException('The request could not be built: the URL or a header value contains characters that HTTP does not allow.'); + } + + try { + $response = $client->sendRequest($request); + } catch (NetworkExceptionInterface $exception) { + if (self::isTimeout($exception)) { + throw new ApiTimeoutException('The ShieldLabs API did not answer in time: ' . $exception->getMessage(), 0, $exception); + } + + throw new ApiConnectionException('Could not reach the ShieldLabs API: ' . $exception->getMessage(), 0, $exception); + } catch (RequestExceptionInterface $exception) { + throw new ApiConnectionException('The HTTP client could not send the request: ' . $exception->getMessage(), 0, $exception); + } catch (ClientExceptionInterface $exception) { + if (self::isTimeout($exception)) { + throw new ApiTimeoutException('The ShieldLabs API did not answer in time: ' . $exception->getMessage(), 0, $exception); + } + + throw new ApiConnectionException('Could not reach the ShieldLabs API: ' . $exception->getMessage(), 0, $exception); + } + + // Some clients (Symfony HttpClient, for one) return once the headers arrive and + // stream the body afterwards, so a timeout or a dropped connection can surface + // only while the body is read. Read it here and treat such a failure like any + // other transport error, retries included. + try { + return [$response, (string) $response->getBody()]; + } catch (\Exception $exception) { + if (self::isTimeoutMessage($exception->getMessage())) { + throw new ApiTimeoutException('The ShieldLabs API did not answer in time: ' . $exception->getMessage(), 0, $exception); + } + + throw new ApiConnectionException('The response of the ShieldLabs API could not be read: ' . $exception->getMessage(), 0, $exception); + } + } + + private static function isTimeout(ClientExceptionInterface $exception): bool + { + if ($exception instanceof NetworkException) { + return $exception->isTimeout(); + } + + return self::isTimeoutMessage($exception->getMessage()); + } + + private static function isTimeoutMessage(string $message): bool + { + return preg_match('/timed? ?out|timeout|max duration|curl error 28\b/i', $message) === 1; + } + + /** + * @throws ApiException when a success response is not JSON + */ + private function decode(ResponseInterface $response, string $raw): mixed + { + try { + return json_decode($raw, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new ApiException( + 'The ShieldLabs API returned a response body that is not valid JSON.', + $response->getStatusCode(), + $raw, + $raw, + ResponseErrors::headers($response), + null, + $exception, + ); + } + } +} diff --git a/src/Internal/UserAgent.php b/src/Internal/UserAgent.php new file mode 100644 index 0000000..927d802 --- /dev/null +++ b/src/Internal/UserAgent.php @@ -0,0 +1,20 @@ + '$', + '%26' => '&', + '%2B' => '+', + '%2C' => ',', + '%3A' => ':', + '%3B' => ';', + '%3D' => '=', + '%40' => '@', + ]; + + private function __construct() {} + + /** + * @throws ValidationException + */ + public static function lookupType(LookupType|string $type): LookupType + { + if ($type instanceof LookupType) { + return $type; + } + $lookup = LookupType::tryFrom($type); + if ($lookup === null) { + throw new ValidationException(\sprintf( + 'Unknown lookup type "%s". Use one of: %s.', + $type, + implode(', ', array_map(static fn(LookupType $case): string => $case->value, LookupType::cases())), + )); + } + + return $lookup; + } + + /** + * Returns the value exactly as it must be sent (UUIDs in lowercase). + * + * @throws ValidationException + */ + public static function lookupValue(LookupType $type, string $value): string + { + return match ($type) { + LookupType::Ip => self::ipv4($value), + LookupType::UserHid => self::userHid($value), + default => self::uuid($value, $type->value), + }; + } + + /** + * @throws ValidationException + */ + public static function uuid(string $value, string $name): string + { + if (preg_match(self::UUID_PATTERN, $value) !== 1) { + throw new ValidationException(\sprintf( + 'The %s must be a UUID in the 8-4-4-4-12 hexadecimal form.', + $name, + )); + } + + return strtolower($value); + } + + /** + * @throws ValidationException + */ + public static function ipv4(string $value): string + { + if (preg_match(self::IPV4_PATTERN, $value) !== 1 || filter_var($value, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4) === false) { + throw new ValidationException('The ip lookup takes a dotted IPv4 address (IPv6 addresses are not searchable).'); + } + + return $value; + } + + /** + * Checks that a key, secret or domain can be sent as an HTTP header value: visible + * ASCII characters only, so no line break, NUL, space or control character. The + * check runs before any request because the errors of HTTP libraries can quote the + * rejected header; the message never includes the value. + * + * @param string $name how the message refers to the value, for example "The API key" + * + * @throws ValidationException + */ + public static function headerValue(string $value, string $name): string + { + if (preg_match('/^[\x21-\x7E]+$/D', $value) !== 1) { + throw new ValidationException(\sprintf( + '%s contains characters that cannot be sent in an HTTP header (only visible ASCII characters are allowed).', + $name, + )); + } + + return $value; + } + + /** + * A User HID travels as one URL path segment. The History API cannot search a value + * that contains "/" (neither sent as is nor escaped), and HTTP clients remove the + * segments "." and ".." from a URL, so such values are refused here. + * + * @throws ValidationException + */ + public static function userHid(string $value): string + { + if ($value === '') { + throw new ValidationException('The user_hid lookup value must be a non-empty string.'); + } + if ($value === '.' || $value === '..') { + throw new ValidationException('The user_hid lookup value cannot be "." or "..": HTTP clients drop such URL path segments, so the History API cannot search them.'); + } + if (str_contains($value, '/')) { + throw new ValidationException('The user_hid lookup value cannot contain "/": the History API cannot search a value with a slash. User HIDs from UserHid::fromUserId() are 64 hex characters and always work.'); + } + + return $value; + } + + /** + * Escapes a validated lookup value as one URL path segment in canonical form: + * A-Z, a-z, 0-9, "-", ".", "_", "~" and $ & + , : ; = @ stay as they are, and every + * other byte becomes %XX with uppercase hex digits. The History API decodes the + * value only when the path is escaped exactly this way; otherwise it compares the + * escaped text, which matches no row. + */ + public static function pathSegment(string $value): string + { + return strtr(rawurlencode($value), self::PLAIN_IN_PATH); + } +} diff --git a/src/Internal/Warning.php b/src/Internal/Warning.php new file mode 100644 index 0000000..9cd6bb6 --- /dev/null +++ b/src/Internal/Warning.php @@ -0,0 +1,41 @@ + */ + private static array $logged = []; + + private function __construct() {} + + public static function once(string $message): void + { + if (isset(self::$logged[$message])) { + return; + } + self::$logged[$message] = true; + error_log(self::PREFIX . $message); + } + + /** + * Forgets which messages were logged. For tests. + */ + public static function reset(): void + { + self::$logged = []; + } +} diff --git a/src/LookupType.php b/src/LookupType.php new file mode 100644 index 0000000..877e314 --- /dev/null +++ b/src/LookupType.php @@ -0,0 +1,32 @@ + false, + default => true, + }; + } +} diff --git a/src/Model/DetectionFlags.php b/src/Model/DetectionFlags.php new file mode 100644 index 0000000..b95ca20 --- /dev/null +++ b/src/Model/DetectionFlags.php @@ -0,0 +1,134 @@ + $flags flag name to value; missing names are false + */ + public static function fromArray(array $flags): self + { + $value = static fn(string $name): bool => ($flags[$name] ?? false) === true; + + return new self( + vpn: $value('vpn'), + privacy_relay: $value('privacy_relay'), + browser_vpn_proxy: $value('browser_vpn_proxy'), + tor: $value('tor'), + proxy: $value('proxy'), + datacenter_ip: $value('datacenter_ip'), + abuser: $value('abuser'), + os_mismatch: $value('os_mismatch'), + os_not_detected: $value('os_not_detected'), + timezone_mismatch: $value('timezone_mismatch'), + anti_detect_browser: $value('anti_detect_browser'), + browser_automation: $value('browser_automation'), + ip_mismatch: $value('ip_mismatch'), + incognito: $value('incognito'), + search_bot: $value('search_bot'), + suspicious_paid_click: $value('suspicious_paid_click'), + javascript_disabled: $value('javascript_disabled'), + stun_not_checked: $value('stun_not_checked'), + check_incomplete: $value('check_incomplete'), + ); + } + + /** + * Value of one flag by name. + * + * @throws ValidationException when the name is not one of {@see self::KEYS} + */ + public function get(string $name): bool + { + $flags = $this->toArray(); + if (!\array_key_exists($name, $flags)) { + throw new ValidationException(\sprintf('Unknown detection flag "%s". Known flags: %s.', $name, implode(', ', self::KEYS))); + } + + return $flags[$name]; + } + + /** + * Names of the flags that are true, in wire order. + * + * @return list + */ + public function active(): array + { + return array_keys(array_filter($this->toArray())); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'vpn' => $this->vpn, + 'privacy_relay' => $this->privacy_relay, + 'browser_vpn_proxy' => $this->browser_vpn_proxy, + 'tor' => $this->tor, + 'proxy' => $this->proxy, + 'datacenter_ip' => $this->datacenter_ip, + 'abuser' => $this->abuser, + 'os_mismatch' => $this->os_mismatch, + 'os_not_detected' => $this->os_not_detected, + 'timezone_mismatch' => $this->timezone_mismatch, + 'anti_detect_browser' => $this->anti_detect_browser, + 'browser_automation' => $this->browser_automation, + 'ip_mismatch' => $this->ip_mismatch, + 'incognito' => $this->incognito, + 'search_bot' => $this->search_bot, + 'suspicious_paid_click' => $this->suspicious_paid_click, + 'javascript_disabled' => $this->javascript_disabled, + 'stun_not_checked' => $this->stun_not_checked, + 'check_incomplete' => $this->check_incomplete, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Model/DomainProfile.php b/src/Model/DomainProfile.php new file mode 100644 index 0000000..1be65c5 --- /dev/null +++ b/src/Model/DomainProfile.php @@ -0,0 +1,78 @@ + $raw the response body as received + */ + public function __construct( + public readonly string $domain, + public readonly int $remaining_identifications, + public readonly string $public_key_masked, + public readonly string $secret_key_masked, + public readonly ?\DateTimeImmutable $created_at, + public readonly array $raw = [], + ) {} + + /** + * Builds a profile from a decoded Management API response body. + * + * @param array $body + */ + public static function fromArray(array $body): self + { + $string = static fn(string $key): string => \is_string($body[$key] ?? null) ? $body[$key] : ''; + $weight = $body['Weight'] ?? null; + if (\is_float($weight) && is_finite($weight)) { + $weight = (int) $weight; + } + + return new self( + domain: $string('Domain'), + remaining_identifications: \is_int($weight) ? $weight : 0, + public_key_masked: $string('PublicKey'), + secret_key_masked: $string('Secret'), + created_at: Normalizer::parseRfc3339($body['CreatedAt'] ?? null), + raw: $body, + ); + } + + /** + * The normalized profile as plain values (without `raw`). + * + * @return array{domain: string, remaining_identifications: int, public_key_masked: string, secret_key_masked: string, created_at: string|null} + */ + public function toArray(): array + { + return [ + 'domain' => $this->domain, + 'remaining_identifications' => $this->remaining_identifications, + 'public_key_masked' => $this->public_key_masked, + 'secret_key_masked' => $this->secret_key_masked, + 'created_at' => Time::format($this->created_at), + ]; + } + + /** + * @return array{domain: string, remaining_identifications: int, public_key_masked: string, secret_key_masked: string, created_at: string|null} + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Model/HistoryPage.php b/src/Model/HistoryPage.php new file mode 100644 index 0000000..e48cc66 --- /dev/null +++ b/src/Model/HistoryPage.php @@ -0,0 +1,62 @@ + + */ +final class HistoryPage implements \IteratorAggregate, \Countable +{ + /** + * @param list $data identifications on this page + * @param int $total number of rows that match the lookup (all pages) + */ + public function __construct( + public readonly array $data, + public readonly int $total, + ) {} + + /** + * Builds a page from a decoded History API response body. + * + * @param array $body + */ + public static function fromArray(array $body): self + { + $rows = $body['data'] ?? null; + $data = []; + if (\is_array($rows)) { + foreach ($rows as $row) { + if (\is_array($row)) { + $data[] = Identification::fromHistoryRow($row); + } + } + } + $total = $body['total'] ?? null; + if (\is_float($total) && is_finite($total)) { + $total = (int) $total; + } + + return new self($data, \is_int($total) ? $total : \count($data)); + } + + /** + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->data); + } + + /** + * Number of identifications on this page (not the total). + */ + public function count(): int + { + return \count($this->data); + } +} diff --git a/src/Model/Identification.php b/src/Model/Identification.php new file mode 100644 index 0000000..f0feb0d --- /dev/null +++ b/src/Model/Identification.php @@ -0,0 +1,198 @@ + $signals weighted risk signals behind the score + * @param \DateTimeImmutable|null $observed_at UTC time of the identification (null only when the payload had no parsable time) + * @param string $source {@see self::SOURCE_WEBHOOK} or {@see self::SOURCE_HISTORY} + * @param array $raw the payload as received (webhook `data` or the History row) + */ + public function __construct( + public readonly string $request_id, + public readonly string $visitor_id, + public readonly string $device_id, + public readonly string $session_id, + public readonly string $cookie_id, + public readonly ?string $user_hid, + public readonly string $domain, + public readonly IpInfo $public_ip, + public readonly IpInfo $local_ip, + public readonly string $connection_type, + public readonly string $os, + public readonly string $browser, + public readonly string $device_type, + public readonly TrafficSource $traffic_source, + public readonly int $risk_score, + public readonly array $signals, + public readonly DetectionFlags $detection_flags, + public readonly ?\DateTimeImmutable $observed_at, + public readonly string $source, + public readonly array $raw = [], + ) {} + + /** + * Builds an identification from one History API row. + * + * @param array $row + */ + public static function fromHistoryRow(array $row): self + { + return self::fromNormalized(Normalizer::fromHistoryRow($row), $row); + } + + /** + * Builds an identification from the `data` object of an identification.scored + * webhook. Missing detection flags are false. + * + * @param array $data + */ + public static function fromWebhookData(array $data): self + { + return self::fromNormalized(Normalizer::fromWebhookData($data), $data); + } + + /** + * Band of the Risk Score ({@see RiskBand::RateLimited} for the 999 marker). + */ + public function riskBand(): RiskBand + { + return RiskBand::fromScore($this->risk_score); + } + + /** + * True when the score is the rate-limit marker (above 100), never a real score. + */ + public function isRateLimited(): bool + { + return $this->risk_score > 100; + } + + /** + * False when the device ID is the all-zero UUID (or empty): no usable device signals. + */ + public function hasDeviceSignals(): bool + { + return $this->device_id !== '' && $this->device_id !== self::NIL_UUID; + } + + /** + * The normalized identification as plain values (without `raw`); `observed_at` + * is RFC 3339 in UTC with milliseconds. + * + * @return array + */ + public function toArray(): array + { + return [ + 'request_id' => $this->request_id, + 'visitor_id' => $this->visitor_id, + 'device_id' => $this->device_id, + 'session_id' => $this->session_id, + 'cookie_id' => $this->cookie_id, + 'user_hid' => $this->user_hid, + 'domain' => $this->domain, + 'public_ip' => $this->public_ip->toArray(), + 'local_ip' => $this->local_ip->toArray(), + 'connection_type' => $this->connection_type, + 'os' => $this->os, + 'browser' => $this->browser, + 'device_type' => $this->device_type, + 'traffic_source' => $this->traffic_source->toArray(), + 'risk_score' => $this->risk_score, + 'signals' => array_map(static fn(Signal $signal): array => $signal->toArray(), $this->signals), + 'detection_flags' => $this->detection_flags->toArray(), + 'observed_at' => Time::format($this->observed_at), + 'source' => $this->source, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * @param array{ + * request_id: string, visitor_id: string, device_id: string, session_id: string, cookie_id: string, + * user_hid: string|null, domain: string, + * public_ip: array{ip: string, country: string}, local_ip: array{ip: string, country: string}, + * connection_type: string, os: string, browser: string, device_type: string, + * traffic_source: array, risk_score: int, + * signals: list, + * detection_flags: array, observed_at: \DateTimeImmutable|null, source: string + * } $normalized + * @param array $raw + */ + private static function fromNormalized(array $normalized, array $raw): self + { + $traffic = $normalized['traffic_source']; + + return new self( + request_id: $normalized['request_id'], + visitor_id: $normalized['visitor_id'], + device_id: $normalized['device_id'], + session_id: $normalized['session_id'], + cookie_id: $normalized['cookie_id'], + user_hid: $normalized['user_hid'], + domain: $normalized['domain'], + public_ip: new IpInfo($normalized['public_ip']['ip'], $normalized['public_ip']['country']), + local_ip: new IpInfo($normalized['local_ip']['ip'], $normalized['local_ip']['country']), + connection_type: $normalized['connection_type'], + os: $normalized['os'], + browser: $normalized['browser'], + device_type: $normalized['device_type'], + traffic_source: new TrafficSource( + channel: $traffic['channel'] ?? '', + referrer_domain: $traffic['referrer_domain'] ?? '', + landing_url: $traffic['landing_url'] ?? '', + click_id_type: $traffic['click_id_type'] ?? '', + utm_source: $traffic['utm_source'] ?? '', + utm_medium: $traffic['utm_medium'] ?? '', + utm_campaign: $traffic['utm_campaign'] ?? '', + utm_content: $traffic['utm_content'] ?? '', + utm_term: $traffic['utm_term'] ?? '', + ), + risk_score: $normalized['risk_score'], + signals: array_map( + static fn(array $signal): Signal => new Signal($signal['name'], $signal['weight'], $signal['description']), + $normalized['signals'], + ), + detection_flags: DetectionFlags::fromArray($normalized['detection_flags']), + observed_at: $normalized['observed_at'], + source: $normalized['source'], + raw: $raw, + ); + } +} diff --git a/src/Model/IpInfo.php b/src/Model/IpInfo.php new file mode 100644 index 0000000..8a07290 --- /dev/null +++ b/src/Model/IpInfo.php @@ -0,0 +1,36 @@ + $this->ip, 'country' => $this->country]; + } + + /** + * @return array{ip: string, country: string} + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Model/Signal.php b/src/Model/Signal.php new file mode 100644 index 0000000..cf97ad1 --- /dev/null +++ b/src/Model/Signal.php @@ -0,0 +1,41 @@ + $this->name, 'weight' => $this->weight, 'description' => $this->description]; + } + + /** + * @return array{name: string, weight: int, description: string|null} + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Model/TrafficSource.php b/src/Model/TrafficSource.php new file mode 100644 index 0000000..b81a91f --- /dev/null +++ b/src/Model/TrafficSource.php @@ -0,0 +1,55 @@ + + */ + public function toArray(): array + { + return [ + 'channel' => $this->channel, + 'referrer_domain' => $this->referrer_domain, + 'landing_url' => $this->landing_url, + 'click_id_type' => $this->click_id_type, + 'utm_source' => $this->utm_source, + 'utm_medium' => $this->utm_medium, + 'utm_campaign' => $this->utm_campaign, + 'utm_content' => $this->utm_content, + 'utm_term' => $this->utm_term, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Resource/History.php b/src/Resource/History.php new file mode 100644 index 0000000..4c66ab1 --- /dev/null +++ b/src/Resource/History.php @@ -0,0 +1,129 @@ +history`. + */ +final class History +{ + public const DEFAULT_LIMIT = 20; + public const MAX_LIMIT = 100; + public const DEFAULT_PAGE_SIZE = 100; + + /** + * @internal use {@see \ShieldLabs\ShieldLabs::$history} + */ + public function __construct(private readonly Transport $transport) {} + + /** + * One page of identifications for an identifier. + * + * @param LookupType|string $type identifier kind (a {@see LookupType} or its string value) + * @param string $value UUID for the ID types, dotted IPv4 for ip, a non-empty string without "/" (and not "." or "..") for user_hid + * @param array{limit?: int, offset?: int} $options limit 1-100 (default 20), offset 0 or more (default 0) + * + * @throws ValidationException before any request when the arguments are invalid + * @throws ShieldLabsException + */ + public function search(LookupType|string $type, string $value, array $options = []): HistoryPage + { + Options::assertKnown($options, ['limit', 'offset'], 'history->search()'); + $lookup = Validate::lookupType($type); + $value = Validate::lookupValue($lookup, $value); + $limit = Options::integer($options, 'limit', self::DEFAULT_LIMIT, 1, self::MAX_LIMIT); + $offset = Options::integer($options, 'offset', 0, 0); + + return $this->fetch($lookup, $value, $limit, $offset); + } + + /** + * Every identification for an identifier, fetched page by page as you iterate. + * Rows are de-duplicated on request_id (offset paging can repeat a row while new + * rows arrive); iteration stops at the reported total, at an empty page or after + * max_items rows. With max_items 0 it yields nothing and sends no request. + * + * @param LookupType|string $type + * @param array{page_size?: int, max_items?: int|null} $options page_size 1-100 (default 100), max_items 0 or more to stop early (default: no limit) + * + * @return \Generator + * + * @throws ValidationException immediately when the arguments are invalid + */ + public function iterate(LookupType|string $type, string $value, array $options = []): \Generator + { + Options::assertKnown($options, ['page_size', 'max_items'], 'history->iterate()'); + $lookup = Validate::lookupType($type); + $value = Validate::lookupValue($lookup, $value); + $pageSize = Options::integer($options, 'page_size', self::DEFAULT_PAGE_SIZE, 1, self::MAX_LIMIT); + $maxItems = ($options['max_items'] ?? null) === null ? null : Options::integer($options, 'max_items', 0, 0); + + return $this->pages($lookup, $value, $pageSize, $maxItems); + } + + /** + * @internal inputs must already be validated + * + * @param int|null $maxRetries retries for this call instead of the client setting + * @param float|null $attemptTimeout seconds each attempt may take at most, where the HTTP client allows it + * + * @throws ShieldLabsException + */ + public function fetch(LookupType $type, string $value, int $limit, int $offset, ?int $maxRetries = null, ?float $attemptTimeout = null): HistoryPage + { + $path = '/api/v1/history/' . $type->value . '/' . Validate::pathSegment($value); + $body = $this->transport->getJson($path, ['limit' => $limit, 'offset' => $offset], $maxRetries, $attemptTimeout); + if (!\is_array($body)) { + throw new ApiException('The History API returned an unexpected response body.', 200, $body); + } + + return HistoryPage::fromArray($body); + } + + /** + * @return \Generator + */ + private function pages(LookupType $type, string $value, int $pageSize, ?int $maxItems): \Generator + { + if ($maxItems === 0) { + return; + } + $seen = []; + $yielded = 0; + $offset = 0; + while (true) { + $page = $this->fetch($type, $value, $pageSize, $offset); + if ($page->data === []) { + return; + } + foreach ($page->data as $identification) { + if (isset($seen[$identification->request_id])) { + continue; + } + $seen[$identification->request_id] = true; + yield $identification; + ++$yielded; + if ($maxItems !== null && $yielded >= $maxItems) { + return; + } + } + $offset += \count($page->data); + if ($offset >= $page->total) { + return; + } + } + } +} diff --git a/src/Resource/Identifications.php b/src/Resource/Identifications.php new file mode 100644 index 0000000..97d01c7 --- /dev/null +++ b/src/Resource/Identifications.php @@ -0,0 +1,150 @@ +identifications`. + */ +final class Identifications +{ + public const DEFAULT_TIMEOUT = 10.0; + public const DEFAULT_POLL_INTERVAL = 0.25; + + /** + * Longest wait between two polls for a poll interval up to 2 s. A longer poll + * interval is its own cap: each wait is at most max(2 s, poll interval). + */ + public const MAX_POLL_WAIT = 2.0; + + /** Shortest time a poll may take, even when less of the budget is left. */ + public const MIN_ATTEMPT_TIMEOUT = 1.0; + + /** Multipliers of the poll interval: 250 ms, 500 ms, 1 s, 1.5 s, then 2 s with the defaults. */ + private const POLL_STEPS = [1, 2, 4, 6, 8]; + + /** + * @internal use {@see \ShieldLabs\ShieldLabs::$identifications} + */ + public function __construct( + private readonly History $history, + private readonly Clock $clock, + ) {} + + /** + * The identification for a request ID, or null when none exists (yet). + * + * Scoring is asynchronous: the row appears about 1-3 s after the browser call and + * can be refined for up to about 10 s while follow-up checks finish; this returns + * the first version it sees. With `wait` (the default) the SDK polls within a total + * budget of `timeout` seconds: + * + * - the first poll is immediate, then it waits 250 ms, 500 ms, 1 s, 1.5 s and then + * 2 s between polls, and the last poll runs when the budget ends. With another + * `poll_interval` p the waits are p, 2p, 4p, 6p and then 8p, each at most 2 s or + * at most p when p is longer: 1 s gives 1, 2, 2, 2 s, and 3 s polls every 3 s; + * - each poll is one HTTP request without retries, limited to the time left but at + * least 1 s (and never longer than the client timeout) where the HTTP client + * allows it: the built-in CurlClient and the clients the SDK creates do, another + * client passed as `http_client` keeps its own timeout; + * - a 429, a 5xx response, a connection error or a timeout does not end the wait. + * After a 429 the next poll waits at least 1 s: the longest of the next scheduled + * wait, 1 s and Retry-After (at most 10 s; 0 or a date in the past counts as 0), + * cut short when the budget ends, and the last poll runs then. When Retry-After + * asks for more than the time left, the 429 is thrown at once; + * - when the budget ends, the error of the last poll is thrown if that poll failed, + * otherwise null is returned; + * - any other error, such as 400, 401, 403 or 404, is thrown at once. + * + * Null means "unverified", never "clean". With `wait` false the SDK makes one + * lookup with the usual timeout and retries. + * + * Options: `wait` (default true), `timeout`, the total budget in seconds (default + * 10), and `poll_interval`, the first wait in seconds (default 0.25; no scheduled + * wait is longer than 2 s or than `poll_interval`, whichever is longer). + * + * @param array{wait?: bool, timeout?: int|float, poll_interval?: int|float} $options + * + * @throws ValidationException before any request when the request ID is not a UUID + * @throws ShieldLabsException + */ + public function get(string $requestId, array $options = []): ?Identification + { + Options::assertKnown($options, ['wait', 'timeout', 'poll_interval'], 'identifications->get()'); + $requestId = Validate::uuid($requestId, 'request ID'); + $wait = Options::boolean($options, 'wait', true); + $timeout = Options::seconds($options, 'timeout', self::DEFAULT_TIMEOUT, true); + $pollInterval = Options::seconds($options, 'poll_interval', self::DEFAULT_POLL_INTERVAL, false); + + if (!$wait) { + return $this->history->fetch(LookupType::RequestId, $requestId, 1, 0)->data[0] ?? null; + } + + $deadline = $this->clock->now() + $timeout; + $lastPoll = false; + for ($step = 0; ; ++$step) { + $attemptTimeout = max($deadline - $this->clock->now(), self::MIN_ATTEMPT_TIMEOUT); + $error = null; + try { + $page = $this->history->fetch(LookupType::RequestId, $requestId, 1, 0, 0, $attemptTimeout); + if ($page->data !== []) { + return $page->data[0]; + } + } catch (RateLimitException|ServerException|ApiConnectionException|ApiTimeoutException $exception) { + // These can pass while the budget lasts; any other error ends the wait here. + $error = $exception; + } + + $remaining = $deadline - $this->clock->now(); + if ($lastPoll || $remaining <= 0) { + if ($error !== null) { + throw $error; + } + + return null; + } + + $delay = self::pollDelay($step, $pollInterval); + if ($error instanceof RateLimitException) { + // No Retry-After, Retry-After: 0 and a date in the past all count as 0 s; + // the 1 s floor applies in every case. + $retryAfter = min(max($error->getRetryAfter() ?? 0.0, 0.0), Transport::RETRY_AFTER_CAP); + if ($retryAfter > $remaining) { + throw $error; + } + $delay = max($delay, Transport::RATE_LIMIT_MIN_WAIT, $retryAfter); + } + + // A wait that reaches the deadline is cut short, and the poll after it is the last. + $lastPoll = $delay >= $remaining; + $this->clock->sleep(min($delay, $remaining)); + } + } + + /** + * Wait before the poll that follows poll number $step (0-based): the poll + * interval times 1, 2, 4, 6, then 8, each at most max(2 s, poll interval), so + * a poll interval longer than 2 s is used as it is. + */ + public static function pollDelay(int $step, float $pollInterval): float + { + $multiplier = self::POLL_STEPS[min(max($step, 0), \count(self::POLL_STEPS) - 1)]; + + return min($pollInterval * $multiplier, max(self::MAX_POLL_WAIT, $pollInterval)); + } +} diff --git a/src/Risk.php b/src/Risk.php new file mode 100644 index 0000000..a31aa00 --- /dev/null +++ b/src/Risk.php @@ -0,0 +1,176 @@ + 100; + } + + /** + * Guard for one protected action (signup, login, checkout). The checks run in + * this order and the first failure wins: + * + * 1. no identification: `missing` (unverified, never "clean") + * 2. `is_replay($requestId)` returns true: `replayed` (the SDK stores nothing itself) + * 3. older than `max_age` seconds by `observed_at`: `stale` + * 4. score above 100 (rate-limit marker): `rate_limited` + * 5. all-zero device ID: `no_device_signals` + * 6. any flag in `block_flags`: `blocked_flag` + * 7. band in `block_bands`: `blocked_band` + * + * The defaults (max_age 300, block_bands [dangerous], block_flags + * [browser_automation, javascript_disabled]) are a starting point to tune for your + * product. + * + * @param array{ + * max_age?: int|float, + * now?: \DateTimeInterface|int|float, + * block_bands?: list, + * block_flags?: list, + * is_replay?: callable(string): bool, + * } $options + * + * @throws ValidationException when an option is invalid + */ + public static function evaluate(?Identification $identification, array $options = []): Evaluation + { + Options::assertKnown($options, ['max_age', 'now', 'block_bands', 'block_flags', 'is_replay'], 'Risk::evaluate()'); + $maxAge = Options::seconds($options, 'max_age', (float) self::DEFAULT_MAX_AGE, true); + $now = self::now($options['now'] ?? null); + $blockBands = self::blockBands($options['block_bands'] ?? [RiskBand::Dangerous]); + $blockFlags = self::blockFlags($options['block_flags'] ?? self::DEFAULT_BLOCK_FLAGS); + $isReplay = $options['is_replay'] ?? null; + if ($isReplay !== null && !\is_callable($isReplay)) { + throw new ValidationException('Option "is_replay" must be a callable that receives the request ID.'); + } + + if ($identification === null) { + return new Evaluation(false, EvaluationReason::Missing, null); + } + $band = $identification->riskBand(); + if ($isReplay !== null && (bool) $isReplay($identification->request_id)) { + return new Evaluation(false, EvaluationReason::Replayed, $band); + } + $observedAt = $identification->observed_at; + if ($observedAt === null || $now - Time::unix($observedAt) > $maxAge) { + return new Evaluation(false, EvaluationReason::Stale, $band); + } + if ($identification->isRateLimited()) { + return new Evaluation(false, EvaluationReason::RateLimited, $band); + } + if (!$identification->hasDeviceSignals()) { + return new Evaluation(false, EvaluationReason::NoDeviceSignals, $band); + } + foreach ($blockFlags as $flag) { + if ($identification->detection_flags->get($flag)) { + return new Evaluation(false, EvaluationReason::BlockedFlag, $band, $flag); + } + } + if (\in_array($band, $blockBands, true)) { + return new Evaluation(false, EvaluationReason::BlockedBand, $band); + } + + return new Evaluation(true, null, $band); + } + + /** + * @throws ValidationException + */ + private static function now(mixed $now): float + { + if ($now === null) { + return microtime(true); + } + if ($now instanceof \DateTimeInterface) { + return Time::unix($now); + } + if ((\is_int($now) || \is_float($now)) && is_finite((float) $now)) { + return (float) $now; + } + + throw new ValidationException('Option "now" must be a DateTimeInterface or a Unix timestamp in seconds.'); + } + + /** + * @return list + * + * @throws ValidationException + */ + private static function blockBands(mixed $bands): array + { + if (!\is_array($bands)) { + throw new ValidationException('Option "block_bands" must be a list of risk bands.'); + } + $result = []; + foreach ($bands as $band) { + $value = $band instanceof RiskBand ? $band : (\is_string($band) ? RiskBand::tryFrom($band) : null); + if ($value === null) { + throw new ValidationException(\sprintf( + 'Option "block_bands" accepts RiskBand cases or %s.', + implode(', ', array_map(static fn(RiskBand $case): string => '"' . $case->value . '"', RiskBand::cases())), + )); + } + $result[] = $value; + } + + return $result; + } + + /** + * @return list + * + * @throws ValidationException + */ + private static function blockFlags(mixed $flags): array + { + if (!\is_array($flags)) { + throw new ValidationException('Option "block_flags" must be a list of detection flag names.'); + } + $result = []; + foreach ($flags as $flag) { + if (!\is_string($flag) || !\in_array($flag, DetectionFlags::KEYS, true)) { + throw new ValidationException(\sprintf( + 'Option "block_flags" accepts detection flag names: %s.', + implode(', ', DetectionFlags::KEYS), + )); + } + $result[] = $flag; + } + + return $result; + } +} diff --git a/src/RiskBand.php b/src/RiskBand.php new file mode 100644 index 0000000..b70fbde --- /dev/null +++ b/src/RiskBand.php @@ -0,0 +1,28 @@ + 100 => self::RateLimited, + $score >= 60 => self::Dangerous, + $score >= 30 => self::Suspicious, + default => self::Trusted, + }; + } +} diff --git a/src/ShieldLabs.php b/src/ShieldLabs.php new file mode 100644 index 0000000..16f455e --- /dev/null +++ b/src/ShieldLabs.php @@ -0,0 +1,132 @@ + 'sec_your_private_key']); + * $identification = $client->identifications->get($requestId); + * ``` + */ +final class ShieldLabs +{ + public const VERSION = '1.0.0'; + public const DEFAULT_BASE_URL = 'https://account.shieldlabs.ai'; + public const DEFAULT_TIMEOUT = 10.0; + public const DEFAULT_MAX_RETRIES = 2; + + private const API_KEY_PATTERN = '/^sec_[a-z0-9]{8}-[a-z0-9]{8}-[a-z0-9]{8}$/D'; + + /** History lookups by identifier. */ + public readonly History $history; + + /** Verdict for one request ID, with wait-for-verdict polling. */ + public readonly Identifications $identifications; + + /** + * Options: + * + * - api_key: Private API Key (visible ASCII characters); when omitted, SHIELDLABS_API_KEY is read from the environment + * - base_url: origin of the History API (default https://account.shieldlabs.ai, or SHIELDLABS_API_BASE_URL); a trailing "/api" is removed. It must use https; plain http is accepted for localhost, 127.0.0.1 and [::1] + * - allow_insecure_http: accept a plain http base_url on another host, for a test server (default false); the key then travels unencrypted + * - timeout: seconds per HTTP attempt (default 10); applied to the built-in client and to a discovered Guzzle or Symfony HttpClient, not to an injected http_client + * - max_retries: retries for connection errors, timeouts, 429 and 5xx (default 2) + * - http_client: a PSR-18 client, used as is; by default a discovered Guzzle or Symfony HttpClient (created with the timeout), else the built-in cURL client + * - request_factory: a PSR-17 request factory; discovered by default + * + * @param array{ + * api_key?: string|false|null, + * base_url?: string|false|null, + * allow_insecure_http?: bool, + * timeout?: int|float, + * max_retries?: int, + * http_client?: ClientInterface|null, + * request_factory?: RequestFactoryInterface|null, + * } $options + * + * @throws ValidationException when the API key is empty or cannot be sent in a header, or an option is invalid + * @throws ShieldLabsException when no HTTP client is available + */ + public function __construct(array $options = []) + { + Options::assertKnown( + $options, + ['api_key', 'base_url', 'allow_insecure_http', 'timeout', 'max_retries', 'http_client', 'request_factory', 'clock'], + 'ShieldLabs', + ); + + $apiKey = trim(Options::stringOrEnv($options, 'api_key', 'SHIELDLABS_API_KEY') ?? ''); + if ($apiKey === '') { + throw new ValidationException('A Private API Key is required: pass "api_key" or set SHIELDLABS_API_KEY.'); + } + Validate::headerValue($apiKey, 'The API key'); + if (preg_match(self::API_KEY_PATTERN, $apiKey) !== 1) { + // Logged with error_log(), once per process: never part of a response, never fatal. + Warning::once('the API key does not look like a Private API Key (sec_xxxxxxxx-xxxxxxxx-xxxxxxxx). Check that you did not pass the Public Key or the Secret Key.'); + } + + $baseUrl = BaseUrl::normalize( + Options::stringOrEnv($options, 'base_url', 'SHIELDLABS_API_BASE_URL') ?? self::DEFAULT_BASE_URL, + 'base_url', + '/api', + Options::boolean($options, 'allow_insecure_http', false), + ); + $timeout = Options::seconds($options, 'timeout', self::DEFAULT_TIMEOUT, false); + $maxRetries = Options::integer($options, 'max_retries', self::DEFAULT_MAX_RETRIES, 0); + $clock = self::clock(Options::get($options, 'clock')); + + $transport = new Transport( + HttpClientFactory::client($options['http_client'] ?? null, $timeout), + HttpClientFactory::requestFactory($options['request_factory'] ?? null), + $baseUrl, + [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Accept' => 'application/json', + 'User-Agent' => UserAgent::value(), + ], + $maxRetries, + true, + $clock, + ); + $this->history = new History($transport); + $this->identifications = new Identifications($this->history, $clock); + } + + /** + * @internal + * + * @throws ValidationException + */ + public static function clock(mixed $clock): Clock + { + if ($clock === null) { + return new SystemClock(); + } + if (!$clock instanceof Clock) { + throw new ValidationException('Option "clock" is internal and must implement ShieldLabs\Internal\Clock.'); + } + + return $clock; + } +} diff --git a/src/ShieldLabsManagement.php b/src/ShieldLabsManagement.php new file mode 100644 index 0000000..4c62b8d --- /dev/null +++ b/src/ShieldLabsManagement.php @@ -0,0 +1,147 @@ +domain = self::normalizeDomain($domain); + + $baseUrl = BaseUrl::normalize( + Options::stringOrEnv($options, 'base_url', 'SHIELDLABS_MANAGEMENT_BASE_URL') ?? self::DEFAULT_BASE_URL, + 'base_url', + '/v1', + Options::boolean($options, 'allow_insecure_http', false), + ); + $timeout = Options::seconds($options, 'timeout', ShieldLabs::DEFAULT_TIMEOUT, false); + $maxRetries = Options::integer($options, 'max_retries', ShieldLabs::DEFAULT_MAX_RETRIES, 0); + + $this->transport = new Transport( + HttpClientFactory::client($options['http_client'] ?? null, $timeout), + HttpClientFactory::requestFactory($options['request_factory'] ?? null), + $baseUrl, + [ + 'Authorization' => 'Bearer ' . $secretKey, + 'X-Shield-Domain' => $this->domain, + 'Accept' => 'application/json', + 'User-Agent' => UserAgent::value(), + ], + $maxRetries, + false, + ShieldLabs::clock(Options::get($options, 'clock')), + ); + } + + /** + * The domain as sent in X-Shield-Domain (after normalization). + */ + public function getDomain(): string + { + return $this->domain; + } + + /** + * `GET /v1/profile`: remaining identifications, masked keys and creation time. + * + * @throws ShieldLabsException + */ + public function getProfile(): DomainProfile + { + $body = $this->transport->getJson('/v1/profile'); + if (!\is_array($body)) { + throw new ApiException('The Management API returned an unexpected response body.', 200, $body); + } + + return DomainProfile::fromArray($body); + } + + /** + * Normalizes a domain the way the server stores it: trimmed, lowercase, without + * scheme, path, trailing slash or a leading "www.". The result is sent in the + * X-Shield-Domain header, so it must consist of visible ASCII characters: pass an + * international domain in its punycode form (xn--...). + * + * @throws ValidationException when nothing usable remains or the domain cannot be sent in a header + */ + public static function normalizeDomain(string $domain): string + { + $normalized = strtolower(trim($domain)); + $normalized = preg_replace('#^(?:[a-z][a-z0-9+.\-]*:)?//#', '', $normalized) ?? $normalized; + $normalized = substr($normalized, 0, strcspn($normalized, '/?#')); + if (str_starts_with($normalized, 'www.')) { + $normalized = substr($normalized, 4); + } + if ($normalized === '') { + throw new ValidationException('No domain name is left after normalization: pass the registered domain, for example example.com.'); + } + if (preg_match('/[\x80-\xFF]/', $normalized) === 1) { + throw new ValidationException('The domain must be ASCII: pass an international domain in its punycode form (xn--...).'); + } + + return Validate::headerValue($normalized, 'The domain'); + } +} diff --git a/src/SignalName.php b/src/SignalName.php new file mode 100644 index 0000000..d25cb73 --- /dev/null +++ b/src/SignalName.php @@ -0,0 +1,37 @@ +`, keyed with the + * endpoint signing secret including its `whsec_` prefix. Always verify the raw + * request body (for example `file_get_contents('php://input')`), never JSON you + * decoded and encoded again. + * + * Today each identification is delivered once per endpoint, with a 1-second timeout + * and no retries: answer with a 2xx quickly. Future retries resend identical bytes, + * so make handlers idempotent on `data.request_id`, and use the History API for + * guaranteed reads. + */ +final class Webhook +{ + public const SIGNATURE_HEADER = 'X-Shield-Signature'; + public const SCHEMA_VERSION = '2026-06-01'; + + private const SIGNATURE_PREFIX = 'sha256='; + + private function __construct() {} + + /** + * Checks the signature header against the raw body. + * + * Returns false for a missing or malformed header, an empty secret or a digest + * that does not match. Pass several secrets while you rotate an endpoint secret: + * the delivery is valid when any of them matches. + * + * @param string $payload the raw request body, byte for byte + * @param string|null $header value of the X-Shield-Signature header + * @param string|array $secret signing secret (`whsec_...`) or a list of secrets + */ + public static function verifySignature(string $payload, ?string $header, string|array $secret): bool + { + $digest = self::digest($header); + if ($digest === null) { + return false; + } + $valid = false; + foreach (self::secrets($secret) as $candidate) { + if (hash_equals(hash_hmac('sha256', $payload, $candidate), $digest)) { + $valid = true; + } + } + + return $valid; + } + + /** + * Verifies the delivery and parses it into a typed event. + * + * Unknown event types become {@see UnknownWebhookEvent} (never an error), and + * unknown schema versions are accepted. + * + * @param string $payload the raw request body, byte for byte + * @param string|null $header value of the X-Shield-Signature header + * @param string|array $secret signing secret (`whsec_...`) or a list of secrets + * + * @throws SignatureVerificationException when the signature is missing, malformed or wrong, or no secret is set + * @throws WebhookParseException when a verified body is not a valid event + */ + public static function constructEvent( + string $payload, + ?string $header, + string|array $secret, + ): IdentificationScoredEvent|WebhookPingEvent|UnknownWebhookEvent { + if (self::secrets($secret) === []) { + throw new SignatureVerificationException('No webhook signing secret is configured (expected the whsec_ value of the endpoint).'); + } + if ($header === null || trim($header) === '') { + throw new SignatureVerificationException('The X-Shield-Signature header is missing.'); + } + if (self::digest($header) === null) { + throw new SignatureVerificationException('The X-Shield-Signature header is malformed (expected sha256= followed by 64 hex characters).'); + } + if (!self::verifySignature($payload, $header, $secret)) { + throw new SignatureVerificationException('The X-Shield-Signature header does not match the request body.'); + } + + return self::parse($payload); + } + + /** + * @throws WebhookParseException + */ + private static function parse(string $payload): IdentificationScoredEvent|WebhookPingEvent|UnknownWebhookEvent + { + try { + $body = json_decode($payload, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new WebhookParseException('The webhook body is not valid JSON.', 0, $exception); + } + if (!\is_array($body) || ($body !== [] && array_is_list($body))) { + throw new WebhookParseException('The webhook body is not a JSON object.'); + } + $eventType = $body['event_type'] ?? null; + if (!\is_string($eventType) || $eventType === '') { + throw new WebhookParseException('The webhook body has no event_type.'); + } + $schemaVersion = \is_string($body['schema_version'] ?? null) ? $body['schema_version'] : ''; + $createdAt = Normalizer::parseRfc3339($body['created_at'] ?? null); + + switch ($eventType) { + case IdentificationScoredEvent::TYPE: + $data = $body['data'] ?? null; + if (!\is_array($data) || ($data !== [] && array_is_list($data))) { + throw new WebhookParseException('The identification.scored event has no data object.'); + } + + return new IdentificationScoredEvent($eventType, $schemaVersion, $createdAt, Identification::fromWebhookData($data), $body); + case WebhookPingEvent::TYPE: + return new WebhookPingEvent($eventType, $schemaVersion, $createdAt, $body); + default: + return new UnknownWebhookEvent($eventType, $schemaVersion, $createdAt, $body); + } + } + + /** + * Lowercase hex digest from the header, or null when the header is malformed. + */ + private static function digest(?string $header): ?string + { + if ($header === null) { + return null; + } + $header = trim($header); + if (!str_starts_with($header, self::SIGNATURE_PREFIX)) { + return null; + } + $digest = substr($header, \strlen(self::SIGNATURE_PREFIX)); + if (preg_match('/^[0-9a-fA-F]{64}$/D', $digest) !== 1) { + return null; + } + + return strtolower($digest); + } + + /** + * @param string|array $secret + * + * @return list the non-empty secrets + */ + private static function secrets(string|array $secret): array + { + $secrets = []; + foreach (\is_string($secret) ? [$secret] : $secret as $candidate) { + if (\is_string($candidate) && $candidate !== '') { + $secrets[] = $candidate; + } + } + + return $secrets; + } +} diff --git a/tests/Integration/CurlClientTest.php b/tests/Integration/CurlClientTest.php new file mode 100644 index 0000000..d494135 --- /dev/null +++ b/tests/Integration/CurlClientTest.php @@ -0,0 +1,261 @@ +stop(); + self::$server = null; + } + + private static function url(string $path): string + { + \assert(self::$server !== null); + + return self::$server->url . $path; + } + + public function testSendsHeadersAndParsesTheResponse(): void + { + $request = (new Request('GET', self::url('/echo?x=1'))) + ->withHeader('Authorization', 'Bearer token') + ->withHeader('Accept', 'application/json') + ->withHeader('User-Agent', 'shieldlabs-php/test'); + + $response = (new CurlClient(5.0))->sendRequest($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('OK', $response->getReasonPhrase()); + self::assertStringStartsWith('application/json', $response->getHeaderLine('Content-Type')); + self::assertSame(['one', 'two'], $response->getHeader('X-Multi')); + $echo = json_decode((string) $response->getBody(), true); + self::assertIsArray($echo); + self::assertSame('GET', $echo['method']); + self::assertSame('/echo?x=1', $echo['uri']); + self::assertIsArray($echo['headers']); + self::assertSame('Bearer token', $echo['headers']['authorization']); + self::assertSame('shieldlabs-php/test', $echo['headers']['user-agent']); + self::assertArrayNotHasKey('expect', $echo['headers']); + } + + public function testSendsARequestBody(): void + { + $request = (new Request('POST', self::url('/echo'), ['Content-Type' => 'application/json'], '{"a":1}')); + + $echo = json_decode((string) (new CurlClient(5.0))->sendRequest($request)->getBody(), true); + + self::assertIsArray($echo); + self::assertSame('POST', $echo['method']); + self::assertSame('{"a":1}', $echo['body']); + } + + public function testHandlesHeadRequests(): void + { + $response = (new CurlClient(5.0))->sendRequest(new Request('HEAD', self::url('/echo'))); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('', (string) $response->getBody()); + } + + public function testReturnsErrorStatusesAsResponses(): void + { + $response = (new CurlClient(5.0))->sendRequest(new Request('GET', self::url('/status/503'))); + + self::assertSame(503, $response->getStatusCode()); + self::assertSame('status 503', (string) $response->getBody()); + } + + public function testDoesNotFollowRedirects(): void + { + $response = (new CurlClient(5.0))->sendRequest(new Request('GET', self::url('/redirect'))); + + self::assertSame(302, $response->getStatusCode()); + self::assertSame('http://127.0.0.1:1/elsewhere', $response->getHeaderLine('Location')); + } + + public function testReportsTimeouts(): void + { + try { + (new CurlClient(0.3))->sendRequest(new Request('GET', self::url('/slow?ms=1500'))); + self::fail('Expected a timeout'); + } catch (NetworkException $exception) { + self::assertTrue($exception->isTimeout()); + self::assertSame(28, $exception->getCode()); + self::assertStringContainsString('cURL error 28', $exception->getMessage()); + self::assertSame('GET', $exception->getRequest()->getMethod()); + } + } + + public function testGivesACopyWithAnotherTimeout(): void + { + $client = new CurlClient(5.0, 2.0); + $shorter = $client->withTimeout(1.5); + + self::assertSame(5.0, $client->getTimeout()); + self::assertSame(1.5, $shorter->getTimeout()); + self::assertNotSame($client, $shorter); + self::assertSame($client, $client->withTimeout(5.0)); + self::assertSame(2.0, (new \ReflectionProperty($shorter, 'connectTimeout'))->getValue($shorter)); + + try { + (new CurlClient(5.0))->withTimeout(0.3)->sendRequest(new Request('GET', self::url('/slow?ms=800'))); + self::fail('Expected a timeout'); + } catch (NetworkException $exception) { + self::assertTrue($exception->isTimeout()); + } + + $this->expectException(ValidationException::class); + $client->withTimeout(0.0); + } + + public function testReportsConnectionFailures(): void + { + try { + (new CurlClient(2.0))->sendRequest(new Request('GET', 'http://127.0.0.1:1/')); + self::fail('Expected a connection failure'); + } catch (NetworkException $exception) { + self::assertFalse($exception->isTimeout()); + } + } + + public function testRefusesHeaderValuesWithLineBreaks(): void + { + foreach (["Bearer token\r\nX-Injected: 1", "Bearer token\n", "Bearer\0token"] as $value) { + $request = self::createStub(RequestInterface::class); + $request->method('getUri')->willReturn(new Uri(self::url('/echo'))); + $request->method('getMethod')->willReturn('GET'); + $request->method('getHeaders')->willReturn(['Authorization' => [$value]]); + $request->method('getBody')->willReturn(Stream::create('')); + + try { + (new CurlClient(5.0))->sendRequest($request); + self::fail('Expected a RequestException'); + } catch (RequestException $exception) { + self::assertStringNotContainsString('token', $exception->getMessage()); + } + } + } + + public function testRefusesOtherProtocols(): void + { + $this->expectException(NetworkException::class); + (new CurlClient(2.0))->sendRequest(new Request('GET', 'file:///etc/passwd')); + } + + public function testTheSdkWorksOutOfTheBoxOverRealHttp(): void + { + \assert(self::$server !== null); + $client = new ShieldLabs(['api_key' => Clients::API_KEY, 'base_url' => self::$server->url . '/api/']); + + $page = $client->history->search(LookupType::DeviceId, 'ac7c303d-971b-41d1-8e25-cd5b46b46aed'); + + self::assertSame(37, $page->total); + self::assertCount(5, $page->data); + self::assertSame('02f1d973-84db-4156-a7f7-e799e6bf389b', $page->data[0]->request_id); + } + + public function testTheSdkMapsARealAuthenticationError(): void + { + \assert(self::$server !== null); + $client = new ShieldLabs(['api_key' => 'sec_zzzz0000-zzzz0000-zzzz0000', 'base_url' => self::$server->url]); + + try { + $client->history->search(LookupType::Ip, '192.0.2.1'); + self::fail('Expected an AuthenticationException'); + } catch (AuthenticationException $exception) { + self::assertSame('invalid api key', $exception->getErrorMessage()); + } + } + + public function testTheSdkSendsUserHidsInCanonicalPathForm(): void + { + \assert(self::$server !== null); + $client = new ShieldLabs(['api_key' => Clients::API_KEY, 'base_url' => self::$server->url . '/capture', 'max_retries' => 0]); + + foreach ([ + ['jane.doe+test@example.com', 'jane.doe+test@example.com'], + ['a$b&c,d:e;f=g', 'a$b&c,d:e;f=g'], + ["x!'()*y", 'x%21%27%28%29%2Ay'], + ['Zoë ✓?#', 'Zo%C3%AB%20%E2%9C%93%3F%23'], + ['%2F.', '%252F.'], + ] as [$userHid, $segment]) { + try { + $client->history->search(LookupType::UserHid, $userHid); + self::fail('Expected the capture route to answer 400'); + } catch (BadRequestException $exception) { + self::assertSame('/capture/api/v1/history/user_hid/' . $segment . '?limit=20&offset=0', $exception->getErrorMessage()); + } + } + } + + /** + * While identifications->get() waits, a poll is limited to the time left but never + * to less than 1 s. The History stand-in here answers only after 1.5 s, so without + * that limit both calls would return null instead of timing out. + */ + public function testAPollIsLimitedToTheTimeLeftButAtLeastOneSecond(): void + { + $server = new PhpServer(__DIR__ . '/servers/echo.php'); + $cases = [ + 'the built-in client, no time left' => [[], 0.0, 1.0], + 'a CurlClient passed in, 1.25 s left' => [['http_client' => new CurlClient(10.0)], 1.25, 1.25], + ]; + + try { + foreach ($cases as $name => [$options, $budget, $expected]) { + $client = new ShieldLabs($options + ['api_key' => Clients::API_KEY, 'base_url' => $server->url . '/slowapi']); + $started = microtime(true); + try { + $client->identifications->get('3b241101-e2bb-4255-8caf-4136c566a962', ['timeout' => $budget]); + self::fail($name . ': expected an ApiTimeoutException'); + } catch (ApiTimeoutException) { + $elapsed = microtime(true) - $started; + self::assertGreaterThan($expected - 0.1, $elapsed, $name); + self::assertLessThan($expected + 0.2, $elapsed, $name); + } + } + } finally { + $server->stop(); + } + } + + public function testTheSdkTimeoutAppliesPerAttempt(): void + { + \assert(self::$server !== null); + $client = new ShieldLabs(['api_key' => Clients::API_KEY, 'base_url' => self::$server->url . '/slowapi', 'timeout' => 0.3, 'max_retries' => 0]); + + $this->expectException(ApiTimeoutException::class); + $client->history->search(LookupType::Ip, '192.0.2.1'); + } +} diff --git a/tests/Integration/ExampleServerTest.php b/tests/Integration/ExampleServerTest.php new file mode 100644 index 0000000..ed8e609 --- /dev/null +++ b/tests/Integration/ExampleServerTest.php @@ -0,0 +1,200 @@ + Clients::API_KEY, + 'SHIELDLABS_API_BASE_URL' => self::$api->url . '/api', + 'SHIELDLABS_WEBHOOK_SECRET' => 'whsec_previous_secret_value, ' . self::SECRET, + ]); + + self::$documentedTempDir = sys_get_temp_dir() . '/shieldlabs-example-' . bin2hex(random_bytes(6)); + mkdir(self::$documentedTempDir); + self::$documentedApp = new PhpServer( + \dirname(__DIR__, 2) . '/examples/server.php', + [ + 'SHIELDLABS_API_KEY' => 'sec_your_private_key', + 'SHIELDLABS_API_BASE_URL' => self::$api->url, + 'SHIELDLABS_WEBHOOK_SECRET' => 'whsec_your_signing_secret', + ], + // Without a php.ini, PHP prints errors into the response (display_errors=1). + ['display_errors' => '1', 'error_reporting' => '-1', 'sys_temp_dir' => self::$documentedTempDir], + ); + } + + public static function tearDownAfterClass(): void + { + self::$app?->stop(); + self::$documentedApp?->stop(); + self::$api?->stop(); + self::$app = null; + self::$documentedApp = null; + self::$api = null; + @unlink(sys_get_temp_dir() . '/shieldlabs-example-keys.json'); + @unlink(self::$documentedTempDir . '/shieldlabs-example-keys.json'); + @rmdir(self::$documentedTempDir); + } + + /** + * @param array $headers + * + * @return array{int, array, string} + */ + private static function post(string $path, string $body, array $headers = ['Content-Type' => 'application/json'], ?PhpServer $app = null): array + { + $app ??= self::$app; + \assert($app !== null); + $response = (new CurlClient(20.0))->sendRequest(new Request('POST', $app->url . $path, $headers, $body)); + $raw = (string) $response->getBody(); + $decoded = json_decode($raw, true); + + return [$response->getStatusCode(), \is_array($decoded) ? $decoded : ['raw' => $raw], $raw]; + } + + private static function log(): string + { + \assert(self::$app !== null); + + return self::$app->log(); + } + + public function testAcceptsATrustedSignupAndRefusesItsReuse(): void + { + [$status, $body] = self::post('/signup', json_encode(['requestId' => self::TRUSTED], \JSON_THROW_ON_ERROR)); + self::assertSame(200, $status, (string) json_encode($body) . self::log()); + self::assertSame(['ok' => true, 'band' => 'trusted'], $body); + + [$status, $body] = self::post('/signup', 'requestId=' . self::TRUSTED, ['Content-Type' => 'application/x-www-form-urlencoded']); + self::assertSame(403, $status); + self::assertSame('replayed', $body['reason']); + } + + /** + * @return iterable + */ + public static function refusals(): iterable + { + yield 'dangerous band' => ['22222222-2222-4222-8222-222222222222', 'blocked_band', null]; + yield 'automation flag' => ['33333333-3333-4333-8333-333333333333', 'blocked_flag', 'browser_automation']; + yield 'rate-limit marker' => ['44444444-4444-4444-8444-444444444444', 'rate_limited', null]; + yield 'stale identification' => ['55555555-5555-4555-8555-555555555555', 'stale', null]; + } + + #[DataProvider('refusals')] + public function testRefusesRiskySignups(string $requestId, string $reason, ?string $flag): void + { + [$status, $body] = self::post('/signup', json_encode(['requestId' => $requestId], \JSON_THROW_ON_ERROR)); + + self::assertSame(403, $status, (string) json_encode($body) . self::log()); + self::assertFalse($body['ok']); + self::assertSame($reason, $body['reason']); + self::assertSame($flag, $body['flag']); + } + + public function testRejectsABadRequestId(): void + { + self::assertSame(400, self::post('/signup', '{"requestId":"not-a-uuid"}')[0]); + self::assertSame(400, self::post('/signup', '{}')[0]); + self::assertSame(400, self::post('/signup', 'not json')[0]); + } + + public function testAcceptsSignedWebhooksOnceAndLogsThem(): void + { + $body = Fixtures::raw('webhook-identification-scored.raw.txt'); + $headers = ['Content-Type' => 'application/json', 'X-Shield-Signature' => 'sha256=' . hash_hmac('sha256', $body, self::SECRET)]; + + self::assertSame([200, ['received' => true]], \array_slice(self::post('/webhooks/shieldlabs', $body, $headers), 0, 2)); + self::assertSame([200, ['received' => true]], \array_slice(self::post('/webhooks/shieldlabs', $body, $headers), 0, 2)); + + $log = self::log(); + self::assertStringContainsString('identification.scored request_id=02f1d973-84db-4156-a7f7-e799e6bf389b risk_score=80 band=dangerous', $log); + self::assertStringContainsString('Duplicate delivery ignored for request_id=02f1d973-84db-4156-a7f7-e799e6bf389b', $log); + } + + public function testAcknowledgesTheVerifyPing(): void + { + $body = Fixtures::raw('webhook-ping.raw.txt'); + $headers = ['X-Shield-Signature' => 'sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8']; + + self::assertSame([200, ['received' => true]], \array_slice(self::post('/webhooks/shieldlabs', $body, $headers), 0, 2)); + self::assertStringContainsString('webhook.ping received', self::log()); + } + + public function testRejectsUnsignedOrTamperedWebhooks(): void + { + $body = Fixtures::raw('webhook-ping.raw.txt'); + + self::assertSame(401, self::post('/webhooks/shieldlabs', $body)[0]); + self::assertSame(401, self::post('/webhooks/shieldlabs', $body . ' ', ['X-Shield-Signature' => 'sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8'])[0]); + } + + public function testRejectsASignedBodyThatIsNotAnEvent(): void + { + $body = '[1,2,3]'; + + self::assertSame(400, self::post('/webhooks/shieldlabs', $body, ['X-Shield-Signature' => 'sha256=' . hash_hmac('sha256', $body, self::SECRET)])[0]); + } + + public function testAnswersNotFoundForOtherRoutes(): void + { + self::assertSame(404, self::post('/elsewhere', '{}')[0]); + } + + /** + * Started as documented (placeholder key, errors displayed), the app logs the SDK's + * key format warning to the server log and every response keeps its status code and + * its exact JSON body. + */ + public function testKeepsTheKeyWarningOutOfResponsesWhenRunAsDocumented(): void + { + $cases = [ + [self::TRUSTED, 200, ['ok' => true, 'band' => 'trusted']], + [self::TRUSTED, 403, ['ok' => false, 'reason' => 'replayed', 'band' => 'trusted', 'flag' => null]], + ['22222222-2222-4222-8222-222222222222', 403, ['ok' => false, 'reason' => 'blocked_band', 'band' => 'dangerous', 'flag' => null]], + ['44444444-4444-4444-8444-444444444444', 403, ['ok' => false, 'reason' => 'rate_limited', 'band' => 'rate_limited', 'flag' => null]], + ['not-a-uuid', 400, ['ok' => false, 'error' => 'requestId must be a UUID']], + ]; + foreach ($cases as [$requestId, $status, $expected]) { + [$actualStatus, , $raw] = self::post('/signup', json_encode(['requestId' => $requestId], \JSON_THROW_ON_ERROR), app: self::$documentedApp); + + self::assertSame($status, $actualStatus, $raw); + self::assertSame(json_encode($expected, \JSON_THROW_ON_ERROR) . "\n", $raw); + } + + $body = Fixtures::raw('webhook-ping.raw.txt'); + $signature = 'sha256=' . hash_hmac('sha256', $body, 'whsec_your_signing_secret'); + self::assertSame([200, ['received' => true], "{\"received\":true}\n"], self::post('/webhooks/shieldlabs', $body, ['X-Shield-Signature' => $signature], self::$documentedApp)); + + \assert(self::$documentedApp !== null); + self::assertStringContainsString('shieldlabs-php: the API key does not look like a Private API Key', self::$documentedApp->log()); + } +} diff --git a/tests/Integration/ReadmeSnippetsTest.php b/tests/Integration/ReadmeSnippetsTest.php new file mode 100644 index 0000000..f18f9bf --- /dev/null +++ b/tests/Integration/ReadmeSnippetsTest.php @@ -0,0 +1,185 @@ + */ + private static array $servers = []; + + public static function setUpBeforeClass(): void + { + self::$project = sys_get_temp_dir() . '/shieldlabs-readme-' . bin2hex(random_bytes(6)); + mkdir(self::$project . '/vendor', 0o755, true); + mkdir(self::$project . '/tmp'); + $autoload = \dirname(__DIR__, 2) . '/vendor/autoload.php'; + file_put_contents(self::$project . '/vendor/autoload.php', "stop(); + } + self::$servers = []; + self::$api?->stop(); + self::$api = null; + self::remove(self::$project); + } + + private static function remove(string $path): void + { + if (is_dir($path) && !is_link($path)) { + foreach (scandir($path) ?: [] as $entry) { + if ($entry !== '.' && $entry !== '..') { + self::remove($path . '/' . $entry); + } + } + @rmdir($path); + } elseif (file_exists($path) || is_link($path)) { + @unlink($path); + } + } + + /** + * @return list + */ + private static function phpBlocks(): array + { + $readme = (string) file_get_contents(\dirname(__DIR__, 2) . '/README.md'); + preg_match_all('/^```php\n(.*?)^```$/ms', $readme, $matches); + + return $matches[1]; + } + + /** + * The one complete script in README.md (it starts with the PHP open tag and loads + * the autoloader) that contains $marker. + */ + private static function script(string $marker): string + { + $scripts = array_values(array_filter( + self::phpBlocks(), + static fn(string $block): bool => str_starts_with($block, " self::$api->url], + ['display_errors' => '1', 'error_reporting' => '-1', 'sys_temp_dir' => self::$project . '/tmp'], + ); + } + + return self::$servers[$name]; + } + + /** + * @param array $headers + * + * @return array{int, string} + */ + private static function post(PhpServer $server, string $body, array $headers): array + { + $response = (new CurlClient(20.0))->sendRequest(new Request('POST', $server->url . '/', $headers, $body)); + + return [$response->getStatusCode(), (string) $response->getBody()]; + } + + /** + * Runs `php -l` on a file (no shell involved). + * + * @return array{int, string} exit code and output + */ + private static function lint(string $file): array + { + $process = proc_open([\PHP_BINARY, '-l', $file], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if ($process === false) { + throw new \RuntimeException('Could not run php -l'); + } + $output = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $output]; + } + + public function testEveryPhpBlockIsValidPhp(): void + { + $blocks = self::phpBlocks(); + self::assertGreaterThanOrEqual(5, \count($blocks)); + + foreach ($blocks as $index => $block) { + $file = self::$project . '/block-' . $index . '.php'; + file_put_contents($file, str_starts_with($block, 'get('); + $json = ['Content-Type' => 'application/json']; + $form = ['Content-Type' => 'application/x-www-form-urlencoded']; + $refused = static fn(string $reason): string => '{"error":"signup refused","reason":"' . $reason . '"}'; + $badRequest = '{"error":"requestId must be a UUID"}'; + + self::assertSame([200, '{"ok":true}'], self::post($server, '{"requestId":"11111111-1111-4111-8111-111111111111"}', $json), $server->log()); + self::assertSame([403, $refused('replayed')], self::post($server, 'requestId=11111111-1111-4111-8111-111111111111', $form)); + self::assertSame([403, $refused('blocked_band')], self::post($server, 'requestId=22222222-2222-4222-8222-222222222222', $form)); + self::assertSame([403, $refused('blocked_flag')], self::post($server, '{"requestId":"33333333-3333-4333-8333-333333333333"}', $json)); + self::assertSame([403, $refused('rate_limited')], self::post($server, '{"requestId":"44444444-4444-4444-8444-444444444444"}', $json)); + self::assertSame([403, $refused('stale')], self::post($server, 'requestId=55555555-5555-4555-8555-555555555555', $form)); + self::assertSame([400, $badRequest], self::post($server, '{"requestId":"not-a-uuid"}', $json)); + self::assertSame([400, $badRequest], self::post($server, 'requestId[]=11111111-1111-4111-8111-111111111111', $form)); + self::assertSame([400, $badRequest], self::post($server, '', $form)); + + self::assertStringContainsString('shieldlabs-php: the API key does not look like a Private API Key', $server->log()); + } + + public function testTheWebhookScriptRunsAsWritten(): void + { + $server = self::serve('webhook', 'Webhook::constructEvent('); + $scored = Fixtures::raw('webhook-identification-scored.raw.txt'); + $ping = Fixtures::raw('webhook-ping.raw.txt'); + $sign = static fn(string $payload): array => ['Content-Type' => 'application/json', 'X-Shield-Signature' => 'sha256=' . hash_hmac('sha256', $payload, self::WEBHOOK_SECRET)]; + + self::assertSame([200, ''], self::post($server, $scored, $sign($scored)), $server->log()); + self::assertSame([200, ''], self::post($server, $ping, $sign($ping))); + self::assertSame([401, ''], self::post($server, $scored, ['Content-Type' => 'application/json'])); + self::assertSame([401, ''], self::post($server, $scored . ' ', $sign($scored))); + self::assertSame([400, ''], self::post($server, '[1,2,3]', $sign('[1,2,3]'))); + } +} diff --git a/tests/Integration/servers/echo.php b/tests/Integration/servers/echo.php new file mode 100644 index 0000000..48cdc83 --- /dev/null +++ b/tests/Integration/servers/echo.php @@ -0,0 +1,59 @@ + $value) { + if (is_string($key) && str_starts_with($key, 'HTTP_')) { + $headers[strtolower(str_replace('_', '-', substr($key, 5)))] = $value; + } +} + +if ($path === '/echo') { + header('Content-Type: application/json'); + header('X-Multi: one', false); + header('X-Multi: two', false); + echo json_encode([ + 'method' => $_SERVER['REQUEST_METHOD'] ?? '', + 'uri' => $uri, + 'headers' => $headers, + 'body' => file_get_contents('php://input'), + ]); +} elseif (preg_match('#^/status/(\d{3})$#', $path, $match) === 1) { + http_response_code((int) $match[1]); + header('Content-Type: text/plain'); + echo 'status ' . $match[1]; +} elseif ($path === '/slow') { + usleep((int) ($query['ms'] ?? 1000) * 1000); + echo 'late'; +} elseif (str_starts_with($path, '/slowapi/')) { + usleep(1_500_000); + header('Content-Type: application/json'); + echo '{"data":[],"total":0}'; +} elseif (str_starts_with($uri, '/capture/')) { + // Answers 400 with the raw request target, so a test sees exactly what was sent. + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => $uri]); +} elseif ($path === '/redirect') { + header('Location: http://127.0.0.1:1/elsewhere', true, 302); +} elseif (str_starts_with($path, '/api/v1/history/')) { + if (($headers['authorization'] ?? '') !== 'Bearer sec_abcd1234-efgh5678-ijkl9012') { + http_response_code(401); + header('Content-Type: text/plain; charset=utf-8'); + echo "{\"error\":\"invalid api key\"}\n"; + } else { + header('Content-Type: application/json'); + readfile(dirname(__DIR__, 2) . '/data/history-page.json'); + } +} else { + http_response_code(404); + header('Content-Type: text/plain; charset=utf-8'); + echo '404 page not found'; +} diff --git a/tests/Integration/servers/mock-api.php b/tests/Integration/servers/mock-api.php new file mode 100644 index 0000000..7139e4c --- /dev/null +++ b/tests/Integration/servers/mock-api.php @@ -0,0 +1,40 @@ + 'Bearer ' . $key, $keys), true)) { + http_response_code(401); + echo "{\"error\":\"invalid api key\"}\n"; + + return; +} +$path = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), \PHP_URL_PATH); +if (preg_match('#^/api/v1/history/request_id/([0-9a-f-]{36})$#', $path, $match) !== 1) { + http_response_code(404); + echo '"not found"'; + + return; +} + +$page = json_decode((string) file_get_contents(dirname(__DIR__, 2) . '/data/history-page.json'), true); +$template = $page['data'][1]; +$variants = [ + '11111111-1111-4111-8111-111111111111' => ['score' => 10], + '22222222-2222-4222-8222-222222222222' => ['score' => 80], + '33333333-3333-4333-8333-333333333333' => ['score' => 45, 'is_browser_automation' => true], + '44444444-4444-4444-8444-444444444444' => ['score' => 999, 'device_id' => '00000000-0000-0000-0000-000000000000'], + '55555555-5555-4555-8555-555555555555' => ['score' => 15, 'created_at' => '2020-01-01 00:00:00.000'], +]; +$requestId = $match[1]; +if (!isset($variants[$requestId])) { + echo json_encode(['data' => [], 'total' => 0]); + + return; +} +$row = array_merge($template, ['created_at' => gmdate('Y-m-d H:i:s') . '.000'], $variants[$requestId], ['request_id' => $requestId]); +echo json_encode(['data' => [$row], 'total' => 1]); diff --git a/tests/Support/Clients.php b/tests/Support/Clients.php new file mode 100644 index 0000000..5485ccd --- /dev/null +++ b/tests/Support/Clients.php @@ -0,0 +1,59 @@ + $options + */ + public static function history(MockHttpClient $http, ?FakeClock $clock = null, array $options = []): ShieldLabs + { + /** @phpstan-ignore argument.type */ + return new ShieldLabs($options + [ + 'api_key' => self::API_KEY, + 'http_client' => $http, + 'clock' => $clock ?? new FakeClock(), + ]); + } + + /** + * @param array $options + */ + public static function management(MockHttpClient $http, ?FakeClock $clock = null, array $options = []): ShieldLabsManagement + { + /** @phpstan-ignore argument.type */ + return new ShieldLabsManagement($options + [ + 'secret_key' => self::SECRET_KEY, + 'domain' => 'example.com', + 'http_client' => $http, + 'clock' => $clock ?? new FakeClock(), + ]); + } + + /** + * A History response with one row whose request_id is $requestId. + */ + public static function rowFor(string $requestId, int $score = 10, string $createdAt = '2026-09-30 12:40:01.007'): \Psr\Http\Message\ResponseInterface + { + $page = Fixtures::json('history-page.json'); + \assert(\is_array($page['data']) && \is_array($page['data'][1])); + $row = $page['data'][1]; + $row['request_id'] = $requestId; + $row['score'] = $score; + $row['created_at'] = $createdAt; + + return MockHttpClient::json(200, ['data' => [$row], 'total' => 1]); + } +} diff --git a/tests/Support/ErrorLog.php b/tests/Support/ErrorLog.php new file mode 100644 index 0000000..2de0ede --- /dev/null +++ b/tests/Support/ErrorLog.php @@ -0,0 +1,59 @@ + + */ + public function warnings(): array + { + $warnings = []; + foreach (explode("\n", $this->contents) as $line) { + $position = strpos($line, Warning::PREFIX); + if ($position !== false) { + $warnings[] = substr($line, $position + \strlen(Warning::PREFIX)); + } + } + + return $warnings; + } +} diff --git a/tests/Support/FakeClock.php b/tests/Support/FakeClock.php new file mode 100644 index 0000000..fdb8d78 --- /dev/null +++ b/tests/Support/FakeClock.php @@ -0,0 +1,43 @@ + */ + public array $sleeps = []; + + /** + * @param float $wakesEarlyBy seconds by which a sleep longer than 1 ms ends early, the + * way a real sleep can end a little before the time asked for + */ + public function __construct(private float $time = 1000.0, private readonly float $wakesEarlyBy = 0.0) {} + + public function now(): float + { + return $this->time; + } + + public function sleep(float $seconds): void + { + $this->sleeps[] = $seconds; + $this->time += $seconds > 0.001 ? $seconds - $this->wakesEarlyBy : $seconds; + } + + public function advance(float $seconds): void + { + $this->time += $seconds; + } + + public function elapsed(float $since = 1000.0): float + { + return $this->time - $since; + } +} diff --git a/tests/Support/Fixtures.php b/tests/Support/Fixtures.php new file mode 100644 index 0000000..cf82896 --- /dev/null +++ b/tests/Support/Fixtures.php @@ -0,0 +1,36 @@ + + */ + public static function json(string $name): array + { + $decoded = json_decode(self::raw($name), true, 512, \JSON_THROW_ON_ERROR); + if (!\is_array($decoded)) { + throw new \RuntimeException('Fixture ' . $name . ' is not a JSON object'); + } + + return $decoded; + } +} diff --git a/tests/Support/MockHttpClient.php b/tests/Support/MockHttpClient.php new file mode 100644 index 0000000..87cdc84 --- /dev/null +++ b/tests/Support/MockHttpClient.php @@ -0,0 +1,133 @@ + */ + public array $requests = []; + + /** @var list */ + private array $queue = []; + + /** @var ResponseInterface|\Throwable|\Closure(RequestInterface): ResponseInterface|null */ + private ResponseInterface|\Throwable|\Closure|null $fallback = null; + + /** @var (\Closure(): void)|null */ + public ?\Closure $onRequest = null; + + /** @var list the timeout of every request sent through {@see timed()} */ + public array $timeouts = []; + + /** + * @param ResponseInterface|\Throwable|\Closure(RequestInterface): ResponseInterface ...$answers + */ + public function queue(ResponseInterface|\Throwable|\Closure ...$answers): self + { + foreach ($answers as $answer) { + $this->queue[] = $answer; + } + + return $this; + } + + /** + * @param ResponseInterface|\Throwable|\Closure(RequestInterface): ResponseInterface $answer + */ + public function always(ResponseInterface|\Throwable|\Closure $answer): self + { + $this->fallback = $answer; + + return $this; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + if ($this->onRequest !== null) { + ($this->onRequest)(); + } + $answer = array_shift($this->queue) ?? $this->fallback; + if ($answer === null) { + throw new \LogicException('MockHttpClient has no answer for ' . $request->getUri()); + } + if ($answer instanceof \Throwable) { + throw $answer; + } + if ($answer instanceof \Closure) { + return $answer($request); + } + + return $answer; + } + + /** + * This mock behind a client whose timeout the SDK can lower for one attempt, like + * the clients it creates itself. Every request records its timeout in $timeouts. + */ + public function timed(float $timeout): ConfiguredClient + { + $mock = $this; + + return new ConfiguredClient( + static fn(float $seconds): ClientInterface => new class ($mock, $seconds) implements ClientInterface { + public function __construct(private readonly MockHttpClient $mock, private readonly float $seconds) {} + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->mock->timeouts[] = $this->seconds; + + return $this->mock->sendRequest($request); + } + }, + $timeout, + ); + } + + public function lastRequest(): RequestInterface + { + $last = end($this->requests); + if ($last === false) { + throw new \LogicException('No request was sent'); + } + + return $last; + } + + /** + * @param array $headers + */ + public static function json(int $status, mixed $body, array $headers = []): ResponseInterface + { + return new Response($status, ['Content-Type' => 'application/json'] + $headers, json_encode($body, \JSON_THROW_ON_ERROR)); + } + + /** + * @param array $headers + */ + public static function text(int $status, string $body, ?string $contentType = null, array $headers = []): ResponseInterface + { + if ($contentType !== null) { + $headers['Content-Type'] = $contentType; + } + + return new Response($status, $headers, $body); + } + + public static function emptyPage(): ResponseInterface + { + return self::json(200, ['data' => [], 'total' => 0]); + } +} diff --git a/tests/Support/PhpServer.php b/tests/Support/PhpServer.php new file mode 100644 index 0000000..a5dd342 --- /dev/null +++ b/tests/Support/PhpServer.php @@ -0,0 +1,114 @@ + */ + private array $pipes = []; + + public readonly string $url; + private readonly string $logFile; + + /** + * @param array $env extra environment variables for the server + * @param array $ini ini settings; by default errors are displayed on stderr (the log), not in responses + */ + public function __construct(string $router, array $env = [], array $ini = []) + { + $port = self::freePort(); + $this->url = 'http://127.0.0.1:' . $port; + $this->logFile = tempnam(sys_get_temp_dir(), 'shieldlabs-server-') ?: sys_get_temp_dir() . '/shieldlabs-server.log'; + $environment = array_merge(self::currentEnvironment(), $env); + $command = [\PHP_BINARY]; + foreach ($ini + ['display_errors' => 'stderr'] as $name => $value) { + $command[] = '-d'; + $command[] = $name . '=' . $value; + } + array_push($command, '-S', '127.0.0.1:' . $port, $router); + $process = proc_open( + $command, + [0 => ['pipe', 'r'], 1 => ['file', $this->logFile, 'a'], 2 => ['file', $this->logFile, 'a']], + $this->pipes, + \dirname($router), + $environment, + ); + if ($process === false) { + throw new \RuntimeException('Could not start php -S'); + } + $this->process = $process; + $deadline = microtime(true) + 10; + while (microtime(true) < $deadline) { + $socket = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.2); + if ($socket !== false) { + fclose($socket); + + return; + } + usleep(50_000); + } + $this->stop(); + + throw new \RuntimeException('php -S did not start: ' . $this->log()); + } + + public function log(): string + { + return (string) @file_get_contents($this->logFile); + } + + public function stop(): void + { + if ($this->process !== null) { + foreach ($this->pipes as $pipe) { + if (\is_resource($pipe)) { + fclose($pipe); + } + } + proc_terminate($this->process); + proc_close($this->process); + $this->process = null; + @unlink($this->logFile); + } + } + + public function __destruct() + { + $this->stop(); + } + + private static function freePort(): int + { + $server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + if ($server === false) { + throw new \RuntimeException('No free port: ' . $errstr); + } + $name = (string) stream_socket_get_name($server, false); + fclose($server); + + return (int) substr($name, (int) strrpos($name, ':') + 1); + } + + /** + * @return array + */ + private static function currentEnvironment(): array + { + $environment = []; + foreach (getenv() as $name => $value) { + if (!str_starts_with((string) $name, 'SHIELDLABS_')) { + $environment[(string) $name] = (string) $value; + } + } + + return $environment; + } +} diff --git a/tests/Support/StubDiscoveryStrategy.php b/tests/Support/StubDiscoveryStrategy.php new file mode 100644 index 0000000..b3dd963 --- /dev/null +++ b/tests/Support/StubDiscoveryStrategy.php @@ -0,0 +1,24 @@ + + */ + public static function getCandidates($type): array + { + return $type === ClientInterface::class + ? [['class' => MockHttpClient::class, 'condition' => MockHttpClient::class]] + : []; + } +} diff --git a/tests/Support/Stubs/guzzle-handler-stack.php b/tests/Support/Stubs/guzzle-handler-stack.php new file mode 100644 index 0000000..53b5c7b --- /dev/null +++ b/tests/Support/Stubs/guzzle-handler-stack.php @@ -0,0 +1,18 @@ + $config + */ + public function __construct(public readonly array $config = []) + { + if (self::$rejectOptions && $config !== []) { + throw new \InvalidArgumentException('Unsupported options'); + } + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + throw new \LogicException('The stub sends no requests.'); + } +} diff --git a/tests/Support/Stubs/symfony-http-client.php b/tests/Support/Stubs/symfony-http-client.php new file mode 100644 index 0000000..5bbc30c --- /dev/null +++ b/tests/Support/Stubs/symfony-http-client.php @@ -0,0 +1,49 @@ + $defaultOptions + */ + public static function create(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50): StubHttpClient|OlderStubHttpClient + { + return self::$withoutWithOptions ? new OlderStubHttpClient($defaultOptions) : new StubHttpClient($defaultOptions); + } +} + +final class StubHttpClient +{ + /** + * @param array $options + * @param self|null $derivedFrom the client withOptions() was called on + */ + public function __construct(public readonly array $options, public readonly ?self $derivedFrom = null) {} + + /** + * @param array $options + */ + public function withOptions(array $options): self + { + return new self(array_replace($this->options, $options), $this); + } +} + +final class OlderStubHttpClient +{ + /** + * @param array $options + */ + public function __construct(public readonly array $options) {} +} diff --git a/tests/Support/Stubs/symfony-psr18-client.php b/tests/Support/Stubs/symfony-psr18-client.php new file mode 100644 index 0000000..8cce2d1 --- /dev/null +++ b/tests/Support/Stubs/symfony-psr18-client.php @@ -0,0 +1,30 @@ +expectException(ValidationException::class); + $this->expectExceptionMessage('Private API Key is required'); + new ShieldLabs(['http_client' => new MockHttpClient()]); + } + + public function testRejectsAnEmptyApiKeyEvenWhenTheEnvironmentHasOne(): void + { + putenv('SHIELDLABS_API_KEY=' . Clients::API_KEY); + + $this->expectException(ValidationException::class); + new ShieldLabs(['api_key' => ' ', 'http_client' => new MockHttpClient()]); + } + + public function testReadsTheApiKeyFromTheEnvironment(): void + { + putenv('SHIELDLABS_API_KEY=' . Clients::API_KEY); + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + (new ShieldLabs(['http_client' => $http]))->history->search(LookupType::Ip, '192.0.2.10'); + + self::assertSame('Bearer ' . Clients::API_KEY, $http->lastRequest()->getHeaderLine('Authorization')); + } + + public function testTreatsFalseFromGetenvAsNotSet(): void + { + putenv('SHIELDLABS_API_KEY=' . Clients::API_KEY); + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + (new ShieldLabs(['api_key' => false, 'base_url' => false, 'http_client' => $http]))->history->search(LookupType::Ip, '192.0.2.10'); + + self::assertSame('Bearer ' . Clients::API_KEY, $http->lastRequest()->getHeaderLine('Authorization')); + self::assertStringStartsWith('https://account.shieldlabs.ai/api/v1/', (string) $http->lastRequest()->getUri()); + } + + public function testExplainsAMissingKeyWhenGetenvReturnsFalse(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('set SHIELDLABS_API_KEY'); + new ShieldLabs(['api_key' => getenv('SHIELDLABS_API_KEY'), 'http_client' => new MockHttpClient()]); + } + + public function testTrimsTheApiKey(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + (new ShieldLabs(['api_key' => ' ' . Clients::API_KEY . "\n", 'http_client' => $http]))->history->search(LookupType::Ip, '192.0.2.10'); + + self::assertSame('Bearer ' . Clients::API_KEY, $http->lastRequest()->getHeaderLine('Authorization')); + } + + /** + * @return iterable + */ + public static function keysThatCannotBeSent(): iterable + { + yield 'line feed' => ["sec_abcd1234-efgh5678-ijkl9012\nX-Injected: 1"]; + yield 'carriage return' => ["sec_abcd1234-efgh5678\rijkl9012"]; + yield 'NUL byte' => ["sec_abcd1234\0efgh5678-ijkl9012"]; + yield 'inner space' => ['sec_abcd1234 efgh5678-ijkl9012']; + yield 'tab' => ["sec_abcd1234\tefgh5678-ijkl9012"]; + yield 'DEL' => ["sec_abcd1234\x7Fefgh5678-ijkl9012"]; + yield 'non-ASCII' => ['sec_abcd1234-efgh5678-ijkl901é']; + } + + #[DataProvider('keysThatCannotBeSent')] + public function testRejectsAKeyThatCannotBeSentInAHeader(string $key): void + { + $http = new MockHttpClient(); + + // An environment variable cannot hold a NUL byte, so that case is only passed directly. + foreach (str_contains($key, "\0") ? [['api_key' => $key]] : [['api_key' => $key], []] as $options) { + putenv('SHIELDLABS_API_KEY=' . str_replace("\0", '', $key)); + try { + new ShieldLabs($options + ['http_client' => $http]); + self::fail('Expected a ValidationException'); + } catch (ValidationException $exception) { + self::assertStringContainsString('The API key contains characters that cannot be sent in an HTTP header', $exception->getMessage()); + self::assertStringNotContainsString('abcd1234', $exception->getMessage(), 'the key is never repeated'); + self::assertNull($exception->getPrevious()); + } + } + self::assertSame([], $http->requests); + } + + public function testNeverRepeatsAHeaderValueThatThePsr7LayerRefuses(): void + { + $refused = new \InvalidArgumentException('"Bearer ' . Clients::API_KEY . '" is not valid header value.'); + $request = self::createStub(RequestInterface::class); + $request->method('withHeader')->willThrowException($refused); + $factory = self::createStub(RequestFactoryInterface::class); + $factory->method('createRequest')->willReturn($request); + $badUri = self::createStub(RequestFactoryInterface::class); + $badUri->method('createRequest')->willThrowException(new \InvalidArgumentException('Unable to parse URI')); + $http = new MockHttpClient(); + + foreach ([$factory, $badUri] as $requestFactory) { + $client = new ShieldLabs(['api_key' => Clients::API_KEY, 'http_client' => $http, 'request_factory' => $requestFactory]); + try { + $client->history->search(LookupType::Ip, '192.0.2.10'); + self::fail('Expected a ValidationException'); + } catch (ValidationException $exception) { + self::assertStringContainsString('The request could not be built', $exception->getMessage()); + self::assertStringNotContainsString(Clients::API_KEY, $exception->getMessage()); + self::assertNull($exception->getPrevious()); + } + } + self::assertSame([], $http->requests); + } + + public function testWarnsButWorksWithAnUnusualKey(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $log = ErrorLog::during(static function () use ($http): void { + $client = new ShieldLabs(['api_key' => '0123456789abcdef0123456789abcdef', 'http_client' => $http]); + $client->history->search(LookupType::Ip, '192.0.2.10'); + }); + + self::assertCount(1, $log->warnings()); + self::assertStringContainsString('does not look like a Private API Key', $log->warnings()[0]); + self::assertStringNotContainsString('0123456789abcdef', $log->contents, 'the key is never logged'); + self::assertCount(1, $http->requests); + } + + public function testLogsTheKeyWarningOncePerProcess(): void + { + $log = ErrorLog::during(static function (): void { + new ShieldLabs(['api_key' => 'sec_your_private_key', 'http_client' => new MockHttpClient()]); + new ShieldLabs(['api_key' => 'sec_your_private_key', 'http_client' => new MockHttpClient()]); + new ShieldLabs(['api_key' => 'legacy-key', 'http_client' => new MockHttpClient()]); + }); + + self::assertCount(1, $log->warnings()); + } + + public function testTheKeyWarningNeverReachesTheOutputOrAnErrorHandler(): void + { + $errors = []; + $output = ''; + $client = null; + $log = ErrorLog::during(static function () use (&$errors, &$output, &$client): void { + set_error_handler(static function (int $level, string $message) use (&$errors): bool { + $errors[] = $message; + + throw new \ErrorException($message, 0, $level); + }); + $displayErrors = ini_set('display_errors', '1'); + ob_start(); + try { + $client = new ShieldLabs(['api_key' => 'legacy-key', 'http_client' => new MockHttpClient()]); + } finally { + $output = (string) ob_get_clean(); + ini_set('display_errors', $displayErrors === false ? '' : $displayErrors); + restore_error_handler(); + } + }); + + self::assertInstanceOf(ShieldLabs::class, $client); + self::assertSame([], $errors, 'no PHP error is raised, so a framework error handler cannot turn it into an exception'); + self::assertSame('', $output, 'nothing is printed into the response'); + self::assertCount(1, $log->warnings()); + } + + public function testDoesNotWarnForAPrivateApiKey(): void + { + $log = ErrorLog::during(static function (): void { + new ShieldLabs(['api_key' => Clients::API_KEY, 'http_client' => new MockHttpClient()]); + }); + + self::assertSame('', $log->contents); + } + + /** + * @return iterable, string}> + */ + public static function invalidOptions(): iterable + { + yield 'unknown option' => [['apiKey' => 'x'], 'Unknown option "apiKey"']; + yield 'numeric key' => [[0 => 'x'], 'Unknown option "0"']; + yield 'zero timeout' => [['timeout' => 0], '"timeout" must be greater than zero']; + yield 'negative timeout' => [['timeout' => -5], '"timeout" must be greater than zero']; + yield 'timeout as string' => [['timeout' => '10'], '"timeout" must be a number']; + yield 'negative retries' => [['max_retries' => -1], '"max_retries" must be an integer']; + yield 'retries as float' => [['max_retries' => 2.0], '"max_retries" must be an integer']; + yield 'api key not a string' => [['api_key' => 123], '"api_key" must be a string']; + yield 'base url not a string' => [['base_url' => 8080], '"base_url" must be a string']; + yield 'allow_insecure_http not a boolean' => [['allow_insecure_http' => 'yes'], '"allow_insecure_http" must be a boolean']; + yield 'http client of wrong type' => [['http_client' => new \stdClass()], 'Psr\Http\Client\ClientInterface']; + yield 'request factory of wrong type' => [['request_factory' => 'factory'], 'RequestFactoryInterface']; + yield 'clock of wrong type' => [['clock' => new \stdClass()], 'internal']; + } + + /** + * @param array $options + */ + #[DataProvider('invalidOptions')] + public function testValidatesOptions(array $options, string $message): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage($message); + /** @phpstan-ignore argument.type */ + new ShieldLabs($options + ['api_key' => Clients::API_KEY, 'http_client' => new MockHttpClient()]); + } + + public function testUsesAnInjectedRequestFactory(): void + { + $factory = new class implements RequestFactoryInterface { + public int $calls = 0; + + public function createRequest(string $method, $uri): RequestInterface + { + ++$this->calls; + + return (new Psr17Factory())->createRequest($method, $uri)->withHeader('X-Test', 'yes'); + } + }; + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + (new ShieldLabs(['api_key' => Clients::API_KEY, 'http_client' => $http, 'request_factory' => $factory])) + ->history->search(LookupType::Ip, '192.0.2.10'); + + self::assertSame(1, $factory->calls); + self::assertSame('yes', $http->lastRequest()->getHeaderLine('X-Test')); + } + + public function testFallsBackToTheBuiltInCurlClient(): void + { + self::assertInstanceOf(CurlClient::class, HttpClientFactory::client(null, 3.0)); + self::assertInstanceOf(RequestFactoryInterface::class, HttpClientFactory::requestFactory(null)); + self::assertInstanceOf(ShieldLabs::class, new ShieldLabs(['api_key' => Clients::API_KEY])); + } + + public function testCurlClientRejectsNonPositiveTimeouts(): void + { + $this->expectException(ValidationException::class); + new CurlClient(0.0); + } + + public function testCurlClientRejectsARequestWithoutUri(): void + { + $this->expectException(\Psr\Http\Client\RequestExceptionInterface::class); + (new CurlClient(1.0))->sendRequest(new Request('GET', '')); + } +} diff --git a/tests/Unit/ContractFixturesTest.php b/tests/Unit/ContractFixturesTest.php new file mode 100644 index 0000000..7074816 --- /dev/null +++ b/tests/Unit/ContractFixturesTest.php @@ -0,0 +1,138 @@ +, array}> + */ + public static function normalizationCases(): iterable + { + foreach (Fixtures::json('normalization-cases.json')['cases'] as $case) { + \assert(\is_array($case) && \is_string($case['name']) && \is_string($case['source'])); + \assert(\is_array($case['input']) && \is_array($case['expected'])); + + yield $case['name'] => [$case['source'], $case['input'], $case['expected']]; + } + } + + /** + * @param array $input + * @param array $expected + */ + #[DataProvider('normalizationCases')] + public function testNormalizesToTheExpectedIdentification(string $source, array $input, array $expected): void + { + $identification = $source === 'history' + ? Identification::fromHistoryRow($input) + : Identification::fromWebhookData($input); + + self::assertSame($expected, $identification->toArray()); + self::assertSame($input, $identification->raw); + self::assertSame($source, $identification->source); + self::assertNotNull($identification->observed_at); + self::assertSame('UTC', $identification->observed_at->getTimezone()->getName()); + } + + public function testCoversBothSources(): void + { + $sources = array_count_values(array_map(static fn(array $case): string => $case[0], iterator_to_array(self::normalizationCases()))); + + self::assertSame(['history' => 5, 'webhook' => 3], $sources); + } + + /** + * @return iterable + */ + public static function slugCases(): iterable + { + foreach (Fixtures::json('signal-slug-cases.json')['cases'] as $index => $case) { + \assert(\is_array($case) && \is_string($case['description']) && \is_string($case['slug'])); + + yield $index . ': ' . $case['description'] => [$case['description'], $case['slug']]; + } + } + + #[DataProvider('slugCases')] + public function testDerivesTheSignalSlug(string $description, string $slug): void + { + self::assertSame($slug, Normalizer::signalSlug($description)); + } + + /** + * @return iterable + */ + public static function bandCases(): iterable + { + foreach (Fixtures::json('risk-band-cases.json')['cases'] as $case) { + \assert(\is_array($case) && \is_int($case['score']) && \is_string($case['band'])); + + yield 'score ' . $case['score'] => [$case['score'], $case['band']]; + } + } + + #[DataProvider('bandCases')] + public function testMapsTheScoreToItsBand(int $score, string $band): void + { + self::assertSame($band, Risk::band($score)->value); + self::assertSame(RiskBand::from($band), RiskBand::fromScore($score)); + self::assertSame($band === 'rate_limited', Risk::isRateLimited($score)); + } + + public function testParsesTheHistoryPage(): void + { + $body = Fixtures::json('history-page.json'); + $page = HistoryPage::fromArray($body); + + self::assertSame(37, $page->total); + self::assertCount(5, $page); + \assert(\is_array($body['data'])); + foreach ($page->data as $index => $identification) { + self::assertSame($body['data'][$index], $identification->raw); + self::assertSame('history', $identification->source); + } + + $expected = []; + foreach (self::normalizationCases() as [$source, , $case]) { + if ($source === 'history') { + $expected[] = $case; + } + } + self::assertSame($expected, array_map(static fn(Identification $item): array => $item->toArray(), $page->data)); + } + + public function testParsesTheEmptyHistoryPage(): void + { + $page = HistoryPage::fromArray(Fixtures::json('history-empty.json')); + + self::assertSame([], $page->data); + self::assertSame(0, $page->total); + self::assertCount(0, $page); + } + + public function testNormalizesTheManagementProfile(): void + { + $body = Fixtures::json('management-profile.json'); + $profile = DomainProfile::fromArray($body); + + self::assertSame(Fixtures::json('management-profile-expected.json'), $profile->toArray()); + self::assertSame($body, $profile->raw); + self::assertSame('', $profile->raw['Callback']); + } +} diff --git a/tests/Unit/ErrorResponsesTest.php b/tests/Unit/ErrorResponsesTest.php new file mode 100644 index 0000000..b04a1bc --- /dev/null +++ b/tests/Unit/ErrorResponsesTest.php @@ -0,0 +1,166 @@ + + */ + public static function cases(): iterable + { + foreach (Fixtures::json('error-responses.json')['cases'] as $index => $case) { + \assert(\is_array($case) && \is_string($case['surface']) && \is_int($case['status'])); + \assert(\is_string($case['body']) && \is_string($case['expected_error']) && \is_bool($case['retry'])); + \assert($case['content_type'] === null || \is_string($case['content_type'])); + + yield \sprintf('%d %s %d', $index, $case['surface'], $case['status']) => [ + $case['surface'], + $case['status'], + $case['content_type'], + $case['body'], + $case['expected_error'], + $case['retry'], + ]; + } + } + + #[DataProvider('cases')] + public function testMapsTheResponseAndRetriesOnlyWhenExpected( + string $surface, + int $status, + ?string $contentType, + string $body, + string $expectedError, + bool $retry, + ): void { + $http = (new MockHttpClient())->always(MockHttpClient::text($status, $body, $contentType)); + $clock = new FakeClock(); + $expectedClass = 'ShieldLabs\\Exception\\' . preg_replace('/Error$/', 'Exception', $expectedError); + + try { + if ($surface === 'history') { + Clients::history($http, $clock)->history->search(LookupType::RequestId, '3b241101-e2bb-4255-8caf-4136c566a962'); + } else { + Clients::management($http, $clock)->getProfile(); + } + self::fail('Expected ' . $expectedClass); + } catch (ApiException $exception) { + self::assertInstanceOf($expectedClass, $exception); + self::assertSame($status, $exception->getStatusCode()); + self::assertSame($body, $exception->getRawBody()); + self::assertStringContainsString('HTTP ' . $status, $exception->getMessage()); + } + + self::assertCount($retry ? 3 : 1, $http->requests, 'requests sent (1 + 2 retries when retried)'); + self::assertCount($retry ? 2 : 0, $clock->sleeps); + } + + public function testParsesJsonSentAsPlainText(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::text(401, "{\"error\":\"invalid api key\"}\n", 'text/plain; charset=utf-8')); + + try { + Clients::history($http)->history->search(LookupType::DeviceId, 'ac7c303d-971b-41d1-8e25-cd5b46b46aed'); + self::fail('Expected an exception'); + } catch (ApiException $exception) { + self::assertSame('invalid api key', $exception->getErrorMessage()); + self::assertSame(['error' => 'invalid api key'], $exception->getBody()); + self::assertSame('ShieldLabs API error (HTTP 401): invalid api key', $exception->getMessage()); + self::assertSame('text/plain; charset=utf-8', $exception->getHeader('Content-Type')); + self::assertSame(['text/plain; charset=utf-8'], $exception->getHeaders()['content-type']); + } + } + + /** + * @return iterable + */ + public static function bodies(): iterable + { + yield 'empty' => ['', null, '', null]; + yield 'null' => ['null', 'application/json', null, null]; + yield 'bare string' => ['"fail parse uuid"', 'application/json', 'fail parse uuid', 'fail parse uuid']; + yield 'object with message' => ['{"message":"nope"}', 'application/json', ['message' => 'nope'], 'nope']; + yield 'object without text' => ['{"code":7}', 'application/json', ['code' => 7], null]; + yield 'plain text' => ['404 page not found', 'text/plain', '404 page not found', '404 page not found']; + yield 'html' => ['Bad Gateway', 'text/html', 'Bad Gateway', null]; + yield 'number' => ['42', 'application/json', 42, null]; + } + + #[DataProvider('bodies')] + public function testParsesErrorBodiesDefensively(string $raw, ?string $contentType, mixed $body, ?string $message): void + { + $http = (new MockHttpClient())->always(MockHttpClient::text(400, $raw, $contentType)); + + try { + Clients::management($http)->getProfile(); + self::fail('Expected an exception'); + } catch (ApiException $exception) { + self::assertSame($body, $exception->getBody()); + self::assertSame($message, $exception->getErrorMessage()); + } + } + + public function testTruncatesVeryLongServerMessages(): void + { + $long = str_repeat('x', 800); + $http = (new MockHttpClient())->always(MockHttpClient::json(400, ['error' => $long])); + + try { + Clients::management($http)->getProfile(); + self::fail('Expected an exception'); + } catch (ApiException $exception) { + self::assertSame(503, \strlen((string) $exception->getErrorMessage())); + } + } + + public function testReadsRetryAfterOnRateLimits(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::json(429, ['error' => 'too many requests'], ['Retry-After' => '3'])); + + try { + Clients::management($http)->getProfile(); + self::fail('Expected an exception'); + } catch (RateLimitException $exception) { + self::assertSame(3.0, $exception->getRetryAfter()); + } + self::assertCount(1, $http->requests); + } + + public function testUsesTheBaseClassForOtherStatuses(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::text(409, 'conflict')); + + try { + Clients::management($http)->getProfile(); + self::fail('Expected an exception'); + } catch (ApiException $exception) { + self::assertSame(ApiException::class, $exception::class); + self::assertSame(409, $exception->getCode()); + } + self::assertCount(1, $http->requests); + } + + public function testMapsForbiddenToAuthentication(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::text(403, '')); + + $this->expectException(\ShieldLabs\Exception\AuthenticationException::class); + Clients::management($http)->getProfile(); + } +} diff --git a/tests/Unit/HistoryIterateTest.php b/tests/Unit/HistoryIterateTest.php new file mode 100644 index 0000000..3cb20c9 --- /dev/null +++ b/tests/Unit/HistoryIterateTest.php @@ -0,0 +1,211 @@ + $requestIds + */ + private static function page(array $requestIds, int $total): ResponseInterface + { + $rows = array_map(static fn(string $id): array => [ + 'request_id' => $id, + 'device_id' => 'ac7c303d-971b-41d1-8e25-cd5b46b46aed', + 'score' => 10, + 'created_at' => '2026-09-30 12:00:00.000', + ], $requestIds); + + return MockHttpClient::json(200, ['data' => $rows, 'total' => $total]); + } + + private static function id(int $n): string + { + return \sprintf('00000000-0000-4000-8000-%012d', $n); + } + + /** + * @param iterable $items + * + * @return list + */ + private static function ids(iterable $items): array + { + $ids = []; + foreach ($items as $item) { + $ids[] = $item->request_id; + } + + return $ids; + } + + /** + * @return list [limit, offset] of each request + */ + private static function paging(MockHttpClient $http): array + { + return array_map(static function (RequestInterface $request): array { + parse_str($request->getUri()->getQuery(), $query); + + return [(string) ($query['limit'] ?? ''), (string) ($query['offset'] ?? '')]; + }, $http->requests); + } + + public function testWalksPagesUntilTheTotal(): void + { + $http = (new MockHttpClient())->queue( + self::page([self::id(1), self::id(2)], 5), + self::page([self::id(3), self::id(4)], 5), + self::page([self::id(5)], 5), + ); + + $ids = self::ids(Clients::history($http)->history->iterate(LookupType::DeviceId, 'ac7c303d-971b-41d1-8e25-cd5b46b46aed', ['page_size' => 2])); + + self::assertSame([self::id(1), self::id(2), self::id(3), self::id(4), self::id(5)], $ids); + self::assertSame([['2', '0'], ['2', '2'], ['2', '4']], self::paging($http)); + } + + public function testDeduplicatesRowsRepeatedAcrossPages(): void + { + // A new row arrived between the two requests, so offset 2 repeats row 2. + $http = (new MockHttpClient())->queue( + self::page([self::id(1), self::id(2)], 4), + self::page([self::id(2), self::id(3)], 5), + self::page([self::id(4)], 5), + ); + + $ids = self::ids(Clients::history($http)->history->iterate(LookupType::UserHid, 'user-7', ['page_size' => 2])); + + self::assertSame([self::id(1), self::id(2), self::id(3), self::id(4)], $ids); + self::assertCount(3, $http->requests); + } + + public function testStopsAtAnEmptyPage(): void + { + $http = (new MockHttpClient())->queue( + self::page([self::id(1), self::id(2)], 10), + self::page([], 10), + ); + + $ids = self::ids(Clients::history($http)->history->iterate(LookupType::Ip, '198.51.100.7', ['page_size' => 2])); + + self::assertSame([self::id(1), self::id(2)], $ids); + self::assertCount(2, $http->requests); + } + + public function testStopsWhenTheFirstPageIsEmpty(): void + { + $http = (new MockHttpClient())->queue(MockHttpClient::emptyPage()); + + self::assertSame([], self::ids(Clients::history($http)->history->iterate(LookupType::Ip, '198.51.100.7'))); + self::assertSame([['100', '0']], self::paging($http)); + } + + public function testStopsAfterMaxItems(): void + { + $http = (new MockHttpClient())->queue( + self::page([self::id(1), self::id(2), self::id(3)], 9), + self::page([self::id(4), self::id(5), self::id(6)], 9), + ); + + $ids = self::ids(Clients::history($http)->history->iterate(LookupType::Ip, '198.51.100.7', ['page_size' => 3, 'max_items' => 4])); + + self::assertSame([self::id(1), self::id(2), self::id(3), self::id(4)], $ids); + self::assertCount(2, $http->requests); + } + + public function testYieldsNothingWithMaxItemsZero(): void + { + $http = (new MockHttpClient())->always(self::page([self::id(1), self::id(2)], 2)); + + $ids = self::ids(Clients::history($http)->history->iterate(LookupType::Ip, '198.51.100.7', ['max_items' => 0])); + + self::assertSame([], $ids); + self::assertSame([], $http->requests); + } + + public function testANullMaxItemsMeansNoLimit(): void + { + $http = (new MockHttpClient())->queue( + self::page([self::id(1), self::id(2)], 3), + self::page([self::id(3)], 3), + ); + + $ids = self::ids(Clients::history($http)->history->iterate(LookupType::Ip, '198.51.100.7', ['page_size' => 2, 'max_items' => null])); + + self::assertSame([self::id(1), self::id(2), self::id(3)], $ids); + self::assertCount(2, $http->requests); + } + + public function testIsLazy(): void + { + $http = (new MockHttpClient())->queue(self::page([self::id(1)], 1)); + + $generator = Clients::history($http)->history->iterate(LookupType::Ip, '198.51.100.7'); + self::assertSame([], $http->requests); + + self::assertSame(self::id(1), $generator->current()?->request_id); + self::assertCount(1, $http->requests); + } + + public function testKeysAreSequentialSoIteratorToArrayKeepsEveryRow(): void + { + $http = (new MockHttpClient())->queue( + self::page([self::id(1), self::id(2)], 3), + self::page([self::id(3)], 3), + ); + + $items = iterator_to_array(Clients::history($http)->history->iterate(LookupType::Ip, '198.51.100.7', ['page_size' => 2])); + + self::assertSame([0, 1, 2], array_keys($items)); + } + + public function testValidatesBeforeIterating(): void + { + $http = new MockHttpClient(); + $client = Clients::history($http); + + foreach ([ + [LookupType::DeviceId, 'not-a-uuid', []], + ['browser', 'x', []], + [LookupType::Ip, '198.51.100.7', ['page_size' => 0]], + [LookupType::Ip, '198.51.100.7', ['page_size' => 101]], + [LookupType::Ip, '198.51.100.7', ['max_items' => -1]], + [LookupType::Ip, '198.51.100.7', ['max_items' => '5']], + [LookupType::Ip, '198.51.100.7', ['limit' => 10]], + [LookupType::UserHid, 'a/b', []], + [LookupType::UserHid, '..', []], + ] as [$type, $value, $options]) { + try { + /** @phpstan-ignore argument.type */ + $client->history->iterate($type, $value, $options); + self::fail('Expected a ValidationException for ' . json_encode([$value, $options])); + } catch (ValidationException) { + self::addToAssertionCount(1); + } + } + self::assertSame([], $http->requests); + } + + public function testPropagatesErrorsFromLaterPages(): void + { + $http = (new MockHttpClient())->queue(self::page([self::id(1)], 3))->always(MockHttpClient::text(500, '')); + $generator = Clients::history($http, null, ['max_retries' => 0])->history->iterate(LookupType::Ip, '198.51.100.7', ['page_size' => 1]); + + self::assertSame(self::id(1), $generator->current()?->request_id); + $this->expectException(ServerException::class); + $generator->next(); + } +} diff --git a/tests/Unit/HistorySearchTest.php b/tests/Unit/HistorySearchTest.php new file mode 100644 index 0000000..1d126ab --- /dev/null +++ b/tests/Unit/HistorySearchTest.php @@ -0,0 +1,376 @@ +always(MockHttpClient::json(200, Fixtures::json('history-page.json'))); + + $page = Clients::history($http)->history->search(LookupType::DeviceId, 'AC7C303D-971B-41D1-8E25-CD5B46B46AED', ['limit' => 50]); + + self::assertSame(37, $page->total); + $request = $http->lastRequest(); + self::assertSame('GET', $request->getMethod()); + self::assertSame( + 'https://account.shieldlabs.ai/api/v1/history/device_id/ac7c303d-971b-41d1-8e25-cd5b46b46aed?limit=50&offset=0', + (string) $request->getUri(), + ); + self::assertSame('Bearer ' . Clients::API_KEY, $request->getHeaderLine('Authorization')); + self::assertSame('application/json', $request->getHeaderLine('Accept')); + self::assertMatchesRegularExpression('#^shieldlabs-php/1\.0\.0 \(PHP \d+\.\d+\.\d+.*; \w+\)$#', $request->getHeaderLine('User-Agent')); + self::assertSame('', (string) $request->getBody()); + } + + public function testUsesTheDefaultLimitAndOffset(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + Clients::history($http)->history->search('ip', '203.0.113.24'); + + self::assertSame('https://account.shieldlabs.ai/api/v1/history/ip/203.0.113.24?limit=20&offset=0', (string) $http->lastRequest()->getUri()); + } + + public function testEscapesTheUserHidAsOnePathSegment(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + Clients::history($http)->history->search(LookupType::UserHid, 'Team A:42 ✓?', ['limit' => 100, 'offset' => 200]); + + self::assertSame( + 'https://account.shieldlabs.ai/api/v1/history/user_hid/Team%20A:42%20%E2%9C%93%3F?limit=100&offset=200', + (string) $http->lastRequest()->getUri(), + ); + } + + /** + * The History API decodes the value only in canonical path form: A-Z a-z 0-9 - . _ ~ + * and $ & + , : ; = @ unescaped, every other byte as uppercase %XX. + * + * @return iterable + */ + public static function canonicalUserHids(): iterable + { + yield 'email address' => ['jane.doe+test@example.com', 'jane.doe+test@example.com']; + yield 'base64 with padding' => ['dXNlcjE+Lw==', 'dXNlcjE+Lw==']; + yield 'reserved characters kept' => ['a$b&c+d,e:f;g=h@i', 'a$b&c+d,e:f;g=h@i']; + yield 'unreserved marks kept' => ['A-z_0.9~', 'A-z_0.9~']; + yield 'sub-delimiters escaped' => ["x!'()*y", 'x%21%27%28%29%2Ay']; + yield 'space and question mark' => ['a b?c', 'a%20b%3Fc']; + yield 'hash, percent and backslash' => ['#50%\\x', '%2350%25%5Cx']; + yield 'escaped-looking text stays literal' => ['%2F%40', '%252F%2540']; + yield 'UTF-8 bytes in uppercase hex' => ['Zoë ✓', 'Zo%C3%AB%20%E2%9C%93']; + yield 'three dots' => ['...', '...']; + yield 'leading dot' => ['.hidden', '.hidden']; + yield 'hex User HID' => [str_repeat('0a', 32), str_repeat('0a', 32)]; + } + + #[DataProvider('canonicalUserHids')] + public function testSendsUserHidsInCanonicalPathForm(string $userHid, string $segment): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $client = Clients::history($http); + + $client->history->search(LookupType::UserHid, $userHid); + iterator_to_array($client->history->iterate('user_hid', $userHid)); + + self::assertCount(2, $http->requests); + foreach ($http->requests as $request) { + self::assertSame('/api/v1/history/user_hid/' . $segment, $request->getUri()->getPath()); + } + } + + /** + * Every byte value, checked against the rule itself: A-Z a-z 0-9 - . _ ~ and + * $ & + , : ; = @ are sent as they are, every other byte as %XX in uppercase. + */ + public function testEscapesEveryByteInCanonicalPathForm(): void + { + $plain = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~$&+,:;=@'; + $userHid = ''; + $expected = ''; + for ($byte = 0; $byte < 256; ++$byte) { + $char = \chr($byte); + if ($char === '/') { + continue; // refused before anything is sent + } + $userHid .= $char; + $expected .= str_contains($plain, $char) ? $char : \sprintf('%%%02X', $byte); + } + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + Clients::history($http)->history->search(LookupType::UserHid, $userHid); + + self::assertSame('/api/v1/history/user_hid/' . $expected, $http->lastRequest()->getUri()->getPath()); + self::assertSame(\strlen($plain) + 3 * (255 - \strlen($plain)), \strlen($expected)); + } + + public function testKeepsSentinelUserHidsAsStrings(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + Clients::history($http)->history->search(LookupType::UserHid, 'anonymous'); + + self::assertStringContainsString('/user_hid/anonymous?', (string) $http->lastRequest()->getUri()); + } + + public function testAcceptsTheNilUuidAndAnyVersion(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $client = Clients::history($http); + + $client->history->search(LookupType::VisitorId, '00000000-0000-0000-0000-000000000000'); + $client->history->search(LookupType::SessionId, 'bde0e249-20d8-5544-838c-ed9a0b6d7a36'); + $client->history->search(LookupType::CookieId, '4449bb58-590c-144c-ae1f-d1ddc768dbdd'); + + self::assertCount(3, $http->requests); + } + + /** + * @return iterable, string}> + */ + public static function invalidLookups(): iterable + { + yield 'unknown type' => ['auto', '203.0.113.24', [], 'Unknown lookup type "auto"']; + yield 'empty type' => ['', '203.0.113.24', [], 'Unknown lookup type']; + yield 'uppercase type' => ['DEVICE_ID', 'ac7c303d-971b-41d1-8e25-cd5b46b46aed', [], 'Unknown lookup type']; + yield 'bad UUID' => [LookupType::DeviceId, 'abc', [], 'must be a UUID']; + yield 'UUID without dashes' => [LookupType::RequestId, 'ac7c303d971b41d18e25cd5b46b46aed', [], 'must be a UUID']; + yield 'UUID in braces' => [LookupType::VisitorId, '{ac7c303d-971b-41d1-8e25-cd5b46b46aed}', [], 'must be a UUID']; + yield 'UUID with newline' => [LookupType::RequestId, "ac7c303d-971b-41d1-8e25-cd5b46b46aed\n", [], 'must be a UUID']; + yield 'IPv6' => [LookupType::Ip, '2001:db8::1', [], 'IPv6']; + yield 'IPv4 out of range' => [LookupType::Ip, '203.0.113.256', [], 'IPv4']; + yield 'IPv4 with spaces' => [LookupType::Ip, ' 203.0.113.5', [], 'IPv4']; + yield 'empty user_hid' => [LookupType::UserHid, '', [], 'non-empty']; + yield 'user_hid with a slash' => [LookupType::UserHid, 'team/42', [], 'cannot contain "/"']; + yield 'user_hid that is a slash' => ['user_hid', '/', [], 'cannot contain "/"']; + yield 'user_hid with a trailing slash' => [LookupType::UserHid, 'user-7/', [], 'cannot contain "/"']; + yield 'user_hid "."' => [LookupType::UserHid, '.', [], 'cannot be "." or ".."']; + yield 'user_hid ".."' => ['user_hid', '..', [], 'cannot be "." or ".."']; + yield 'limit 0' => [LookupType::Ip, '203.0.113.24', ['limit' => 0], 'from 1 to 100']; + yield 'limit 101' => [LookupType::Ip, '203.0.113.24', ['limit' => 101], 'from 1 to 100']; + yield 'limit as string' => [LookupType::Ip, '203.0.113.24', ['limit' => '50'], 'integer']; + yield 'negative offset' => [LookupType::Ip, '203.0.113.24', ['offset' => -1], 'greater than or equal to 0']; + yield 'unknown option' => [LookupType::Ip, '203.0.113.24', ['page' => 2], 'Unknown option "page"']; + } + + /** + * @param array $options + */ + #[DataProvider('invalidLookups')] + public function testRejectsInvalidLookupsWithoutSendingAnything(LookupType|string $type, string $value, array $options, string $message): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + try { + /** @phpstan-ignore argument.type */ + Clients::history($http)->history->search($type, $value, $options); + self::fail('Expected a ValidationException'); + } catch (ValidationException $exception) { + self::assertStringContainsString($message, $exception->getMessage()); + } + self::assertSame([], $http->requests); + } + + /** + * @return iterable + */ + public static function baseUrls(): iterable + { + yield 'origin' => ['https://account.shieldlabs.ai', 'https://account.shieldlabs.ai/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'trailing slash' => ['https://account.shieldlabs.ai/', 'https://account.shieldlabs.ai/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'api suffix' => ['https://account.shieldlabs.ai/api', 'https://account.shieldlabs.ai/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'api suffix with slash' => ['https://dev.account.shieldlabs.ai/api/', 'https://dev.account.shieldlabs.ai/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'path prefix' => ['https://proxy.example.com/shieldlabs/api', 'https://proxy.example.com/shieldlabs/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'host named api' => ['https://api', 'https://api/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'local port' => ['http://127.0.0.1:8080', 'http://127.0.0.1:8080/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'other loopback address' => ['http://127.0.0.2/api', 'http://127.0.0.2/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'localhost' => ['http://localhost:8080/', 'http://localhost:8080/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'subdomain of localhost' => ['http://mock.localhost', 'http://mock.localhost/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'IPv6 loopback' => ['http://[::1]:8080/api', 'http://[::1]:8080/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'IPv6 loopback, long form' => ['http://[0:0:0:0:0:0:0:1]', 'http://[0:0:0:0:0:0:0:1]/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + yield 'uppercase scheme' => ['HTTPS://account.shieldlabs.ai', 'https://account.shieldlabs.ai/api/v1/history/ip/192.0.2.1?limit=20&offset=0']; + } + + #[DataProvider('baseUrls')] + public function testNeverBuildsApiApiPaths(string $baseUrl, string $expected): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + Clients::history($http, null, ['base_url' => $baseUrl])->history->search(LookupType::Ip, '192.0.2.1'); + + self::assertSame($expected, (string) $http->lastRequest()->getUri()); + } + + /** + * @return iterable + */ + public static function invalidBaseUrls(): iterable + { + yield 'no scheme' => ['account.shieldlabs.ai']; + yield 'ftp' => ['ftp://account.shieldlabs.ai']; + yield 'query' => ['https://account.shieldlabs.ai?x=1']; + yield 'credentials' => ['https://user:pass@account.shieldlabs.ai']; + yield 'password only' => ['https://:secret@account.shieldlabs.ai']; + yield 'fragment' => ['https://account.shieldlabs.ai#x']; + yield 'empty' => ['']; + yield 'line break inside' => ["https://account.shield\nlabs.ai"]; + yield 'space in the host' => ['https://account shieldlabs.ai']; + yield 'escaped host' => ['https://account.shieldl%61bs.ai']; + yield 'not ASCII' => ['https://bücher.example']; + yield 'plain http to a remote host' => ['http://account.shieldlabs.ai']; + yield 'plain http to a name that starts like localhost' => ['http://localhost.example.com']; + yield 'plain http to a name that starts like a loopback address' => ['http://127.0.0.1.example.com']; + yield 'plain http to another address' => ['http://192.0.2.10:8080']; + } + + #[DataProvider('invalidBaseUrls')] + public function testRejectsInvalidBaseUrls(string $baseUrl): void + { + $this->expectException(ValidationException::class); + Clients::history(new MockHttpClient(), null, ['base_url' => $baseUrl]); + } + + public function testExplainsWhyPlainHttpIsRefused(): void + { + foreach (['base_url' => 'http://account.shieldlabs.ai', 'env' => 'http://mock.example.test:8080'] as $source => $url) { + putenv($source === 'env' ? 'SHIELDLABS_API_BASE_URL=' . $url : 'SHIELDLABS_API_BASE_URL'); + try { + Clients::history(new MockHttpClient(), null, $source === 'env' ? [] : ['base_url' => $url]); + self::fail('Expected a ValidationException'); + } catch (ValidationException $exception) { + self::assertStringContainsString('must be an https URL', $exception->getMessage()); + self::assertStringContainsString('"allow_insecure_http" => true', $exception->getMessage()); + } finally { + putenv('SHIELDLABS_API_BASE_URL'); + } + } + } + + public function testAcceptsPlainHttpToAnotherHostOnlyWhenAskedTo(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $log = ErrorLog::during(static function () use ($http): void { + $options = ['base_url' => 'http://shieldlabs-mock:8080/api', 'allow_insecure_http' => true]; + Clients::history($http, null, $options)->history->search(LookupType::Ip, '192.0.2.1'); + Clients::history($http, null, $options)->history->search(LookupType::Ip, '192.0.2.1'); + }); + + self::assertSame('http://shieldlabs-mock:8080/api/v1/history/ip/192.0.2.1?limit=20&offset=0', (string) $http->lastRequest()->getUri()); + self::assertCount(1, $log->warnings(), 'warned once'); + self::assertStringContainsString('uses plain http', $log->warnings()[0]); + self::assertStringNotContainsString(Clients::API_KEY, $log->contents); + } + + public function testHttpsNeedsNoOptIn(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $log = ErrorLog::during(static function () use ($http): void { + Clients::history($http, null, ['base_url' => 'https://proxy.example.com', 'allow_insecure_http' => true])->history->search(LookupType::Ip, '192.0.2.1'); + Clients::history($http, null, ['base_url' => 'http://127.0.0.1:9'])->history->search(LookupType::Ip, '192.0.2.1'); + }); + + self::assertSame('', $log->contents); + self::assertCount(2, $http->requests); + } + + public function testReadsTheBaseUrlFromTheEnvironment(): void + { + putenv('SHIELDLABS_API_BASE_URL=https://dev.account.shieldlabs.ai/api'); + try { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + Clients::history($http)->history->search(LookupType::Ip, '192.0.2.1'); + self::assertStringStartsWith('https://dev.account.shieldlabs.ai/api/v1/history/', (string) $http->lastRequest()->getUri()); + } finally { + putenv('SHIELDLABS_API_BASE_URL'); + } + } + + public function testPrefersTheOptionOverTheEnvironment(): void + { + putenv('SHIELDLABS_API_BASE_URL=https://dev.account.shieldlabs.ai'); + try { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + Clients::history($http, null, ['base_url' => 'http://127.0.0.1:9'])->history->search(LookupType::Ip, '192.0.2.1'); + self::assertStringStartsWith('http://127.0.0.1:9/api/v1/history/', (string) $http->lastRequest()->getUri()); + } finally { + putenv('SHIELDLABS_API_BASE_URL'); + } + } + + public function testRejectsAnUnexpectedSuccessBody(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::json(200, 'oops')); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('unexpected response body'); + Clients::history($http)->history->search(LookupType::Ip, '192.0.2.1'); + } + + public function testRejectsASuccessBodyThatIsNotJson(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::text(200, 'maintenance', 'text/html')); + + try { + Clients::history($http)->history->search(LookupType::Ip, '192.0.2.1'); + self::fail('Expected an ApiException'); + } catch (ApiException $exception) { + self::assertSame(200, $exception->getStatusCode()); + self::assertStringContainsString('not valid JSON', $exception->getMessage()); + } + self::assertCount(1, $http->requests); + } + + public function testToleratesOddPageShapes(): void + { + $http = (new MockHttpClient())->queue( + MockHttpClient::json(200, ['data' => [['request_id' => '3b241101-e2bb-4255-8caf-4136c566a962'], 'junk', 7], 'total' => 2.0]), + MockHttpClient::json(200, ['data' => 'nope']), + MockHttpClient::json(200, ['total' => 'x']), + ); + $client = Clients::history($http); + + $page = $client->history->search(LookupType::Ip, '192.0.2.1'); + self::assertCount(1, $page->data); + self::assertSame(2, $page->total); + self::assertSame('3b241101-e2bb-4255-8caf-4136c566a962', $page->data[0]->request_id); + + self::assertSame(0, $client->history->search(LookupType::Ip, '192.0.2.1')->total); + self::assertSame(0, $client->history->search(LookupType::Ip, '192.0.2.1')->total); + } + + public function testIteratesAPage(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::json(200, Fixtures::json('history-page.json'))); + + $page = Clients::history($http)->history->search(LookupType::Ip, '192.0.2.1'); + $ids = []; + foreach ($page as $identification) { + $ids[] = $identification->request_id; + } + + self::assertCount(5, $ids); + self::assertSame('02f1d973-84db-4156-a7f7-e799e6bf389b', $ids[0]); + } + + public function testVersionConstantMatchesTheChangelog(): void + { + $changelog = (string) file_get_contents(\dirname(__DIR__, 2) . '/CHANGELOG.md'); + + self::assertStringContainsString('## [' . ShieldLabs::VERSION . '] - 2026-09-30', $changelog); + } +} diff --git a/tests/Unit/HttpClientFactoryTest.php b/tests/Unit/HttpClientFactoryTest.php new file mode 100644 index 0000000..c0c7a19 --- /dev/null +++ b/tests/Unit/HttpClientFactoryTest.php @@ -0,0 +1,300 @@ +history : $client; + $transport = (new \ReflectionProperty($owner, 'transport'))->getValue($owner); + \assert(\is_object($transport)); + $http = (new \ReflectionProperty($transport, 'client'))->getValue($transport); + \assert($http instanceof ClientInterface); + + return $http; + } + + private static function curlTimeout(ClientInterface $client): mixed + { + self::assertInstanceOf(CurlClient::class, $client); + + return (new \ReflectionProperty($client, 'timeout'))->getValue($client); + } + + /** + * The Guzzle stand-in inside a client the SDK configured, and its timeout. + * + * @return array{GuzzleClient, float} + */ + private static function guzzleOf(ClientInterface $client): array + { + self::assertInstanceOf(ConfiguredClient::class, $client); + self::assertInstanceOf(GuzzleClient::class, $client->client); + + return [$client->client, $client->getTimeout()]; + } + + /** + * The Symfony HttpClient stand-in inside a client the SDK configured. + */ + private static function symfonyOf(ClientInterface $client): StubHttpClient|OlderStubHttpClient + { + self::assertInstanceOf(ConfiguredClient::class, $client); + self::assertInstanceOf(SymfonyPsr18Client::class, $client->client); + self::assertNotNull($client->client->responseFactory); + self::assertNotNull($client->client->streamFactory); + $httpClient = $client->client->client; + self::assertTrue($httpClient instanceof StubHttpClient || $httpClient instanceof OlderStubHttpClient); + + return $httpClient; + } + + public function testKeepsAnInjectedClientAsIs(): void + { + $http = new MockHttpClient(); + + self::assertSame($http, HttpClientFactory::client($http, 3.0)); + self::assertSame($http, HttpClientFactory::client($http, 3.0, false)); + } + + public function testUsesTheBuiltInClientWithTheTimeoutWhenNoClientIsDiscovered(): void + { + $log = ErrorLog::during(static function (): void { + self::assertSame(3.0, self::curlTimeout(HttpClientFactory::client(null, 3.0))); + self::assertSame(4.5, self::curlTimeout(self::httpClientOf(new ShieldLabs(['api_key' => Clients::API_KEY, 'timeout' => 4.5])))); + }); + + self::assertSame('', $log->contents); + } + + public function testFailsWhenNoClientIsDiscoveredAndCurlIsMissing(): void + { + $this->expectException(ShieldLabsException::class); + $this->expectExceptionMessage('No PSR-18 HTTP client was found and ext-curl is not loaded'); + + HttpClientFactory::client(null, 3.0, false); + } + + public function testPrefersTheBuiltInClientOverADiscoveredClientItCannotConfigure(): void + { + $log = ErrorLog::during(static function (): void { + self::withUnknownDiscoveredClient(static function (): void { + self::assertSame(3.0, self::curlTimeout(HttpClientFactory::client(null, 3.0))); + }); + }); + + self::assertSame('', $log->contents); + } + + public function testUsesADiscoveredClientItCannotConfigureWithoutCurlAndWarnsOnce(): void + { + $log = ErrorLog::during(static function (): void { + self::withUnknownDiscoveredClient(static function (): void { + self::assertInstanceOf(MockHttpClient::class, HttpClientFactory::client(null, 3.0, false)); + self::assertInstanceOf(MockHttpClient::class, HttpClientFactory::client(null, 3.0, false)); + }); + }); + + $warnings = $log->warnings(); + self::assertCount(1, $warnings); + self::assertStringContainsString('the "timeout" option cannot be applied to the discovered HTTP client ' . MockHttpClient::class, $warnings[0]); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testCreatesADiscoveredGuzzleClientWithTheTimeout(): void + { + self::loadStubs('guzzle'); + + $client = null; + $log = ErrorLog::during(static function () use (&$client): void { + $client = HttpClientFactory::client(null, 3.0); + }); + + \assert($client instanceof ClientInterface); + [$guzzle, $timeout] = self::guzzleOf($client); + self::assertSame(3.0, $timeout); + self::assertSame(['timeout' => 3.0, 'connect_timeout' => 3.0], $guzzle->config); + self::assertSame('', $log->contents); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testCreatesTheGuzzleClientAgainWithAShorterTimeoutForOnePoll(): void + { + self::loadStubs('guzzle'); + $client = HttpClientFactory::client(null, 3.0); + self::assertInstanceOf(ConfiguredClient::class, $client); + + $shorter = $client->withTimeout(1.5); + + [$guzzle, $timeout] = self::guzzleOf($shorter); + self::assertSame(1.5, $timeout); + self::assertSame(['timeout' => 1.5, 'connect_timeout' => 1.5], $guzzle->config); + self::assertSame(['timeout' => 3.0, 'connect_timeout' => 3.0], self::guzzleOf($client)[0]->config); + self::assertSame($client, $client->withTimeout(3.0)); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testGuzzleClientsWithAnotherTimeoutShareOneHandlerStack(): void + { + self::loadStubs('guzzle', 'guzzle-handler-stack'); + $client = HttpClientFactory::client(null, 3.0); + self::assertInstanceOf(ConfiguredClient::class, $client); + + $config = self::guzzleOf($client)[0]->config; + $shorter = self::guzzleOf($client->withTimeout(1.5))[0]->config; + + self::assertInstanceOf(HandlerStack::class, $config['handler']); + self::assertSame($config['handler'], $shorter['handler']); + self::assertSame([3.0, 3.0], [$config['timeout'], $config['connect_timeout']]); + self::assertSame([1.5, 1.5], [$shorter['timeout'], $shorter['connect_timeout']]); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testPassesTheTimeoutOptionOfBothClientsToAGuzzleClient(): void + { + self::loadStubs('guzzle'); + + $history = self::httpClientOf(new ShieldLabs(['api_key' => Clients::API_KEY, 'timeout' => 4])); + $management = self::httpClientOf(new ShieldLabsManagement(['secret_key' => Clients::SECRET_KEY, 'domain' => 'example.com', 'timeout' => 2.5])); + + self::assertSame(['timeout' => 4.0, 'connect_timeout' => 4.0], self::guzzleOf($history)[0]->config); + self::assertSame(['timeout' => 2.5, 'connect_timeout' => 2.5], self::guzzleOf($management)[0]->config); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testCreatesADiscoveredSymfonyClientWithTheTimeout(): void + { + self::loadStubs('symfony-psr18-client', 'symfony-http-client'); + + $client = null; + $log = ErrorLog::during(static function () use (&$client): void { + $client = HttpClientFactory::client(null, 3.0); + }); + + \assert($client instanceof ClientInterface); + self::assertSame(['timeout' => 3.0, 'max_duration' => 3.0, 'max_redirects' => 0], self::symfonyOf($client)->options); + self::assertSame('', $log->contents); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testDerivesTheSymfonyClientWithAShorterTimeoutForOnePoll(): void + { + self::loadStubs('symfony-psr18-client', 'symfony-http-client'); + $client = HttpClientFactory::client(null, 3.0); + self::assertInstanceOf(ConfiguredClient::class, $client); + + $base = self::symfonyOf($client); + $shorter = self::symfonyOf($client->withTimeout(1.5)); + + self::assertInstanceOf(StubHttpClient::class, $shorter); + self::assertSame($base, $shorter->derivedFrom); + self::assertSame(['timeout' => 1.5, 'max_duration' => 1.5, 'max_redirects' => 0], $shorter->options); + self::assertSame(['timeout' => 3.0, 'max_duration' => 3.0, 'max_redirects' => 0], $base->options); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testCreatesAnotherSymfonyClientWhenWithOptionsIsMissing(): void + { + self::loadStubs('symfony-psr18-client', 'symfony-http-client'); + SymfonyHttpClient::$withoutWithOptions = true; + $client = HttpClientFactory::client(null, 3.0); + self::assertInstanceOf(ConfiguredClient::class, $client); + + $shorter = self::symfonyOf($client->withTimeout(1.5)); + + self::assertInstanceOf(OlderStubHttpClient::class, $shorter); + self::assertNotSame(self::symfonyOf($client), $shorter); + self::assertSame(['timeout' => 1.5, 'max_duration' => 1.5, 'max_redirects' => 0], $shorter->options); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testFallsBackToTheBuiltInClientWhenGuzzleRejectsTheOptions(): void + { + self::loadStubs('guzzle'); + GuzzleClient::$rejectOptions = true; + + self::assertSame(3.0, self::curlTimeout(HttpClientFactory::client(null, 3.0))); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testFallsBackToTheBuiltInClientWhenTheSymfonyFactoryIsMissing(): void + { + self::loadStubs('symfony-psr18-client'); + + self::assertSame(3.0, self::curlTimeout(HttpClientFactory::client(null, 3.0))); + } +} diff --git a/tests/Unit/IdentificationsGetTest.php b/tests/Unit/IdentificationsGetTest.php new file mode 100644 index 0000000..6f69032 --- /dev/null +++ b/tests/Unit/IdentificationsGetTest.php @@ -0,0 +1,722 @@ + 'too many requests'], $retryAfter === null ? [] : ['Retry-After' => $retryAfter]); + } + + public function testReadsOnceWithoutWaiting(): void + { + $http = (new MockHttpClient())->queue(Clients::rowFor(self::REQUEST_ID)); + $clock = new FakeClock(); + + $identification = Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['wait' => false]); + + self::assertSame(self::REQUEST_ID, $identification?->request_id); + self::assertSame( + 'https://account.shieldlabs.ai/api/v1/history/request_id/' . self::REQUEST_ID . '?limit=1&offset=0', + (string) $http->lastRequest()->getUri(), + ); + self::assertSame([], $clock->sleeps); + } + + public function testReturnsNullWithoutWaitingWhenThereIsNoRow(): void + { + $http = (new MockHttpClient())->queue(MockHttpClient::emptyPage()); + $clock = new FakeClock(); + + self::assertNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['wait' => false])); + self::assertCount(1, $http->requests); + self::assertSame([], $clock->sleeps); + } + + public function testASingleLookupKeepsTheUsualRetries(): void + { + $http = (new MockHttpClient())->queue(MockHttpClient::json(503, ['error' => 'busy']), Clients::rowFor(self::REQUEST_ID)); + $clock = new FakeClock(); + + $identification = Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['wait' => false]); + + self::assertSame(self::REQUEST_ID, $identification?->request_id); + self::assertCount(2, $http->requests); + self::assertCount(1, $clock->sleeps); + self::assertGreaterThanOrEqual(0.25, $clock->sleeps[0]); + self::assertLessThanOrEqual(0.5, $clock->sleeps[0]); + } + + public function testSendsTheRequestIdInLowercase(): void + { + $http = (new MockHttpClient())->queue(Clients::rowFor(self::REQUEST_ID)); + + Clients::history($http)->identifications->get(strtoupper(self::REQUEST_ID)); + + self::assertStringContainsString('/request_id/' . self::REQUEST_ID . '?', (string) $http->lastRequest()->getUri()); + } + + public function testPollsWithTheBackoffScheduleUntilTheRowAppears(): void + { + $http = (new MockHttpClient())->queue( + MockHttpClient::emptyPage(), + MockHttpClient::emptyPage(), + MockHttpClient::emptyPage(), + MockHttpClient::emptyPage(), + Clients::rowFor(self::REQUEST_ID), + ); + $clock = new FakeClock(); + + $identification = Clients::history($http, $clock)->identifications->get(self::REQUEST_ID); + + self::assertSame(self::REQUEST_ID, $identification?->request_id); + self::assertSame([0.25, 0.5, 1.0, 1.5], $clock->sleeps); + self::assertCount(5, $http->requests); + } + + public function testReturnsNullAfterTheLastPollAtTheDeadline(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $clock = new FakeClock(); + + self::assertNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID)); + + self::assertSame(self::DEFAULT_SLEEPS, $clock->sleeps); + self::assertEqualsWithDelta(10.0, $clock->elapsed(), 1e-9); + self::assertCount(9, $http->requests); + } + + public function testPollsOnlyOnceAtTheDeadlineWhenASleepEndsSlightlyEarly(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $clock = new FakeClock(wakesEarlyBy: 0.000001); + + self::assertNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID)); + + self::assertCount(9, $http->requests); + self::assertEqualsWithDelta(10.0, $clock->elapsed(), 0.0001); + } + + /** + * @return iterable}> + */ + public static function customSchedules(): iterable + { + // [poll_interval, timeout, sleeps]: the waits are p, 2p, 4p, 6p, 8p, then 8p, each at most + // max(2 s, p), and the last one ends at the deadline. + yield 'default 250 ms in 10 s' => [0.25, 10, [0.25, 0.5, 1.0, 1.5, 2.0, 2.0, 2.0, 0.75]]; + yield '1 s in 10 s' => [1, 10, [1.0, 2.0, 2.0, 2.0, 2.0, 1.0]]; + yield '3 s in 10 s, longer than 2 s and used as it is' => [3, 10, [3.0, 3.0, 3.0, 1.0]]; + yield '500 ms in 3 s' => [0.5, 3, [0.5, 1.0, 1.5]]; + yield '125 ms in 3 s' => [0.125, 3, [0.125, 0.25, 0.5, 0.75, 1.0, 0.375]]; + yield '3 s in 5 s' => [3, 5, [3.0, 2.0]]; + } + + /** + * @param list $sleeps + */ + #[DataProvider('customSchedules')] + public function testHonoursACustomTimeoutAndPollInterval(int|float $pollInterval, int|float $timeout, array $sleeps): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $clock = new FakeClock(); + + self::assertNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => $timeout, 'poll_interval' => $pollInterval])); + + self::assertSame($sleeps, $clock->sleeps); + self::assertCount(\count($sleeps) + 1, $http->requests); + self::assertEqualsWithDelta((float) $timeout, $clock->elapsed(), 1e-9); + } + + /** + * A budget that is not a sum of the waits: exactly one poll runs at the deadline, + * and what it finds decides the result. + * + * @return iterable}> + */ + public static function lastPolls(): iterable + { + yield 'nothing yet' => [MockHttpClient::emptyPage(), null, null]; + yield 'the row' => [Clients::rowFor(self::REQUEST_ID), self::REQUEST_ID, null]; + yield 'a server error' => [MockHttpClient::json(503, ['error' => 'busy']), null, ServerException::class]; + } + + /** + * @param class-string|null $exception + */ + #[DataProvider('lastPolls')] + public function testTheLastPollRunsAtTheDeadline(ResponseInterface $last, ?string $requestId, ?string $exception): void + { + $empty = MockHttpClient::emptyPage(); + $http = (new MockHttpClient())->queue($empty, $empty, $empty, $empty, $last); + $clock = new FakeClock(); + + try { + $identification = Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => 2.3]); + self::assertNull($exception, 'Expected ' . $exception); + self::assertSame($requestId, $identification?->request_id); + } catch (ShieldLabsException $thrown) { + self::assertSame($exception, $thrown::class); + } + + // Polls at 0, 0.25, 0.75 and 1.75 s, then the 1.5 s step is cut to the 0.55 s left. + self::assertCount(5, $http->requests); + self::assertCount(4, $clock->sleeps); + self::assertSame([0.25, 0.5, 1.0], \array_slice($clock->sleeps, 0, 3)); + self::assertEqualsWithDelta(0.55, $clock->sleeps[3], 1e-9); + self::assertEqualsWithDelta(2.3, $clock->elapsed(), 1e-9); + } + + public function testPollsOnceWithAZeroTimeout(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $clock = new FakeClock(); + + self::assertNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => 0])); + self::assertCount(1, $http->requests); + self::assertSame([], $clock->sleeps); + } + + public function testThrowsTheErrorOfTheOnlyPollWithAZeroTimeout(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::json(503, ['error' => 'busy'])); + $clock = new FakeClock(); + + $this->expectException(ServerException::class); + try { + Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => 0]); + } finally { + self::assertCount(1, $http->requests); + self::assertSame([], $clock->sleeps); + } + } + + public function testKeepsPollingThroughTransientErrorsUntilTheRowAppears(): void + { + $http = (new MockHttpClient())->queue( + MockHttpClient::json(503, ['error' => 'busy']), + self::networkError('connection reset'), + self::networkError('cURL error 28: timed out', true), + self::rateLimited(), + Clients::rowFor(self::REQUEST_ID), + ); + $clock = new FakeClock(); + + $identification = Clients::history($http, $clock)->identifications->get(self::REQUEST_ID); + + self::assertSame(self::REQUEST_ID, $identification?->request_id); + // The schedule goes on through the errors. After the 429 the 1.5 s step is already + // longer than the 1 s minimum. + self::assertSame([0.25, 0.5, 1.0, 1.5], $clock->sleeps); + self::assertCount(5, $http->requests); + } + + /** + * Each answer carries the number of the poll, so a test can tell which error was thrown. + * + * @return iterable}> + */ + public static function transientErrors(): iterable + { + yield 'server error' => [ + static fn(int $poll): ResponseInterface => MockHttpClient::json(503, ['error' => 'busy on poll ' . $poll]), + ServerException::class, + ]; + yield 'edge proxy page' => [ + static fn(int $poll): ResponseInterface => MockHttpClient::text(502, 'bad gateway on poll ' . $poll . '', 'text/html'), + ServerException::class, + ]; + yield 'connection error' => [ + static fn(int $poll): ResponseInterface => throw self::networkError('connection reset on poll ' . $poll), + ApiConnectionException::class, + ]; + yield 'timeout' => [ + static fn(int $poll): ResponseInterface => throw self::networkError('cURL error 28: timed out on poll ' . $poll, true), + ApiTimeoutException::class, + ]; + } + + /** + * @param \Closure(int): ResponseInterface $answer + * @param class-string $exception + */ + #[DataProvider('transientErrors')] + public function testKeepsPollingAfterTransientErrorsAndThrowsTheLastOneAtTheDeadline(\Closure $answer, string $exception): void + { + $poll = 0; + $http = (new MockHttpClient())->always(static function () use (&$poll, $answer): ResponseInterface { + return $answer(++$poll); + }); + $clock = new FakeClock(); + + try { + Clients::history($http, $clock)->identifications->get(self::REQUEST_ID); + self::fail('Expected ' . $exception); + } catch (ShieldLabsException $thrown) { + self::assertInstanceOf($exception, $thrown); + $text = $thrown instanceof ApiException ? $thrown->getRawBody() : $thrown->getMessage(); + self::assertStringContainsString('on poll 9', $text); + } + + self::assertSame(self::DEFAULT_SLEEPS, $clock->sleeps); + self::assertEqualsWithDelta(10.0, $clock->elapsed(), 1e-9); + self::assertCount(9, $http->requests); + } + + public function testThrowsTheLastRateLimitAtTheDeadline(): void + { + $http = (new MockHttpClient())->always(self::rateLimited()); + $clock = new FakeClock(); + + try { + Clients::history($http, $clock)->identifications->get(self::REQUEST_ID); + self::fail('Expected a RateLimitException'); + } catch (RateLimitException $exception) { + self::assertNull($exception->getRetryAfter()); + } + + // At least 1 s after each 429, then the schedule; the last wait ends at the deadline. + self::assertSame([1.0, 1.0, 1.0, 1.5, 2.0, 2.0, 1.5], $clock->sleeps); + self::assertEqualsWithDelta(10.0, $clock->elapsed(), 1e-9); + self::assertCount(8, $http->requests); + } + + public function testReturnsNullWhenTheLastPollFindsNothingAfterErrors(): void + { + $busy = MockHttpClient::json(503, ['error' => 'busy']); + $http = (new MockHttpClient())->queue($busy, $busy, $busy, $busy, $busy, $busy, $busy, $busy, MockHttpClient::emptyPage()); + $clock = new FakeClock(); + + self::assertNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID)); + + self::assertSame(self::DEFAULT_SLEEPS, $clock->sleeps); + self::assertCount(9, $http->requests); + } + + public function testSendsOneRequestPerPollWhateverMaxRetries(): void + { + $http = (new MockHttpClient())->queue( + MockHttpClient::json(503, ['error' => 'busy']), + self::networkError('connection refused'), + Clients::rowFor(self::REQUEST_ID), + ); + $clock = new FakeClock(); + + $identification = Clients::history($http, $clock, ['max_retries' => 5])->identifications->get(self::REQUEST_ID); + + self::assertSame(self::REQUEST_ID, $identification?->request_id); + self::assertCount(3, $http->requests); + self::assertSame([0.25, 0.5], $clock->sleeps); + } + + public function testWaitsAtLeastOneSecondAfterARateLimitWithoutRetryAfter(): void + { + $http = (new MockHttpClient())->queue(self::rateLimited(), Clients::rowFor(self::REQUEST_ID)); + $clock = new FakeClock(); + + self::assertNotNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID)); + self::assertSame([1.0], $clock->sleeps); + self::assertCount(2, $http->requests); + + // A later step of the schedule is already longer than 1 s and stays as it is. + $empty = MockHttpClient::emptyPage(); + $http = (new MockHttpClient())->queue($empty, $empty, $empty, self::rateLimited(), Clients::rowFor(self::REQUEST_ID)); + $clock = new FakeClock(); + + self::assertNotNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID)); + self::assertSame([0.25, 0.5, 1.0, 1.5], $clock->sleeps); + } + + /** + * After a 429 the next poll waits the longest of the next step of the schedule, + * 1 s and Retry-After (at most 10 s). + * + * @return iterable}> + */ + public static function waitsAfterARateLimit(): iterable + { + // [Retry-After, empty polls before the 429, sleeps] + yield 'Retry-After 0' => ['0', 0, [1.0]]; + yield 'Retry-After as a date in the past' => ['Thu, 01 Jan 2015 00:00:00 GMT', 0, [1.0]]; + yield 'Retry-After under 1 s' => ['0.5', 0, [1.0]]; + yield 'Retry-After that cannot be read' => ['soon', 0, [1.0]]; + yield 'Retry-After longer than the step' => ['2', 1, [0.25, 2.0]]; + yield 'the 1.5 s step, longer than Retry-After' => ['1', 3, [0.25, 0.5, 1.0, 1.5]]; + yield 'the 1.5 s step, with Retry-After 0' => ['0', 3, [0.25, 0.5, 1.0, 1.5]]; + } + + /** + * @param list $sleeps + */ + #[DataProvider('waitsAfterARateLimit')] + public function testWaitsTheLongestOfTheStepOneSecondAndRetryAfterAfterARateLimit(string $retryAfter, int $emptyPolls, array $sleeps): void + { + $http = new MockHttpClient(); + for ($i = 0; $i < $emptyPolls; ++$i) { + $http->queue(MockHttpClient::emptyPage()); + } + $http->queue(self::rateLimited($retryAfter), Clients::rowFor(self::REQUEST_ID)); + $clock = new FakeClock(); + + $identification = Clients::history($http, $clock)->identifications->get(self::REQUEST_ID); + + self::assertSame(self::REQUEST_ID, $identification?->request_id); + self::assertSame($sleeps, $clock->sleeps); + self::assertCount($emptyPolls + 2, $http->requests); + } + + /** + * @return iterable + */ + public static function shortRetryAfters(): iterable + { + yield 'no Retry-After' => [null]; + yield 'Retry-After 0' => ['0']; + } + + #[DataProvider('shortRetryAfters')] + public function testCutsTheWaitAfterARateLimitShortAndPollsAtTheDeadline(?string $retryAfter): void + { + // With 0.625 s left, the 1 s wait after the 429 ends at the deadline, where the last poll runs. + $http = (new MockHttpClient())->queue(self::rateLimited($retryAfter), Clients::rowFor(self::REQUEST_ID)); + $clock = new FakeClock(); + + $identification = Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => 0.625]); + + self::assertSame(self::REQUEST_ID, $identification?->request_id); + self::assertSame([0.625], $clock->sleeps); + self::assertCount(2, $http->requests); + + // When the poll at the deadline gets another 429, that 429 is thrown. + $http = (new MockHttpClient())->always(self::rateLimited($retryAfter)); + $clock = new FakeClock(); + + try { + Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => 0.625]); + self::fail('Expected a RateLimitException'); + } catch (RateLimitException $exception) { + self::assertSame($retryAfter === null ? null : 0.0, $exception->getRetryAfter()); + } + self::assertSame([0.625], $clock->sleeps); + self::assertCount(2, $http->requests); + } + + public function testWaitsForRetryAfterBetweenPolls(): void + { + $http = (new MockHttpClient())->queue( + MockHttpClient::emptyPage(), + self::rateLimited('3'), + self::rateLimited('3'), + self::rateLimited('3'), + Clients::rowFor(self::REQUEST_ID), + ); + $clock = new FakeClock(); + + $identification = Clients::history($http, $clock)->identifications->get(self::REQUEST_ID); + + self::assertSame(self::REQUEST_ID, $identification?->request_id); + self::assertSame([0.25, 3.0, 3.0, 3.0], $clock->sleeps); + self::assertCount(5, $http->requests); + } + + public function testWaitsForRetryAfterThatFitsAndStillPollsAtTheDeadline(): void + { + $empty = MockHttpClient::emptyPage(); + $http = (new MockHttpClient())->queue($empty, $empty, self::rateLimited('2'))->always($empty); + $clock = new FakeClock(); + + self::assertNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => 4])); + + self::assertSame([0.25, 0.5, 2.0, 1.25], $clock->sleeps); + self::assertEqualsWithDelta(4.0, $clock->elapsed(), 1e-9); + self::assertCount(5, $http->requests); + } + + public function testTheWaitAfterARateLimitKeepsALadderStepLongerThanTwoSeconds(): void + { + // With a 3 s poll interval every step is 3 s: after a 429 the step is already longer than + // the 1 s minimum and than Retry-After: 2, and a Retry-After of 5 s is longer still. + $http = (new MockHttpClient())->queue( + self::rateLimited(), + self::rateLimited('2'), + self::rateLimited('5'), + Clients::rowFor(self::REQUEST_ID), + ); + $clock = new FakeClock(); + + $identification = Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => 20, 'poll_interval' => 3]); + + self::assertSame(self::REQUEST_ID, $identification?->request_id); + self::assertSame([3.0, 3.0, 5.0], $clock->sleeps); + self::assertCount(4, $http->requests); + } + + public function testCapsRetryAfterAtTenSeconds(): void + { + $http = (new MockHttpClient())->queue(self::rateLimited('60'), Clients::rowFor(self::REQUEST_ID)); + $clock = new FakeClock(); + + self::assertNotNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => 20])); + self::assertSame([10.0], $clock->sleeps); + } + + /** + * @return iterable}> + */ + public static function retryAftersLongerThanTheTimeLeft(): iterable + { + // Polls at 0, 0.25 and 0.75 s find nothing; the 429 at 1.75 s leaves 8.25 s. + yield 'longer than the time left' => ['9', 3, [0.25, 0.5, 1.0]]; + // After one empty poll 9.75 s are left, and 60 s is taken as 10 s. + yield 'capped at 10 s, still longer' => ['60', 1, [0.25]]; + } + + /** + * @param list $sleeps + */ + #[DataProvider('retryAftersLongerThanTheTimeLeft')] + public function testThrowsARateLimitAtOnceWhenRetryAfterExceedsTheTimeLeft(string $retryAfter, int $emptyPolls, array $sleeps): void + { + $http = new MockHttpClient(); + for ($i = 0; $i < $emptyPolls; ++$i) { + $http->queue(MockHttpClient::emptyPage()); + } + $http->queue(self::rateLimited($retryAfter))->always(Clients::rowFor(self::REQUEST_ID)); + $clock = new FakeClock(); + + try { + Clients::history($http, $clock)->identifications->get(self::REQUEST_ID); + self::fail('Expected a RateLimitException'); + } catch (RateLimitException $exception) { + self::assertSame((float) $retryAfter, $exception->getRetryAfter()); + } + + self::assertSame($sleeps, $clock->sleeps); + self::assertCount($emptyPolls + 1, $http->requests); + } + + public function testThrowsTheRateLimitWhenTimeRunsOut(): void + { + $http = (new MockHttpClient())->always(self::rateLimited('1')); + $clock = new FakeClock(); + + try { + Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => 4]); + self::fail('Expected a RateLimitException'); + } catch (RateLimitException $exception) { + self::assertSame(1.0, $exception->getRetryAfter()); + } + self::assertSame([1.0, 1.0, 1.0, 1.0], $clock->sleeps); + self::assertEqualsWithDelta(4.0, $clock->elapsed(), 1e-9); + self::assertCount(5, $http->requests); + } + + /** + * @return iterable}> + */ + public static function attemptTimeouts(): iterable + { + // Time left before each poll: 10, 9.75, 9.25, 8.25, 6.75, 4.75, 2.75, 0.75 and 0 s. + yield 'client timeout 10 s' => [10.0, [10.0, 9.75, 9.25, 8.25, 6.75, 4.75, 2.75, 1.0, 1.0]]; + yield 'client timeout 3 s' => [3.0, [3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 2.75, 1.0, 1.0]]; + yield 'client timeout under 1 s' => [0.5, array_fill(0, 9, 0.5)]; + } + + /** + * @param list $expected + */ + #[DataProvider('attemptTimeouts')] + public function testLimitsEachPollToTheTimeLeftButAtLeastOneSecond(float $clientTimeout, array $expected): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $clock = new FakeClock(); + + $client = Clients::history($http, $clock, ['http_client' => $http->timed($clientTimeout)]); + + self::assertNull($client->identifications->get(self::REQUEST_ID)); + self::assertSame($expected, $http->timeouts); + } + + public function testLimitsTheOnlyPollOfAZeroTimeoutToOneSecond(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + + $client = Clients::history($http, new FakeClock(), ['http_client' => $http->timed(10.0)]); + + self::assertNull($client->identifications->get(self::REQUEST_ID, ['timeout' => 0])); + self::assertSame([1.0], $http->timeouts); + } + + public function testSearchesAndSingleLookupsKeepTheClientTimeout(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $client = Clients::history($http, new FakeClock(), ['http_client' => $http->timed(10.0)]); + + $client->history->search(LookupType::RequestId, self::REQUEST_ID); + $client->identifications->get(self::REQUEST_ID, ['wait' => false]); + + self::assertSame([10.0, 10.0], $http->timeouts); + } + + /** + * @return iterable}> + */ + public static function errorsThatStopTheWait(): iterable + { + yield '400' => [MockHttpClient::json(400, ['error' => 'bad request']), BadRequestException::class]; + yield '401' => [MockHttpClient::text(401, "{\"error\":\"invalid api key\"}\n", 'text/plain; charset=utf-8'), AuthenticationException::class]; + yield '403' => [MockHttpClient::text(403, ''), AuthenticationException::class]; + yield '404' => [MockHttpClient::text(404, '404 page not found', 'text/plain'), NotFoundException::class]; + yield '402' => [MockHttpClient::json(402, ['error' => 'no requests left']), QuotaExceededException::class]; + yield 'another status' => [MockHttpClient::json(409, ['error' => 'conflict']), ApiException::class]; + yield 'a body that is not JSON' => [MockHttpClient::text(200, 'maintenance', 'text/html'), ApiException::class]; + } + + /** + * @param class-string $exception + */ + #[DataProvider('errorsThatStopTheWait')] + public function testStopsAtOnceOnOtherErrors(ResponseInterface $response, string $exception): void + { + $http = (new MockHttpClient())->queue($response)->always(Clients::rowFor(self::REQUEST_ID)); + $clock = new FakeClock(); + + try { + Clients::history($http, $clock)->identifications->get(self::REQUEST_ID); + self::fail('Expected ' . $exception); + } catch (ShieldLabsException $thrown) { + self::assertSame($exception, $thrown::class); + } + self::assertCount(1, $http->requests); + self::assertSame([], $clock->sleeps); + } + + public function testStopsAtOnceOnAuthenticationErrorsAfterEmptyPolls(): void + { + $http = (new MockHttpClient())->queue( + MockHttpClient::emptyPage(), + MockHttpClient::text(401, "{\"error\":\"invalid api key\"}\n", 'text/plain; charset=utf-8'), + )->always(Clients::rowFor(self::REQUEST_ID)); + $clock = new FakeClock(); + + $this->expectException(AuthenticationException::class); + try { + Clients::history($http, $clock)->identifications->get(self::REQUEST_ID); + } finally { + self::assertCount(2, $http->requests); + self::assertSame([0.25], $clock->sleeps); + } + } + + /** + * @return iterable}> + */ + public static function invalidCalls(): iterable + { + yield 'not a UUID' => ['abc', []]; + yield 'empty' => ['', []]; + yield 'unknown option' => [self::REQUEST_ID, ['interval' => 1]]; + yield 'wait not bool' => [self::REQUEST_ID, ['wait' => 'yes']]; + yield 'negative timeout' => [self::REQUEST_ID, ['timeout' => -1]]; + yield 'timeout as string' => [self::REQUEST_ID, ['timeout' => '10']]; + yield 'zero poll interval' => [self::REQUEST_ID, ['poll_interval' => 0]]; + yield 'infinite timeout' => [self::REQUEST_ID, ['timeout' => \INF]]; + } + + /** + * @param array $options + */ + #[DataProvider('invalidCalls')] + public function testValidatesBeforeSendingAnything(string $requestId, array $options): void + { + $http = new MockHttpClient(); + + try { + /** @phpstan-ignore argument.type */ + Clients::history($http)->identifications->get($requestId, $options); + self::fail('Expected a ValidationException'); + } catch (ValidationException) { + self::assertSame([], $http->requests); + } + } + + /** + * @return iterable}> + */ + public static function ladders(): iterable + { + // Waits after the first eight polls for a poll interval p: p, 2p, 4p, 6p, 8p, then 8p, each at + // most max(2 s, p). + yield 'default 250 ms' => [0.25, [0.25, 0.5, 1.0, 1.5, 2.0, 2.0, 2.0, 2.0]]; + yield '125 ms' => [0.125, [0.125, 0.25, 0.5, 0.75, 1.0, 1.0, 1.0, 1.0]]; + yield '375 ms' => [0.375, [0.375, 0.75, 1.5, 2.0, 2.0, 2.0, 2.0, 2.0]]; + yield '1 s' => [1.0, [1.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0]]; + yield '2 s' => [2.0, [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0]]; + yield '2.5 s, its own cap' => [2.5, [2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5]]; + yield '3 s, its own cap' => [3.0, [3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0]]; + } + + /** + * @param list $expected + */ + #[DataProvider('ladders')] + public function testPollDelayFollowsTheScheduleForAnyPollInterval(float $pollInterval, array $expected): void + { + $delays = array_map(static fn(int $step): float => Identifications::pollDelay($step, $pollInterval), range(0, 7)); + + self::assertSame($expected, $delays); + self::assertSame($expected[0], Identifications::pollDelay(-1, $pollInterval)); + } + + public function testKeepsAPollIntervalBelow100MillisecondsAsItIs(): void + { + // No floor: 50 ms waits 50, 100, 200, 300 and 400 ms, then 400 ms again. + $delays = array_map(static fn(int $step): float => Identifications::pollDelay($step, 0.05), range(0, 6)); + self::assertEqualsWithDelta([0.05, 0.1, 0.2, 0.3, 0.4, 0.4, 0.4], $delays, 1e-9); + + // The same ladder in a whole 2 s wait, whose last wait is cut short so the last poll runs at the deadline. + $http = (new MockHttpClient())->always(MockHttpClient::emptyPage()); + $clock = new FakeClock(); + + self::assertNull(Clients::history($http, $clock)->identifications->get(self::REQUEST_ID, ['timeout' => 2, 'poll_interval' => 0.05])); + + self::assertEqualsWithDelta([0.05, 0.1, 0.2, 0.3, 0.4, 0.4, 0.4, 0.15], $clock->sleeps, 1e-9); + self::assertCount(9, $http->requests); + self::assertEqualsWithDelta(2.0, $clock->elapsed(), 1e-9); + } +} diff --git a/tests/Unit/InternalHelpersTest.php b/tests/Unit/InternalHelpersTest.php new file mode 100644 index 0000000..350a1cc --- /dev/null +++ b/tests/Unit/InternalHelpersTest.php @@ -0,0 +1,89 @@ +now(); + $clock->sleep(0.02); + $clock->sleep(0.0); + $clock->sleep(-1.0); + + self::assertGreaterThanOrEqual(0.015, $clock->now() - $start); + self::assertLessThan(5.0, $clock->now() - $start); + } + + public function testSystemClockSleepsWholeSeconds(): void + { + $clock = new SystemClock(); + $start = $clock->now(); + $clock->sleep(1.01); + + self::assertGreaterThanOrEqual(1.0, $clock->now() - $start); + } + + public function testEnvFallsBackToSuperglobals(): void + { + putenv('SHIELDLABS_TEST_VALUE'); + $_ENV['SHIELDLABS_TEST_VALUE'] = 'from-env-array'; + try { + self::assertSame('from-env-array', Env::get('SHIELDLABS_TEST_VALUE')); + unset($_ENV['SHIELDLABS_TEST_VALUE']); + $_SERVER['SHIELDLABS_TEST_VALUE'] = 'from-server-array'; + self::assertSame('from-server-array', Env::get('SHIELDLABS_TEST_VALUE')); + $_SERVER['SHIELDLABS_TEST_VALUE'] = ''; + self::assertNull(Env::get('SHIELDLABS_TEST_VALUE')); + } finally { + unset($_ENV['SHIELDLABS_TEST_VALUE'], $_SERVER['SHIELDLABS_TEST_VALUE']); + } + } + + public function testOptionsStringIsNullWhenMissing(): void + { + self::assertNull(Options::string([], 'base_url')); + self::assertNull(Options::get(['x' => null], 'x')); + } + + public function testErrorBodiesThatCannotBeReadBecomeEmpty(): void + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('__toString')->willThrowException(new \RuntimeException('stream closed')); + $response = (new Response(500))->withBody($stream); + + self::assertSame('', ResponseErrors::body($response)); + self::assertSame('', ResponseErrors::fromResponse($response)->getRawBody()); + } + + public function testWholeNumberFloatsBecomeIntegers(): void + { + $profile = DomainProfile::fromArray(json_decode('{"Weight": 12.0}', true, 512, \JSON_THROW_ON_ERROR)); + $page = HistoryPage::fromArray(json_decode('{"data": [], "total": 2.0}', true, 512, \JSON_THROW_ON_ERROR)); + + self::assertSame(12, $profile->remaining_identifications); + self::assertSame(2, $page->total); + } + + public function testTimeBuildRejectsInvalidParts(): void + { + self::assertNull(Time::build('2026-09-30', '12:00:00', '', 'Z')); + self::assertNull(Time::build('2026-09-30', '25:00:00', '', '+00:00')); + self::assertNull(Time::format(null)); + self::assertSame(1790769720.5, Time::unix(new \DateTimeImmutable('2026-09-30T12:02:00.5Z'))); + } +} diff --git a/tests/Unit/ManagementClientTest.php b/tests/Unit/ManagementClientTest.php new file mode 100644 index 0000000..f5ea244 --- /dev/null +++ b/tests/Unit/ManagementClientTest.php @@ -0,0 +1,235 @@ +always(MockHttpClient::json(200, Fixtures::json('management-profile.json'))); + $client = Clients::management($http, null, ['domain' => 'https://www.Example.com/login']); + + $profile = $client->getProfile(); + + self::assertSame(Fixtures::json('management-profile-expected.json'), $profile->toArray()); + self::assertSame($profile->toArray(), $profile->jsonSerialize()); + $request = $http->lastRequest(); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https://api.shieldlabs.ai/v1/profile', (string) $request->getUri()); + self::assertSame('example.com', $request->getHeaderLine('X-Shield-Domain')); + self::assertSame('Bearer ' . Clients::SECRET_KEY, $request->getHeaderLine('Authorization')); + self::assertSame('application/json', $request->getHeaderLine('Accept')); + self::assertStringStartsWith('shieldlabs-php/1.0.0 ', $request->getHeaderLine('User-Agent')); + self::assertSame('example.com', $client->getDomain()); + } + + /** + * @return iterable + */ + public static function domains(): iterable + { + yield 'plain' => ['example.com', 'example.com']; + yield 'uppercase' => ['Example.COM', 'example.com']; + yield 'spaces' => [" example.com \n", 'example.com']; + yield 'https scheme' => ['https://example.com', 'example.com']; + yield 'http scheme and path' => ['http://example.com/signup?x=1', 'example.com']; + yield 'trailing slash' => ['example.com/', 'example.com']; + yield 'leading www' => ['www.example.com', 'example.com']; + yield 'everything' => [' HTTPS://WWW.Example.com/path/ ', 'example.com']; + yield 'subdomain kept' => ['shop.example.com', 'shop.example.com']; + yield 'www inside kept' => ['app.www.example.com', 'app.www.example.com']; + yield 'protocol relative' => ['//example.com/', 'example.com']; + yield 'fragment' => ['example.com#top', 'example.com']; + } + + #[DataProvider('domains')] + public function testNormalizesTheDomain(string $input, string $expected): void + { + self::assertSame($expected, ShieldLabsManagement::normalizeDomain($input)); + } + + /** + * @return iterable + */ + public static function unusableDomains(): iterable + { + yield 'empty' => ['']; + yield 'blank' => [' ']; + yield 'scheme only' => ['https://']; + yield 'inner space' => ['exa mple.com']; + yield 'line break' => ["example.com\nX-Injected: 1"]; + yield 'NUL byte' => ["exam\0ple.com"]; + yield 'control character' => ["exam\x01ple.com"]; + yield 'not ASCII' => ['bücher.example']; + } + + #[DataProvider('unusableDomains')] + public function testRejectsUnusableDomains(string $domain): void + { + $http = new MockHttpClient(); + + try { + ShieldLabsManagement::normalizeDomain($domain); + self::fail('Expected a ValidationException'); + } catch (ValidationException $exception) { + if (trim($domain) !== '') { + self::assertStringNotContainsString($domain, $exception->getMessage(), 'the value is not repeated'); + } + } + $this->expectException(ValidationException::class); + Clients::management($http, null, ['domain' => $domain]); + } + + public function testAsksForThePunycodeFormOfAnInternationalDomain(): void + { + self::assertSame('xn--bcher-kva.example', ShieldLabsManagement::normalizeDomain('https://www.XN--BCHER-KVA.example/')); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('punycode'); + ShieldLabsManagement::normalizeDomain('bücher.example'); + } + + /** + * @return iterable + */ + public static function secretsThatCannotBeSent(): iterable + { + yield 'line feed' => ["0123456789abcdef\nX-Injected: 1"]; + yield 'carriage return' => ["0123456789abcdef\r0123456789abcdef"]; + yield 'NUL byte' => ["0123456789abcdef\x000123456789abcdef"]; + yield 'inner space' => ['0123456789abcdef 0123456789abcdef']; + } + + #[DataProvider('secretsThatCannotBeSent')] + public function testRejectsASecretKeyThatCannotBeSentInAHeader(string $secret): void + { + $http = new MockHttpClient(); + + try { + Clients::management($http, null, ['secret_key' => $secret]); + self::fail('Expected a ValidationException'); + } catch (ValidationException $exception) { + self::assertStringContainsString('The Secret Key contains characters that cannot be sent in an HTTP header', $exception->getMessage()); + self::assertStringNotContainsString('0123456789abcdef', $exception->getMessage()); + self::assertNull($exception->getPrevious()); + } + self::assertSame([], $http->requests); + } + + public function testRequiresASecretKey(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Secret Key is required'); + new ShieldLabsManagement(['secret_key' => ' ', 'domain' => 'example.com', 'http_client' => new MockHttpClient()]); + } + + public function testRequiresADomain(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('domain is required'); + new ShieldLabsManagement(['secret_key' => Clients::SECRET_KEY, 'http_client' => new MockHttpClient()]); + } + + public function testReadsCredentialsAndBaseUrlFromTheEnvironment(): void + { + putenv('SHIELDLABS_SECRET_KEY=' . Clients::SECRET_KEY); + putenv('SHIELDLABS_DOMAIN=WWW.example.org'); + putenv('SHIELDLABS_MANAGEMENT_BASE_URL=https://dev.api.shieldlabs.ai/v1/'); + $http = (new MockHttpClient())->always(MockHttpClient::json(200, Fixtures::json('management-profile.json'))); + + (new ShieldLabsManagement(['http_client' => $http]))->getProfile(); + + $request = $http->lastRequest(); + self::assertSame('https://dev.api.shieldlabs.ai/v1/profile', (string) $request->getUri()); + self::assertSame('example.org', $request->getHeaderLine('X-Shield-Domain')); + self::assertSame('Bearer ' . Clients::SECRET_KEY, $request->getHeaderLine('Authorization')); + } + + public function testRefusesPlainHttpOutsideLoopbackHosts(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::json(200, Fixtures::json('management-profile.json'))); + + Clients::management($http, null, ['base_url' => 'http://localhost:8081/v1'])->getProfile(); + self::assertSame('http://localhost:8081/v1/profile', (string) $http->lastRequest()->getUri()); + + try { + Clients::management($http, null, ['base_url' => 'http://api.shieldlabs.ai']); + self::fail('Expected a ValidationException'); + } catch (ValidationException $exception) { + self::assertStringContainsString('must be an https URL', $exception->getMessage()); + } + + putenv('SHIELDLABS_MANAGEMENT_BASE_URL=http://api.shieldlabs.ai'); + $this->expectException(ValidationException::class); + Clients::management($http); + } + + public function testAcceptsPlainHttpToAnotherHostWhenAskedTo(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::json(200, Fixtures::json('management-profile.json'))); + + $log = ErrorLog::during(static function () use ($http): void { + Clients::management($http, null, ['base_url' => 'http://management-mock:8081', 'allow_insecure_http' => true])->getProfile(); + }); + + self::assertSame('http://management-mock:8081/v1/profile', (string) $http->lastRequest()->getUri()); + self::assertCount(1, $log->warnings()); + self::assertStringNotContainsString(Clients::SECRET_KEY, $log->contents); + } + + public function testMapsQuotaExceeded(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::text(402, '')); + + $this->expectException(QuotaExceededException::class); + Clients::management($http)->getProfile(); + } + + public function testRejectsAnUnexpectedProfileBody(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::json(200, null)); + + $this->expectException(ApiException::class); + Clients::management($http)->getProfile(); + } + + public function testToleratesAPartialProfile(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::json(200, ['Weight' => 12.0, 'CreatedAt' => 'yesterday'])); + + $profile = Clients::management($http)->getProfile(); + + self::assertSame('', $profile->domain); + self::assertSame(12, $profile->remaining_identifications); + self::assertNull($profile->created_at); + self::assertSame('', $profile->public_key_masked); + } + + public function testRejectsUnknownOptions(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Unknown option "api_key"'); + /** @phpstan-ignore argument.type */ + new ShieldLabsManagement(['api_key' => 'sec_x', 'secret_key' => 'x', 'domain' => 'example.com']); + } +} diff --git a/tests/Unit/ModelTest.php b/tests/Unit/ModelTest.php new file mode 100644 index 0000000..5beb0f1 --- /dev/null +++ b/tests/Unit/ModelTest.php @@ -0,0 +1,140 @@ +data[0]; + } + + public function testExposesSnakeCaseProperties(): void + { + $identification = self::first(); + + self::assertSame('02f1d973-84db-4156-a7f7-e799e6bf389b', $identification->request_id); + self::assertSame('9f86d081884c7d659a2feaa0c55ad015', $identification->user_hid); + self::assertSame('example.com', $identification->domain); + self::assertSame('Netherlands', $identification->public_ip->country); + self::assertSame('198.51.100.23', $identification->local_ip->ip); + self::assertSame(ConnectionType::PROXY, $identification->connection_type); + self::assertSame('Google Ads', $identification->traffic_source->channel); + self::assertSame(80, $identification->risk_score); + self::assertSame(RiskBand::Dangerous, $identification->riskBand()); + self::assertFalse($identification->isRateLimited()); + self::assertTrue($identification->hasDeviceSignals()); + self::assertTrue($identification->detection_flags->anti_detect_browser); + self::assertSame(SignalName::ANTIDETECT_BROWSER, $identification->signals[2]->name); + self::assertSame('Antidetect browser (turn_block)', $identification->signals[2]->description); + self::assertSame(1790771696123, $identification->raw['ver']); + } + + public function testObservedAtIsUtcWithMilliseconds(): void + { + $observedAt = self::first()->observed_at; + + self::assertNotNull($observedAt); + self::assertSame('2026-09-30 12:34:56.123000 UTC', $observedAt->format('Y-m-d H:i:s.u T')); + } + + public function testSerializesToTheNormalizedShape(): void + { + $identification = self::first(); + $json = json_decode((string) json_encode($identification), true); + + self::assertSame($identification->toArray(), $json); + self::assertArrayNotHasKey('raw', $json); + self::assertSame('2026-09-30T12:34:56.123Z', $json['observed_at']); + } + + public function testSignalsAreAnOpenSet(): void + { + $identification = Identification::fromWebhookData([ + 'signals' => [['name' => 'brand_new_signal', 'weight' => 25], ['name' => 'stun_late_correction', 'weight' => -30], 'junk'], + ]); + + self::assertSame( + [['name' => 'brand_new_signal', 'weight' => 25, 'description' => null], ['name' => 'stun_late_correction', 'weight' => -30, 'description' => null]], + array_map(static fn(Signal $signal): array => $signal->jsonSerialize(), $identification->signals), + ); + } + + public function testDetectionFlagsLookup(): void + { + $flags = new DetectionFlags(vpn: true, check_incomplete: true); + + self::assertTrue($flags->get('vpn')); + self::assertFalse($flags->get('tor')); + self::assertSame(['vpn', 'check_incomplete'], $flags->active()); + self::assertSame(DetectionFlags::KEYS, array_keys($flags->toArray())); + self::assertSame($flags->toArray(), $flags->jsonSerialize()); + self::assertCount(19, DetectionFlags::KEYS); + + $this->expectException(ValidationException::class); + $flags->get('bot'); + } + + public function testDetectionFlagsFromArrayIgnoresNonBooleans(): void + { + /** @var array $input */ + $input = ['vpn' => true, 'tor' => 1, 'proxy' => 'true', 'unknown_flag' => true]; + $flags = DetectionFlags::fromArray($input); + + self::assertSame(['vpn'], $flags->active()); + } + + public function testNilDeviceIdMeansNoDeviceSignals(): void + { + $identification = Identification::fromWebhookData(Fixtures::json('webhook-rate-limited.json')['data'] ?? []); + + self::assertSame(Identification::NIL_UUID, $identification->device_id); + self::assertFalse($identification->hasDeviceSignals()); + self::assertTrue($identification->isRateLimited()); + } + + public function testHistoryPageIsCountableAndIterable(): void + { + $page = HistoryPage::fromArray(Fixtures::json('history-page.json')); + + self::assertCount(5, $page); + self::assertSame(5, iterator_count($page->getIterator())); + self::assertSame(37, $page->total); + } + + public function testLookupTypeKnowsItsUuidTypes(): void + { + $uuidTypes = array_values(array_filter(LookupType::cases(), static fn(LookupType $type): bool => $type->isUuid())); + + self::assertSame( + [LookupType::VisitorId, LookupType::RequestId, LookupType::DeviceId, LookupType::SessionId, LookupType::CookieId], + $uuidTypes, + ); + self::assertSame(['ip', 'user_hid', 'visitor_id', 'request_id', 'device_id', 'session_id', 'cookie_id'], array_map(static fn(LookupType $type): string => $type->value, LookupType::cases())); + } + + public function testIpInfoAndTrafficSourceSerialize(): void + { + $identification = self::first(); + + self::assertSame(['ip' => '203.0.113.24', 'country' => 'Netherlands'], $identification->public_ip->jsonSerialize()); + self::assertSame($identification->traffic_source->toArray(), $identification->traffic_source->jsonSerialize()); + self::assertCount(9, $identification->traffic_source->toArray()); + } +} diff --git a/tests/Unit/NormalizerEdgeCaseTest.php b/tests/Unit/NormalizerEdgeCaseTest.php new file mode 100644 index 0000000..10c07e6 --- /dev/null +++ b/tests/Unit/NormalizerEdgeCaseTest.php @@ -0,0 +1,248 @@ +request_id); + self::assertNull($identification->user_hid); + self::assertSame('', $identification->domain); + self::assertSame(['ip' => '', 'country' => ''], $identification->public_ip->toArray()); + self::assertSame(0, $identification->risk_score); + self::assertSame([], $identification->signals); + self::assertSame([], $identification->detection_flags->active()); + self::assertNull($identification->observed_at); + self::assertSame('history', $identification->source); + } + + public function testAnEmptyWebhookPayloadProducesDefaults(): void + { + $identification = Identification::fromWebhookData([]); + + self::assertSame('', $identification->device_id); + self::assertSame('', $identification->traffic_source->channel); + self::assertSame([], $identification->detection_flags->active()); + self::assertSame('webhook', $identification->source); + } + + /** + * @return iterable + */ + public static function unusableScoreDetails(): iterable + { + yield 'empty string' => ['']; + yield 'invalid JSON' => ['[{"Value":10,']; + yield 'JSON object' => ['{"Value":10,"Description":"Is proxy"}']; + yield 'JSON null' => ['null']; + yield 'already decoded array' => [[['Value' => 10, 'Description' => 'Is proxy']]]; + yield 'number' => [5]; + yield 'null' => [null]; + } + + #[DataProvider('unusableScoreDetails')] + public function testUnusableScoreDetailsGiveNoSignals(mixed $details): void + { + self::assertSame([], Identification::fromHistoryRow(['score_details' => $details])->signals); + } + + public function testKeepsOnlyIntegerNonZeroWeights(): void + { + $details = json_encode([ + ['Value' => 10, 'Description' => 'Is proxy'], + ['Value' => 0, 'Description' => 'Check Incomplete'], + ['Value' => 10.5, 'Description' => 'Is abuser'], + ['Value' => true, 'Description' => 'Is VPN'], + ['Value' => '30', 'Description' => 'Stun is not checked'], + ['Description' => 'No value'], + ['Value' => -30, 'Description' => 'Stun passed (late arrival, corrected)'], + ['Value' => 7, 'Description' => 42], + 'not an object', + [1, 2], + [], + ]); + + $signals = array_map( + static fn($signal): array => $signal->toArray(), + Identification::fromHistoryRow(['score_details' => $details])->signals, + ); + + self::assertSame([ + ['name' => 'proxy', 'weight' => 10, 'description' => 'Is proxy'], + ['name' => 'stun_late_correction', 'weight' => -30, 'description' => 'Stun passed (late arrival, corrected)'], + ['name' => '42', 'weight' => 7, 'description' => '42'], + ], $signals); + } + + public function testIpMismatchFromTheLeakDetailOrDifferentAddresses(): void + { + $leakDetail = json_encode([['Value' => 0, 'Description' => 'IP ≠ leakIP (192.0.2.1 ≠ 198.51.100.2, source=shield)']]); + + self::assertTrue(Identification::fromHistoryRow(['score_details' => $leakDetail])->detection_flags->ip_mismatch); + self::assertTrue(Identification::fromHistoryRow(['ip' => '192.0.2.1', 'web_rtc_ip' => '198.51.100.2'])->detection_flags->ip_mismatch); + self::assertFalse(Identification::fromHistoryRow(['ip' => '192.0.2.1', 'web_rtc_ip' => '192.0.2.1'])->detection_flags->ip_mismatch); + self::assertFalse(Identification::fromHistoryRow(['ip' => '192.0.2.1', 'web_rtc_ip' => '0.0.0.0'])->detection_flags->ip_mismatch); + self::assertFalse(Identification::fromHistoryRow(['ip' => '192.0.2.1', 'web_rtc_ip' => '198.51.100.2', 'is_search_bot' => true])->detection_flags->ip_mismatch); + self::assertFalse(Identification::fromHistoryRow(['score_details' => $leakDetail, 'is_search_bot' => true])->detection_flags->ip_mismatch); + } + + public function testLocalIpPrefersTheLeakSource(): void + { + $row = [ + 'web_rtc_ip' => '198.51.100.2', 'web_rtc_country' => 'Germany', + 'webrtc_leak_ip' => '203.0.113.9', 'webrtc_leak_country' => 'Spain', + ]; + + self::assertSame('198.51.100.2', Identification::fromHistoryRow($row + ['webrtc_leak_source' => 'none'])->local_ip->ip); + self::assertSame('198.51.100.2', Identification::fromHistoryRow($row + ['webrtc_leak_source' => ' '])->local_ip->ip); + self::assertSame(['ip' => '203.0.113.9', 'country' => 'Spain'], Identification::fromHistoryRow($row + ['webrtc_leak_source' => 'shield'])->local_ip->toArray()); + } + + public function testBrowserVpnProxyComesFromTheConnectionType(): void + { + self::assertTrue(Identification::fromHistoryRow(['connection_type' => 'browser_vpn_proxy'])->detection_flags->browser_vpn_proxy); + self::assertFalse(Identification::fromHistoryRow(['connection_type' => 'vpn'])->detection_flags->browser_vpn_proxy); + } + + public function testSiteDomainWinsOverTheRequestHost(): void + { + self::assertSame('example.com', Identification::fromHistoryRow(['domain' => 'shop.example.com', 'site_domain' => 'example.com'])->domain); + self::assertSame('shop.example.com', Identification::fromHistoryRow(['domain' => 'shop.example.com', 'site_domain' => ''])->domain); + } + + public function testFlagTruthinessFollowsJson(): void + { + $identification = Identification::fromHistoryRow(['is_vpn' => 'false', 'is_tor' => 0, 'is_proxy' => '', 'is_abuser' => 1, 'is_datacenter' => null]); + + self::assertSame(['vpn', 'abuser'], $identification->detection_flags->active()); + } + + public function testToleratesWrongTypes(): void + { + $identification = Identification::fromWebhookData([ + 'request_id' => 12345, + 'user_hid' => false, + 'risk_score' => 45.0, + 'public_ip' => 'x', + 'traffic_source' => 'x', + 'detection_flags' => 'x', + 'signals' => ['name' => 'not a list'], + 'observed_at' => 1790769720, + ]); + + self::assertSame('12345', $identification->request_id); + self::assertNull($identification->user_hid); + self::assertSame(45, $identification->risk_score); + self::assertSame('', $identification->public_ip->ip); + self::assertSame([], $identification->signals); + self::assertNull($identification->observed_at); + } + + public function testWebhookSignalsKeepZeroWeights(): void + { + $identification = Identification::fromWebhookData(['signals' => [['name' => 'x', 'weight' => 0], ['weight' => 5]]]); + + self::assertSame( + [['name' => 'x', 'weight' => 0, 'description' => null], ['name' => '', 'weight' => 5, 'description' => null]], + array_map(static fn($signal): array => $signal->toArray(), $identification->signals), + ); + } + + public function testNormalizesIpSentinelsOnWebhooksToo(): void + { + $identification = Identification::fromWebhookData(['public_ip' => ['ip' => ' 0.0.0.0 ', 'country' => 'Germany'], 'local_ip' => ['ip' => ' 198.51.100.2 ']]); + + self::assertSame(['ip' => '', 'country' => 'Germany'], $identification->public_ip->toArray()); + self::assertSame('198.51.100.2', $identification->local_ip->ip); + } + + /** + * @return iterable + */ + public static function historyTimes(): iterable + { + yield 'milliseconds' => ['2026-09-30 12:34:56.123', '2026-09-30T12:34:56.123Z']; + yield 'seconds' => ['2026-09-30 13:05:12', '2026-09-30T13:05:12.000Z']; + yield 'one digit' => ['2026-09-30 13:10:00.5', '2026-09-30T13:10:00.500Z']; + yield 'nine digits truncated' => ['2026-09-30 13:10:00.999999999', '2026-09-30T13:10:00.999Z']; + yield 'T separator' => ['2026-09-30T13:10:00', '2026-09-30T13:10:00.000Z']; + yield 'zone ignored' => ['2026-09-30 13:10:00+02:00', '2026-09-30T13:10:00.000Z']; + yield 'surrounding space' => [" 2026-09-30 13:10:00.250\n", '2026-09-30T13:10:00.250Z']; + yield 'empty' => ['', null]; + yield 'garbage' => ['yesterday', null]; + yield 'impossible date' => ['2026-02-30 10:00:00', null]; + yield 'not a string' => [1790769720, null]; + yield 'null' => [null, null]; + } + + #[DataProvider('historyTimes')] + public function testParsesHistoryTimestamps(mixed $input, ?string $expected): void + { + self::assertSame($expected, Time::format(Normalizer::parseHistoryTime($input))); + } + + /** + * @return iterable + */ + public static function rfc3339Times(): iterable + { + yield 'nanoseconds' => ['2026-09-30T12:34:57.482913041Z', '2026-09-30T12:34:57.482Z']; + yield 'seconds' => ['2026-09-30T12:34:56Z', '2026-09-30T12:34:56.000Z']; + yield 'positive offset' => ['2026-09-30T14:34:56.100+02:00', '2026-09-30T12:34:56.100Z']; + yield 'negative offset' => ['2026-09-30T07:04:56-05:30', '2026-09-30T12:34:56.000Z']; + yield 'day boundary' => ['2026-10-01T01:00:00+03:00', '2026-09-30T22:00:00.000Z']; + yield 'zero date' => ['0001-01-01T00:00:00Z', '0001-01-01T00:00:00.000Z']; + yield 'space separator' => ['2026-09-30 12:34:56Z', null]; + yield 'lowercase z' => ['2026-09-30T12:34:56z', null]; + yield 'no zone' => ['2026-09-30T12:34:56', null]; + yield 'impossible date' => ['2026-13-01T00:00:00Z', null]; + yield 'not a string' => [false, null]; + } + + #[DataProvider('rfc3339Times')] + public function testParsesRfc3339Timestamps(mixed $input, ?string $expected): void + { + self::assertSame($expected, Time::format(Normalizer::parseRfc3339($input))); + } + + public function testKeepsMicrosecondsInTheModel(): void + { + $value = Normalizer::parseRfc3339('2026-09-30T12:34:57.482913041Z'); + + self::assertSame('482913', $value?->format('u')); + } + + public function testStripsUnicodeWhitespace(): void + { + self::assertSame('a b', Normalizer::strip("\u{00A0}\u{2003}\t a b \u{3000}\n")); + self::assertSame('x', Normalizer::strip("\x1Fx\x1C")); + self::assertSame("\xFF", Normalizer::strip(" \xFF "), 'invalid UTF-8 falls back to ASCII trimming'); + } + + public function testFallbackSlugHandlesInvalidUtf8(): void + { + self::assertSame('abc', Normalizer::fallbackSlug("abc\xFF")); + } + + public function testSlugsForUnicodeDigitsAndCase(): void + { + self::assertSame('ärger_größe_2', Normalizer::signalSlug('Ärger Größe 2')); + self::assertSame('x_neq_y', Normalizer::signalSlug('x≠y')); + self::assertSame('a_b', Normalizer::signalSlug('a - / b')); + self::assertSame('unknown', Normalizer::signalSlug('Sticky verdict: (request 11111111-2222-4333-8444-555555555555)')); + } +} diff --git a/tests/Unit/RetryPolicyTest.php b/tests/Unit/RetryPolicyTest.php new file mode 100644 index 0000000..476489b --- /dev/null +++ b/tests/Unit/RetryPolicyTest.php @@ -0,0 +1,354 @@ + 0.5, 1 => 1.0, 2 => 2.0, 3 => 4.0, 4 => 8.0, 5 => 8.0, 9 => 8.0] as $attempt => $cap) { + $delay = $transport->retryDelay($attempt, $error); + self::assertGreaterThanOrEqual($cap / 2, $delay, "attempt $attempt"); + self::assertLessThanOrEqual($cap, $delay, "attempt $attempt"); + } + } + } + + public function testHonoursRetryAfterUpToTenSeconds(): void + { + $transport = new Transport(new MockHttpClient(), new Psr17Factory(), 'https://x', [], 2, true, new FakeClock()); + + self::assertSame(2.0, $transport->retryDelay(0, new RateLimitException('x', 429, retryAfter: 2.0))); + self::assertSame(10.0, $transport->retryDelay(0, new RateLimitException('x', 429, retryAfter: 60.0))); + self::assertSame(0.0, $transport->retryDelay(3, new RateLimitException('x', 429, retryAfter: 0.0))); + self::assertSame(7.0, $transport->retryDelay(0, new ServerException('x', 503, headers: ['retry-after' => ['7']]))); + } + + public function testWaitsAtLeastOneSecondBeforeRetryingARateLimitWithoutRetryAfter(): void + { + $transport = new Transport(new MockHttpClient(), new Psr17Factory(), 'https://x', [], 2, true, new FakeClock()); + $error = new RateLimitException('x', 429); + + for ($i = 0; $i < 200; ++$i) { + self::assertSame(1.0, $transport->retryDelay(0, $error)); + self::assertSame(1.0, $transport->retryDelay(1, $error)); + $delay = $transport->retryDelay(2, $error); + self::assertGreaterThanOrEqual(1.0, $delay); + self::assertLessThanOrEqual(2.0, $delay); + } + + $http = (new MockHttpClient())->queue(MockHttpClient::json(429, ['error' => 'too many requests']), MockHttpClient::emptyPage()); + $clock = new FakeClock(); + + Clients::history($http, $clock)->history->search(LookupType::Ip, self::IP); + + self::assertSame([1.0], $clock->sleeps); + self::assertCount(2, $http->requests); + } + + public function testRetriesServerErrorsThenSucceeds(): void + { + $http = (new MockHttpClient())->queue( + MockHttpClient::text(502, 'bad gateway', 'text/html'), + MockHttpClient::json(503, ['error' => 'server is busy']), + MockHttpClient::emptyPage(), + ); + $clock = new FakeClock(); + + $page = Clients::history($http, $clock)->history->search(LookupType::Ip, self::IP); + + self::assertSame(0, $page->total); + self::assertCount(3, $http->requests); + self::assertCount(2, $clock->sleeps); + } + + /** + * Outside the wait for a verdict, Retry-After is followed as sent: no 1 s minimum. + * + * @return iterable + */ + public static function retryAfters(): iterable + { + yield '1 s' => ['1', 1.0]; + yield '3 s' => ['3', 3.0]; + yield 'under 1 s' => ['0.5', 0.5]; + yield '0, retried at once' => ['0', 0.0]; + yield 'a date in the past, retried at once' => ['Thu, 01 Jan 2015 00:00:00 GMT', 0.0]; + yield 'capped at 10 s' => ['60', 10.0]; + } + + #[DataProvider('retryAfters')] + public function testRetriesHistoryRateLimitsFollowingRetryAfterAsSent(string $retryAfter, float $sleep): void + { + $http = (new MockHttpClient())->queue( + MockHttpClient::json(429, ['error' => 'too many requests'], ['Retry-After' => $retryAfter]), + MockHttpClient::emptyPage(), + ); + $clock = new FakeClock(); + + Clients::history($http, $clock)->history->search(LookupType::Ip, self::IP); + + self::assertSame([$sleep], $clock->sleeps); + self::assertCount(2, $http->requests); + } + + public function testRetryAfterAsAnHttpDate(): void + { + self::assertSame(30.0, ResponseErrors::retryAfter('Wed, 30 Sep 2026 12:00:30 GMT', (int) strtotime('2026-09-30 12:00:00 UTC'))); + self::assertSame(0.0, ResponseErrors::retryAfter('Wed, 30 Sep 2026 11:00:00 GMT', (int) strtotime('2026-09-30 12:00:00 UTC'))); + self::assertSame(1.5, ResponseErrors::retryAfter(' 1.5 ')); + self::assertNull(ResponseErrors::retryAfter('soon')); + self::assertNull(ResponseErrors::retryAfter(null)); + } + + public function testMaxRetriesZeroSendsOneRequest(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::text(500, '')); + $clock = new FakeClock(); + + $this->expectException(ServerException::class); + try { + Clients::history($http, $clock, ['max_retries' => 0])->history->search(LookupType::Ip, self::IP); + } finally { + self::assertCount(1, $http->requests); + self::assertSame([], $clock->sleeps); + } + } + + public function testCustomMaxRetries(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::text(500, '')); + + try { + Clients::history($http, null, ['max_retries' => 4])->history->search(LookupType::Ip, self::IP); + self::fail('Expected a ServerException'); + } catch (ServerException) { + self::assertCount(5, $http->requests); + } + } + + public function testMapsNetworkFailuresAndRetriesThem(): void + { + $http = (new MockHttpClient())->always(new NetworkException('cURL error 7: Failed to connect', self::request())); + + try { + Clients::history($http)->history->search(LookupType::Ip, self::IP); + self::fail('Expected an ApiConnectionException'); + } catch (ApiConnectionException $exception) { + self::assertStringContainsString('Could not reach the ShieldLabs API', $exception->getMessage()); + self::assertInstanceOf(NetworkException::class, $exception->getPrevious()); + } + self::assertCount(3, $http->requests); + } + + public function testMapsTimeoutsAndRetriesThem(): void + { + $http = (new MockHttpClient())->always(new NetworkException('cURL error 28: timed out', self::request(), true)); + + try { + Clients::history($http)->history->search(LookupType::Ip, self::IP); + self::fail('Expected an ApiTimeoutException'); + } catch (ApiTimeoutException $exception) { + self::assertStringContainsString('did not answer in time', $exception->getMessage()); + } + self::assertCount(3, $http->requests); + } + + public function testRecognisesTimeoutsFromOtherPsr18Clients(): void + { + $foreign = new class ('cURL error 28: Operation timed out after 10001 milliseconds', self::request()) extends \RuntimeException implements \Psr\Http\Client\NetworkExceptionInterface { + public function __construct(string $message, private readonly RequestInterface $request) + { + parent::__construct($message); + } + + public function getRequest(): RequestInterface + { + return $this->request; + } + }; + $http = (new MockHttpClient())->always($foreign); + + $this->expectException(ApiTimeoutException::class); + Clients::history($http, null, ['max_retries' => 0])->history->search(LookupType::Ip, self::IP); + } + + public function testGenericClientExceptionsAreConnectionErrors(): void + { + $generic = new class ('socket closed') extends \RuntimeException implements ClientExceptionInterface {}; + $idle = new class ('Idle timeout reached') extends \RuntimeException implements ClientExceptionInterface {}; + $http = (new MockHttpClient())->queue($generic, $idle, MockHttpClient::emptyPage()); + + self::assertSame(0, Clients::history($http)->history->search(LookupType::Ip, self::IP)->total); + self::assertCount(3, $http->requests); + } + + public function testDoesNotRetryRequestsTheClientCannotSend(): void + { + $invalid = new class ('invalid request', self::request()) extends \InvalidArgumentException implements RequestExceptionInterface { + public function __construct(string $message, private readonly RequestInterface $request) + { + parent::__construct($message); + } + + public function getRequest(): RequestInterface + { + return $this->request; + } + }; + $http = (new MockHttpClient())->always($invalid); + + try { + Clients::history($http)->history->search(LookupType::Ip, self::IP); + self::fail('Expected an ApiConnectionException'); + } catch (ApiConnectionException $exception) { + self::assertStringContainsString('could not send the request', $exception->getMessage()); + } + self::assertCount(1, $http->requests); + } + + /** + * A response whose body fails to read, the way a streamed body behaves when the + * connection stalls or drops after the headers arrived. + */ + private function unreadable(int $status, string $message): ResponseInterface + { + $body = self::createStub(StreamInterface::class); + $body->method('__toString')->willThrowException(new \RuntimeException($message)); + $body->method('getContents')->willThrowException(new \RuntimeException($message)); + + return new Response($status, ['Content-Type' => 'application/json'], $body); + } + + public function testTreatsATimeoutWhileReadingTheBodyAsATimeout(): void + { + $http = (new MockHttpClient())->queue( + $this->unreadable(200, 'Unable to read stream contents: Idle timeout reached for "https://account.shieldlabs.ai/api/v1/history/ip/198.51.100.23".'), + $this->unreadable(200, 'Unable to read stream contents: Max duration was reached for "https://account.shieldlabs.ai/api/v1/history/ip/198.51.100.23".'), + MockHttpClient::emptyPage(), + ); + $clock = new FakeClock(); + + self::assertSame(0, Clients::history($http, $clock)->history->search(LookupType::Ip, self::IP)->total); + self::assertCount(3, $http->requests); + self::assertCount(2, $clock->sleeps); + + $http = (new MockHttpClient())->always($this->unreadable(200, 'Unable to read stream contents: Idle timeout reached')); + try { + Clients::history($http, null, ['max_retries' => 0])->history->search(LookupType::Ip, self::IP); + self::fail('Expected an ApiTimeoutException'); + } catch (ApiTimeoutException $exception) { + self::assertStringContainsString('did not answer in time', $exception->getMessage()); + self::assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + } + } + + public function testTreatsAnyOtherBodyReadFailureAsAConnectionError(): void + { + $http = (new MockHttpClient())->queue( + $this->unreadable(401, 'Unable to read stream contents: Connection reset by peer'), + MockHttpClient::emptyPage(), + ); + $clock = new FakeClock(); + + self::assertSame(0, Clients::history($http, $clock)->history->search(LookupType::Ip, self::IP)->total); + self::assertCount(2, $http->requests, 'retried like other transport failures'); + + $http = (new MockHttpClient())->always($this->unreadable(200, 'Unable to read stream contents: Connection reset by peer')); + try { + Clients::management($http, null, ['max_retries' => 0])->getProfile(); + self::fail('Expected an ApiConnectionException'); + } catch (ApiConnectionException $exception) { + self::assertStringContainsString('could not be read', $exception->getMessage()); + } + } + + public function testReadsAStreamedBodyOnlyOnce(): void + { + // Like a network stream, a socket cannot rewind: a second read returns nothing. + $once = static function (int $status, string $json): ResponseInterface { + $pair = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + \assert($pair !== false); + fwrite($pair[0], $json); + fclose($pair[0]); + + return new Response($status, ['Content-Type' => 'application/json'], Stream::create($pair[1])); + }; + $http = (new MockHttpClient())->queue( + $once(429, '{"error":"too many requests"}'), + $once(200, '{"data":[],"total":7}'), + ); + + try { + Clients::history($http, null, ['max_retries' => 0])->history->search(LookupType::Ip, self::IP); + self::fail('Expected a RateLimitException'); + } catch (RateLimitException $exception) { + self::assertSame('too many requests', $exception->getErrorMessage()); + } + self::assertSame(7, Clients::history($http)->history->search(LookupType::Ip, self::IP)->total); + } + + public function testManagementNeverRetriesRateLimits(): void + { + $http = (new MockHttpClient())->always(MockHttpClient::json(429, ['error' => 'too many requests'], ['Retry-After' => '1'])); + $clock = new FakeClock(); + + $this->expectException(RateLimitException::class); + try { + Clients::management($http, $clock, ['max_retries' => 5])->getProfile(); + } finally { + self::assertCount(1, $http->requests); + self::assertSame([], $clock->sleeps); + } + } + + public function testManagementRetriesServerErrors(): void + { + $http = (new MockHttpClient())->queue( + MockHttpClient::json(503, ['error' => 'server is busy']), + MockHttpClient::json(200, ['Domain' => 'example.com', 'Weight' => -5, 'CreatedAt' => '0001-01-01T00:00:00Z']), + ); + + $profile = Clients::management($http)->getProfile(); + + self::assertSame(-5, $profile->remaining_identifications); + self::assertSame('0001-01-01T00:00:00.000Z', $profile->toArray()['created_at']); + self::assertCount(2, $http->requests); + } +} diff --git a/tests/Unit/RiskEvaluateTest.php b/tests/Unit/RiskEvaluateTest.php new file mode 100644 index 0000000..529fc72 --- /dev/null +++ b/tests/Unit/RiskEvaluateTest.php @@ -0,0 +1,217 @@ +getTimestamp()); + } + + public function testAllowsAFreshTrustedIdentification(): void + { + $evaluation = Risk::evaluate(self::identification(), ['now' => self::NOW]); + + self::assertTrue($evaluation->ok); + self::assertNull($evaluation->reason); + self::assertSame(RiskBand::Trusted, $evaluation->band); + self::assertNull($evaluation->flag); + self::assertSame(['ok' => true, 'reason' => null, 'band' => 'trusted', 'flag' => null], $evaluation->toArray()); + self::assertSame('{"ok":true,"reason":null,"band":"trusted","flag":null}', json_encode($evaluation)); + } + + public function testSuspiciousPassesWithTheDefaults(): void + { + $evaluation = Risk::evaluate(self::identification(45), ['now' => self::NOW]); + + self::assertTrue($evaluation->ok); + self::assertSame(RiskBand::Suspicious, $evaluation->band); + } + + public function testMissingIdentificationIsNeverClean(): void + { + $evaluation = Risk::evaluate(null); + + self::assertFalse($evaluation->ok); + self::assertSame(EvaluationReason::Missing, $evaluation->reason); + self::assertNull($evaluation->band); + } + + public function testReplayCallbackRunsBeforeTheOtherChecks(): void + { + $seen = []; + $isReplay = static function (string $requestId) use (&$seen): bool { + $seen[] = $requestId; + + return true; + }; + + $evaluation = Risk::evaluate(self::identification(999, observedAt: null), ['is_replay' => $isReplay]); + + self::assertSame(EvaluationReason::Replayed, $evaluation->reason); + self::assertSame(RiskBand::RateLimited, $evaluation->band); + self::assertSame(['3b241101-e2bb-4255-8caf-4136c566a962'], $seen); + } + + public function testReplayCallbackReturningFalseContinues(): void + { + $evaluation = Risk::evaluate(self::identification(), ['now' => self::NOW, 'is_replay' => static fn(string $id): bool => false]); + + self::assertTrue($evaluation->ok); + } + + public function testStaleIdentificationsAreRefused(): void + { + self::assertSame(EvaluationReason::Stale, Risk::evaluate(self::identification(), ['now' => self::NOW + 181])->reason); + self::assertTrue(Risk::evaluate(self::identification(), ['now' => self::NOW + 180])->ok, 'exactly 300 s old is still fresh'); + self::assertSame(EvaluationReason::Stale, Risk::evaluate(self::identification(), ['now' => self::NOW, 'max_age' => 60])->reason); + self::assertTrue(Risk::evaluate(self::identification(), ['now' => self::NOW, 'max_age' => 120.5])->ok); + } + + public function testMissingTimestampIsStale(): void + { + self::assertSame(EvaluationReason::Stale, Risk::evaluate(self::identification(observedAt: null), ['now' => self::NOW])->reason); + } + + public function testAcceptsNowAsADateTime(): void + { + $now = new \DateTimeImmutable('2026-09-30T14:05:00+02:00'); + + self::assertTrue(Risk::evaluate(self::identification(), ['now' => $now])->ok); + self::assertSame(EvaluationReason::Stale, Risk::evaluate(self::identification(), ['now' => $now->modify('+10 minutes')])->reason); + } + + public function testDefaultNowIsTheCurrentTime(): void + { + $fresh = self::identification(observedAt: (new \DateTimeImmutable('-30 seconds'))->format(\DATE_ATOM)); + + self::assertTrue(Risk::evaluate($fresh)->ok); + self::assertSame(EvaluationReason::Stale, Risk::evaluate(self::identification(observedAt: '2020-01-01T00:00:00Z'))->reason); + } + + public function testRateLimitMarkerComesBeforeDeviceAndFlags(): void + { + $evaluation = Risk::evaluate( + self::identification(999, Identification::NIL_UUID, new DetectionFlags(browser_automation: true)), + ['now' => self::NOW], + ); + + self::assertSame(EvaluationReason::RateLimited, $evaluation->reason); + self::assertSame(RiskBand::RateLimited, $evaluation->band); + } + + public function testNilDeviceIdMeansNoDeviceSignals(): void + { + $evaluation = Risk::evaluate(self::identification(5, Identification::NIL_UUID, new DetectionFlags(javascript_disabled: true)), ['now' => self::NOW]); + + self::assertSame(EvaluationReason::NoDeviceSignals, $evaluation->reason); + self::assertSame(RiskBand::Trusted, $evaluation->band); + self::assertSame(EvaluationReason::NoDeviceSignals, Risk::evaluate(self::identification(deviceId: ''), ['now' => self::NOW])->reason); + } + + public function testBlocksAutomationAndDisabledJavaScriptByDefault(): void + { + $automation = Risk::evaluate(self::identification(60, flags: new DetectionFlags(browser_automation: true)), ['now' => self::NOW]); + self::assertSame(EvaluationReason::BlockedFlag, $automation->reason); + self::assertSame('browser_automation', $automation->flag); + self::assertSame(RiskBand::Dangerous, $automation->band); + + $noJs = Risk::evaluate(self::identification(flags: new DetectionFlags(javascript_disabled: true)), ['now' => self::NOW]); + self::assertSame('javascript_disabled', $noJs->flag); + + self::assertTrue(Risk::evaluate(self::identification(flags: new DetectionFlags(vpn: true, incognito: true)), ['now' => self::NOW])->ok); + } + + public function testBlocksTheDangerousBandByDefault(): void + { + $evaluation = Risk::evaluate(self::identification(60), ['now' => self::NOW]); + + self::assertFalse($evaluation->ok); + self::assertSame(EvaluationReason::BlockedBand, $evaluation->reason); + self::assertSame(RiskBand::Dangerous, $evaluation->band); + } + + public function testCustomBandsAndFlags(): void + { + $options = ['now' => self::NOW, 'block_bands' => ['suspicious', RiskBand::Dangerous], 'block_flags' => ['vpn', 'tor']]; + + self::assertSame(EvaluationReason::BlockedBand, Risk::evaluate(self::identification(30), $options)->reason); + $vpn = Risk::evaluate(self::identification(10, flags: new DetectionFlags(vpn: true)), $options); + self::assertSame(['blocked_flag', 'vpn'], [$vpn->reason?->value, $vpn->flag]); + self::assertTrue(Risk::evaluate(self::identification(10, flags: new DetectionFlags(browser_automation: true)), $options)->ok); + self::assertTrue(Risk::evaluate(self::identification(95), ['now' => self::NOW, 'block_bands' => [], 'block_flags' => []])->ok); + } + + /** + * @return iterable}> + */ + public static function invalidOptions(): iterable + { + yield 'unknown option' => [['maxAge' => 10]]; + yield 'negative max age' => [['max_age' => -1]]; + yield 'max age as string' => [['max_age' => '300']]; + yield 'bad now' => [['now' => 'yesterday']]; + yield 'bad band' => [['block_bands' => ['high']]]; + yield 'bands not a list' => [['block_bands' => 'dangerous']]; + yield 'bad flag' => [['block_flags' => ['bot']]]; + yield 'flags not a list' => [['block_flags' => 'vpn']]; + yield 'replay not callable' => [['is_replay' => 'not a function name']]; + } + + /** + * @param array $options + */ + #[DataProvider('invalidOptions')] + public function testRejectsInvalidOptions(array $options): void + { + $this->expectException(ValidationException::class); + /** @phpstan-ignore argument.type */ + Risk::evaluate(self::identification(), $options); + } +} diff --git a/tests/Unit/UserHidTest.php b/tests/Unit/UserHidTest.php new file mode 100644 index 0000000..90a985a --- /dev/null +++ b/tests/Unit/UserHidTest.php @@ -0,0 +1,51 @@ +expectException(ValidationException::class); + UserHid::fromUserId('', 'secret'); + } + + public function testRejectsAnEmptySecret(): void + { + $this->expectException(ValidationException::class); + UserHid::fromUserId('user-42', ''); + } +} diff --git a/tests/Unit/WebhookEventTest.php b/tests/Unit/WebhookEventTest.php new file mode 100644 index 0000000..c188ce7 --- /dev/null +++ b/tests/Unit/WebhookEventTest.php @@ -0,0 +1,211 @@ + + */ + private static function expected(string $caseName): array + { + foreach (Fixtures::json('normalization-cases.json')['cases'] as $case) { + \assert(\is_array($case)); + if ($case['name'] === $caseName) { + \assert(\is_array($case['expected'])); + + return $case['expected']; + } + } + + throw new \LogicException('No case ' . $caseName); + } + + public function testParsesTheScoredEventFromTheExactBytes(): void + { + $body = Fixtures::raw('webhook-identification-scored.raw.txt'); + $event = Webhook::constructEvent($body, self::sign($body), self::SECRET); + + self::assertInstanceOf(IdentificationScoredEvent::class, $event); + self::assertSame('identification.scored', $event->event_type); + self::assertSame('2026-06-01', $event->schema_version); + self::assertSame('2026-09-30T12:34:57.482913+00:00', $event->created_at?->format('Y-m-d\TH:i:s.uP')); + self::assertSame(self::expected('webhook_scored'), $event->data->toArray()); + self::assertSame('https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123', $event->data->traffic_source->landing_url); + self::assertSame(RiskBand::Dangerous, $event->data->riskBand()); + self::assertSame(['proxy', 'datacenter_ip', 'anti_detect_browser', 'ip_mismatch', 'suspicious_paid_click'], $event->data->detection_flags->active()); + self::assertIsArray($event->raw['data']); + self::assertSame($event->raw['data'], $event->data->raw); + } + + public function testPrettyAndCompactScoredBodiesGiveTheSameIdentification(): void + { + $pretty = Fixtures::raw('webhook-identification-scored.json'); + $compact = Fixtures::raw('webhook-identification-scored.raw.txt'); + $a = Webhook::constructEvent($pretty, self::sign($pretty), self::SECRET); + $b = Webhook::constructEvent($compact, self::sign($compact), self::SECRET); + + self::assertInstanceOf(IdentificationScoredEvent::class, $a); + self::assertInstanceOf(IdentificationScoredEvent::class, $b); + self::assertSame($a->data->toArray(), $b->data->toArray()); + } + + public function testParsesTheVerifyPing(): void + { + $body = Fixtures::raw('webhook-ping.raw.txt'); + $event = Webhook::constructEvent($body, 'sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8', self::SECRET); + + self::assertInstanceOf(WebhookPingEvent::class, $event); + self::assertInstanceOf(WebhookEvent::class, $event); + self::assertSame('webhook.ping', $event->event_type); + self::assertSame('2026-06-01', $event->schema_version); + self::assertSame('2026-09-30T12:34:56+00:00', $event->created_at?->format(\DATE_ATOM)); + self::assertArrayNotHasKey('data', $event->raw); + } + + public function testPrettyPingParsesLikeTheExactBytes(): void + { + $pretty = Fixtures::raw('webhook-ping.json'); + $event = Webhook::constructEvent($pretty, self::sign($pretty), self::SECRET); + + self::assertInstanceOf(WebhookPingEvent::class, $event); + self::assertSame(Fixtures::json('webhook-ping.json'), $event->raw); + self::assertSame(json_decode(Fixtures::raw('webhook-ping.raw.txt'), true), $event->raw); + self::assertFalse(Webhook::verifySignature($pretty, 'sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8', self::SECRET)); + } + + public function testParsesTheRateLimitMarkerEvent(): void + { + $body = Fixtures::raw('webhook-rate-limited.json'); + $event = Webhook::constructEvent($body, self::sign($body), self::SECRET); + + self::assertInstanceOf(IdentificationScoredEvent::class, $event); + self::assertSame(self::expected('webhook_rate_limited'), $event->data->toArray()); + self::assertTrue($event->data->isRateLimited()); + self::assertSame(RiskBand::RateLimited, $event->data->riskBand()); + self::assertFalse($event->data->hasDeviceSignals()); + self::assertSame('2026-09-30T13:10:00.500000', $event->created_at?->format('Y-m-d\TH:i:s.u')); + } + + public function testParsesTheDashboardTestDeliveryWithSeventeenFlags(): void + { + $body = Fixtures::raw('webhook-test-delivery.json'); + $decoded = Fixtures::json('webhook-test-delivery.json'); + \assert(\is_array($decoded['data']) && \is_array($decoded['data']['detection_flags'])); + self::assertCount(17, $decoded['data']['detection_flags']); + + $event = Webhook::constructEvent($body, self::sign($body), self::SECRET); + + self::assertInstanceOf(IdentificationScoredEvent::class, $event); + self::assertSame(self::expected('webhook_test_delivery'), $event->data->toArray()); + self::assertFalse($event->data->detection_flags->browser_automation); + self::assertFalse($event->data->detection_flags->search_bot); + self::assertCount(\count(DetectionFlags::KEYS), $event->data->detection_flags->toArray()); + self::assertNull($event->data->user_hid); + self::assertSame('2026-09-30T12:34:56.000Z', $event->data->toArray()['observed_at']); + } + + public function testReturnsAnUnknownEventForNewEventTypes(): void + { + $body = '{"event_type":"identification.refined","schema_version":"2027-01-01","created_at":"2027-01-01T00:00:00Z","data":{"x":1}}'; + $event = Webhook::constructEvent($body, self::sign($body), self::SECRET); + + self::assertInstanceOf(UnknownWebhookEvent::class, $event); + self::assertSame('identification.refined', $event->event_type); + self::assertSame('2027-01-01', $event->schema_version); + self::assertSame(['x' => 1], $event->raw['data']); + } + + public function testAcceptsAnUnknownSchemaVersionAndTolerantData(): void + { + $body = '{"event_type":"identification.scored","schema_version":"2099-01-01","created_at":"not a date","data":{"request_id":"3b241101-e2bb-4255-8caf-4136c566a962","extra":{"nested":true}}}'; + $event = Webhook::constructEvent($body, self::sign($body), self::SECRET); + + self::assertInstanceOf(IdentificationScoredEvent::class, $event); + self::assertSame('2099-01-01', $event->schema_version); + self::assertNull($event->created_at); + self::assertSame('3b241101-e2bb-4255-8caf-4136c566a962', $event->data->request_id); + self::assertSame(0, $event->data->risk_score); + self::assertSame([], $event->data->signals); + self::assertNull($event->data->observed_at); + } + + public function testRejectsABadSignatureBeforeParsing(): void + { + $this->expectException(SignatureVerificationException::class); + $this->expectExceptionMessage('does not match'); + Webhook::constructEvent('not json', self::sign('other body'), self::SECRET); + } + + public function testExplainsAMissingHeader(): void + { + $this->expectException(SignatureVerificationException::class); + $this->expectExceptionMessage('missing'); + Webhook::constructEvent('{}', ' ', self::SECRET); + } + + public function testExplainsAMalformedHeader(): void + { + $this->expectException(SignatureVerificationException::class); + $this->expectExceptionMessage('malformed'); + Webhook::constructEvent('{}', 'sha1=abc', self::SECRET); + } + + public function testExplainsAMissingSecret(): void + { + $this->expectException(SignatureVerificationException::class); + $this->expectExceptionMessage('No webhook signing secret'); + Webhook::constructEvent('{}', self::sign('{}'), ''); + } + + /** + * @return iterable + */ + public static function malformedBodies(): iterable + { + yield 'invalid JSON' => ['{"event_type":', 'not valid JSON']; + yield 'JSON list' => ['[1,2]', 'not a JSON object']; + yield 'JSON string' => ['"x"', 'not a JSON object']; + yield 'no event type' => ['{"schema_version":"2026-06-01"}', 'no event_type']; + yield 'empty object' => ['{}', 'no event_type']; + yield 'scored without data' => ['{"event_type":"identification.scored"}', 'no data object']; + yield 'scored with list data' => ['{"event_type":"identification.scored","data":[1]}', 'no data object']; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('malformedBodies')] + public function testRejectsAVerifiedBodyThatIsNotAnEvent(string $body, string $message): void + { + $this->expectException(WebhookParseException::class); + $this->expectExceptionMessage($message); + Webhook::constructEvent($body, self::sign($body), self::SECRET); + } + + public function testAcceptsASecretListDuringRotation(): void + { + $body = Fixtures::raw('webhook-ping.raw.txt'); + $event = Webhook::constructEvent($body, self::sign($body), ['whsec_new_secret_not_yet_live', self::SECRET]); + + self::assertInstanceOf(WebhookPingEvent::class, $event); + } +} diff --git a/tests/Unit/WebhookSignatureTest.php b/tests/Unit/WebhookSignatureTest.php new file mode 100644 index 0000000..c3368d5 --- /dev/null +++ b/tests/Unit/WebhookSignatureTest.php @@ -0,0 +1,109 @@ +, bool}> + */ + public static function vectors(): iterable + { + $fixture = Fixtures::json('webhook-signature-vectors.json'); + \assert(\is_array($fixture['vectors'])); + foreach ($fixture['vectors'] as $vector) { + \assert(\is_array($vector) && \is_string($vector['name']) && \is_string($vector['body_base64'])); + \assert(\is_string($vector['signature_header']) && \is_bool($vector['valid'])); + $body = base64_decode($vector['body_base64'], true); + \assert(\is_string($body)); + $secret = $vector['secrets'] ?? $vector['secret']; + \assert(\is_string($secret) || \is_array($secret)); + /** @var string|list $secret */ + yield $vector['name'] => [$body, $vector['signature_header'], $secret, $vector['valid']]; + } + } + + public function testHasAllSharedVectors(): void + { + self::assertCount(21, iterator_to_array(self::vectors())); + } + + /** + * @param string|list $secret + */ + #[DataProvider('vectors')] + public function testVerifiesTheSharedVector(string $body, string $header, string|array $secret, bool $valid): void + { + self::assertSame($valid, Webhook::verifySignature($body, $header, $secret)); + } + + /** + * @param string|list $secret + */ + #[DataProvider('vectors')] + public function testConstructEventAgreesWithTheVector(string $body, string $header, string|array $secret, bool $valid): void + { + if ($valid) { + $event = Webhook::constructEvent($body, $header, $secret); + self::assertNotSame('', $event->event_type); + + return; + } + + $this->expectException(SignatureVerificationException::class); + Webhook::constructEvent($body, $header, $secret); + } + + public function testBodyFieldMatchesTheBase64Bytes(): void + { + $fixture = Fixtures::json('webhook-signature-vectors.json'); + \assert(\is_array($fixture['vectors'])); + foreach ($fixture['vectors'] as $vector) { + \assert(\is_array($vector)); + self::assertSame($vector['body'], base64_decode((string) $vector['body_base64'], true), (string) $vector['name']); + } + } + + public function testRejectsAMissingHeader(): void + { + self::assertFalse(Webhook::verifySignature('{}', null, 'whsec_x')); + } + + public function testRejectsAnEmptySecretList(): void + { + $body = Fixtures::raw('webhook-ping.raw.txt'); + $header = 'sha256=' . hash_hmac('sha256', $body, 'whsec_x'); + + self::assertTrue(Webhook::verifySignature($body, $header, ['whsec_x'])); + self::assertFalse(Webhook::verifySignature($body, $header, [])); + self::assertFalse(Webhook::verifySignature($body, $header, [''])); + } + + public function testIgnoresEmptyAndNonStringSecretsInAList(): void + { + $body = Fixtures::raw('webhook-ping.raw.txt'); + $header = 'sha256=' . hash_hmac('sha256', $body, 'whsec_rotated'); + + /** @var list $secrets */ + $secrets = ['', 42, 'whsec_rotated']; + self::assertTrue(Webhook::verifySignature($body, $header, $secrets)); + } + + public function testRequiresTheLowercasePrefix(): void + { + $body = Fixtures::raw('webhook-ping.raw.txt'); + $digest = hash_hmac('sha256', $body, 'whsec_x'); + + self::assertFalse(Webhook::verifySignature($body, 'SHA256=' . $digest, 'whsec_x')); + self::assertFalse(Webhook::verifySignature($body, 'sha256=' . $digest . 'ab', 'whsec_x')); + self::assertFalse(Webhook::verifySignature($body, 'sha256=' . $digest . "\nx", 'whsec_x')); + } +} diff --git a/tests/VerifyWebhookTest.php b/tests/VerifyWebhookTest.php deleted file mode 100644 index c9e2564..0000000 --- a/tests/VerifyWebhookTest.php +++ /dev/null @@ -1,47 +0,0 @@ -assertTrue(Client::verifyWebhook(self::BODY, $this->sign(self::SECRET, self::BODY), self::SECRET)); - } - - public function testRejectsWrongSecret(): void - { - $this->assertFalse(Client::verifyWebhook(self::BODY, $this->sign(self::SECRET, self::BODY), 'other')); - } - - public function testRejectsTamperedBody(): void - { - $this->assertFalse(Client::verifyWebhook(self::BODY . ' ', $this->sign(self::SECRET, self::BODY), self::SECRET)); - } - - public function testRejectsMissingHeader(): void - { - $this->assertFalse(Client::verifyWebhook(self::BODY, '', self::SECRET)); - } - - public function testRejectsTruncatedSignature(): void - { - $this->assertFalse(Client::verifyWebhook(self::BODY, 'sha256=ab', self::SECRET)); - } - - public function testRejectsEmptySecret(): void - { - $this->assertFalse(Client::verifyWebhook(self::BODY, $this->sign('x', self::BODY), '')); - } -} diff --git a/tests/data/README.md b/tests/data/README.md new file mode 100644 index 0000000..0cc55f3 --- /dev/null +++ b/tests/data/README.md @@ -0,0 +1,7 @@ +# Shared test fixtures + +Shared test data for the ShieldLabs server SDKs: History API responses, webhook +deliveries, signature vectors, Management API profiles, error responses and the +expected normalized identifications. Every ShieldLabs server SDK runs the same +files, so do not edit them by hand. The `.raw.txt` files hold exact request bodies: +keep their bytes unchanged (no added newline). diff --git a/tests/data/error-responses.json b/tests/data/error-responses.json new file mode 100644 index 0000000..2c96d9b --- /dev/null +++ b/tests/data/error-responses.json @@ -0,0 +1,109 @@ +{ + "description": "Error responses exactly as the ShieldLabs servers send them. Bodies are not uniform: parse defensively and branch on status.", + "cases": [ + { + "surface": "history", + "status": 401, + "content_type": "text/plain; charset=utf-8", + "body": "{\"error\":\"missing or invalid authorization header\"}\n", + "expected_error": "AuthenticationError", + "retry": false + }, + { + "surface": "history", + "status": 401, + "content_type": "text/plain; charset=utf-8", + "body": "{\"error\":\"invalid api key\"}\n", + "expected_error": "AuthenticationError", + "retry": false + }, + { + "surface": "history", + "status": 429, + "content_type": "application/json", + "body": "{\"error\":\"too many requests\"}\n", + "expected_error": "RateLimitError", + "retry": true + }, + { + "surface": "history", + "status": 500, + "content_type": "application/json", + "body": "{\"error\":\"code: 53, message: Cannot convert string 'abc' to type UUID\"}\n", + "expected_error": "ServerError", + "retry": true + }, + { + "surface": "history", + "status": 500, + "content_type": "text/plain; charset=utf-8", + "body": "{\"error\":\"internal error\"}\n", + "expected_error": "ServerError", + "retry": true + }, + { + "surface": "history", + "status": 404, + "content_type": "text/plain; charset=utf-8", + "body": "404 page not found", + "expected_error": "NotFoundError", + "retry": false + }, + { + "surface": "history", + "status": 502, + "content_type": "text/html", + "body": "

502 Bad Gateway

", + "expected_error": "ServerError", + "retry": true + }, + { + "surface": "management", + "status": 401, + "content_type": null, + "body": "", + "expected_error": "AuthenticationError", + "retry": false + }, + { + "surface": "management", + "status": 429, + "content_type": "application/json; charset=utf-8", + "body": "{\"error\":\"too many requests\"}", + "expected_error": "RateLimitError", + "retry": false + }, + { + "surface": "management", + "status": 503, + "content_type": "application/json; charset=utf-8", + "body": "{\"error\":\"server is busy\"}", + "expected_error": "ServerError", + "retry": true + }, + { + "surface": "management", + "status": 400, + "content_type": "application/json; charset=utf-8", + "body": "null", + "expected_error": "BadRequestError", + "retry": false + }, + { + "surface": "management", + "status": 402, + "content_type": null, + "body": "", + "expected_error": "QuotaExceededError", + "retry": false + }, + { + "surface": "management", + "status": 404, + "content_type": "text/plain; charset=utf-8", + "body": "404 page not found", + "expected_error": "NotFoundError", + "retry": false + } + ] +} diff --git a/tests/data/history-empty.json b/tests/data/history-empty.json new file mode 100644 index 0000000..e88e669 --- /dev/null +++ b/tests/data/history-empty.json @@ -0,0 +1,4 @@ +{ + "data": [], + "total": 0 +} diff --git a/tests/data/history-page.json b/tests/data/history-page.json new file mode 100644 index 0000000..047866b --- /dev/null +++ b/tests/data/history-page.json @@ -0,0 +1,278 @@ +{ + "data": [ + { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "domain": "shop.example.com", + "site_domain": "example.com", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "ip": "203.0.113.24", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Netherlands", + "connection_type": "proxy", + "score": 80, + "score_details": "[{\"Value\":10,\"Description\":\"Is proxy\"},{\"Value\":10,\"Description\":\"Is datacenter\"},{\"Value\":60,\"Description\":\"Antidetect browser (turn_block)\"},{\"Value\":0,\"Description\":\"Check Incomplete\"}]", + "created_at": "2026-09-30 12:34:56.123", + "ver": 1790771696123, + "web_rtc_ip": "198.51.100.23", + "web_rtc_country": "Germany", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "none", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": true, + "is_datacenter": true, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": true, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": true, + "is_scanner_stun_passed": false, + "stun_flow_status": "ok", + "entry_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "utm_source": "google", + "utm_medium": "cpc", + "traffic_channel": "Google Ads", + "traffic_channel_group": "Paid Search", + "traffic_reason": "gclid_present", + "click_id_type": "gclid", + "is_suspicious_paid_click": true + }, + { + "request_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", + "session_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "cookie_id": "f0e1d2c3-b4a5-4968-8776-655443322110", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "5d9a1f3e-7b2c-5e4d-8f6a-9b0c1d2e3f4a", + "visitor_id": "3c4d5e6f-7a8b-5c9d-8e0f-1a2b3c4d5e6f", + "ip": "192.0.2.44", + "os": "Mac OS X", + "browser": "Safari", + "device_type": "desktop", + "country": "United States", + "connection_type": "direct", + "score": 10, + "score_details": "[{\"Value\":10,\"Description\":\"Browser timezone ≠ IP-timezone\"}]", + "created_at": "2026-09-30 12:40:01.007", + "ver": 1790772001007, + "web_rtc_ip": "192.0.2.44", + "web_rtc_country": "United States", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": true, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": true, + "is_search_bot": false, + "stun_request_seen": true, + "is_scanner_stun_passed": false, + "stun_flow_status": "ok" + }, + { + "request_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", + "session_id": "b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e", + "cookie_id": "c3d4e5f6-a7b8-4c9d-8e0f-a1b2c3d4e5f6", + "domain": "example.com", + "user_hid": "", + "device_id": "e1f2a3b4-c5d6-5e7f-8a9b-0c1d2e3f4a5b", + "visitor_id": "d2e3f4a5-b6c7-5d8e-9f0a-1b2c3d4e5f6a", + "ip": "198.51.100.7", + "os": "Android", + "browser": "Chrome", + "device_type": "mobile", + "country": "France", + "connection_type": "vpn", + "score": 45, + "score_details": "[{\"Value\":15,\"Description\":\"Is VPN\"},{\"Value\":30,\"Description\":\"Stun is not checked\"},{\"Value\":-30,\"Description\":\"Stun passed (late arrival, corrected)\"},{\"Value\":30,\"Description\":\"Sticky verdict: Stun is not checked (request 11111111-2222-4333-8444-555555555555)\"},{\"Value\":0,\"Description\":\"IP ≠ leakIP (198.51.100.7 ≠ 203.0.113.9, source=scanner)\"}]", + "created_at": "2026-09-30 13:05:12", + "ver": 1790773512000, + "web_rtc_ip": "0.0.0.0", + "web_rtc_country": "", + "web_rtc_connection_type": "", + "scanner_web_rtc_ip": "203.0.113.9", + "scanner_web_rtc_country": "Spain", + "scanner_web_rtc_connection_type": "direct", + "webrtc_leak_ip": "203.0.113.9", + "webrtc_leak_country": "Spain", + "webrtc_leak_connection_type": "direct", + "webrtc_leak_source": "scanner", + "tcp_mss": 1380, + "mtu_value": 1420, + "mtu_hint": "vpn_likely", + "is_vpn": true, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": true, + "check_incomplete": true, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": false, + "is_scanner_stun_passed": true, + "stun_flow_status": "reply_without_request", + "entry_url": "https://example.com/pricing", + "referrer_domain": "news.example.org", + "traffic_channel": "Referral", + "traffic_channel_group": "Referral", + "traffic_reason": "external_referrer" + }, + { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "domain": "example.com", + "user_hid": "-1", + "device_id": "00000000-0000-0000-0000-000000000000", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "ip": "203.0.113.200", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "country": "", + "connection_type": "unknown", + "score": 999, + "score_details": "[{\"Value\":999,\"Description\":\"User has been banned 1H, to many requests\"}]", + "created_at": "2026-09-30 13:10:00.500", + "ver": 1790773800500, + "web_rtc_ip": "0.0.0.0", + "web_rtc_country": "", + "web_rtc_connection_type": "", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "", + "tcp_mss": 0, + "mtu_value": 0, + "mtu_hint": "", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": false, + "is_scanner_stun_passed": false, + "stun_flow_status": "" + }, + { + "request_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", + "session_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", + "cookie_id": "6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7c8d9e0f-1a2b-5c3d-8e4f-5a6b7c8d9e0f", + "visitor_id": "8d9e0f1a-2b3c-5d4e-9f5a-6b7c8d9e0f1a", + "ip": "198.51.100.66", + "os": "Linux", + "browser": "Chrome", + "device_type": "desktop", + "country": "United States", + "connection_type": "proxy", + "score": 0, + "score_details": "", + "created_at": "2026-09-30 13:20:30.250", + "ver": 1790774430250, + "web_rtc_ip": "203.0.113.77", + "web_rtc_country": "United States", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "none", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": true, + "stun_request_seen": false, + "is_scanner_stun_passed": false, + "stun_flow_status": "", + "referrer_domain": "GoogleBot", + "traffic_channel": "Search bot", + "traffic_channel_group": "Bot", + "traffic_reason": "ip_crawler_detected" + } + ], + "total": 37 +} diff --git a/tests/data/management-profile-expected.json b/tests/data/management-profile-expected.json new file mode 100644 index 0000000..99c5f3f --- /dev/null +++ b/tests/data/management-profile-expected.json @@ -0,0 +1,7 @@ +{ + "domain": "example.com", + "remaining_identifications": 148230, + "public_key_masked": "****************************a3f8", + "secret_key_masked": "****************************9c2d", + "created_at": "2026-01-15T09:00:00.000Z" +} diff --git a/tests/data/management-profile.json b/tests/data/management-profile.json new file mode 100644 index 0000000..b0d1c74 --- /dev/null +++ b/tests/data/management-profile.json @@ -0,0 +1,8 @@ +{ + "Domain": "example.com", + "Weight": 148230, + "Callback": "", + "PublicKey": "****************************a3f8", + "Secret": "****************************9c2d", + "CreatedAt": "2026-01-15T09:00:00Z" +} diff --git a/tests/data/normalization-cases.json b/tests/data/normalization-cases.json new file mode 100644 index 0000000..0ccaff3 --- /dev/null +++ b/tests/data/normalization-cases.json @@ -0,0 +1,1050 @@ +{ + "description": "History API rows and webhook data objects with the Identification every SDK must produce. Compare observed_at at millisecond precision (truncate, never round). Keys not listed in expected (for example raw) are free.", + "cases": [ + { + "name": "history_02f1d973", + "source": "history", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "domain": "shop.example.com", + "site_domain": "example.com", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "ip": "203.0.113.24", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Netherlands", + "connection_type": "proxy", + "score": 80, + "score_details": "[{\"Value\":10,\"Description\":\"Is proxy\"},{\"Value\":10,\"Description\":\"Is datacenter\"},{\"Value\":60,\"Description\":\"Antidetect browser (turn_block)\"},{\"Value\":0,\"Description\":\"Check Incomplete\"}]", + "created_at": "2026-09-30 12:34:56.123", + "ver": 1790771696123, + "web_rtc_ip": "198.51.100.23", + "web_rtc_country": "Germany", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "none", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": true, + "is_datacenter": true, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": true, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": true, + "is_scanner_stun_passed": false, + "stun_flow_status": "ok", + "entry_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "utm_source": "google", + "utm_medium": "cpc", + "traffic_channel": "Google Ads", + "traffic_channel_group": "Paid Search", + "traffic_reason": "gclid_present", + "click_id_type": "gclid", + "is_suspicious_paid_click": true + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": "Is proxy" + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": "Is datacenter" + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": "Antidetect browser (turn_block)" + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:56.123Z", + "source": "history" + } + }, + { + "name": "history_7c1e2f4a", + "source": "history", + "input": { + "request_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", + "session_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "cookie_id": "f0e1d2c3-b4a5-4968-8776-655443322110", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "5d9a1f3e-7b2c-5e4d-8f6a-9b0c1d2e3f4a", + "visitor_id": "3c4d5e6f-7a8b-5c9d-8e0f-1a2b3c4d5e6f", + "ip": "192.0.2.44", + "os": "Mac OS X", + "browser": "Safari", + "device_type": "desktop", + "country": "United States", + "connection_type": "direct", + "score": 10, + "score_details": "[{\"Value\":10,\"Description\":\"Browser timezone ≠ IP-timezone\"}]", + "created_at": "2026-09-30 12:40:01.007", + "ver": 1790772001007, + "web_rtc_ip": "192.0.2.44", + "web_rtc_country": "United States", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": true, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": true, + "is_search_bot": false, + "stun_request_seen": true, + "is_scanner_stun_passed": false, + "stun_flow_status": "ok" + }, + "expected": { + "request_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", + "visitor_id": "3c4d5e6f-7a8b-5c9d-8e0f-1a2b3c4d5e6f", + "device_id": "5d9a1f3e-7b2c-5e4d-8f6a-9b0c1d2e3f4a", + "session_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "cookie_id": "f0e1d2c3-b4a5-4968-8776-655443322110", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "192.0.2.44", + "country": "United States" + }, + "local_ip": { + "ip": "192.0.2.44", + "country": "United States" + }, + "connection_type": "direct", + "os": "Mac OS X", + "browser": "Safari", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 10, + "signals": [ + { + "name": "timezone_mismatch", + "weight": 10, + "description": "Browser timezone ≠ IP-timezone" + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": true, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": true, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:40:01.007Z", + "source": "history" + } + }, + { + "name": "history_9e8d7c6b", + "source": "history", + "input": { + "request_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", + "session_id": "b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e", + "cookie_id": "c3d4e5f6-a7b8-4c9d-8e0f-a1b2c3d4e5f6", + "domain": "example.com", + "user_hid": "", + "device_id": "e1f2a3b4-c5d6-5e7f-8a9b-0c1d2e3f4a5b", + "visitor_id": "d2e3f4a5-b6c7-5d8e-9f0a-1b2c3d4e5f6a", + "ip": "198.51.100.7", + "os": "Android", + "browser": "Chrome", + "device_type": "mobile", + "country": "France", + "connection_type": "vpn", + "score": 45, + "score_details": "[{\"Value\":15,\"Description\":\"Is VPN\"},{\"Value\":30,\"Description\":\"Stun is not checked\"},{\"Value\":-30,\"Description\":\"Stun passed (late arrival, corrected)\"},{\"Value\":30,\"Description\":\"Sticky verdict: Stun is not checked (request 11111111-2222-4333-8444-555555555555)\"},{\"Value\":0,\"Description\":\"IP ≠ leakIP (198.51.100.7 ≠ 203.0.113.9, source=scanner)\"}]", + "created_at": "2026-09-30 13:05:12", + "ver": 1790773512000, + "web_rtc_ip": "0.0.0.0", + "web_rtc_country": "", + "web_rtc_connection_type": "", + "scanner_web_rtc_ip": "203.0.113.9", + "scanner_web_rtc_country": "Spain", + "scanner_web_rtc_connection_type": "direct", + "webrtc_leak_ip": "203.0.113.9", + "webrtc_leak_country": "Spain", + "webrtc_leak_connection_type": "direct", + "webrtc_leak_source": "scanner", + "tcp_mss": 1380, + "mtu_value": 1420, + "mtu_hint": "vpn_likely", + "is_vpn": true, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": true, + "check_incomplete": true, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": false, + "is_scanner_stun_passed": true, + "stun_flow_status": "reply_without_request", + "entry_url": "https://example.com/pricing", + "referrer_domain": "news.example.org", + "traffic_channel": "Referral", + "traffic_channel_group": "Referral", + "traffic_reason": "external_referrer" + }, + "expected": { + "request_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", + "visitor_id": "d2e3f4a5-b6c7-5d8e-9f0a-1b2c3d4e5f6a", + "device_id": "e1f2a3b4-c5d6-5e7f-8a9b-0c1d2e3f4a5b", + "session_id": "b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e", + "cookie_id": "c3d4e5f6-a7b8-4c9d-8e0f-a1b2c3d4e5f6", + "user_hid": null, + "domain": "example.com", + "public_ip": { + "ip": "198.51.100.7", + "country": "France" + }, + "local_ip": { + "ip": "203.0.113.9", + "country": "Spain" + }, + "connection_type": "vpn", + "os": "Android", + "browser": "Chrome", + "device_type": "mobile", + "traffic_source": { + "channel": "Referral", + "referrer_domain": "news.example.org", + "landing_url": "https://example.com/pricing", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 45, + "signals": [ + { + "name": "vpn", + "weight": 15, + "description": "Is VPN" + }, + { + "name": "stun_not_checked", + "weight": 30, + "description": "Stun is not checked" + }, + { + "name": "stun_late_correction", + "weight": -30, + "description": "Stun passed (late arrival, corrected)" + }, + { + "name": "stun_is_not_checked", + "weight": 30, + "description": "Sticky verdict: Stun is not checked (request 11111111-2222-4333-8444-555555555555)" + } + ], + "detection_flags": { + "vpn": true, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": true, + "check_incomplete": true + }, + "observed_at": "2026-09-30T13:05:12.000Z", + "source": "history" + } + }, + { + "name": "history_1a2b3c4d", + "source": "history", + "input": { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "domain": "example.com", + "user_hid": "-1", + "device_id": "00000000-0000-0000-0000-000000000000", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "ip": "203.0.113.200", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "country": "", + "connection_type": "unknown", + "score": 999, + "score_details": "[{\"Value\":999,\"Description\":\"User has been banned 1H, to many requests\"}]", + "created_at": "2026-09-30 13:10:00.500", + "ver": 1790773800500, + "web_rtc_ip": "0.0.0.0", + "web_rtc_country": "", + "web_rtc_connection_type": "", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "", + "tcp_mss": 0, + "mtu_value": 0, + "mtu_hint": "", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": false, + "is_scanner_stun_passed": false, + "stun_flow_status": "" + }, + "expected": { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "device_id": "00000000-0000-0000-0000-000000000000", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "user_hid": "-1", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.200", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "unknown", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 999, + "signals": [ + { + "name": "rate_limited", + "weight": 999, + "description": "User has been banned 1H, to many requests" + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T13:10:00.500Z", + "source": "history" + } + }, + { + "name": "history_4f5e6d7c", + "source": "history", + "input": { + "request_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", + "session_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", + "cookie_id": "6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7c8d9e0f-1a2b-5c3d-8e4f-5a6b7c8d9e0f", + "visitor_id": "8d9e0f1a-2b3c-5d4e-9f5a-6b7c8d9e0f1a", + "ip": "198.51.100.66", + "os": "Linux", + "browser": "Chrome", + "device_type": "desktop", + "country": "United States", + "connection_type": "proxy", + "score": 0, + "score_details": "", + "created_at": "2026-09-30 13:20:30.250", + "ver": 1790774430250, + "web_rtc_ip": "203.0.113.77", + "web_rtc_country": "United States", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "none", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": true, + "stun_request_seen": false, + "is_scanner_stun_passed": false, + "stun_flow_status": "", + "referrer_domain": "GoogleBot", + "traffic_channel": "Search bot", + "traffic_channel_group": "Bot", + "traffic_reason": "ip_crawler_detected" + }, + "expected": { + "request_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", + "visitor_id": "8d9e0f1a-2b3c-5d4e-9f5a-6b7c8d9e0f1a", + "device_id": "7c8d9e0f-1a2b-5c3d-8e4f-5a6b7c8d9e0f", + "session_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", + "cookie_id": "6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "198.51.100.66", + "country": "United States" + }, + "local_ip": { + "ip": "203.0.113.77", + "country": "United States" + }, + "connection_type": "proxy", + "os": "Linux", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Search bot", + "referrer_domain": "GoogleBot", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": true, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T13:20:30.250Z", + "source": "history" + } + }, + { + "name": "webhook_scored", + "source": "webhook", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "webhook_rate_limited", + "source": "webhook", + "input": { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "device_id": "00000000-0000-0000-0000-000000000000", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "user_hid": "-1", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.200", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "unknown", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 999, + "signals": [ + { + "name": "rate_limited", + "weight": 999 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T13:10:00.5Z" + }, + "expected": { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "device_id": "00000000-0000-0000-0000-000000000000", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "user_hid": "-1", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.200", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "unknown", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 999, + "signals": [ + { + "name": "rate_limited", + "weight": 999, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T13:10:00.500Z", + "source": "webhook" + } + }, + { + "name": "webhook_test_delivery", + "source": "webhook", + "input": { + "browser": "Chrome", + "connection_type": "proxy", + "cookie_id": "2c9d1e8f-4b7a-4c3e-9d2f-1a8b7c6d5e4f", + "detection_flags": { + "abuser": true, + "anti_detect_browser": false, + "check_incomplete": false, + "datacenter_ip": true, + "incognito": false, + "ip_mismatch": false, + "javascript_disabled": false, + "os_mismatch": false, + "os_not_detected": false, + "privacy_relay": false, + "proxy": true, + "stun_not_checked": false, + "suspicious_paid_click": false, + "timezone_mismatch": false, + "tor": false, + "vpn": false, + "browser_vpn_proxy": false + }, + "device_id": "6f1e2d3c-4b5a-5968-8776-655443322110", + "device_type": "desktop", + "domain": "example.com", + "local_ip": { + "country": "BY", + "ip": "198.51.100.10" + }, + "observed_at": "2026-09-30T12:34:56Z", + "os": "Windows", + "public_ip": { + "country": "BY", + "ip": "203.0.113.10" + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "risk_score": 30, + "session_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "abuser", + "weight": 10 + } + ], + "traffic_source": { + "channel": "Direct", + "click_id_type": "", + "landing_url": "https://example.com/", + "referrer_domain": "", + "utm_campaign": "", + "utm_content": "", + "utm_medium": "", + "utm_source": "", + "utm_term": "" + }, + "user_hid": null, + "visitor_id": "7a6b5c4d-3e2f-5a1b-9c8d-7e6f5a4b3c2d" + }, + "expected": { + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "visitor_id": "7a6b5c4d-3e2f-5a1b-9c8d-7e6f5a4b3c2d", + "device_id": "6f1e2d3c-4b5a-5968-8776-655443322110", + "session_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", + "cookie_id": "2c9d1e8f-4b7a-4c3e-9d2f-1a8b7c6d5e4f", + "user_hid": null, + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.10", + "country": "BY" + }, + "local_ip": { + "ip": "198.51.100.10", + "country": "BY" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Direct", + "referrer_domain": "", + "landing_url": "https://example.com/", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 30, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "abuser", + "weight": 10, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": true, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:56.000Z", + "source": "webhook" + } + } + ] +} diff --git a/tests/data/risk-band-cases.json b/tests/data/risk-band-cases.json new file mode 100644 index 0000000..98c7d1c --- /dev/null +++ b/tests/data/risk-band-cases.json @@ -0,0 +1,44 @@ +{ + "cases": [ + { + "score": 0, + "band": "trusted" + }, + { + "score": 5, + "band": "trusted" + }, + { + "score": 29, + "band": "trusted" + }, + { + "score": 30, + "band": "suspicious" + }, + { + "score": 45, + "band": "suspicious" + }, + { + "score": 59, + "band": "suspicious" + }, + { + "score": 60, + "band": "dangerous" + }, + { + "score": 85, + "band": "dangerous" + }, + { + "score": 100, + "band": "dangerous" + }, + { + "score": 999, + "band": "rate_limited" + } + ] +} diff --git a/tests/data/signal-slug-cases.json b/tests/data/signal-slug-cases.json new file mode 100644 index 0000000..2a73957 --- /dev/null +++ b/tests/data/signal-slug-cases.json @@ -0,0 +1,129 @@ +{ + "description": "Description (History score_details) to signal slug (webhook signals[].name). The slug function: exact map, then known prefixes, then 'Sticky verdict: ' prefix, then the fallback slugger.", + "cases": [ + { + "description": "Is tor", + "slug": "tor" + }, + { + "description": "Is VPN", + "slug": "vpn" + }, + { + "description": "Is privacy relay", + "slug": "privacy_relay" + }, + { + "description": "Is proxy", + "slug": "proxy" + }, + { + "description": "Is datacenter", + "slug": "datacenter_ip" + }, + { + "description": "Is abuser", + "slug": "abuser" + }, + { + "description": "Stun is not checked", + "slug": "stun_not_checked" + }, + { + "description": "Stun passed (late arrival, corrected)", + "slug": "stun_late_correction" + }, + { + "description": "UA OS is not detected", + "slug": "os_not_detected" + }, + { + "description": "Network OS is not detected", + "slug": "os_not_detected" + }, + { + "description": "Browser timezone ≠ IP-timezone", + "slug": "timezone_mismatch" + }, + { + "description": "Browser VPN/Proxy", + "slug": "browser_vpn_proxy" + }, + { + "description": "Browser Automation", + "slug": "browser_automation" + }, + { + "description": "Port scan routed via proxy (antidetect browser pattern)", + "slug": "proxy_routed_antidetect" + }, + { + "description": "User has been banned 1H, to many requests", + "slug": "rate_limited" + }, + { + "description": "Antidetect browser (turn_block)", + "slug": "antidetect_browser" + }, + { + "description": "Antidetect browser (port: 51234)", + "slug": "antidetect_browser" + }, + { + "description": "Os_mismatch (Fail by windows detect)", + "slug": "os_mismatch" + }, + { + "description": "OS mismatch2 (TCP behaviour: ttl)", + "slug": "os_mismatch2" + }, + { + "description": "TCP handshake (window)", + "slug": "tcp_handshake_v2" + }, + { + "description": "Latency test (WS vs TCP: 40ms)", + "slug": "ws_tcp_latency" + }, + { + "description": "JavaScript disabled (WebRTC, WebGL)", + "slug": "javascript_disabled" + }, + { + "description": "Sticky verdict: Antidetect browser (turn_block) (request 11111111-2222-4333-8444-555555555555)", + "slug": "antidetect_browser" + }, + { + "description": "Sticky verdict: Port scan routed via proxy (antidetect browser pattern) (request 11111111-2222-4333-8444-555555555555)", + "slug": "port_scan_routed_via_proxy" + }, + { + "description": "IP ≠ leakIP (198.51.100.7 ≠ 203.0.113.9, source=scanner)", + "slug": "ip__neq__leakip" + }, + { + "description": "Device spoofing", + "slug": "device_spoofing" + }, + { + "description": " Weird -- Name / Here ", + "slug": "weird_name_here" + }, + { + "description": "État Spécial (x)", + "slug": "état_spécial" + }, + { + "description": "(only parens)", + "slug": "unknown" + }, + { + "description": "!!!", + "slug": "unknown" + }, + { + "description": "", + "slug": "unknown" + } + ] +} diff --git a/tests/data/webhook-identification-scored.json b/tests/data/webhook-identification-scored.json new file mode 100644 index 0000000..f2de2d2 --- /dev/null +++ b/tests/data/webhook-identification-scored.json @@ -0,0 +1,74 @@ +{ + "event_type": "identification.scored", + "schema_version": "2026-06-01", + "created_at": "2026-09-30T12:34:57.482913041Z", + "data": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + } +} diff --git a/tests/data/webhook-identification-scored.raw.txt b/tests/data/webhook-identification-scored.raw.txt new file mode 100644 index 0000000..a4da089 --- /dev/null +++ b/tests/data/webhook-identification-scored.raw.txt @@ -0,0 +1 @@ +{"event_type":"identification.scored","schema_version":"2026-06-01","created_at":"2026-09-30T12:34:57.482913041Z","data":{"request_id":"02f1d973-84db-4156-a7f7-e799e6bf389b","visitor_id":"bde0e249-20d8-4544-838c-ed9a0b6d7a36","device_id":"ac7c303d-971b-41d1-8e25-cd5b46b46aed","session_id":"bde78778-efd2-4c49-952f-1f11b9c05f35","cookie_id":"4449bb58-590c-444c-ae1f-d1ddc768dbdd","user_hid":"9f86d081884c7d659a2feaa0c55ad015","domain":"example.com","public_ip":{"ip":"203.0.113.24","country":"Netherlands"},"local_ip":{"ip":"198.51.100.23","country":"Germany"},"connection_type":"proxy","os":"Windows","browser":"Chrome","device_type":"desktop","traffic_source":{"channel":"Google Ads","referrer_domain":"google.com","landing_url":"https://shop.example.com/signup?utm_source=google\u0026utm_medium=cpc\u0026gclid=abc123","click_id_type":"gclid","utm_source":"google","utm_medium":"cpc","utm_campaign":"","utm_content":"","utm_term":""},"risk_score":80,"signals":[{"name":"proxy","weight":10},{"name":"datacenter_ip","weight":10},{"name":"antidetect_browser","weight":60}],"detection_flags":{"vpn":false,"privacy_relay":false,"browser_vpn_proxy":false,"tor":false,"proxy":true,"datacenter_ip":true,"abuser":false,"os_mismatch":false,"os_not_detected":false,"timezone_mismatch":false,"anti_detect_browser":true,"browser_automation":false,"ip_mismatch":true,"incognito":false,"search_bot":false,"suspicious_paid_click":true,"javascript_disabled":false,"stun_not_checked":false,"check_incomplete":false},"observed_at":"2026-09-30T12:34:57.482913041Z"}} \ No newline at end of file diff --git a/tests/data/webhook-ping.json b/tests/data/webhook-ping.json new file mode 100644 index 0000000..94568cc --- /dev/null +++ b/tests/data/webhook-ping.json @@ -0,0 +1,5 @@ +{ + "created_at": "2026-09-30T12:34:56Z", + "event_type": "webhook.ping", + "schema_version": "2026-06-01" +} diff --git a/tests/data/webhook-ping.raw.txt b/tests/data/webhook-ping.raw.txt new file mode 100644 index 0000000..0f9f73b --- /dev/null +++ b/tests/data/webhook-ping.raw.txt @@ -0,0 +1 @@ +{"created_at":"2026-09-30T12:34:56Z","event_type":"webhook.ping","schema_version":"2026-06-01"} \ No newline at end of file diff --git a/tests/data/webhook-rate-limited.json b/tests/data/webhook-rate-limited.json new file mode 100644 index 0000000..b9edf47 --- /dev/null +++ b/tests/data/webhook-rate-limited.json @@ -0,0 +1,66 @@ +{ + "event_type": "identification.scored", + "schema_version": "2026-06-01", + "created_at": "2026-09-30T13:10:00.5Z", + "data": { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "device_id": "00000000-0000-0000-0000-000000000000", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "user_hid": "-1", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.200", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "unknown", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 999, + "signals": [ + { + "name": "rate_limited", + "weight": 999 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T13:10:00.5Z" + } +} diff --git a/tests/data/webhook-signature-vectors.json b/tests/data/webhook-signature-vectors.json new file mode 100644 index 0000000..7529cf0 --- /dev/null +++ b/tests/data/webhook-signature-vectors.json @@ -0,0 +1,201 @@ +{ + "description": "Shared webhook signature test vectors for every ShieldLabs server SDK. Header format: X-Shield-Signature: sha256=. body_base64 holds the exact raw bytes; body is the same bytes decoded as UTF-8 for convenience. A vector has either 'secret' (string) or 'secrets' (list: valid when any matches).", + "header_name": "X-Shield-Signature", + "vectors": [ + { + "name": "valid_lowercase_hex", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": true, + "note": "Canonical header as sent by ShieldLabs" + }, + { + "name": "valid_uppercase_hex", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=1C2D0251D83EBA5ED62C98D3758B805D227022850CDB8DDB1AA3754524345453", + "valid": true, + "note": "Hex digest compared case-insensitively" + }, + { + "name": "valid_surrounding_whitespace", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": " sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453 ", + "valid": true, + "note": "Leading/trailing whitespace is trimmed from the header value" + }, + { + "name": "valid_utf8_multibyte_body", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"webhook.ping\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"note\":\"проверка ✓ 日本\"}", + "body_base64": "eyJldmVudF90eXBlIjoid2ViaG9vay5waW5nIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwibm90ZSI6ItC/0YDQvtCy0LXRgNC60LAg4pyTIOaXpeacrCJ9", + "signature_header": "sha256=64a1212632b11b58dde55df7689bad609ae82f84681608ce3394da4d399b4362", + "valid": true, + "note": "HMAC is computed over the raw UTF-8 bytes" + }, + { + "name": "valid_pretty_printed_body", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\n \"event_type\": \"identification.scored\",\n \"schema_version\": \"2026-06-01\",\n \"created_at\": \"2026-09-30T10:15:00Z\",\n \"data\": {\n \"request_id\": \"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\n \"risk_score\": 35,\n \"user_hid\": null\n }\n}", + "body_base64": "ewogICJldmVudF90eXBlIjogImlkZW50aWZpY2F0aW9uLnNjb3JlZCIsCiAgInNjaGVtYV92ZXJzaW9uIjogIjIwMjYtMDYtMDEiLAogICJjcmVhdGVkX2F0IjogIjIwMjYtMDktMzBUMTA6MTU6MDBaIiwKICAiZGF0YSI6IHsKICAgICJyZXF1ZXN0X2lkIjogIjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsCiAgICAicmlza19zY29yZSI6IDM1LAogICAgInVzZXJfaGlkIjogbnVsbAogIH0KfQ==", + "signature_header": "sha256=263308293bcccb571464f60e0f8ef6076f9d23559b1658b6dc6f74ac3fdffd35", + "valid": true, + "note": "Whatever bytes arrive are what was signed" + }, + { + "name": "invalid_reserialized_body", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\n \"event_type\": \"identification.scored\",\n \"schema_version\": \"2026-06-01\",\n \"created_at\": \"2026-09-30T10:15:00Z\",\n \"data\": {\n \"request_id\": \"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\n \"risk_score\": 35,\n \"user_hid\": null\n }\n}", + "body_base64": "ewogICJldmVudF90eXBlIjogImlkZW50aWZpY2F0aW9uLnNjb3JlZCIsCiAgInNjaGVtYV92ZXJzaW9uIjogIjIwMjYtMDYtMDEiLAogICJjcmVhdGVkX2F0IjogIjIwMjYtMDktMzBUMTA6MTU6MDBaIiwKICAiZGF0YSI6IHsKICAgICJyZXF1ZXN0X2lkIjogIjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsCiAgICAicmlza19zY29yZSI6IDM1LAogICAgInVzZXJfaGlkIjogbnVsbAogIH0KfQ==", + "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": false, + "note": "Signature of the compact body does not match a re-serialized body: always verify raw bytes" + }, + { + "name": "invalid_tampered_body", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":5,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6NSwidXNlcl9oaWQiOm51bGx9fQ==", + "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": false, + "note": "Body changed after signing" + }, + { + "name": "invalid_wrong_secret", + "secret": "whsec_test_0000000000000000000000000000000", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": false, + "note": "Different endpoint secret" + }, + { + "name": "invalid_missing_prefix", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": false, + "note": "The sha256= prefix is required" + }, + { + "name": "invalid_wrong_algorithm_prefix", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha1=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": false, + "note": "Only sha256 is accepted" + }, + { + "name": "invalid_truncated_digest", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa37545243454", + "valid": false, + "note": "Digest must be 64 hex characters" + }, + { + "name": "invalid_non_hex_digest", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "valid": false, + "note": "Digest must be hexadecimal" + }, + { + "name": "invalid_empty_header", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "", + "valid": false, + "note": "Missing signature header" + }, + { + "name": "invalid_prefix_only", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=", + "valid": false, + "note": "Prefix without digest" + }, + { + "name": "invalid_empty_secret", + "secret": "", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=68ae6c4becfcbc22bb017c96fec281973bc554f96afe7ba875854cd65b3368dc", + "valid": false, + "note": "An empty secret is a configuration error and must never verify" + }, + { + "name": "valid_real_ping_delivery", + "secret": "whsec_00112233445566778899aabbccddeeff", + "body": "{\"created_at\":\"2026-09-30T12:34:56Z\",\"event_type\":\"webhook.ping\",\"schema_version\":\"2026-06-01\"}", + "signature_header": "sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8", + "valid": true, + "note": "Exact webhook.ping body and signature as produced by the ShieldLabs servers", + "body_base64": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1NloiLCJldmVudF90eXBlIjoid2ViaG9vay5waW5nIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIn0=" + }, + { + "name": "valid_scored_with_escaped_ampersand", + "secret": "whsec_00112233445566778899aabbccddeeff", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"02f1d973-84db-4156-a7f7-e799e6bf389b\",\"visitor_id\":\"bde0e249-20d8-4544-838c-ed9a0b6d7a36\",\"device_id\":\"ac7c303d-971b-41d1-8e25-cd5b46b46aed\",\"session_id\":\"bde78778-efd2-4c49-952f-1f11b9c05f35\",\"cookie_id\":\"4449bb58-590c-444c-ae1f-d1ddc768dbdd\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google\\u0026utm_medium=cpc\\u0026gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", + "signature_header": "sha256=c4d44b7873625bdfda98cdb7a02d460f8492ca6a68fad8d147a5f30ad16f92a9", + "valid": true, + "note": "Server bodies escape & as \\u0026; verify the bytes as received", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZVx1MDAyNnV0bV9tZWRpdW09Y3BjXHUwMDI2Z2NsaWQ9YWJjMTIzIiwiY2xpY2tfaWRfdHlwZSI6ImdjbGlkIiwidXRtX3NvdXJjZSI6Imdvb2dsZSIsInV0bV9tZWRpdW0iOiJjcGMiLCJ1dG1fY2FtcGFpZ24iOiIiLCJ1dG1fY29udGVudCI6IiIsInV0bV90ZXJtIjoiIn0sInJpc2tfc2NvcmUiOjgwLCJzaWduYWxzIjpbeyJuYW1lIjoicHJveHkiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJkYXRhY2VudGVyX2lwIiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiYW50aWRldGVjdF9icm93c2VyIiwid2VpZ2h0Ijo2MH1dLCJkZXRlY3Rpb25fZmxhZ3MiOnsidnBuIjpmYWxzZSwicHJpdmFjeV9yZWxheSI6ZmFsc2UsImJyb3dzZXJfdnBuX3Byb3h5IjpmYWxzZSwidG9yIjpmYWxzZSwicHJveHkiOnRydWUsImRhdGFjZW50ZXJfaXAiOnRydWUsImFidXNlciI6ZmFsc2UsIm9zX21pc21hdGNoIjpmYWxzZSwib3Nfbm90X2RldGVjdGVkIjpmYWxzZSwidGltZXpvbmVfbWlzbWF0Y2giOmZhbHNlLCJhbnRpX2RldGVjdF9icm93c2VyIjp0cnVlLCJicm93c2VyX2F1dG9tYXRpb24iOmZhbHNlLCJpcF9taXNtYXRjaCI6dHJ1ZSwiaW5jb2duaXRvIjpmYWxzZSwic2VhcmNoX2JvdCI6ZmFsc2UsInN1c3BpY2lvdXNfcGFpZF9jbGljayI6dHJ1ZSwiamF2YXNjcmlwdF9kaXNhYmxlZCI6ZmFsc2UsInN0dW5fbm90X2NoZWNrZWQiOmZhbHNlLCJjaGVja19pbmNvbXBsZXRlIjpmYWxzZX0sIm9ic2VydmVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1Ny40ODI5MTMwNDFaIn19" + }, + { + "name": "invalid_scored_unescaped_reserialization", + "secret": "whsec_00112233445566778899aabbccddeeff", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"02f1d973-84db-4156-a7f7-e799e6bf389b\",\"visitor_id\":\"bde0e249-20d8-4544-838c-ed9a0b6d7a36\",\"device_id\":\"ac7c303d-971b-41d1-8e25-cd5b46b46aed\",\"session_id\":\"bde78778-efd2-4c49-952f-1f11b9c05f35\",\"cookie_id\":\"4449bb58-590c-444c-ae1f-d1ddc768dbdd\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", + "signature_header": "sha256=c4d44b7873625bdfda98cdb7a02d460f8492ca6a68fad8d147a5f30ad16f92a9", + "valid": false, + "note": "Re-serializing (unescaping \\u0026) changes the bytes", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZSZ1dG1fbWVkaXVtPWNwYyZnY2xpZD1hYmMxMjMiLCJjbGlja19pZF90eXBlIjoiZ2NsaWQiLCJ1dG1fc291cmNlIjoiZ29vZ2xlIiwidXRtX21lZGl1bSI6ImNwYyIsInV0bV9jYW1wYWlnbiI6IiIsInV0bV9jb250ZW50IjoiIiwidXRtX3Rlcm0iOiIifSwicmlza19zY29yZSI6ODAsInNpZ25hbHMiOlt7Im5hbWUiOiJwcm94eSIsIndlaWdodCI6MTB9LHsibmFtZSI6ImRhdGFjZW50ZXJfaXAiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJhbnRpZGV0ZWN0X2Jyb3dzZXIiLCJ3ZWlnaHQiOjYwfV0sImRldGVjdGlvbl9mbGFncyI6eyJ2cG4iOmZhbHNlLCJwcml2YWN5X3JlbGF5IjpmYWxzZSwiYnJvd3Nlcl92cG5fcHJveHkiOmZhbHNlLCJ0b3IiOmZhbHNlLCJwcm94eSI6dHJ1ZSwiZGF0YWNlbnRlcl9pcCI6dHJ1ZSwiYWJ1c2VyIjpmYWxzZSwib3NfbWlzbWF0Y2giOmZhbHNlLCJvc19ub3RfZGV0ZWN0ZWQiOmZhbHNlLCJ0aW1lem9uZV9taXNtYXRjaCI6ZmFsc2UsImFudGlfZGV0ZWN0X2Jyb3dzZXIiOnRydWUsImJyb3dzZXJfYXV0b21hdGlvbiI6ZmFsc2UsImlwX21pc21hdGNoIjp0cnVlLCJpbmNvZ25pdG8iOmZhbHNlLCJzZWFyY2hfYm90IjpmYWxzZSwic3VzcGljaW91c19wYWlkX2NsaWNrIjp0cnVlLCJqYXZhc2NyaXB0X2Rpc2FibGVkIjpmYWxzZSwic3R1bl9ub3RfY2hlY2tlZCI6ZmFsc2UsImNoZWNrX2luY29tcGxldGUiOmZhbHNlfSwib2JzZXJ2ZWRfYXQiOiIyMDI2LTA5LTMwVDEyOjM0OjU3LjQ4MjkxMzA0MVoifX0=" + }, + { + "name": "valid_rotation_second_secret_matches", + "secrets": [ + "whsec_old_secret_value_0000000000", + "whsec_00112233445566778899aabbccddeeff" + ], + "body": "{\"created_at\":\"2026-09-30T12:34:56Z\",\"event_type\":\"webhook.ping\",\"schema_version\":\"2026-06-01\"}", + "signature_header": "sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8", + "valid": true, + "note": "verify accepts a list of secrets during rotation; valid when any matches", + "body_base64": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1NloiLCJldmVudF90eXBlIjoid2ViaG9vay5waW5nIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIn0=" + }, + { + "name": "invalid_rotation_no_secret_matches", + "secrets": [ + "whsec_old_secret_value_0000000000", + "whsec_other_secret_value_11111111" + ], + "body": "{\"created_at\":\"2026-09-30T12:34:56Z\",\"event_type\":\"webhook.ping\",\"schema_version\":\"2026-06-01\"}", + "signature_header": "sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8", + "valid": false, + "note": "No secret in the list matches", + "body_base64": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1NloiLCJldmVudF90eXBlIjoid2ViaG9vay5waW5nIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIn0=" + }, + { + "name": "invalid_secret_without_prefix", + "secret": "00112233445566778899aabbccddeeff", + "body": "{\"created_at\":\"2026-09-30T12:34:56Z\",\"event_type\":\"webhook.ping\",\"schema_version\":\"2026-06-01\"}", + "signature_header": "sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8", + "valid": false, + "note": "The key is the full secret string including whsec_; stripping the prefix breaks verification", + "body_base64": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1NloiLCJldmVudF90eXBlIjoid2ViaG9vay5waW5nIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIn0=" + } + ] +} diff --git a/tests/data/webhook-test-delivery.json b/tests/data/webhook-test-delivery.json new file mode 100644 index 0000000..8e214ce --- /dev/null +++ b/tests/data/webhook-test-delivery.json @@ -0,0 +1,72 @@ +{ + "created_at": "2026-09-30T12:34:56Z", + "data": { + "browser": "Chrome", + "connection_type": "proxy", + "cookie_id": "2c9d1e8f-4b7a-4c3e-9d2f-1a8b7c6d5e4f", + "detection_flags": { + "abuser": true, + "anti_detect_browser": false, + "browser_vpn_proxy": false, + "check_incomplete": false, + "datacenter_ip": true, + "incognito": false, + "ip_mismatch": false, + "javascript_disabled": false, + "os_mismatch": false, + "os_not_detected": false, + "privacy_relay": false, + "proxy": true, + "stun_not_checked": false, + "suspicious_paid_click": false, + "timezone_mismatch": false, + "tor": false, + "vpn": false + }, + "device_id": "6f1e2d3c-4b5a-5968-8776-655443322110", + "device_type": "desktop", + "domain": "example.com", + "local_ip": { + "country": "BY", + "ip": "198.51.100.10" + }, + "observed_at": "2026-09-30T12:34:56Z", + "os": "Windows", + "public_ip": { + "country": "BY", + "ip": "203.0.113.10" + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "risk_score": 30, + "session_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "abuser", + "weight": 10 + } + ], + "traffic_source": { + "channel": "Direct", + "click_id_type": "", + "landing_url": "https://example.com/", + "referrer_domain": "", + "utm_campaign": "", + "utm_content": "", + "utm_medium": "", + "utm_source": "", + "utm_term": "" + }, + "user_hid": null, + "visitor_id": "7a6b5c4d-3e2f-5a1b-9c8d-7e6f5a4b3c2d" + }, + "event_type": "identification.scored", + "schema_version": "2026-06-01" +}