From e0b0fd7735fed997d6ec3bc10416366edda6645d Mon Sep 17 00:00:00 2001 From: EgorMajj <91486022+EgorMajj@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:38:43 +0300 Subject: [PATCH] chore: pull the shared API contract from shieldlabs-openapi Adds contract-sync.json, .shieldlabs-contract.lock and scripts/sync_contract.py so CI can --check the committed fixtures offline. The contract-sync workflow takes a new shieldlabs-openapi release (daily, by hand, or repository_dispatch contract-release), runs the test suite and opens a pull request. The fixtures themselves are now those of contract v1.0.1. --- .gitattributes | 3 + .github/workflows/ci.yml | 10 + .github/workflows/contract-sync.yml | 179 ++++++++ .shieldlabs-contract.lock | 21 + CONTRIBUTING.md | 5 +- contract-sync.json | 20 + scripts/sync_contract.py | 388 ++++++++++++++++++ tests/Integration/CurlClientTest.php | 4 +- tests/Integration/ExampleServerTest.php | 4 +- tests/Unit/ErrorResponsesTest.php | 2 +- tests/Unit/HistoryIterateTest.php | 4 +- tests/Unit/HistorySearchTest.php | 18 +- tests/Unit/ModelTest.php | 2 +- tests/Unit/RiskEvaluateTest.php | 8 +- tests/data/README.md | 2 + tests/data/history-page.json | 10 +- tests/data/normalization-cases.json | 42 +- tests/data/webhook-identification-scored.json | 10 +- .../webhook-identification-scored.raw.txt | 2 +- tests/data/webhook-signature-vectors.json | 12 +- 20 files changed, 686 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/contract-sync.yml create mode 100644 .shieldlabs-contract.lock create mode 100644 contract-sync.json create mode 100644 scripts/sync_contract.py diff --git a/.gitattributes b/.gitattributes index ef70083..8869bf7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,3 +14,6 @@ /phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore /CONTRIBUTING.md export-ignore +/contract-sync.json export-ignore +/.shieldlabs-contract.lock export-ignore +/scripts export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2837a78..4527ca3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,3 +62,13 @@ jobs: run: vendor/bin/php-cs-fixer check --diff - name: Coverage run: vendor/bin/phpunit --coverage-text --only-summary-for-coverage-text + + contract: + name: Shared contract files match the lock + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - name: Contract files match .shieldlabs-contract.lock + run: python3 scripts/sync_contract.py --check diff --git a/.github/workflows/contract-sync.yml b/.github/workflows/contract-sync.yml new file mode 100644 index 0000000..1216288 --- /dev/null +++ b/.github/workflows/contract-sync.yml @@ -0,0 +1,179 @@ +name: Contract sync + +# Takes the shared API contract (test fixtures, see contract-sync.json) from the newest +# shieldlabs-openapi release, or from the release given by hand or by a contract-release +# dispatch. When files change, it runs the full test suite and opens a pull request with the +# result. Pull requests opened with GITHUB_TOKEN start no other workflow, so the tests run here. + +on: + schedule: + - cron: '17 6 * * *' + workflow_dispatch: + inputs: + ref: + description: "Contract release: 'latest' or a tag such as v1.0.1" + required: false + default: latest + type: string + repository_dispatch: + types: [contract-release] + +permissions: + contents: read + +concurrency: + group: contract-sync-${{ github.repository }} + cancel-in-progress: false + +jobs: + sync: + name: Sync the shared contract + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + + - name: Sync the contract files + id: sync + env: + INPUT_REF: ${{ inputs.ref }} + DISPATCH_REF: ${{ github.event.client_payload.ref }} + # Only raises the GitHub API rate limit when resolving 'latest'. + GITHUB_TOKEN: ${{ github.token }} + run: | + ref="${DISPATCH_REF:-${INPUT_REF:-latest}}" + if ! [[ "$ref" =~ ^(latest|v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "::error::Invalid contract ref: use latest or a tag such as v1.0.1." + exit 1 + fi + python3 scripts/sync_contract.py --ref "$ref" + resolved="$(python3 -c 'import json; print(json.load(open(".shieldlabs-contract.lock"))["ref"])')" + if ! [[ "$resolved" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Unexpected ref in .shieldlabs-contract.lock." + exit 1 + fi + mapfile -t files < <(python3 -c 'import json; print("\n".join(json.load(open("contract-sync.json"))["files"].values()))') + if [ -n "$(git status --porcelain -- "${files[@]}")" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + # Same bytes under a newer tag: keep the lock as it is, no pull request. + git checkout -- .shieldlabs-contract.lock + echo "Contract files are already those of $resolved." + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + echo "ref=$resolved" >> "$GITHUB_OUTPUT" + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + if: steps.sync.outputs.changed == 'true' + with: + php-version: '8.4' + extensions: curl, json, mbstring + coverage: none + tools: composer:v2 + + - name: Run the full test suite + id: tests + if: steps.sync.outputs.changed == 'true' + continue-on-error: true + shell: bash + run: | + { + composer validate --strict + composer update --no-interaction --no-progress --prefer-dist + vendor/bin/phpunit + vendor/bin/phpstan analyse --no-progress + vendor/bin/php-cs-fixer check --diff + } 2>&1 | tee "$RUNNER_TEMP/contract-tests.log" + + - name: Push the branch and open or update the pull request + if: steps.sync.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + CONTRACT_REF: ${{ steps.sync.outputs.ref }} + TESTS: ${{ steps.tests.outcome }} + run: | + set -euo pipefail + if ! [[ "$CONTRACT_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Unexpected contract ref." + exit 1 + fi + export GH_REPO="$GITHUB_REPOSITORY" + base="$GITHUB_REF_NAME" + branch="contract-sync/$CONTRACT_REF" + label="contract-change-needs-code" + version="$(python3 -c 'import json; print(json.load(open(".shieldlabs-contract.lock"))["contract_version"])')" + mapfile -t files < <(python3 -c 'import json; print("\n".join(json.load(open("contract-sync.json"))["files"].values()))') + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add -- "${files[@]}" .shieldlabs-contract.lock + changes="$(git diff --cached --name-status)" + git commit -q -m "chore: sync the shared contract $CONTRACT_REF" + + remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$branch" --jq .object.sha 2>/dev/null || true)" + remote_tree="" + if [[ "$remote_sha" =~ ^[0-9a-f]{40}$ ]]; then + remote_tree="$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$remote_sha" --jq .tree.sha)" + fi + if [ "$remote_tree" = "$(git rev-parse 'HEAD^{tree}')" ]; then + echo "$branch already holds these changes." + else + git push --force "https://x-access-token:$GH_TOKEN@${GITHUB_SERVER_URL#https://}/$GITHUB_REPOSITORY.git" "HEAD:refs/heads/$branch" + fi + + body="$RUNNER_TEMP/contract-pr.md" + { + echo "Syncs the shared API contract to [$CONTRACT_REF](https://github.com/ShieldLabs-ai/shieldlabs-openapi/releases/tag/$CONTRACT_REF) of shieldlabs-openapi (contract_version $version)." + echo + echo "Changed files:" + echo + echo '```' + echo "$changes" + echo '```' + echo + if [ "$TESTS" = "success" ]; then + echo "Tests: the full test suite passes with these files." + else + echo "Tests: **the full test suite fails with these files** ($TESTS). The SDK must change before it can take this contract; push the fixes to this branch." + echo + echo "
Last lines of the test log" + echo + echo '```' + tail -n 80 "$RUNNER_TEMP/contract-tests.log" 2>/dev/null | cut -c1-300 || true + echo '```' + echo + echo "
" + fi + echo + echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo + echo "Pull requests opened by this workflow start no other workflow. Close and reopen this pull request (or push to its branch) to run the usual checks." + } > "$body" + + number="$(gh pr list --head "$branch" --base "$base" --state open --json number --jq '.[0].number // empty')" + if [ -n "$number" ]; then + gh pr edit "$number" --body-file "$body" + elif ! gh pr create --base "$base" --head "$branch" --title "chore: sync the shared contract $CONTRACT_REF" --body-file "$body"; then + echo "::error::Could not open the pull request. Enable 'Allow GitHub Actions to create and approve pull requests' for this repository, or open it by hand: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/$base...$branch" + exit 1 + fi + + if [ "$TESTS" = "success" ]; then + gh pr edit "$branch" --remove-label "$label" >/dev/null 2>&1 || true + else + gh label create "$label" --force --color D93F0B --description "A contract sync whose tests fail: the SDK must change first" \ + || echo "::warning::Could not create the $label label." + gh pr edit "$branch" --add-label "$label" + fi + + - name: Fail when the new contract breaks the tests + if: steps.tests.outcome == 'failure' + run: | + echo "::error::The test suite fails with the new contract files. See the pull request labeled contract-change-needs-code." + exit 1 diff --git a/.shieldlabs-contract.lock b/.shieldlabs-contract.lock new file mode 100644 index 0000000..a4cda52 --- /dev/null +++ b/.shieldlabs-contract.lock @@ -0,0 +1,21 @@ +{ + "ref": "v1.0.1", + "contract_version": "1.0.1", + "files": { + "error-responses.json": "2be02da69063dcbbf40643559ac6eb6250ae22f5b41562f79810e97e9e3915e0", + "history-empty.json": "5616a1180d8234908daca7a08a05afa49a2d0ce06426cc97ebaf7971ba05b339", + "history-page.json": "50abc4a9622f800154043b2c67c0479f7e445d01ea46e882e973b217f32f7c9e", + "management-profile-expected.json": "5069ddc195bd0a11f5f53c8c964a6096bb54c116c0797fd3a6dd5d26149bc6f9", + "management-profile.json": "a02126112d37f0e5857f71d51cbfa201e876217001433af4f41ff27c31e488e6", + "normalization-cases.json": "66f1dae0293c26bf55370e713dc54a801b81d433e892022eede22ab592c5bcbe", + "risk-band-cases.json": "c9b168f5df1738faccffd69cea23bfdc8d299329ac2a129206ddf7cf9891c20c", + "signal-slug-cases.json": "8a79760a89503d48eb11c1e5e5d9b7dbe6756734092fc5a0a93f110f46228365", + "webhook-identification-scored.json": "be64dd698516acd46c773c62000bacf5e152ff54b7845c3b1819a563b948ffb2", + "webhook-identification-scored.raw.txt": "4cfa0fd3fe0fcfed1de1172d2b7d94c17246c0d6f27c2125909f03d05c71b029", + "webhook-ping.json": "90569a442e10c0af00393fcc455e5347d88b1e60624749056815b09ca267a471", + "webhook-ping.raw.txt": "5d0fde10a301d142970dc184d2a61cbb03ee58394481abb2e316065a390d4f77", + "webhook-rate-limited.json": "4985e08b2b306187d4ad3a78dc9e9d6ecf3bfcbd5fb781921bc02bb9f8a90ccd", + "webhook-signature-vectors.json": "33daee0c2823bb9b86b63a688b3065e9cfde3b8ecec14f9a988dfbdd5552fe89", + "webhook-test-delivery.json": "7678d32cf757ec604adb35b5b1dbe2a226530cec25266756ed96268f81c6d16d" + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d33d46..c50d15a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,10 @@ docker run --rm -v "$PWD":/app -w /app composer:2 sh -c "composer install && com 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. + together. They are synced from `contract/` in shieldlabs-openapi: `contract-sync.json` + maps each file, `.shieldlabs-contract.lock` records the release, CI runs + `python3 scripts/sync_contract.py --check`, and the `contract-sync.yml` workflow opens a + pull request when a new release changes them. - 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 diff --git a/contract-sync.json b/contract-sync.json new file mode 100644 index 0000000..db5e87c --- /dev/null +++ b/contract-sync.json @@ -0,0 +1,20 @@ +{ + "source": "shieldlabs-openapi", + "files": { + "error-responses.json": "tests/data/error-responses.json", + "history-empty.json": "tests/data/history-empty.json", + "history-page.json": "tests/data/history-page.json", + "management-profile-expected.json": "tests/data/management-profile-expected.json", + "management-profile.json": "tests/data/management-profile.json", + "normalization-cases.json": "tests/data/normalization-cases.json", + "risk-band-cases.json": "tests/data/risk-band-cases.json", + "signal-slug-cases.json": "tests/data/signal-slug-cases.json", + "webhook-identification-scored.json": "tests/data/webhook-identification-scored.json", + "webhook-identification-scored.raw.txt": "tests/data/webhook-identification-scored.raw.txt", + "webhook-ping.json": "tests/data/webhook-ping.json", + "webhook-ping.raw.txt": "tests/data/webhook-ping.raw.txt", + "webhook-rate-limited.json": "tests/data/webhook-rate-limited.json", + "webhook-signature-vectors.json": "tests/data/webhook-signature-vectors.json", + "webhook-test-delivery.json": "tests/data/webhook-test-delivery.json" + } +} diff --git a/scripts/sync_contract.py b/scripts/sync_contract.py new file mode 100644 index 0000000..bf25d3a --- /dev/null +++ b/scripts/sync_contract.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""Sync the shared ShieldLabs API contract files into an SDK repository. + +The contract lives in the public shieldlabs-openapi repository under contract/: shared test +fixtures plus contract/manifest.json, which lists the SHA-256 of every file. An SDK repository +says where each file goes in contract-sync.json: + + {"source": "shieldlabs-openapi", + "files": {"normalization-cases.json": "tests/data/normalization-cases.json"}} + +and records what it synced in .shieldlabs-contract.lock: + + {"ref": "v1.0.1", "contract_version": "1.0.1", + "files": {"normalization-cases.json": ""}} + +Usage, from the repository root (Python 3.9 or later, standard library only): + + python3 scripts/sync_contract.py sync the newest vX.Y.Z tag + python3 scripts/sync_contract.py --ref v1.0.1 sync one release + python3 scripts/sync_contract.py --check offline: committed files match the lock + +The canonical copy of this script is scripts/sync_contract.py in shieldlabs-openapi. SDK +repositories keep an identical copy, so --check works offline. +""" + +import argparse +import hashlib +import json +import os +import re +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path, PurePosixPath +from typing import Callable, Optional, TextIO, Union + +DEFAULT_OWNER = "ShieldLabs-ai" +CONFIG_FILE = "contract-sync.json" +LOCK_FILE = ".shieldlabs-contract.lock" +MANIFEST_FILE = "manifest.json" +CONTRACT_DIR = "contract" +RAW_BASE = "https://raw.githubusercontent.com" +API_BASE = "https://api.github.com" +USER_AGENT = "shieldlabs-contract-sync" + +REF_PATTERN = re.compile(r"latest|v[0-9]+\.[0-9]+\.[0-9]+") +TAG_PATTERN = re.compile(r"v([0-9]+)\.([0-9]+)\.([0-9]+)") +NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") +SOURCE_PATTERN = re.compile(r"(?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_.-]+") +SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") +NEXT_LINK = re.compile(r'<([^>]+)>;\s*rel="next"') + +# (url, headers) -> (body, URL of the next page from the Link header, if any) +Fetch = Callable[[str, dict[str, str]], tuple[bytes, Optional[str]]] + + +class ContractError(Exception): + """A problem to fix: bad input, a missing file, a hash mismatch or drift.""" + + +class NotFoundError(ContractError): + """The requested file does not exist.""" + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def validate_ref(ref: str) -> str: + if not REF_PATTERN.fullmatch(ref): + raise ContractError(f"invalid ref {ref!r}: use 'latest' or a tag such as v1.2.3") + return ref + + +def validate_name(name: object) -> str: + if not isinstance(name, str) or not NAME_PATTERN.fullmatch(name) or name == MANIFEST_FILE: + raise ContractError(f"invalid contract file name {name!r}") + return name + + +def validate_digests(files: dict, where: str) -> dict[str, str]: + for name, digest in files.items(): + validate_name(name) + if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest): + raise ContractError(f"{where}: invalid SHA-256 for {name}") + return files + + +def destination(root: Path, value: object) -> Path: + if not isinstance(value, str) or not value or "\\" in value: + raise ContractError(f"invalid destination path {value!r}") + rel = PurePosixPath(value) + if rel.is_absolute() or ".." in rel.parts or not rel.parts: + raise ContractError(f"destination must be a relative path inside the repository: {value!r}") + return root.joinpath(*rel.parts) + + +def read_json(path: Path, what: str) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise ContractError(f"{what} not found: {path}") from None + except (ValueError, UnicodeDecodeError) as exc: + raise ContractError(f"{what} is not valid JSON: {path}: {exc}") from None + + +class Config: + """contract-sync.json: the source repository and where each contract file goes.""" + + def __init__(self, root: Path, owner: str, repo: str, files: dict[str, Path]) -> None: + self.root = root + self.owner = owner + self.repo = repo + self.files = files + + @classmethod + def load(cls, root: Path, path: Path) -> "Config": + data = read_json(path, CONFIG_FILE) + if not isinstance(data, dict): + raise ContractError(f"{path}: expected a JSON object") + source = data.get("source") + if not isinstance(source, str) or not SOURCE_PATTERN.fullmatch(source): + raise ContractError(f"{path}: 'source' must be a repository such as shieldlabs-openapi") + owner, _, repo = source.rpartition("/") + files = data.get("files") + if not isinstance(files, dict) or not files: + raise ContractError(f"{path}: 'files' must map contract file names to paths") + mapped: dict[str, Path] = {} + for name, value in files.items(): + target = destination(root, value) + if target in mapped.values(): + raise ContractError(f"{path}: two contract files map to {value}") + mapped[validate_name(name)] = target + return cls(root, owner or DEFAULT_OWNER, repo, mapped) + + def display(self, path: Path) -> str: + return path.relative_to(self.root).as_posix() + + +def parse_manifest(data: bytes) -> tuple[str, dict[str, str]]: + try: + manifest = json.loads(data.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + raise ContractError(f"{MANIFEST_FILE} is not valid JSON: {exc}") from None + if not isinstance(manifest, dict): + raise ContractError(f"{MANIFEST_FILE}: expected a JSON object") + version, files = manifest.get("contract_version"), manifest.get("files") + if not isinstance(version, str) or not version or not isinstance(files, dict): + raise ContractError(f"{MANIFEST_FILE} needs 'contract_version' and 'files'") + return version, validate_digests(files, MANIFEST_FILE) + + +def load_lock(path: Path) -> tuple[str, str, dict[str, str]]: + data = read_json(path, LOCK_FILE) + if not isinstance(data, dict): + raise ContractError(f"{path}: expected a JSON object") + ref, version, files = data.get("ref"), data.get("contract_version"), data.get("files") + if not isinstance(ref, str) or not TAG_PATTERN.fullmatch(ref): + raise ContractError(f"{path}: 'ref' must be a tag such as v1.2.3") + if not isinstance(version, str) or not isinstance(files, dict): + raise ContractError(f"{path}: 'contract_version' and 'files' are required") + return ref, version, validate_digests(files, str(path)) + + +def render_lock(ref: str, version: str, files: dict[str, str]) -> str: + lock = {"ref": ref, "contract_version": version, "files": dict(sorted(files.items()))} + return json.dumps(lock, indent=2) + "\n" + + +def http_fetch(url: str, headers: dict[str, str]) -> tuple[bytes, Optional[str]]: + """GET with a timeout; network errors and 5xx answers are retried twice.""" + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, **headers}) + for attempt in range(3): + if attempt: + time.sleep(2**attempt) + try: + with urllib.request.urlopen(request, timeout=30) as response: + match = NEXT_LINK.search(response.headers.get("Link") or "") + return response.read(), match.group(1) if match else None + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise NotFoundError(f"not found: {url}") from None + if exc.code < 500 or attempt == 2: + raise ContractError(f"HTTP {exc.code} for {url}") from None + except (urllib.error.URLError, OSError) as exc: + if attempt == 2: + raise ContractError(f"cannot fetch {url}: {exc}") from None + raise ContractError(f"cannot fetch {url}") + + +def newest_tag(names: list[str]) -> Optional[str]: + versions = [] + for name in names: + match = TAG_PATTERN.fullmatch(name) + if match: + versions.append((tuple(int(part) for part in match.groups()), name)) + return max(versions)[1] if versions else None + + +def resolve_latest(owner: str, repo: str, fetch: Fetch = http_fetch) -> str: + """The newest vX.Y.Z tag of owner/repo from the public GitHub API. No token is needed; + GITHUB_TOKEN, when set, only raises the API rate limit.""" + headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} + token = os.environ.get("GITHUB_TOKEN", "").strip() + if token: + headers["Authorization"] = f"Bearer {token}" + url: Optional[str] = f"{API_BASE}/repos/{owner}/{repo}/tags?per_page=100" + names: list[str] = [] + pages = 0 + while url and pages < 20: + body, url = fetch(url, headers) + pages += 1 + try: + page = json.loads(body.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + raise ContractError(f"unexpected answer from the GitHub API: {exc}") from None + if not isinstance(page, list): + raise ContractError("unexpected answer from the GitHub API: expected a list of tags") + names.extend( + t["name"] for t in page if isinstance(t, dict) and isinstance(t.get("name"), str) + ) + tag = newest_tag(names) + if tag is None: + raise ContractError(f"{owner}/{repo} has no vX.Y.Z tag yet") + return tag + + +class RemoteSource: + """Contract files of one tag, downloaded from raw.githubusercontent.com.""" + + def __init__(self, owner: str, repo: str, ref: str, fetch: Fetch = http_fetch) -> None: + self.base = f"{RAW_BASE}/{owner}/{repo}/{ref}/{CONTRACT_DIR}" + self.ref = ref + self.fetch = fetch + + def read(self, name: str) -> bytes: + try: + return self.fetch(f"{self.base}/{name}", {})[0] + except NotFoundError: + if name == MANIFEST_FILE: + raise ContractError( + f"{self.ref} has no {CONTRACT_DIR}/{MANIFEST_FILE}: " + "that release predates the contract" + ) from None + raise + + +class LocalSource: + """Contract files from a local directory, such as contract/ in a checkout.""" + + def __init__(self, directory: Path) -> None: + self.directory = directory + + def read(self, name: str) -> bytes: + try: + return (self.directory / name).read_bytes() + except FileNotFoundError: + raise NotFoundError(f"not found: {self.directory / name}") from None + + +Source = Union[RemoteSource, LocalSource] + + +def write_atomic(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.") + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + os.replace(tmp, path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + +def sync(config: Config, source: Source, ref: str, lock: Path, out: TextIO) -> list[str]: + """Downloads and verifies every mapped file, then writes the files and the lock. + + Nothing is written unless every file matches the manifest. Returns the changed paths.""" + version, manifest = parse_manifest(source.read(MANIFEST_FILE)) + missing = sorted(set(config.files) - set(manifest)) + if missing: + raise ContractError(f"contract {ref} does not contain: {', '.join(missing)}") + payloads: dict[str, bytes] = {} + for name in sorted(config.files): + data = source.read(name) + digest = sha256_bytes(data) + if digest != manifest[name]: + raise ContractError( + f"{name} at {ref} does not match {MANIFEST_FILE} " + f"(expected {manifest[name][:12]}, got {digest[:12]}); nothing was written" + ) + payloads[name] = data + lock_bytes = render_lock(ref, version, {n: manifest[n] for n in payloads}).encode("utf-8") + changed = [] + for path, data in [*((config.files[n], d) for n, d in payloads.items()), (lock, lock_bytes)]: + if not path.is_file() or path.read_bytes() != data: + write_atomic(path, data) + changed.append(config.display(path)) + print(f"Contract {ref} (contract_version {version}): {len(payloads)} files", file=out) + for path in changed: + print(f" updated {path}", file=out) + if not changed: + print(" already up to date", file=out) + return changed + + +def check(config: Config, lock: Path, out: TextIO) -> list[str]: + """Offline: compares the committed files with the lock. Returns one message per problem.""" + ref, version, locked = load_lock(lock) + mapped, listed = set(config.files), set(locked) + problems = [f"{n}: in {CONFIG_FILE} but not in the lock" for n in sorted(mapped - listed)] + problems += [f"{n}: in the lock but not in {CONFIG_FILE}" for n in sorted(listed - mapped)] + for name in sorted(mapped & listed): + path = config.files[name] + if not path.is_file(): + problems.append(f"{config.display(path)}: missing") + continue + digest = sha256_bytes(path.read_bytes()) + if digest != locked[name]: + problems.append( + f"{config.display(path)}: SHA-256 {digest[:12]} differs from the lock " + f"({locked[name][:12]}, contract {ref})" + ) + if not problems: + print( + f"Contract files match the lock: {ref}, " + f"contract_version {version}, {len(locked)} files", + file=out, + ) + return problems + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Sync the shared ShieldLabs API contract files into this repository." + ) + parser.add_argument( + "--ref", default="latest", help="'latest' (default) or a tag such as v1.2.3" + ) + parser.add_argument( + "--check", action="store_true", help="offline: compare the files with the lock" + ) + parser.add_argument("--root", default=".", help="repository root (default: current directory)") + parser.add_argument("--config", default=CONFIG_FILE, help="mapping file, relative to --root") + parser.add_argument("--lock", default=LOCK_FILE, help="lock file, relative to --root") + parser.add_argument( + "--source-dir", help="read the contract from a local directory (needs a tag in --ref)" + ) + args = parser.parse_args(argv) + root = Path(args.root).resolve() + try: + config = Config.load(root, root / args.config) + lock = root / args.lock + if args.check: + problems = check(config, lock, sys.stdout) + if problems: + print(f"Contract drift: these files do not match {args.lock}:", file=sys.stderr) + for problem in problems: + print(f" {problem}", file=sys.stderr) + print( + "Contract files are not edited by hand. Change them in shieldlabs-openapi, or " + "restore them with: python3 scripts/sync_contract.py --ref ", + file=sys.stderr, + ) + return 1 + return 0 + ref = validate_ref(args.ref) + source: Source + if args.source_dir: + if ref == "latest": + raise ContractError("--source-dir needs a tag in --ref, such as v1.2.3") + source = LocalSource(Path(args.source_dir)) + else: + if ref == "latest": + ref = resolve_latest(config.owner, config.repo) + source = RemoteSource(config.owner, config.repo, ref) + sync(config, source, ref, lock, sys.stdout) + return 0 + except ContractError as exc: + print(f"contract-sync: error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/Integration/CurlClientTest.php b/tests/Integration/CurlClientTest.php index d494135..e042de0 100644 --- a/tests/Integration/CurlClientTest.php +++ b/tests/Integration/CurlClientTest.php @@ -178,11 +178,11 @@ 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'); + $page = $client->history->search(LookupType::DeviceId, 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a'); self::assertSame(37, $page->total); self::assertCount(5, $page->data); - self::assertSame('02f1d973-84db-4156-a7f7-e799e6bf389b', $page->data[0]->request_id); + self::assertSame('a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d', $page->data[0]->request_id); } public function testTheSdkMapsARealAuthenticationError(): void diff --git a/tests/Integration/ExampleServerTest.php b/tests/Integration/ExampleServerTest.php index ed8e609..d751930 100644 --- a/tests/Integration/ExampleServerTest.php +++ b/tests/Integration/ExampleServerTest.php @@ -136,8 +136,8 @@ public function testAcceptsSignedWebhooksOnceAndLogsThem(): void 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); + self::assertStringContainsString('identification.scored request_id=a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d risk_score=80 band=dangerous', $log); + self::assertStringContainsString('Duplicate delivery ignored for request_id=a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d', $log); } public function testAcknowledgesTheVerifyPing(): void diff --git a/tests/Unit/ErrorResponsesTest.php b/tests/Unit/ErrorResponsesTest.php index b04a1bc..63446eb 100644 --- a/tests/Unit/ErrorResponsesTest.php +++ b/tests/Unit/ErrorResponsesTest.php @@ -76,7 +76,7 @@ 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'); + Clients::history($http)->history->search(LookupType::DeviceId, 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a'); self::fail('Expected an exception'); } catch (ApiException $exception) { self::assertSame('invalid api key', $exception->getErrorMessage()); diff --git a/tests/Unit/HistoryIterateTest.php b/tests/Unit/HistoryIterateTest.php index 3cb20c9..692ea65 100644 --- a/tests/Unit/HistoryIterateTest.php +++ b/tests/Unit/HistoryIterateTest.php @@ -23,7 +23,7 @@ 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', + 'device_id' => 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a', 'score' => 10, 'created_at' => '2026-09-30 12:00:00.000', ], $requestIds); @@ -71,7 +71,7 @@ public function testWalksPagesUntilTheTotal(): void self::page([self::id(5)], 5), ); - $ids = self::ids(Clients::history($http)->history->iterate(LookupType::DeviceId, 'ac7c303d-971b-41d1-8e25-cd5b46b46aed', ['page_size' => 2])); + $ids = self::ids(Clients::history($http)->history->iterate(LookupType::DeviceId, 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a', ['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)); diff --git a/tests/Unit/HistorySearchTest.php b/tests/Unit/HistorySearchTest.php index 1d126ab..08d2c95 100644 --- a/tests/Unit/HistorySearchTest.php +++ b/tests/Unit/HistorySearchTest.php @@ -21,13 +21,13 @@ public function testSendsTheDocumentedRequest(): void { $http = (new MockHttpClient())->always(MockHttpClient::json(200, Fixtures::json('history-page.json'))); - $page = Clients::history($http)->history->search(LookupType::DeviceId, 'AC7C303D-971B-41D1-8E25-CD5B46B46AED', ['limit' => 50]); + $page = Clients::history($http)->history->search(LookupType::DeviceId, 'D8E0F2A4-B6C8-4D0E-BF2A-4B6C8D0E2F4A', ['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', + 'https://account.shieldlabs.ai/api/v1/history/device_id/d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a?limit=50&offset=0', (string) $request->getUri(), ); self::assertSame('Bearer ' . Clients::API_KEY, $request->getHeaderLine('Authorization')); @@ -134,8 +134,8 @@ public function testAcceptsTheNilUuidAndAnyVersion(): void $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'); + $client->history->search(LookupType::SessionId, 'e9f1a3b5-c7d9-5e1f-8a3b-5c7d9e1f3a5b'); + $client->history->search(LookupType::CookieId, 'c7d9e1f3-a5b7-1c9d-ae1f-3a5b7c9d1e3f'); self::assertCount(3, $http->requests); } @@ -147,11 +147,11 @@ 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 'uppercase type' => ['DEVICE_ID', 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a', [], '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 'UUID without dashes' => [LookupType::RequestId, 'd8e0f2a4b6c84d0ebf2a4b6c8d0e2f4a', [], 'must be a UUID']; + yield 'UUID in braces' => [LookupType::VisitorId, '{d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a}', [], 'must be a UUID']; + yield 'UUID with newline' => [LookupType::RequestId, "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a\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']; @@ -364,7 +364,7 @@ public function testIteratesAPage(): void } self::assertCount(5, $ids); - self::assertSame('02f1d973-84db-4156-a7f7-e799e6bf389b', $ids[0]); + self::assertSame('a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d', $ids[0]); } public function testVersionConstantMatchesTheChangelog(): void diff --git a/tests/Unit/ModelTest.php b/tests/Unit/ModelTest.php index 5beb0f1..32e26a3 100644 --- a/tests/Unit/ModelTest.php +++ b/tests/Unit/ModelTest.php @@ -29,7 +29,7 @@ public function testExposesSnakeCaseProperties(): void { $identification = self::first(); - self::assertSame('02f1d973-84db-4156-a7f7-e799e6bf389b', $identification->request_id); + self::assertSame('a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d', $identification->request_id); self::assertSame('9f86d081884c7d659a2feaa0c55ad015', $identification->user_hid); self::assertSame('example.com', $identification->domain); self::assertSame('Netherlands', $identification->public_ip->country); diff --git a/tests/Unit/RiskEvaluateTest.php b/tests/Unit/RiskEvaluateTest.php index 529fc72..a66c807 100644 --- a/tests/Unit/RiskEvaluateTest.php +++ b/tests/Unit/RiskEvaluateTest.php @@ -22,16 +22,16 @@ final class RiskEvaluateTest extends TestCase private static function identification( int $score = 10, - string $deviceId = 'ac7c303d-971b-41d1-8e25-cd5b46b46aed', + string $deviceId = 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a', ?DetectionFlags $flags = null, ?string $observedAt = self::OBSERVED, ): Identification { return new Identification( request_id: '3b241101-e2bb-4255-8caf-4136c566a962', - visitor_id: 'bde0e249-20d8-4544-838c-ed9a0b6d7a36', + visitor_id: 'e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b', device_id: $deviceId, - session_id: 'bde78778-efd2-4c49-952f-1f11b9c05f35', - cookie_id: '4449bb58-590c-444c-ae1f-d1ddc768dbdd', + session_id: 'b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e', + cookie_id: 'c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f', user_hid: '9f86d081884c7d659a2feaa0c55ad015', domain: 'example.com', public_ip: new IpInfo('203.0.113.24', 'Netherlands'), diff --git a/tests/data/README.md b/tests/data/README.md index 0cc55f3..729cd26 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -5,3 +5,5 @@ 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). + +They come from `contract/` in [shieldlabs-openapi](https://github.com/ShieldLabs-ai/shieldlabs-openapi/tree/main/contract): `contract-sync.json` maps each file, `.shieldlabs-contract.lock` records the release they come from, CI runs `python3 scripts/sync_contract.py --check`, and the `contract-sync.yml` workflow opens a pull request when a new release changes them. diff --git a/tests/data/history-page.json b/tests/data/history-page.json index 047866b..6b8c00c 100644 --- a/tests/data/history-page.json +++ b/tests/data/history-page.json @@ -1,14 +1,14 @@ { "data": [ { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", - "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", - "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "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", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", "ip": "203.0.113.24", "os": "Windows", "browser": "Chrome", diff --git a/tests/data/normalization-cases.json b/tests/data/normalization-cases.json index 0ccaff3..0ffe1ac 100644 --- a/tests/data/normalization-cases.json +++ b/tests/data/normalization-cases.json @@ -2,17 +2,17 @@ "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", + "name": "history_a5b7c9d1", "source": "history", "input": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", - "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", - "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "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", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", "ip": "203.0.113.24", "os": "Windows", "browser": "Chrome", @@ -65,11 +65,11 @@ "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", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "user_hid": "9f86d081884c7d659a2feaa0c55ad015", "domain": "example.com", "public_ip": { @@ -632,11 +632,11 @@ "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", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "user_hid": "9f86d081884c7d659a2feaa0c55ad015", "domain": "example.com", "public_ip": { @@ -701,11 +701,11 @@ "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", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "user_hid": "9f86d081884c7d659a2feaa0c55ad015", "domain": "example.com", "public_ip": { diff --git a/tests/data/webhook-identification-scored.json b/tests/data/webhook-identification-scored.json index f2de2d2..7978a58 100644 --- a/tests/data/webhook-identification-scored.json +++ b/tests/data/webhook-identification-scored.json @@ -3,11 +3,11 @@ "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", + "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d", + "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", + "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a", + "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e", + "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f", "user_hid": "9f86d081884c7d659a2feaa0c55ad015", "domain": "example.com", "public_ip": { diff --git a/tests/data/webhook-identification-scored.raw.txt b/tests/data/webhook-identification-scored.raw.txt index a4da089..4657d9f 100644 --- a/tests/data/webhook-identification-scored.raw.txt +++ b/tests/data/webhook-identification-scored.raw.txt @@ -1 +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 +{"event_type":"identification.scored","schema_version":"2026-06-01","created_at":"2026-09-30T12:34:57.482913041Z","data":{"request_id":"a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d","visitor_id":"e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b","device_id":"d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a","session_id":"b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e","cookie_id":"c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f","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-signature-vectors.json b/tests/data/webhook-signature-vectors.json index 7529cf0..2085113 100644 --- a/tests/data/webhook-signature-vectors.json +++ b/tests/data/webhook-signature-vectors.json @@ -149,20 +149,20 @@ { "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", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d\",\"visitor_id\":\"e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b\",\"device_id\":\"d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a\",\"session_id\":\"b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e\",\"cookie_id\":\"c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f\",\"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=397ff9bd26888e9e86addc2d920a8c5b2037251a3a1181f3b4810ca6c5f78062", "valid": true, "note": "Server bodies escape & as \\u0026; verify the bytes as received", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZVx1MDAyNnV0bV9tZWRpdW09Y3BjXHUwMDI2Z2NsaWQ9YWJjMTIzIiwiY2xpY2tfaWRfdHlwZSI6ImdjbGlkIiwidXRtX3NvdXJjZSI6Imdvb2dsZSIsInV0bV9tZWRpdW0iOiJjcGMiLCJ1dG1fY2FtcGFpZ24iOiIiLCJ1dG1fY29udGVudCI6IiIsInV0bV90ZXJtIjoiIn0sInJpc2tfc2NvcmUiOjgwLCJzaWduYWxzIjpbeyJuYW1lIjoicHJveHkiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJkYXRhY2VudGVyX2lwIiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiYW50aWRldGVjdF9icm93c2VyIiwid2VpZ2h0Ijo2MH1dLCJkZXRlY3Rpb25fZmxhZ3MiOnsidnBuIjpmYWxzZSwicHJpdmFjeV9yZWxheSI6ZmFsc2UsImJyb3dzZXJfdnBuX3Byb3h5IjpmYWxzZSwidG9yIjpmYWxzZSwicHJveHkiOnRydWUsImRhdGFjZW50ZXJfaXAiOnRydWUsImFidXNlciI6ZmFsc2UsIm9zX21pc21hdGNoIjpmYWxzZSwib3Nfbm90X2RldGVjdGVkIjpmYWxzZSwidGltZXpvbmVfbWlzbWF0Y2giOmZhbHNlLCJhbnRpX2RldGVjdF9icm93c2VyIjp0cnVlLCJicm93c2VyX2F1dG9tYXRpb24iOmZhbHNlLCJpcF9taXNtYXRjaCI6dHJ1ZSwiaW5jb2duaXRvIjpmYWxzZSwic2VhcmNoX2JvdCI6ZmFsc2UsInN1c3BpY2lvdXNfcGFpZF9jbGljayI6dHJ1ZSwiamF2YXNjcmlwdF9kaXNhYmxlZCI6ZmFsc2UsInN0dW5fbm90X2NoZWNrZWQiOmZhbHNlLCJjaGVja19pbmNvbXBsZXRlIjpmYWxzZX0sIm9ic2VydmVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1Ny40ODI5MTMwNDFaIn19" + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6ImE1YjdjOWQxLWUzZjUtNGE3Yi05YzFkLTNlNWY3YTliMWMzZCIsInZpc2l0b3JfaWQiOiJlOWYxYTNiNS1jN2Q5LTRlMWYtOGEzYi01YzdkOWUxZjNhNWIiLCJkZXZpY2VfaWQiOiJkOGUwZjJhNC1iNmM4LTRkMGUtYmYyYS00YjZjOGQwZTJmNGEiLCJzZXNzaW9uX2lkIjoiYjZjOGQwZTItZjRhNi00YjhjLThkMGUtMmY0YTZiOGMwZDJlIiwiY29va2llX2lkIjoiYzdkOWUxZjMtYTViNy00YzlkLWFlMWYtM2E1YjdjOWQxZTNmIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZVx1MDAyNnV0bV9tZWRpdW09Y3BjXHUwMDI2Z2NsaWQ9YWJjMTIzIiwiY2xpY2tfaWRfdHlwZSI6ImdjbGlkIiwidXRtX3NvdXJjZSI6Imdvb2dsZSIsInV0bV9tZWRpdW0iOiJjcGMiLCJ1dG1fY2FtcGFpZ24iOiIiLCJ1dG1fY29udGVudCI6IiIsInV0bV90ZXJtIjoiIn0sInJpc2tfc2NvcmUiOjgwLCJzaWduYWxzIjpbeyJuYW1lIjoicHJveHkiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJkYXRhY2VudGVyX2lwIiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiYW50aWRldGVjdF9icm93c2VyIiwid2VpZ2h0Ijo2MH1dLCJkZXRlY3Rpb25fZmxhZ3MiOnsidnBuIjpmYWxzZSwicHJpdmFjeV9yZWxheSI6ZmFsc2UsImJyb3dzZXJfdnBuX3Byb3h5IjpmYWxzZSwidG9yIjpmYWxzZSwicHJveHkiOnRydWUsImRhdGFjZW50ZXJfaXAiOnRydWUsImFidXNlciI6ZmFsc2UsIm9zX21pc21hdGNoIjpmYWxzZSwib3Nfbm90X2RldGVjdGVkIjpmYWxzZSwidGltZXpvbmVfbWlzbWF0Y2giOmZhbHNlLCJhbnRpX2RldGVjdF9icm93c2VyIjp0cnVlLCJicm93c2VyX2F1dG9tYXRpb24iOmZhbHNlLCJpcF9taXNtYXRjaCI6dHJ1ZSwiaW5jb2duaXRvIjpmYWxzZSwic2VhcmNoX2JvdCI6ZmFsc2UsInN1c3BpY2lvdXNfcGFpZF9jbGljayI6dHJ1ZSwiamF2YXNjcmlwdF9kaXNhYmxlZCI6ZmFsc2UsInN0dW5fbm90X2NoZWNrZWQiOmZhbHNlLCJjaGVja19pbmNvbXBsZXRlIjpmYWxzZX0sIm9ic2VydmVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1Ny40ODI5MTMwNDFaIn19" }, { "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", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d\",\"visitor_id\":\"e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b\",\"device_id\":\"d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a\",\"session_id\":\"b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e\",\"cookie_id\":\"c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f\",\"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=397ff9bd26888e9e86addc2d920a8c5b2037251a3a1181f3b4810ca6c5f78062", "valid": false, "note": "Re-serializing (unescaping \\u0026) changes the bytes", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZSZ1dG1fbWVkaXVtPWNwYyZnY2xpZD1hYmMxMjMiLCJjbGlja19pZF90eXBlIjoiZ2NsaWQiLCJ1dG1fc291cmNlIjoiZ29vZ2xlIiwidXRtX21lZGl1bSI6ImNwYyIsInV0bV9jYW1wYWlnbiI6IiIsInV0bV9jb250ZW50IjoiIiwidXRtX3Rlcm0iOiIifSwicmlza19zY29yZSI6ODAsInNpZ25hbHMiOlt7Im5hbWUiOiJwcm94eSIsIndlaWdodCI6MTB9LHsibmFtZSI6ImRhdGFjZW50ZXJfaXAiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJhbnRpZGV0ZWN0X2Jyb3dzZXIiLCJ3ZWlnaHQiOjYwfV0sImRldGVjdGlvbl9mbGFncyI6eyJ2cG4iOmZhbHNlLCJwcml2YWN5X3JlbGF5IjpmYWxzZSwiYnJvd3Nlcl92cG5fcHJveHkiOmZhbHNlLCJ0b3IiOmZhbHNlLCJwcm94eSI6dHJ1ZSwiZGF0YWNlbnRlcl9pcCI6dHJ1ZSwiYWJ1c2VyIjpmYWxzZSwib3NfbWlzbWF0Y2giOmZhbHNlLCJvc19ub3RfZGV0ZWN0ZWQiOmZhbHNlLCJ0aW1lem9uZV9taXNtYXRjaCI6ZmFsc2UsImFudGlfZGV0ZWN0X2Jyb3dzZXIiOnRydWUsImJyb3dzZXJfYXV0b21hdGlvbiI6ZmFsc2UsImlwX21pc21hdGNoIjp0cnVlLCJpbmNvZ25pdG8iOmZhbHNlLCJzZWFyY2hfYm90IjpmYWxzZSwic3VzcGljaW91c19wYWlkX2NsaWNrIjp0cnVlLCJqYXZhc2NyaXB0X2Rpc2FibGVkIjpmYWxzZSwic3R1bl9ub3RfY2hlY2tlZCI6ZmFsc2UsImNoZWNrX2luY29tcGxldGUiOmZhbHNlfSwib2JzZXJ2ZWRfYXQiOiIyMDI2LTA5LTMwVDEyOjM0OjU3LjQ4MjkxMzA0MVoifX0=" + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6ImE1YjdjOWQxLWUzZjUtNGE3Yi05YzFkLTNlNWY3YTliMWMzZCIsInZpc2l0b3JfaWQiOiJlOWYxYTNiNS1jN2Q5LTRlMWYtOGEzYi01YzdkOWUxZjNhNWIiLCJkZXZpY2VfaWQiOiJkOGUwZjJhNC1iNmM4LTRkMGUtYmYyYS00YjZjOGQwZTJmNGEiLCJzZXNzaW9uX2lkIjoiYjZjOGQwZTItZjRhNi00YjhjLThkMGUtMmY0YTZiOGMwZDJlIiwiY29va2llX2lkIjoiYzdkOWUxZjMtYTViNy00YzlkLWFlMWYtM2E1YjdjOWQxZTNmIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZSZ1dG1fbWVkaXVtPWNwYyZnY2xpZD1hYmMxMjMiLCJjbGlja19pZF90eXBlIjoiZ2NsaWQiLCJ1dG1fc291cmNlIjoiZ29vZ2xlIiwidXRtX21lZGl1bSI6ImNwYyIsInV0bV9jYW1wYWlnbiI6IiIsInV0bV9jb250ZW50IjoiIiwidXRtX3Rlcm0iOiIifSwicmlza19zY29yZSI6ODAsInNpZ25hbHMiOlt7Im5hbWUiOiJwcm94eSIsIndlaWdodCI6MTB9LHsibmFtZSI6ImRhdGFjZW50ZXJfaXAiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJhbnRpZGV0ZWN0X2Jyb3dzZXIiLCJ3ZWlnaHQiOjYwfV0sImRldGVjdGlvbl9mbGFncyI6eyJ2cG4iOmZhbHNlLCJwcml2YWN5X3JlbGF5IjpmYWxzZSwiYnJvd3Nlcl92cG5fcHJveHkiOmZhbHNlLCJ0b3IiOmZhbHNlLCJwcm94eSI6dHJ1ZSwiZGF0YWNlbnRlcl9pcCI6dHJ1ZSwiYWJ1c2VyIjpmYWxzZSwib3NfbWlzbWF0Y2giOmZhbHNlLCJvc19ub3RfZGV0ZWN0ZWQiOmZhbHNlLCJ0aW1lem9uZV9taXNtYXRjaCI6ZmFsc2UsImFudGlfZGV0ZWN0X2Jyb3dzZXIiOnRydWUsImJyb3dzZXJfYXV0b21hdGlvbiI6ZmFsc2UsImlwX21pc21hdGNoIjp0cnVlLCJpbmNvZ25pdG8iOmZhbHNlLCJzZWFyY2hfYm90IjpmYWxzZSwic3VzcGljaW91c19wYWlkX2NsaWNrIjp0cnVlLCJqYXZhc2NyaXB0X2Rpc2FibGVkIjpmYWxzZSwic3R1bl9ub3RfY2hlY2tlZCI6ZmFsc2UsImNoZWNrX2luY29tcGxldGUiOmZhbHNlfSwib2JzZXJ2ZWRfYXQiOiIyMDI2LTA5LTMwVDEyOjM0OjU3LjQ4MjkxMzA0MVoifX0=" }, { "name": "valid_rotation_second_secret_matches",