From b3071770a84bfb9e935eb1fe127e77ff7417590b 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. --- .github/workflows/ci.yml | 10 + .github/workflows/contract-sync.yml | 180 ++++++++ .shieldlabs-contract.lock | 21 + CONTRIBUTING.md | 5 + contract-sync.json | 20 + scripts/sync_contract.py | 388 ++++++++++++++++++ tests/_support.py | 4 +- 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 +- tests/test_history.py | 10 +- tests/test_webhooks.py | 2 +- 14 files changed, 670 insertions(+), 46 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9976f0f..5707ee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,3 +70,13 @@ jobs: python -m pip install build==1.6.1 twine==7.0.0 python -m build twine check --strict dist/* + + contract: + name: Shared contract files match the lock + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.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..c3ad913 --- /dev/null +++ b/.github/workflows/contract-sync.yml @@ -0,0 +1,180 @@ +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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.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: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + if: steps.sync.outputs.changed == 'true' + with: + python-version: '3.12' + + - name: Run the full test suite + id: tests + if: steps.sync.outputs.changed == 'true' + continue-on-error: true + shell: bash + run: | + { + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" -r examples/requirements.txt + ruff check . + ruff format --check . + mypy --strict src + pytest -q --cov=shieldlabs --cov-report=term-missing + python -m pip install build==1.6.1 twine==7.0.0 + python -m build + twine check --strict dist/* + } 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 c51a850..8e5bd51 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,11 @@ They are identical in every SDK and compared byte for byte (the `.raw.txt` files webhook bodies without a trailing newline), so do not edit them in a pull request. If a fixture looks wrong, open an issue. +They come from `contract/` in shieldlabs-openapi. `contract-sync.json` maps each file, +`.shieldlabs-contract.lock` records the release they come from, and CI runs +`python3 scripts/sync_contract.py --check`. The `contract-sync.yml` workflow checks for a new +release every day and opens a pull request, with the test result, when it changes the files. + ## Writing style Docs, docstrings and comments use plain technical English and the terms used in the README. 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/_support.py b/tests/_support.py index dd66bd9..bf224a4 100644 --- a/tests/_support.py +++ b/tests/_support.py @@ -19,7 +19,7 @@ DOMAIN = "example.com" HISTORY_HOST = "account.shieldlabs.ai" MANAGEMENT_HOST = "api.shieldlabs.ai" -REQUEST_ID = "02f1d973-84db-4156-a7f7-e799e6bf389b" +REQUEST_ID = "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d" REQUEST_PATH = f"/api/v1/history/request_id/{REQUEST_ID}" @@ -43,7 +43,7 @@ def row(request_id: str, **extra: Any) -> dict[str, Any]: """A minimal History row for paging tests.""" base: dict[str, Any] = { "request_id": request_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", } 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", diff --git a/tests/test_history.py b/tests/test_history.py index b6d965b..3e362d9 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -32,7 +32,7 @@ ) from shieldlabs._http import USER_AGENT -DEVICE_ID = "AC7C303D-971B-41D1-8E25-CD5B46B46AED" +DEVICE_ID = "D8E0F2A4-B6C8-4D0E-BF2A-4B6C8D0E2F4A" @pytest.fixture @@ -78,11 +78,11 @@ def test_search_defaults_and_empty_page(client: ShieldLabs, mock: Any) -> None: def test_uuid_values_are_sent_lowercase(client: ShieldLabs, mock: Any) -> None: route = mock.get(host=HISTORY_HOST).respond(200, json=history_body()) client.history.search("device_id", DEVICE_ID) - client.history.search("visitor_id", UUID("bde0e249-20d8-4544-838c-ed9a0b6d7a36")) + client.history.search("visitor_id", UUID("e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b")) paths = [call.request.url.path for call in route.calls] assert paths == [ f"/api/v1/history/device_id/{DEVICE_ID.lower()}", - "/api/v1/history/visitor_id/bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "/api/v1/history/visitor_id/e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b", ] @@ -184,8 +184,8 @@ def test_ip_lookup(client: ShieldLabs, mock: Any) -> None: ("REQUEST_ID", REQUEST_ID, "type must be one of"), ("request_id", "not-a-uuid", "must be a UUID"), ("request_id", REQUEST_ID + " ", "must be a UUID"), - ("device_id", "ac7c303d971b41d18e25cd5b46b46aed", "must be a UUID"), - ("session_id", "{bde78778-efd2-4c49-952f-1f11b9c05f35}", "must be a UUID"), + ("device_id", "d8e0f2a4b6c84d0ebf2a4b6c8d0e2f4a", "must be a UUID"), + ("session_id", "{b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e}", "must be a UUID"), ("cookie_id", "", "must be a UUID"), ("ip", "2001:db8::1", "IPv6"), ("ip", "203.0.113", "dotted IPv4"), diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 1406e85..1c22d25 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -150,7 +150,7 @@ def test_unknown_event_type_is_not_an_error() -> None: "event_type": "identification.refined", "schema_version": "2026-06-01", "created_at": "2026-09-30T12:00:00Z", - "data": {"request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b"}, + "data": {"request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d"}, "extra": True, } body = json.dumps(envelope).encode()