diff --git a/CLAUDE.md b/CLAUDE.md index 1a4c010..b729d96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**py-libtea** is a hand-crafted Python client library for the Transparency Exchange API (TEA) v0.4.0. Consumer-focused (read-only); publisher API is not yet supported (blocked on TEA spec). Licensed Apache 2.0, maintained under sbomify. +**py-libtea** is a hand-crafted Python client library for the Transparency Exchange API (TEA) 1.0.0 with 0.4.0 server compatibility. Consumer-focused (read-only); publisher API is not yet supported (blocked on TEA spec). Licensed Apache 2.0, maintained under sbomify. ## Build & Dev Commands @@ -27,7 +27,7 @@ uv build # Build wheel and sdist - **Line length**: 120, ruff rules: E, F, I - **Models**: Pydantic v2 with `frozen=True`, `extra="ignore"`, `alias_generator=to_camel` - **HTTP mocking**: `responses` library (not `unittest.mock` for HTTP) -- **Coverage**: Branch coverage enabled, target ~97% +- **Coverage**: Branch coverage enabled, target ~95% ## Architecture @@ -35,9 +35,9 @@ The library has a layered design with strict separation of concerns: ``` __init__.py Public API re-exports (all models, exceptions, client, discovery) -client.py TeaClient — high-level consumer API, checksum verification +client.py TeaClient — high-level consumer API, spec version resolution, checksum verification ↓ uses -_validation.py Input validation helpers (path segments, page size/offset, Pydantic wrappers) +_validation.py Input validation helpers (path segments, page size, Pydantic wrappers) _http.py TeaHttpClient — low-level requests wrapper, auth, streaming downloads Also: probe_endpoint() for endpoint failover _security.py SSRF protection (_validate_download_url, DNS rebinding checks, internal IP detection) @@ -52,7 +52,7 @@ _cli_entry.py Entry point wrapper that handles missing CLI extras gracefu conformance/ Conformance test suite (optional dependency: pytest) __init__.py Public API: run_conformance, CheckResult, CheckStatus, ConformanceResult - _checks.py 27 check functions organized by category, CheckContext shared state + _checks.py 28 check functions organized by category, CheckContext shared state _runner.py Orchestrates checks, collects results into ConformanceResult _types.py CheckStatus (StrEnum), CheckResult (frozen dataclass), ConformanceResult plugin.py pytest plugin — --tea-base-url options, parametrized test generation @@ -65,9 +65,10 @@ conformance/ Conformance test suite (optional dependency: pytest) - Downloads follow redirects manually with SSRF validation at each hop - Discovery redirects are validated against internal networks (SSRF protection via `_security._validate_download_url`) - `_validation._validate()` wraps Pydantic `ValidationError` into `TeaValidationError` so all client errors are `TeaError` subclasses -- Endpoint failover: `from_well_known()` probes candidates in priority order, skipping unreachable ones +- Endpoint failover: `from_well_known()` probes candidates in priority order, skipping unreachable ones. With `version=None` it negotiates 1.0.0 first, then 0.4.0, and the candidates span both versions +- `download_artifact_content()` (server-hosted artifact bytes) sends the token only on the first hop, to the API base; every redirect hop goes through the unauthenticated session with SSRF validation - `probe_endpoint()` lives in `_http.py` (not `client.py`) to maintain the HTTP layer boundary -- `_raise_for_status()` uses bounded reads (201 bytes) for error body snippets to avoid memory issues on streaming responses +- `_raise_for_status()` reads 4xx bodies once, bounded to 4 KiB, to parse the TEA `error` value (`_tea_error_type`); the message snippet is cut to 200 characters. This avoids memory issues on streaming responses - CLI formatters in `_cli_fmt.py` escape all server-controlled strings with `rich.markup.escape()` to prevent Rich markup injection - Conformance checks run sequentially; earlier checks populate `CheckContext` with discovered UUIDs for later checks - `from __future__ import annotations` is safe in conformance modules (no Pydantic models, only dataclasses) @@ -83,6 +84,8 @@ conformance/ Conformance test suite (optional dependency: pytest) - `ChecksumAlgorithm` values may arrive as `SHA_256` (underscore) or `SHA-256` (hyphen) from servers — the `@field_validator` in `Checksum` normalizes both - BLAKE3 is in the enum for spec completeness but NOT supported at runtime (not in stdlib `hashlib`) — raises `TeaChecksumError` - `Identifier.id_type` is typed as `str` (not `IdentifierType` enum) so unknown types from future spec versions pass through +- **Behaviour follows the negotiated version, never response-shape sniffing.** `TeaClient` resolves `spec_version` once (explicit argument, then the base URL's `/v` suffix, then `TEA_SPEC_VERSION` = 1.0.0) and exposes `is_legacy`. Pagination parameters, page-size limits, the artifact metadata path (`/artifact/{uuid}` on 0.4.0, `/latest` on 1.x), artifact versions, discovery by PURL and server-hosted downloads all branch on it. Never infer the version from what a response looks like +- **0.4.0 shims run only under the validation context `{"tea_legacy": True}`.** `_validation._validate(..., legacy=client.is_legacy)` supplies it; no context means 1.0.0 rules. Fields required by both versions are non-Optional model types; fields only 1.0.0 requires stay `X | None` and are rejected when missing outside the legacy context (`_v1_required` / `_v1_problems()` on `_TeaModel`). Building an MD5 `Checksum` by hand therefore needs `Checksum.model_validate({...}, context={"tea_legacy": True})` - CGNAT range (100.64.0.0/10, RFC 6598) is checked separately in SSRF protection because `ipaddress.is_private` misses it on Python 3.11+ ## TEA Spec Reference @@ -93,7 +96,7 @@ The TEA spec repo should be cloned to `/tmp/transparency-exchange-api/` for cros git clone https://github.com/CycloneDX/transparency-exchange-api /tmp/transparency-exchange-api ``` -Key spec files: `spec/openapi.yaml`, `discovery/readme.md`, `auth/readme.md` +Key spec files: `spec/openapi.yaml`, `discovery/readme.md`, `auth/readme.md`. The 0.4.0 baseline is `git -C /tmp/transparency-exchange-api show e0c986b:spec/openapi.yaml`. ## Design Docs diff --git a/README.md b/README.md index 6266f18..2b317d9 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,28 @@ [![License](https://img.shields.io/github/license/sbomify/py-libtea.svg)](https://github.com/sbomify/py-libtea/blob/master/LICENSE) [![sbomified](https://sbomify.com/assets/images/logo/badge.svg)](https://trust.sbomify.com/component/py-libtea/) -Python client library for the [Transparency Exchange API (TEA)](https://transparency.exchange/) v0.4.0. +Python client library for the [Transparency Exchange API (TEA)](https://transparency.exchange/) 1.0.0, with compatibility for 0.4.0 servers. TEA is an open standard for discovering and retrieving software transparency artifacts (SBOMs, VEX, build metadata) for any software product or component. A [TEI identifier](https://github.com/CycloneDX/transparency-exchange-api/blob/main/discovery/readme.md) resolves via DNS to the right endpoint, similar to how email uses MX records — so consumers can fetch artifacts without knowing which server hosts them. **Specification:** [Ecma TC54-TG1](https://tc54.org/tea/) | [OpenAPI spec](https://github.com/CycloneDX/transparency-exchange-api) > **Status**: Alpha — API is subject to change. +> +> **Upgrading?** libtea now targets TEA 1.0.0. See [Upgrading](#upgrading) for the Python API changes. ### Features -- Auto-discovery via `.well-known/tea` and TEI URNs -- Flexible version negotiation — connects to older server versions within the same major series +- Auto-discovery via `.well-known/tea` and TEIs (`tei://` URIs; the legacy URN form for 0.4.0 servers) +- Discovery by PURL on TEA 1.x servers +- Spec version negotiation — prefers TEA 1.0.0, falls back to 0.4.0 servers, and fails over across both - Products, components, releases, and versioned collections - Search by PURL, CPE, or TEI identifier -- Pagination auto-iterators for search results +- Token-based pagination (`nextPageToken`) with auto-iterators for search results - Bulk parallel fetch for products, releases, and artifacts - Common Lifecycle Enumeration (CLE) — ECMA-428 lifecycle events -- Artifact download with on-the-fly checksum verification (MD5 through BLAKE2b) +- Artifact download with on-the-fly checksum verification (SHA-1 through BLAKE2b; MD5 on 0.4.0 servers only) +- Download of artifact content hosted by the TEA server itself (TEA 1.x) - Optional TTL response caching - Endpoint failover with SemVer-compatible version selection - SSRF protection — blocks private IPs, cloud metadata endpoints, DNS rebinding @@ -60,10 +64,10 @@ pip install libtea[conformance] ```python from libtea import TeaClient -# Auto-discover the sbomify TEA server from its .well-known/tea -with TeaClient.from_well_known("trust.sbomify.com") as client: +# Auto-discover the TEA server from the domain's .well-known/tea +with TeaClient.from_well_known("example.com") as client: # Discover a product by TEI - results = client.discover("urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify") + results = client.discover("tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1") for info in results: print(info.product_release_uuid, info.servers) @@ -72,54 +76,83 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: print(pr.version, pr.product_name) ``` -Or connect directly to a known endpoint: +Or connect directly to a known endpoint. The `/v` suffix selects the spec version; `trust.sbomify.com` is a working TEA 0.4.0 server: ```python client = TeaClient( base_url="https://trust.sbomify.com/tea/v0.4.0", timeout=30.0, ) + +# A TEA 1.0.0 server +client = TeaClient(base_url="https://tea.example.com/tea/v1.0.0", timeout=30.0) ``` -Using `from_well_known`, you can also override the spec version and timeout: +`from_well_known` negotiates the spec version for you (TEA 1.0.0 first, then 0.4.0). You can also pin a version and set the timeout: ```python client = TeaClient.from_well_known( "trust.sbomify.com", timeout=15.0, - version="0.4.0", # default + version="0.4.0", # default: None, i.e. negotiate 1.0.0 first, then 0.4.0 ) ``` ## Usage +### Spec versions + +libtea speaks TEA 1.0.0 and 0.4.0. Each client knows which one it talks to, and its behaviour follows that version, never the shape of a response: + +```python +client = TeaClient("https://trust.sbomify.com/tea/v0.4.0") +client.spec_version # "0.4.0" +client.is_legacy # True for 0.4.0, False for 1.x +``` + +The version comes from the `spec_version=` argument, else a `/v` suffix on `base_url`, else the default (`TEA_SPEC_VERSION`, `"1.0.0"`). A base URL without a version suffix, such as `https://tea.example.com/v1`, is treated as TEA 1.0.0. `libtea.client.SUPPORTED_SPEC_VERSIONS` lists the supported versions, highest first. + +On a 0.4.0 server: + +- the page token is the next result offset as a string, and `total_results`, `page_start_index`, `page_size` and `timestamp` are filled in (they are `None` on 1.x); +- `page_size` may be up to 10000 (up to 100 on 1.x); +- MD5 checksums are accepted; +- fields that only 1.0.0 requires may be missing; +- `get_artifact` reads `/artifact/{uuid}` (1.x uses `/artifact/{uuid}/latest`); +- discovery by PURL, `get_artifact_version` and `download_artifact_content` are unavailable and raise `TeaValidationError`. + ### Discovery ```python from libtea import TeaClient -# Discover sbomify products via TEI -with TeaClient.from_well_known("trust.sbomify.com") as client: - results = client.discover("urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify") +with TeaClient.from_well_known("example.com") as client: + # Discover product releases via TEI + results = client.discover("tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1") for info in results: print(info.product_release_uuid, info.servers) + + # Discover by PURL in this server's inventory (TEA 1.x servers only) + results = client.discover(purl="pkg:github/sbomify/sbomify") ``` +`discover` takes exactly one of `tei` and `purl`. The value is sent to the server verbatim, so pass a TEI in the form the server speaks: `tei:////` for TEA 1.x, the legacy URN form for 0.4.0 servers (see [Upgrading](#upgrading)). On a 1.x server an unknown TEI is a 404 (`TeaNotFoundError`). + Low-level discovery functions are also available: ```python from libtea.discovery import parse_tei, fetch_well_known, select_endpoint -# Parse a TEI URN -tei_type, domain, identifier = parse_tei("urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify") +# Parse a TEI: ("uuid", "example.com", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1") +tei_type, domain, identifier = parse_tei("tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1") # Fetch and select an endpoint manually -well_known = fetch_well_known("trust.sbomify.com") -endpoint = select_endpoint(well_known, "0.4.0") +well_known = fetch_well_known(domain) +endpoint = select_endpoint(well_known, "1.0.0") print(endpoint.url, endpoint.priority) ``` -Supported TEI types: `uuid`, `purl`, `hash`, `swid`, `eanupc`, `gtin`, `asin`, `udi`. +Known TEI types: `uuid`, `purl`, `hash`, `swid`, `eanupc`, `gtin`, `asin`, `udi` (`TeiType`). In the `tei://` form `parse_tei` accepts any type matching `[a-z][a-z0-9]*` (a lowercase letter, then lowercase letters or digits), so types added to the spec's registry keep working. A `purl` identifier in that form is the unpadded Base64URL encoding of the PURL; libtea passes it through unchanged. ### Search @@ -130,16 +163,23 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: for product in results.results: print(product.name, product.uuid) - # Search product releases (with pagination) - releases = client.search_product_releases( - "PURL", - "pkg:github/sbomify/sbomify", - page_offset=0, - page_size=100, - ) - print(releases.total_results) + # Search product releases, following the page token + page = client.search_product_releases("PURL", "pkg:github/sbomify/sbomify", page_size=100) + while True: + for release in page.results: + print(release.version) + if not page.has_next: + break + page = client.search_product_releases( + "PURL", + "pkg:github/sbomify/sbomify", + page_token=page.next_page_token, + page_size=100, + ) ``` +Each page has `results`, `has_next` and an opaque `next_page_token`, which you pass back as `page_token`. `page_size` is at most 100 on TEA 1.x servers. The 0.4.0 offset fields (`total_results` and the like) are `None` on 1.x; see [Spec versions](#spec-versions). + ### Listing (unfiltered) ```python @@ -151,7 +191,7 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: # List all product releases releases = client.list_product_releases(page_size=50) - print(releases.total_results) + print(len(releases.results), releases.has_next) ``` ### Auto-paginated iterators @@ -173,6 +213,8 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: print(release.version, release.created_date) ``` +The iterators follow `next_page_token` and raise `TeaServerError` if a server repeats a token or keeps paging past 10,000 pages. + ### Bulk parallel fetch Fetch multiple resources by UUID in parallel: @@ -200,6 +242,7 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: # Single product release pr = client.get_product_release("release-uuid") print(pr.version, pr.components) + print(len(pr.latest_collection.artifacts)) # latest_collection is None on 0.4.0 servers # Product release collections latest = client.get_product_release_collection_latest("release-uuid") @@ -212,7 +255,7 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: ```python with TeaClient.from_well_known("trust.sbomify.com") as client: component = client.get_component("component-uuid") - releases = client.get_component_releases("component-uuid") + releases = client.get_component_releases("component-uuid") # a list; every page is fetched on 1.x # Get a component release with its latest collection cr = client.get_component_release("release-uuid") @@ -247,13 +290,34 @@ with TeaClient.from_well_known("trust.sbomify.com") as client: path = client.download_artifact( fmt.url, Path("sbom.json"), - verify_checksums=fmt.checksums, + verify_checksums=list(fmt.checksums), ) ``` -Supported checksum algorithms: MD5, SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512. BLAKE3 is recognized in the model but not verifiable (Python's `hashlib` has no BLAKE3 support — a clear error is raised). +A format without a `url` is hosted by the TEA server itself (TEA 1.x). Fetch it through the server's download endpoint: + +```python +from pathlib import Path + +from libtea import TeaClient + +with TeaClient("https://tea.example.com/tea/v1.0.0") as client: + artifact = client.get_artifact("artifact-uuid") + fmt = artifact.formats[0] + + if fmt.url is None: # hosted by the TEA server itself + path = client.download_artifact_content( + artifact.uuid, + Path("sbom.json"), + media_type=fmt.media_type, # required on TEA 1.x + version=artifact.version, # omit for the latest version + verify_checksums=list(fmt.checksums), + ) +``` + +Supported checksum algorithms: SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512, and MD5 on 0.4.0 servers (TEA 1.0.0 dropped MD5, so a 1.x response that carries it is rejected). BLAKE3 is recognized in the model but not verifiable (Python's `hashlib` has no BLAKE3 support — a clear error is raised). -Artifact downloads use a separate unauthenticated HTTP session so the bearer token is never leaked to third-party hosts (CDNs, Maven Central, etc.). On checksum mismatch, the downloaded file is automatically deleted. +`download_artifact` uses a separate unauthenticated HTTP session so the bearer token is never leaked to third-party hosts (CDNs, Maven Central, etc.). `download_artifact_content` sends the token only on its first request, to the API base, and never on a redirect. On checksum mismatch, the downloaded file is automatically deleted. ### Common Lifecycle Enumeration (CLE) @@ -309,7 +373,7 @@ client = TeaClient.from_well_known( ) ``` -Scheme validation and cloud metadata endpoint blocking are always enforced. +Scheme validation and cloud metadata endpoint blocking are always enforced. If the API base itself is a private address and its download endpoint redirects to its own host, `download_artifact_content` checks that redirect too, so it needs `allow_private_ips=True`. ### Testing with TeaClientProtocol @@ -348,24 +412,27 @@ All commands also accept connection options: `--base-url`, `--domain`, `--token` ### Discover ```bash -# Discover sbomify product releases via TEI -tea-cli discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +# Discover product releases via TEI (the TEI's domain is used to find the server) +tea-cli discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # UUIDs only (for scripting) -tea-cli discover -q "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli discover -q "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # JSON output -tea-cli --json discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli --json discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + +# Resolve a PURL on a TEA 1.x server (needs --base-url) +tea-cli discover --purl "pkg:github/sbomify/sbomify" --base-url https://tea.example.com/tea/v1.0.0 ``` ### Inspect (full flow) ```bash # TEI -> discovery -> releases -> components -> artifacts in one shot -tea-cli inspect "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli inspect "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # Limit component resolution -tea-cli inspect --max-components 10 "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli inspect --max-components 10 "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ``` ### Search @@ -378,6 +445,10 @@ tea-cli search-products --id-type PURL --id-value "pkg:github/sbomify/sbomify" \ # Search product releases tea-cli search-releases --id-type PURL --id-value "pkg:github/sbomify/sbomify" \ --domain trust.sbomify.com --page-size 50 + +# Next page: pass the token printed under the results (an offset on TEA 0.x servers) +tea-cli search-releases --id-type PURL --id-value "pkg:github/sbomify/sbomify" \ + --domain trust.sbomify.com --page-size 50 --page-token "" ``` ### Products and releases @@ -425,6 +496,9 @@ tea-cli get-artifact --domain trust.sbomify.com tea-cli download "https://cdn.example.com/sbom.json" ./sbom.json \ --checksum "SHA-256:abc123..." \ --domain trust.sbomify.com + +# Download every artifact of a TEI (formats without a url are fetched from the TEA 1.x server) +tea-cli download "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ./sboms/ ``` ### Lifecycle (CLE) @@ -444,7 +518,7 @@ tea-cli get-cle --entity component-release --domain trust.sbomify.com tea-cli conformance --base-url https://tea.example.com/v1 # With TEI for discovery-driven testing -tea-cli conformance --base-url https://tea.example.com/v1 --tei "urn:tei:..." +tea-cli conformance --base-url https://tea.example.com/v1 --tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # JSON output for CI pipelines tea-cli conformance --base-url https://tea.example.com/v1 --json @@ -473,7 +547,7 @@ _TEA_CLI_COMPLETE=fish_source tea-cli | source ## Conformance testing -The conformance suite validates that a TEA server correctly implements the specification. It runs 26 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns (UUID format, pagination, camelCase). +The conformance suite validates that a TEA server correctly implements the specification. It runs 28 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns (UUID format, pagination, camelCase). ### Programmatic @@ -482,7 +556,7 @@ from libtea.conformance import run_conformance result = run_conformance( "https://tea.example.com/v1", - tei="urn:tei:purl:example.com:pkg:pypi/mylib", + tei="tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1", ) print(f"{result.passed} passed, {result.failed} failed, {result.skipped} skipped") ``` @@ -490,14 +564,14 @@ print(f"{result.passed} passed, {result.failed} failed, {result.skipped} skipped ### CLI ```bash -tea-cli conformance --base-url https://tea.example.com/v1 --tei "urn:tei:..." +tea-cli conformance --base-url https://tea.example.com/v1 --tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" tea-cli conformance --base-url https://tea.example.com/v1 --json ``` ### pytest plugin ```bash -pytest --tea-base-url https://tea.example.com/v1 --tea-tei "urn:tei:..." +pytest --tea-base-url https://tea.example.com/v1 --tea-tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ``` The plugin generates one test per conformance check. See the [conformance docs](docs/conformance.md) for the full check reference. @@ -519,16 +593,27 @@ tea_datetime_serializer(datetime.now(timezone.utc)) All exceptions inherit from `TeaError`: ```python -from libtea.exceptions import TeaError, TeaNotFoundError, TeaChecksumError +from libtea.exceptions import TeaError, TeaNotFoundError, TeaRequestError try: - product = client.get_product("unknown-uuid") + product = client.get_product("d4d9f54a-abcf-11ee-ac79-1a52914d44b1") except TeaNotFoundError as exc: - print(exc.error_type) # "OBJECT_UNKNOWN" or "OBJECT_NOT_SHAREABLE" + print(exc.error_type) # "OBJECT_UNKNOWN" ("OBJECT_NOT_SHAREABLE" on 0.4.0 servers) +except TeaError: + print("Something went wrong") + +try: + client.list_products(page_token="stale-token") +except TeaRequestError as exc: + print(exc.error_type) # "INVALID_PAGE_TOKEN" from a TEA 1.x server; None if the body has no TEA error except TeaError: print("Something went wrong") ``` +The UUID argument must be well formed (a malformed one raises `TeaValidationError` before any request). A TEA 0.x client also rejects a non-numeric `page_token` with `TeaValidationError`, so the second example needs a 1.x server. + +`error_type` is the `error` value of the server's TEA error body. `libtea.ErrorType` lists the known values (`OBJECT_UNKNOWN`, `NOT_IMPLEMENTED`, `NO_ACCEPTABLE_FORMAT`, `SIGNATURE_NOT_FOUND`, `INVALID_REQUEST`, `INVALID_PAGE_TOKEN`, and `OBJECT_NOT_SHAREABLE` on 0.4.0); servers may send others, which pass through as plain strings. Compare with `==`. + Exception hierarchy: | Exception | When | @@ -536,7 +621,7 @@ Exception hierarchy: | `TeaConnectionError` | Network failure or timeout | | `TeaAuthenticationError` | HTTP 401/403 | | `TeaNotFoundError` | HTTP 404 (`.error_type` has the TEA error code) | -| `TeaRequestError` | Other HTTP 4xx | +| `TeaRequestError` | Other HTTP 4xx (`.error_type` has the TEA error code) | | `TeaServerError` | HTTP 5xx | | `TeaDiscoveryError` | Invalid TEI, `.well-known` failure, or no compatible endpoint | | `TeaChecksumError` | Checksum mismatch (`.algorithm`, `.expected`, `.actual`) | @@ -545,6 +630,63 @@ Exception hierarchy: Using a bearer token over plaintext HTTP raises `ValueError` immediately — HTTPS is required for authenticated requests. +## Upgrading + +libtea now targets TEA 1.0.0 and still talks to 0.4.0 servers. These Python API changes can break code written against libtea 0.5.1. + +**Spec versions** + +- A base URL without a `/v` suffix (and no `spec_version=`) is now TEA 1.0.0, not 0.4.0. `TEA_SPEC_VERSION` is `"1.0.0"`. To keep talking to a 0.4.0 server, pass `spec_version="0.4.0"` or end the base URL in `/v0.4.0`. +- `TeaClient.from_well_known(version=None)` now negotiates: it prefers 1.0.0, falls back to 0.4.0, and fails over across both. It used to default to `"0.4.0"`. Pass `version="0.4.0"` to pin the old behaviour. It downgrades to another compatible version (with a warning) only when neither is advertised exactly; a prerelease such as `1.0.0-rc.1` does not count as 1.0.0. The API path uses the version spelling exactly as the server advertises it (for example `/v1.0.0+build.5`). +- A base URL ending in `/v` with a major version other than 0 or 1 (for example `/v2.0.0`) now raises `ValueError`. +- On 0.4.0 servers `get_artifact` now calls the 0.4.0 path `/artifact/{uuid}` (libtea 0.5.1 called `/artifact/{uuid}/latest`, which 0.4.0 servers answer with 404), and `get_artifact_version` raises `TeaValidationError` before any request, since 0.4.0 has no versioned artifact endpoint. +- `TeaClient` gains `spec_version` and `is_legacy`. `TeaClientProtocol` gains them too, plus `download_artifact_content` and the new `discover` signature, so test doubles that implement the protocol need updating. + +**Discovery and TEIs** + +- TEIs are now `tei:////`. `parse_tei` and the CLI still accept the legacy `urn:tei:::` form, and `TeaClient.discover` sends whatever TEI you give it verbatim; use the legacy form with 0.4.0 servers. +- `discover(tei=None, *, purl=None)` takes exactly one of the two, otherwise `TeaValidationError`. PURL lookup needs a 1.x server. On a 1.x server an empty discovery array also raises `TeaValidationError`; an unknown TEI is a 404. + +**Pagination** + +- `page_offset: int` is replaced by `page_token: str | None` on `list_*`, `search_*` and `get_product_releases`; the CLI's `--page-offset` is now `--page-token`. On 0.4.0 servers the token is the next offset as a string (`"100"`). +- `Paginated*Response` has `has_next` and `next_page_token`. `timestamp`, `page_start_index`, `page_size` and `total_results` are now optional and are `None` on 1.x servers, so code that reads them must handle `None`. `PaginatedCollectionResponse` is new. +- `page_size` is at most 100 on 1.x servers (10000 on 0.4.0), otherwise `TeaValidationError`. +- The `iter_*` iterators follow tokens and raise `TeaServerError` when a token repeats or after 10,000 pages. On 1.x servers `get_component_releases` and `get_*_release_collections` still return a list, but they now fetch every page. + +**Models** + +- `Collection.date` is now `Collection.created_date`. A 0.4.0 `date` is mapped to it. +- `get_product_release` returns `ProductReleaseWithCollection`, a `ProductRelease` subclass with `latest_collection` (`None` on 0.4.0 servers). Because of that, JSON output for a product release (`tea-cli --json get-release`, `tea-cli inspect`) now includes `latestCollection`: the collection on 1.x servers, `null` on 0.4.0 servers. +- `ComponentReleaseWithCollection` still exposes `.release` and `.latest_collection`, and now accepts the flattened 1.0.0 wire shape. Keyword construction with a dict, `ComponentReleaseWithCollection(release={...}, ...)`, now fails; pass a `Release` instance instead. +- Fields that both spec versions require are no longer optional: `Checksum.algorithm_type` and `algorithm_value`, `Artifact.uuid`, `type` and `formats`, and `ReleaseDistribution.distribution_id`. Building one without them now fails. +- Fields that only 1.0.0 requires (for example `Release.component`, `ProductRelease.product`, `Artifact.version` and `created_date`, `ArtifactFormat.media_type`, `Identifier.id_type` and `id_value`, and the `Collection` fields) keep their optional types, but a 1.x response that omits one raises `TeaValidationError`. 0.4.0 responses may omit them. +- Models you build in Python follow the 1.0.0 rules too: leaving out a field that only 1.0.0 requires raises pydantic `ValidationError`. That breaks test doubles such as `Collection()`, `Identifier(id_type="PURL")` and a `Paginated*Response` built without `has_next` or `results`. For 0.4.0-shaped data, pass the wire (camelCase) dict to `model_validate(..., context={"tea_legacy": True})`. Don't nest instances built that way into a model you build without that context; the outer model checks them against the 1.0.0 rules and fails. +- Removed: `ReleaseDistribution.distribution_type`, `Artifact.distribution_types`, and the `mimeType` alias for `ArtifactFormat.media_type`. +- `ComplianceDocumentType.FEDRAMP` is now `"FEDRAMP"` (was `"FedRAMP"`), and `EU_DECLARATION_OF_CONFORMITY` is new. + +**Checksums** + +MD5 is rejected in responses from 1.x servers and still accepted from 0.4.0 servers (and by the CLI's `--checksum`). To build an MD5 `Checksum` yourself, validate it in the legacy context; a plain `Checksum(...)` follows the 1.0.0 rules: + +```python +from libtea import Checksum + +md5 = Checksum.model_validate( + {"algType": "MD5", "algValue": "d41d8cd98f00b204e9800998ecf8427e"}, + context={"tea_legacy": True}, +) +``` + +**Downloads** + +- `download_artifact` and `download_artifact_content` now refuse a redirect from HTTPS to HTTP and raise `TeaValidationError`; the partial file is deleted. HTTP-to-HTTP and HTTP-to-HTTPS redirects still work. + +**Errors** + +- `ErrorResponse.error` is a `str` (was `ErrorType`); compare it with `==`. `ErrorResponse.message` is new, and `ErrorType` has the 1.0.0 values. +- `TeaRequestError` now has `.error_type`, like `TeaNotFoundError`. libtea reads at most 4 KiB of a 4xx body to find it, and cuts the message snippet at 200 characters. + ## Requirements - Python >= 3.11 @@ -558,8 +700,9 @@ Optional: ## Not yet supported -- Publisher API (spec is consumer-only in v0.4.0) +- Publisher API (blocked on the TEA spec) - Async client +- Some TEA 1.0.0 consumer features: the `/token` exchange, `ETag` revalidation, signature downloads, `sortField`/`sortOrder` and following `servers[].rootUrl`. See [docs/FUTURE.md](docs/FUTURE.md) ## Development diff --git a/docs/FUTURE.md b/docs/FUTURE.md index eeb579a..326672e 100644 --- a/docs/FUTURE.md +++ b/docs/FUTURE.md @@ -20,7 +20,7 @@ Items that depend on external factors or are deferred indefinitely. These are ** **TEA spec tracking:** [CycloneDX/transparency-exchange-api](https://github.com/CycloneDX/transparency-exchange-api) -**Additional note for Publisher API:** When serializing models back to the server, use `model_dump(mode="json", by_alias=True, exclude_none=True)` and explicitly exclude deprecated fields (`distribution_type`, `distribution_types`) — `exclude_none` alone won't help if they were populated from an older server response. Serialization should also be gated by the negotiated spec version so deprecated fields are only sent to servers that expect them. +**Additional note for Publisher API:** When serializing models back to the server, use `model_dump(mode="json", by_alias=True, exclude_none=True)`. Serialization should also be gated by the negotiated spec version (`TeaClient.is_legacy`) so 0.4.0-only wire shapes (for example `Collection.date` instead of `createdDate`, offset pagination fields, MD5 checksums) are only sent to servers that expect them. **Action:** When the Publisher API reaches beta (stable naming, stable schema), create a versioned design doc in `docs/plans/` and schedule for the next minor release. @@ -39,14 +39,43 @@ Items that depend on external factors or are deferred indefinitely. These are ** --- -## Spec v0.4.0 Follow-ups +## TEA 1.0.0 Deferred Items -**Blocked on:** Nothing — these are low-priority items from the v0.4.0 alignment review (PR #11). +**Blocked on:** Nothing — consumer-side parts of the TEA 1.0.0 spec that the 1.0.0 alignment left out on purpose. None is needed to read from a 1.x server. + +| Item | Notes | +|------|-------| +| `/token` client-credentials exchange | `POST /token` with the `client_credentials` grant (see `auth/readme.md`). Today a caller supplies an already-issued bearer token or basic-auth credentials. | +| `ETag` / `If-None-Match` / `304` | Conditional requests and revalidation for the artifact download endpoints. Downloads currently always fetch the full body. | +| Signature download endpoints | `/artifact/{uuid}/latest/signature/download` and `/artifact/{uuid}/{artifactVersion}/signature/download`. `signatureUrl` on a format is exposed in the models but not fetched. | +| `sortField` / `sortOrder` | Sorting query parameters on `/products`, `/productReleases`, `/components` and `/componentReleases`, and on the per-parent child lists (a product's releases, a component's releases, and both collections lists). The client sends none of them. | +| Routing follow-up requests to the discovery `servers[].rootUrl` | `discover()` returns the servers that host a product release, but later calls keep using the client's own API base. Honouring `rootUrl` + `/v` per release needs a client per server and token scoping (a token must go only to its own API base). | +| `purl` → `tei:///purl/` builder | Helper that turns a PURL into the unpadded Base64URL TEI identifier. `parse_tei` already accepts such TEIs and passes the identifier through unchanged; callers must encode it themselves. | +| `--spec-version` override for the CLI, `run_conformance` and the pytest plugin | A base URL without a `/v` suffix now means TEA 1.0.0. `TeaClient(spec_version=...)` can force 0.4.0, but `tea-cli`, `run_conformance` and `--tea-base-url` cannot, so they treat a 0.4.0 server behind an unversioned base URL as 1.0.0. | +| Lowercase-hex checksum conformance check | The spec wants lowercase hexadecimal checksum values. Verification already lowercases both sides; a conformance check should flag servers that send uppercase. | + +**Action:** Schedule individual items when a consumer needs them; each is independent. + +--- + +## HTTP Layer Follow-ups + +**Blocked on:** Nothing — a known gap in how `requests` handles redirects. + +| Item | Notes | +|------|-------| +| Bound 3xx bodies during downloads | Even with `allow_redirects=False` and `stream=True`, `requests.Session.send` reads the whole 3xx response body (to release the connection) before libtea sees it, so `max_download_bytes` does not cap a redirect body. Fixing it needs a transport adapter (or a urllib3-level request) that skips that read. | + +**Action:** Address if a server is found sending large redirect bodies. + +--- + +## Conformance Follow-ups + +**Blocked on:** Nothing — low-priority items from earlier alignment reviews. | Item | Notes | |------|-------| -| Deprecation warnings for legacy fields | `distribution_type` and `distribution_types` are spec-deprecated in v0.4.0. Consider adding `DeprecationWarning` via `@model_validator(mode="after")` when only old fields are populated. | -| Conformance checks for v0.4.0 required fields | `Artifact.uuid` and `ReleaseDistribution.distribution_id` are now required per spec. Add conformance checks that verify presence on v0.4.0 servers (gated behind version check). Note: `Artifact.formats` check is implemented as `check_artifact_formats_required` (WARN status). | | Component conformance checks | Add conformance checks for `/components` and `/componentReleases` endpoints (list, search) to match the product-level checks. | -**Action:** Address when a v0.4.0 server is available for testing. +**Action:** Address opportunistically. diff --git a/docs/cli.md b/docs/cli.md index 4f069bf..0ea3883 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,7 +10,7 @@ tea-cli [GLOBAL OPTIONS] COMMAND [COMMAND OPTIONS] ## Description -`tea-cli` is a command-line interface for the Transparency Exchange API (TEA) v0.4.0. It discovers, searches, and retrieves software transparency artifacts (SBOMs, VEX, build metadata) from TEA-compliant servers. +`tea-cli` is a command-line interface for the Transparency Exchange API (TEA) 1.0.0, with compatibility for 0.4.0 servers. It discovers, searches, and retrieves software transparency artifacts (SBOMs, VEX, build metadata) from TEA-compliant servers. Output is rich-formatted by default (tables, panels) for interactive use. Use `--json` for machine-readable JSON output suitable for piping. @@ -41,8 +41,8 @@ Every command accepts the following options for server selection and authenticat | Option | Description | |--------|-------------| -| `--base-url` *URL* | TEA server base URL (e.g. `https://trust.sbomify.com/tea/v0.4.0`). Can also be set via the `TEA_BASE_URL` environment variable. Mutually exclusive with `--domain`. | -| `--domain` *DOMAIN* | Discover server from the domain's `.well-known/tea` endpoint. The domain can also be auto-extracted from a TEI URN argument. | +| `--base-url` *URL* | TEA server base URL (e.g. `https://trust.sbomify.com/tea/v0.4.0`, a TEA 0.4.0 server, or `https://tea.example.com/tea/v1.0.0`). A `/v` suffix selects the spec version; without one, TEA 1.0.0 is assumed. Can also be set via the `TEA_BASE_URL` environment variable. Mutually exclusive with `--domain`. | +| `--domain` *DOMAIN* | Discover server from the domain's `.well-known/tea` endpoint. TEA 1.0.0 is preferred, falling back to 0.4.0. The domain can also be auto-extracted from a TEI argument. | | `--token` *TOKEN* | Bearer token for authentication. Prefer the `TEA_TOKEN` environment variable to avoid exposing the token in shell history. | | `--auth` *USER:PASSWORD* | HTTP basic authentication credentials. Prefer the `TEA_AUTH` environment variable to avoid exposing credentials in shell history. Mutually exclusive with `--token`. | | `--timeout` *SECONDS* | Request timeout in seconds (default: 30). | @@ -54,29 +54,31 @@ Every command accepts the following options for server selection and authenticat ### discover -Resolve a TEI URN to product release UUID(s). +Resolve a TEI, or a PURL, to product release UUID(s). Give exactly one of `TEI` and `--purl`. ``` tea-cli discover [--quiet | -q] TEI +tea-cli discover [--quiet | -q] --purl PURL --base-url URL ``` **Arguments:** | Argument | Description | |----------|-------------| -| *TEI* | A TEI URN (e.g. `urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify`). The domain is auto-extracted for server discovery when `--base-url` and `--domain` are omitted. | +| *TEI* | A TEI (e.g. `tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1`). The domain is auto-extracted for server discovery when `--base-url` and `--domain` are omitted. TEA 0.4.0 servers expect the legacy URN form, which is also accepted (see Upgrading in the README). | **Options:** | Option | Description | |--------|-------------| +| `--purl` *PURL* | Resolve a Package URL in the inventory of the server given by `--base-url` (required), instead of a TEI. Needs a TEA 1.x server. | | `--quiet`, `-q` | Output only UUIDs, one per line. Useful for scripting. | --- ### inspect -Full flow: TEI -> discovery -> product releases -> components -> artifacts. Resolves a TEI and fetches the full object graph in one shot. +Full flow: TEI -> discovery -> product releases -> components -> artifacts. Resolves a TEI (`tei://` or the legacy URN form) and fetches the full object graph in one shot. ``` tea-cli inspect [--max-components N] TEI @@ -95,7 +97,7 @@ tea-cli inspect [--max-components N] TEI Search for products by identifier. ``` -tea-cli search-products --id-type TYPE --id-value VALUE [--page-offset N] [--page-size N] +tea-cli search-products --id-type TYPE --id-value VALUE [--page-token TOKEN] [--page-size N] ``` **Options:** @@ -104,8 +106,8 @@ tea-cli search-products --id-type TYPE --id-value VALUE [--page-offset N] [--pag |--------|-------------| | `--id-type` *TYPE* | Identifier type: `CPE`, `TEI`, or `PURL`. | | `--id-value` *VALUE* | Identifier value to search for. | -| `--page-offset` *N* | Page offset for pagination (default: 0). | -| `--page-size` *N* | Page size for pagination (default: 100). | +| `--page-token` *TOKEN* | `nextPageToken` from the previous page; an integer offset on TEA 0.x servers (default: first page). | +| `--page-size` *N* | Page size for pagination (default: 100; max 100 on TEA 1.x). | --- @@ -114,7 +116,7 @@ tea-cli search-products --id-type TYPE --id-value VALUE [--page-offset N] [--pag Search for product releases by identifier. ``` -tea-cli search-releases --id-type TYPE --id-value VALUE [--page-offset N] [--page-size N] +tea-cli search-releases --id-type TYPE --id-value VALUE [--page-token TOKEN] [--page-size N] ``` Options are the same as `search-products`. @@ -136,15 +138,15 @@ tea-cli get-product UUID List releases for a product UUID. ``` -tea-cli get-product-releases UUID [--page-offset N] [--page-size N] +tea-cli get-product-releases UUID [--page-token TOKEN] [--page-size N] ``` **Options:** | Option | Description | |--------|-------------| -| `--page-offset` *N* | Page offset for pagination (default: 0). | -| `--page-size` *N* | Page size for pagination (default: 100). | +| `--page-token` *TOKEN* | `nextPageToken` from the previous page; an integer offset on TEA 0.x servers (default: first page). | +| `--page-size` *N* | Page size for pagination (default: 100; max 100 on TEA 1.x). | --- @@ -162,6 +164,8 @@ tea-cli get-release [--component] UUID |--------|-------------| | `--component` | Get a component release instead of a product release. | +With `--json`, a product release also carries `latestCollection`: its latest collection on TEA 1.x servers, `null` on 0.4.0 servers. + --- ### get-component @@ -222,7 +226,7 @@ tea-cli list-collections [--component] UUID Search for components by identifier. ``` -tea-cli search-components --id-type TYPE --id-value VALUE [--page-offset N] [--page-size N] +tea-cli search-components --id-type TYPE --id-value VALUE [--page-token TOKEN] [--page-size N] ``` Options are the same as `search-products`. @@ -234,7 +238,7 @@ Options are the same as `search-products`. Search for component releases by identifier. ``` -tea-cli search-component-releases --id-type TYPE --id-value VALUE [--page-offset N] [--page-size N] +tea-cli search-component-releases --id-type TYPE --id-value VALUE [--page-token TOKEN] [--page-size N] ``` Options are the same as `search-products`. @@ -253,7 +257,7 @@ tea-cli get-artifact UUID ### get-artifact-version -Get a specific version of artifact metadata by UUID. +Get a specific version of artifact metadata by UUID. Needs a TEA 1.x server: TEA 0.4.0 has no versioned artifact endpoint. ``` tea-cli get-artifact-version UUID VERSION @@ -270,25 +274,33 @@ tea-cli get-artifact-version UUID VERSION ### download -Download an artifact file with optional checksum verification. +Download an artifact file with optional checksum verification, or every artifact of a TEI. ``` tea-cli download URL DEST [--checksum ALG:VALUE]... [--max-download-bytes N] +tea-cli download TEI [DEST_DIR] [--max-download-bytes N] [-y] [-n] [-q] ``` **Arguments:** | Argument | Description | |----------|-------------| -| *URL* | Artifact download URL. | -| *DEST* | Local file path to save the downloaded artifact. | +| *URL* | Artifact download URL (URL mode). | +| *TEI* | A TEI (`tei://` or the legacy URN form). Resolves it and downloads every artifact format of the latest collection into `DEST_DIR` (TEI mode). Checksums come from the server metadata. | +| *DEST* | Local file path to save the downloaded artifact (URL mode). | +| *DEST_DIR* | Directory for the downloaded artifacts (TEI mode). If omitted, you are asked to confirm downloading into the current directory; `-y` skips the prompt. | + +On a TEA 1.x server, a format without a `url` is hosted by the server itself and is fetched through its download endpoint; the bearer token is sent only to the API server, never on a redirect. **Options:** | Option | Description | |--------|-------------| -| `--checksum` *ALG:VALUE* | Checksum to verify, as `ALGORITHM:HEX_VALUE`. Can be specified multiple times. Supported algorithms: MD5, SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512. | +| `--checksum` *ALG:VALUE* | Checksum to verify, as `ALGORITHM:HEX_VALUE` (URL mode only). Can be specified multiple times. Supported algorithms: MD5, SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-384, SHA3-512, BLAKE2b-256, BLAKE2b-384, BLAKE2b-512. MD5 is accepted here because you supply the hash. | | `--max-download-bytes` *N* | Maximum download size in bytes. The download is aborted if the response exceeds this limit. | +| `-y`, `--yes` | TEI mode: skip the confirmation prompt for downloading into the current directory. | +| `-n`, `--dry-run` | TEI mode: show what would be downloaded without downloading. | +| `-q`, `--quiet` | Suppress progress output (errors still shown). | --- @@ -308,7 +320,7 @@ tea-cli get-cle [--entity TYPE] UUID ### conformance -Run conformance checks against a TEA server. Validates that the server correctly implements the TEA specification by running 27 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns. +Run conformance checks against a TEA server. Validates that the server correctly implements the TEA specification by running 28 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns. ``` tea-cli conformance --base-url URL [OPTIONS] @@ -319,7 +331,7 @@ tea-cli conformance --base-url URL [OPTIONS] | Option | Description | |--------|-------------| | `--base-url` *URL* | TEA server base URL (required). Can also be set via `TEA_BASE_URL`. | -| `--tei` *TEI* | TEI URN for discovery-driven testing. Seeds the check context with a product release UUID discovered from the TEI. | +| `--tei` *TEI* | TEI for discovery-driven testing. Seeds the check context with a product release UUID discovered from the TEI. | | `--product-uuid` *UUID* | Explicit product UUID for direct testing. | | `--release-uuid` *UUID* | Explicit product release UUID. | | `--component-uuid` *UUID* | Explicit component UUID. | @@ -362,13 +374,20 @@ Using credentials over plaintext HTTP raises an error. Use `--use-http` only for Discover sbomify product releases: ```bash -tea-cli discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" +``` + +Resolve a PURL on a TEA 1.x server: + +```bash +tea-cli discover --purl "pkg:github/sbomify/sbomify" \ + --base-url https://tea.example.com/tea/v1.0.0 ``` Full inspection with JSON output: ```bash -tea-cli --json inspect "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli --json inspect "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ``` Search for a product by PURL: @@ -398,18 +417,18 @@ tea-cli download "https://cdn.example.com/sbom.json" ./sbom.json \ Pipe UUIDs to another tool: ```bash -tea-cli discover -q "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" | \ - xargs -I {} tea-cli --json get-release {} --domain trust.sbomify.com +tea-cli discover -q "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" | \ + xargs -I {} tea-cli --json get-release {} --domain example.com ``` Verbose logging to diagnose issues: ```bash # libtea debug logs only (no urllib3 noise) -tea-cli -v discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli -v discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # Full HTTP firehose -tea-cli -d discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli -d discover "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" ``` Local development with private IPs: @@ -417,7 +436,7 @@ Local development with private IPs: ```bash # Allow downloads from a local TEA server tea-cli download "http://192.168.1.100:8080/sbom.json" ./sbom.json \ - --allow-private-ips --base-url "http://192.168.1.100:8080/tea/v0.4.0" + --allow-private-ips --base-url "http://192.168.1.100:8080/tea/v1.0.0" ``` Using environment variables to avoid repeating credentials: @@ -425,7 +444,7 @@ Using environment variables to avoid repeating credentials: ```bash export TEA_TOKEN="your-bearer-token" export TEA_BASE_URL="https://trust.sbomify.com/tea/v0.4.0" -tea-cli discover "urn:tei:purl:trust.sbomify.com:pkg:github/sbomify/sbomify" +tea-cli search-products --id-type PURL --id-value "pkg:github/sbomify/sbomify" tea-cli get-product ``` diff --git a/docs/conformance.md b/docs/conformance.md index 3151945..2705ec8 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -1,6 +1,6 @@ # Conformance Test Suite -The `libtea.conformance` module validates that a TEA server correctly implements the [Transparency Exchange API specification](https://github.com/CycloneDX/transparency-exchange-api). It runs 27 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns. +The `libtea.conformance` module validates that a TEA server correctly implements the [Transparency Exchange API specification](https://github.com/CycloneDX/transparency-exchange-api). It runs 28 checks covering discovery, products, releases, components, artifacts, CLE, and cross-cutting concerns. ## Installation @@ -19,7 +19,7 @@ from libtea.conformance import run_conformance result = run_conformance( "https://tea.example.com/v1", - tei="urn:tei:purl:example.com:pkg:pypi/mylib", + tei="tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1", token="your-bearer-token", timeout=30.0, ) @@ -38,7 +38,7 @@ for check in result.checks: | Parameter | Type | Description | |-----------|------|-------------| | `base_url` | `str` | TEA server base URL (required) | -| `tei` | `str \| None` | TEI URN for discovery-driven testing | +| `tei` | `str \| None` | TEI for discovery-driven testing | | `product_uuid` | `str \| None` | Explicit product UUID | | `product_release_uuid` | `str \| None` | Explicit product release UUID | | `component_uuid` | `str \| None` | Explicit component UUID | @@ -49,6 +49,8 @@ for check in result.checks: | `timeout` | `float` | Request timeout in seconds (default: 30) | | `allow_private_ips` | `bool` | Allow private IPs in downloads (default: False) | +The spec version under test comes from `base_url`: a `/v` suffix such as `/v0.4.0` selects that version, otherwise TEA 1.0.0 is assumed. Checks that differ between versions adapt to it (see the check reference). + **Returns:** `ConformanceResult` with `.passed`, `.failed`, `.warned`, `.skipped` counts and `.checks` list. ### CLI @@ -58,7 +60,7 @@ for check in result.checks: tea-cli conformance --base-url https://tea.example.com/v1 # With TEI for discovery-driven testing -tea-cli conformance --base-url https://tea.example.com/v1 --tei "urn:tei:..." +tea-cli conformance --base-url https://tea.example.com/v1 --tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # With explicit UUIDs tea-cli conformance --base-url https://tea.example.com/v1 \ @@ -83,7 +85,7 @@ The conformance suite registers as a pytest plugin via the `pytest11` entry poin ```bash # Run conformance checks as pytest tests -pytest --tea-base-url https://tea.example.com/v1 --tea-tei "urn:tei:..." +pytest --tea-base-url https://tea.example.com/v1 --tea-tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" # With authentication pytest --tea-base-url https://tea.example.com/v1 --tea-token "$TEA_TOKEN" @@ -97,7 +99,7 @@ pytest --tea-base-url https://tea.example.com/v1 -v | Option | Description | |--------|-------------| | `--tea-base-url` | TEA server base URL (required to enable conformance tests) | -| `--tea-tei` | TEI URN for discovery-driven testing | +| `--tea-tei` | TEI for discovery-driven testing | | `--tea-token` | Bearer token | | `--tea-product-uuid` | Explicit product UUID | | `--tea-release-uuid` | Explicit product release UUID | @@ -139,7 +141,7 @@ Each check returns one of four statuses: | Check | Description | |-------|-------------| | `discovery` | Discover a TEI and verify the server returns results | -| `discovery_404` | Verify unknown TEI returns 404 or empty list | +| `discovery_404` | Verify unknown TEI returns 404 (TEA 0.x also accepts an empty list; on TEA 1.x the 404 must carry a TEA error body and a successful discovery response must be a non-empty array) | ### Products (5 checks) @@ -188,12 +190,13 @@ Each check returns one of four statuses: | `component_release_cle` | Get CLE data for a component release | | `cle_event_ordering` | Verify CLE events are ordered by id descending | -### Cross-cutting (3 checks) +### Cross-cutting (4 checks) | Check | Description | |-------|-------------| | `uuid_format` | Validate all collected UUIDs match canonical lowercase format | -| `pagination_fields` | Verify paginated listing exposes valid pagination metadata fields | +| `pagination_fields` | TEA 0.x: verify the `/products` listing exposes valid offset metadata (`pageSize`, `pageStartIndex`, `totalResults`, `timestamp`). TEA 1.x: verify the listing honours `pageSize` (no more than 10 results for `pageSize=10`) | +| `pagination_token` | TEA 1.x: request `/products` with `pageSize=1`, follow `nextPageToken`, and verify the next page returns different products. Skipped on TEA 0.x or when the server has fewer than two products | | `camel_case_fields` | Confirm server uses camelCase field names | ## CI integration diff --git a/src/libtea/__init__.py b/src/libtea/__init__.py index 22fe346..3087a78 100644 --- a/src/libtea/__init__.py +++ b/src/libtea/__init__.py @@ -5,7 +5,7 @@ from libtea import TeaClient with TeaClient("https://tea.example.com/v1", token="...") as client: - results = client.discover("urn:tei:purl:example.com:pkg:pypi/lib@1.0") + results = client.discover("tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1") Or auto-discover the server from a domain's ``.well-known/tea``:: @@ -138,6 +138,9 @@ from libtea.models import ( IdentifierType as IdentifierType, ) + from libtea.models import ( + PaginatedCollectionResponse as PaginatedCollectionResponse, + ) from libtea.models import ( PaginatedComponentReleaseResponse as PaginatedComponentReleaseResponse, ) @@ -156,6 +159,9 @@ from libtea.models import ( ProductRelease as ProductRelease, ) + from libtea.models import ( + ProductReleaseWithCollection as ProductReleaseWithCollection, + ) from libtea.models import ( Release as Release, ) @@ -225,12 +231,14 @@ "ErrorType", "Identifier", "IdentifierType", + "PaginatedCollectionResponse", "PaginatedComponentReleaseResponse", "PaginatedComponentResponse", "PaginatedProductReleaseResponse", "PaginatedProductResponse", "Product", "ProductRelease", + "ProductReleaseWithCollection", "Release", "ReleaseDistribution", "TeaEndpoint", @@ -287,12 +295,14 @@ "ErrorType": "libtea.models", "Identifier": "libtea.models", "IdentifierType": "libtea.models", + "PaginatedCollectionResponse": "libtea.models", "PaginatedComponentReleaseResponse": "libtea.models", "PaginatedComponentResponse": "libtea.models", "PaginatedProductReleaseResponse": "libtea.models", "PaginatedProductResponse": "libtea.models", "Product": "libtea.models", "ProductRelease": "libtea.models", + "ProductReleaseWithCollection": "libtea.models", "Release": "libtea.models", "ReleaseDistribution": "libtea.models", "TeaEndpoint": "libtea.models", diff --git a/src/libtea/_cli_fmt.py b/src/libtea/_cli_fmt.py index fb2ac08..7ff2139 100644 --- a/src/libtea/_cli_fmt.py +++ b/src/libtea/_cli_fmt.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import shlex from collections.abc import Sequence from typing import TYPE_CHECKING, Any @@ -81,11 +82,15 @@ def _pagination_header( console: Console, ) -> None: """Render a dim pagination summary line.""" - if not data.results: + if data.total_results is None: # TEA 1.x: no totals, only a continuation token + console.print(Text(f"{len(data.results)} result(s)", style="dim")) + if data.has_next: + console.print(Text(f"More results: --page-token {shlex.quote(data.next_page_token or '')}", style="dim")) + elif not data.results: console.print(Text(f"No results (total: {data.total_results})", style="dim")) else: - end = data.page_start_index + len(data.results) - console.print(Text(f"Results {data.page_start_index + 1}-{end} of {data.total_results}", style="dim")) + start = data.page_start_index or 0 # 0.4.0 default + console.print(Text(f"Results {start + 1}-{start + len(data.results)} of {data.total_results}", style="dim")) def _distributions_table(distributions: Sequence[ReleaseDistribution], *, console: Console) -> None: @@ -93,7 +98,7 @@ def _distributions_table(distributions: Sequence[ReleaseDistribution], *, consol if not distributions: return tbl = Table(title="Distributions") - tbl.add_column("ID / Type") + tbl.add_column("ID") tbl.add_column("Description") tbl.add_column("URL") tbl.add_column("Signature URL") @@ -102,9 +107,7 @@ def _distributions_table(distributions: Sequence[ReleaseDistribution], *, consol checksums = ( ", ".join(f"{_opt(cs.algorithm_type)}:{(_opt(cs.algorithm_value))[:12]}..." for cs in d.checksums) or "-" ) - # Prefer v0.4.0 distribution_id, fall back to legacy distribution_type - id_or_type = d.distribution_id or d.distribution_type - tbl.add_row(_esc(id_or_type), _esc(d.description), _esc(d.url), _esc(d.signature_url), escape(checksums)) + tbl.add_row(_esc(d.distribution_id), _esc(d.description), _esc(d.url), _esc(d.signature_url), escape(checksums)) console.print(tbl) @@ -120,11 +123,7 @@ def _artifacts_table(artifacts: Sequence[Artifact], *, console: Console) -> None tbl.add_column("Formats") for a in artifacts: fmt_str = ", ".join(f.media_type or "?" for f in a.formats) or "-" - # Prefer v0.4.0 distribution_ids, fall back to legacy distribution_types. - # Use `is not None` so an explicitly empty list from a v0.4.0 server - # does not fall back to legacy data (empty tuple is falsy). - applies_seq = a.distribution_ids if a.distribution_ids is not None else (a.distribution_types or ()) - applies = ", ".join(applies_seq) or "-" + applies = ", ".join(a.distribution_ids or ()) or "-" tbl.add_row(_esc(a.uuid), _esc(a.name), _esc(a.type), escape(applies), escape(fmt_str)) console.print(tbl) @@ -275,7 +274,7 @@ def fmt_component_release(data: ComponentReleaseWithCollection, *, console: Cons [ ("UUID", _opt(col.uuid)), ("Version", _opt(col.version)), - ("Date", _opt(col.date)), + ("Date", _opt(col.created_date)), ("Belongs To", _opt(col.belongs_to)), ], console=console, @@ -295,7 +294,7 @@ def fmt_collection(data: Collection, *, console: Console) -> None: [ ("UUID", _opt(data.uuid)), ("Version", _opt(data.version)), - ("Date", _opt(data.date)), + ("Date", _opt(data.created_date)), ("Belongs To", _opt(data.belongs_to)), ("Update Reason", reason), ], @@ -363,7 +362,7 @@ def fmt_collections(data: list[Collection], *, console: Console) -> None: tbl.add_row( _esc(col.uuid), _esc(col.version), - _esc(col.date), + _esc(col.created_date), _esc(col.belongs_to), str(len(col.artifacts)), ) @@ -479,7 +478,7 @@ def _inspect_component_details(comp: dict[str, Any], *, console: Console) -> Non if distributions: comp_name = comp.get("name") or release.get("componentName", "Component") tbl = Table(title=f"Distributions ({_esc(comp_name)})") - tbl.add_column("ID / Type") + tbl.add_column("ID") tbl.add_column("Description") tbl.add_column("URL") tbl.add_column("Signature URL") @@ -489,10 +488,8 @@ def _inspect_component_details(comp: dict[str, Any], *, console: Console) -> Non checksums = ( ", ".join(f"{cs.get('algType', '?')}:{cs.get('algValue', '')[:12]}..." for cs in checksums_list) or "-" ) - # Prefer v0.4.0 distributionId, fall back to legacy distributionType - id_or_type = d.get("distributionId") or d.get("distributionType") tbl.add_row( - _esc(id_or_type), + _esc(d.get("distributionId")), _esc(d.get("description")), _esc(d.get("url")), _esc(d.get("signatureUrl")), @@ -518,29 +515,25 @@ def _inspect_component_details(comp: dict[str, Any], *, console: Console) -> Non tbl.add_column("URL") tbl.add_column("Signature URL") for art in artifacts: - # Prefer v0.4.0 distributionIds, fall back to legacy distributionTypes. - # Use explicit key check so an empty v0.4.0 list does not fall back. - dist_ids = art.get("distributionIds") - applies_seq = dist_ids if dist_ids is not None else (art.get("distributionTypes") or []) - applies = ", ".join(applies_seq) or "-" + applies = ", ".join(art.get("distributionIds") or []) or "-" formats = art.get("formats", []) if formats: for fmt in formats: tbl.add_row( - escape(art.get("uuid", "-")), - escape(art.get("name", "-")), - escape(art.get("type", "-")), + _esc(art.get("uuid")), + _esc(art.get("name")), + _esc(art.get("type")), escape(applies), - escape(fmt.get("mediaType", "-")), + _esc(fmt.get("mediaType")), _esc(fmt.get("description")), - escape(fmt.get("url", "-")), + _esc(fmt.get("url")), _esc(fmt.get("signatureUrl")), ) else: tbl.add_row( - escape(art.get("uuid", "-")), - escape(art.get("name", "-")), - escape(art.get("type", "-")), + _esc(art.get("uuid")), + _esc(art.get("name")), + _esc(art.get("type")), escape(applies), "-", "-", @@ -632,7 +625,7 @@ def format_output(data: object, *, command: str | None = None, console: Console for model_type, formatter in _TYPE_FORMATTERS.items(): if isinstance(data, model_type): - formatter(data, console=c) # type: ignore[operator] + formatter(data, console=c) return # Fallback: render as JSON diff --git a/src/libtea/_http.py b/src/libtea/_http.py index d3e56ea..abb3a03 100644 --- a/src/libtea/_http.py +++ b/src/libtea/_http.py @@ -35,9 +35,26 @@ _MAX_DOWNLOAD_REDIRECTS = 10 _ERROR_BODY_SNIPPET_BYTES = 200 +# A TEA error body may carry a long `message`; read enough to parse it so `error` is not lost to the snippet cut. +_ERROR_BODY_PARSE_BYTES = 4096 _DOWNLOAD_CHUNK_SIZE = 8192 +def _tea_error_type(raw: bytes) -> str | None: + """Return the ``error`` value of a TEA error body, or ``None``. + + A TEA error is a JSON object with a string ``error`` property. Anything else + (empty, non-JSON, non-object, non-string ``error``) yields ``None``. Unknown + ``error`` values are returned as-is; clients must not reject them. + """ + try: + body = json.loads(raw) + except (ValueError, RecursionError): # ValueError: bad JSON/UTF-8, empty; RecursionError: deep nesting on 3.11 + return None + error = body.get("error") if isinstance(body, dict) else None + return error if isinstance(error, str) else None + + def probe_endpoint(url: str, timeout: float = 5.0) -> None: """Probe a URL to verify the server is reachable. @@ -229,7 +246,7 @@ def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> Any: raise TeaValidationError(f"Response body exceeds limit: >{limit} bytes (limit {limit})") try: result = json.loads(body) - except (ValueError, UnicodeDecodeError) as exc: + except (ValueError, UnicodeDecodeError, RecursionError) as exc: # RecursionError: deep nesting raise TeaValidationError(f"Invalid JSON in response: {exc}") from exc # Cache store (bounded: evict least-recently-stored entry when full) @@ -252,6 +269,8 @@ def download_with_hashes( *, max_download_bytes: int | None = None, allow_private_ips: bool = False, + params: dict[str, str] | None = None, + authenticated: bool = False, ) -> dict[str, str]: """Download a file and compute checksums on-the-fly. @@ -259,21 +278,34 @@ def download_with_hashes( is not leaked to third-party artifact hosts (CDNs, Maven Central, etc.). Redirects are followed manually with SSRF validation at each hop. + With ``authenticated=True`` the first request goes through the API session + (credentials, retries) and, like :meth:`get_json`, trusts the configured API + base without an SSRF check. Every redirect hop still uses the unauthenticated + session, so credentials never leave the API base, not even on a same-host redirect. + Args: - url: Direct download URL. + url: Direct download URL. Must be under the API base when ``authenticated``. dest: Local file path to write to. Parent directories are created. algorithms: Optional list of checksum algorithm names to compute. max_download_bytes: Optional maximum download size in bytes. + params: Optional query parameters for the first request. + authenticated: Send the first request with the API credentials. Returns: Dict mapping algorithm name to hex digest string. Raises: + ValueError: If ``authenticated`` and ``url`` is not under the API base. TeaConnectionError: On network failure. Partial files are deleted. TeaChecksumError: If an unsupported algorithm is requested. - TeaValidationError: If download exceeds max_download_bytes or fails SSRF check. + TeaValidationError: If download exceeds max_download_bytes, fails SSRF check, or a redirect + downgrades HTTPS to HTTP. """ - _validate_download_url(url, allow_private_ips=allow_private_ips) + if authenticated: + if not url.startswith(f"{self._base_url}/"): + raise ValueError(f"Authenticated download URL must be under the API base {self._base_url}") + else: + _validate_download_url(url, allow_private_ips=allow_private_ips) logger.debug("DOWNLOAD %s -> %s", url, dest) hashers = _build_hashers(algorithms) if algorithms else {} @@ -284,12 +316,13 @@ def download_with_hashes( # Follow redirects manually with SSRF validation at each hop current_url = url + session = self._session if authenticated else download_session response = None try: redirects = 0 while True: - response = download_session.get( - current_url, stream=True, timeout=self._timeout, allow_redirects=False + response = session.get( + current_url, params=params, stream=True, timeout=self._timeout, allow_redirects=False ) if 300 <= response.status_code < 400: redirects += 1 @@ -298,8 +331,12 @@ def download_with_hashes( location = response.headers.get("Location") if not location: raise TeaRequestError(f"Redirect without Location header: HTTP {response.status_code}") - current_url = urljoin(current_url, location) - _validate_download_url(current_url, allow_private_ips=allow_private_ips) + next_url = urljoin(current_url, location) + _validate_download_url(next_url, allow_private_ips=allow_private_ips) + if urlparse(current_url).scheme == "https" and urlparse(next_url).scheme == "http": + raise TeaValidationError(f"Download redirect downgraded HTTPS to HTTP: {next_url}") + current_url = next_url + session, params = download_session, None response.close() response = None continue @@ -360,19 +397,20 @@ def __exit__( @staticmethod def _read_error_body(response: requests.Response) -> bytes: - """Read a bounded snippet of the response body for error messages. + """Read a bounded prefix of the response body for error messages and TEA error parsing. - Returns up to ``_ERROR_BODY_SNIPPET_BYTES + 1`` bytes so callers can - detect truncation. Falls back to ``response.content`` when the raw - stream is empty (e.g. already consumed by the transport layer). + Returns up to ``_ERROR_BODY_PARSE_BYTES`` bytes; callers compare the + length with ``_ERROR_BODY_SNIPPET_BYTES`` to detect a truncated snippet. + Falls back to ``response.content`` when the raw stream is empty + (e.g. already consumed by the transport layer). """ - limit = _ERROR_BODY_SNIPPET_BYTES + limit = _ERROR_BODY_PARSE_BYTES try: if response.raw: response.raw.decode_content = True - raw_bytes = response.raw.read(limit + 1) if response.raw else b"" + raw_bytes = response.raw.read(limit) if response.raw else b"" if not raw_bytes: - raw_bytes = response.content[: limit + 1] + raw_bytes = response.content[:limit] except Exception: raw_bytes = b"" return raw_bytes @@ -394,30 +432,19 @@ def _raise_for_status(response: requests.Response) -> None: if status in (401, 403): logger.warning("Authentication failed: HTTP %d for %s", status, response.url) raise TeaAuthenticationError(f"Authentication failed: HTTP {status}") - if status == 404: - error_type = None - try: - raw_bytes = TeaHttpClient._read_error_body(response) - if raw_bytes: - body = json.loads(raw_bytes) - if isinstance(body, dict): - error_type = body.get("error") - except (ValueError, UnicodeDecodeError): - pass - raise TeaNotFoundError(f"Not found: HTTP {status}", error_type=error_type) if status >= 500: raise TeaServerError(f"Server error: HTTP {status}") + # 4xx: read the bounded body once; the snippet and the TEA error type both come from it. + raw_bytes = TeaHttpClient._read_error_body(response) + error_type = _tea_error_type(raw_bytes) + if status == 404: + raise TeaNotFoundError(f"Not found: HTTP {status}", error_type=error_type) # Remaining 4xx codes (400, 405-499 excluding 401/403/404) limit = _ERROR_BODY_SNIPPET_BYTES - body_text = "" - try: - raw_bytes = TeaHttpClient._read_error_body(response) - body_text = raw_bytes.decode("utf-8", errors="replace")[:limit] - if len(raw_bytes) > limit: - body_text += " (truncated)" - except Exception: - pass + body_text = raw_bytes.decode("utf-8", errors="replace")[:limit] + if len(raw_bytes) > limit: + body_text += " (truncated)" msg = f"Client error: HTTP {status}" if body_text: msg = f"{msg} — {body_text}" - raise TeaRequestError(msg) + raise TeaRequestError(msg, error_type=error_type) diff --git a/src/libtea/_validation.py b/src/libtea/_validation.py index 8ef7e53..e696a3c 100644 --- a/src/libtea/_validation.py +++ b/src/libtea/_validation.py @@ -14,20 +14,26 @@ _M = TypeVar("_M", bound=BaseModel) -def _validate(model_cls: type[_M], data: Any) -> _M: +def _context(legacy: bool) -> dict[str, bool] | None: + # No context means 1.0.0 rules; 0.4.0 compatibility shims key off ``tea_legacy``. + return {"tea_legacy": True} if legacy else None + + +def _validate(model_cls: type[_M], data: Any, *, legacy: bool = False) -> _M: """Validate a JSON-decoded value against a Pydantic model. Wraps :meth:`pydantic.BaseModel.model_validate`, converting any :class:`~pydantic.ValidationError` into :class:`TeaValidationError` - so callers only need to catch the ``TeaError`` hierarchy. + so callers only need to catch the ``TeaError`` hierarchy. With + ``legacy=True`` the model is validated in the 0.4.0 compatibility context. """ try: - return model_cls.model_validate(data) + return model_cls.model_validate(data, context=_context(legacy)) except ValidationError as exc: raise TeaValidationError(f"Invalid {model_cls.__name__} response: {exc}") from exc -def _validate_list(model_cls: type[_M], data: Any) -> list[_M]: +def _validate_list(model_cls: type[_M], data: Any, *, legacy: bool = False) -> list[_M]: """Validate a JSON array where each element conforms to a Pydantic model. Raises :class:`TeaValidationError` if ``data`` is not a list or any @@ -35,8 +41,9 @@ def _validate_list(model_cls: type[_M], data: Any) -> list[_M]: """ if not isinstance(data, list): raise TeaValidationError(f"Expected list for {model_cls.__name__}, got {type(data).__name__}") + context = _context(legacy) try: - return [model_cls.model_validate(item) for item in data] + return [model_cls.model_validate(item, context=context) for item in data] except ValidationError as exc: raise TeaValidationError(f"Invalid {model_cls.__name__} response: {exc}") from exc @@ -61,19 +68,14 @@ def _validate_path_segment(value: str, name: str = "uuid") -> str: return str(parsed) -_MAX_PAGE_SIZE = 10000 +_MAX_PAGE_SIZE = 10000 # TEA 0.4.0 +_MAX_PAGE_SIZE_V1 = 100 # TEA 1.0.0 ``pageSize`` maximum -def _validate_page_size(page_size: int) -> None: +def _validate_page_size(page_size: int, max_size: int = _MAX_PAGE_SIZE) -> None: """Validate that page_size is within acceptable bounds.""" - if page_size < 1 or page_size > _MAX_PAGE_SIZE: - raise TeaValidationError(f"page_size must be between 1 and {_MAX_PAGE_SIZE}, got {page_size}") - - -def _validate_page_offset(page_offset: int) -> None: - """Validate that page_offset is non-negative.""" - if page_offset < 0: - raise TeaValidationError(f"page_offset must be >= 0, got {page_offset}") + if page_size < 1 or page_size > max_size: + raise TeaValidationError(f"page_size must be between 1 and {max_size}, got {page_size}") def _validate_collection_version(version: int) -> None: diff --git a/src/libtea/cli.py b/src/libtea/cli.py index a0964d7..6209fca 100644 --- a/src/libtea/cli.py +++ b/src/libtea/cli.py @@ -17,7 +17,7 @@ from contextlib import contextmanager from inspect import signature as _signature from pathlib import Path -from typing import Any, NoReturn +from typing import Any, NoReturn, cast from urllib.parse import urlparse import click @@ -38,6 +38,8 @@ logger = logging.getLogger("libtea") +_TEI_PREFIXES = ("tei://", "urn:tei:") + def _configure_logging(*, verbose: bool, debug: bool) -> None: """Set up logging based on --verbose / --debug flags.""" @@ -85,9 +87,7 @@ def shared_options(fn): # type: ignore[no-untyped-def] click.option("--no-input", is_flag=True, help="Never prompt for input (for scripts and CI)"), ] if not has_positional_tei: - options.append( - click.option("--tei", "tei_urn", default=None, help="TEI URN for base-url/domain auto-discovery") - ) + options.append(click.option("--tei", "tei_urn", default=None, help="TEI for base-url/domain auto-discovery")) options.extend( [ click.option("--port", type=int, default=None, help="Port for well-known resolution"), @@ -181,9 +181,9 @@ def _parse_basic_auth(auth: str | None) -> tuple[str, str] | None: def _domain_from_tei(tei: str | None) -> str | None: - """Extract the domain component from a TEI URN for auto-discovery. + """Extract the domain component from a TEI (``tei://`` URI or legacy URN form) for auto-discovery. - Returns ``None`` if ``tei`` is falsy or not a valid TEI URN. + Returns ``None`` if ``tei`` is falsy or not a valid TEI. """ if not tei: return None @@ -209,7 +209,7 @@ def _build_client( """Build a TeaClient from CLI options. When neither --base-url nor --domain is provided, the domain is extracted - from the TEI URN (if given) and used for .well-known/tea discovery. + from the TEI (if given) and used for .well-known/tea discovery. """ # Fall back to --tei from shared_options (stored in ctx.obj) when tei is not provided directly if not tei: @@ -321,7 +321,8 @@ def _output(data: Any, *, command: str | None = None) -> None: """Output ``data`` as JSON (when ``--json``) or rich-formatted tables/panels. In JSON mode, Pydantic models are serialized via ``model_dump(mode="json", - by_alias=True)`` to produce camelCase keys matching the TEA API wire format. + by_alias=True)``: libtea's model shape with camelCase aliases, stable across + TEA spec versions (not a byte-for-byte copy of any server's wire format). """ output_file, no_color = _get_output_options() if _is_json_output(): @@ -358,8 +359,8 @@ def _error(message: str) -> NoReturn: \b Quick start: - tea-cli inspect 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' - tea-cli download 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' ./sboms/ + tea-cli inspect 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' + tea-cli download 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' ./sboms/ \b Environment variables: @@ -428,11 +429,15 @@ def app( @app.command() -@click.argument("tei") +@click.argument("tei", required=False) +@click.option( + "--purl", default=None, help="Resolve a PURL on the server given by --base-url (TEA 1.x), instead of a TEI" +) @click.option("--quiet", "-q", is_flag=True, help="Output only UUIDs, one per line") @shared_options def discover( - tei: str, + tei: str | None, + purl: str | None, quiet: bool, base_url: str | None, token: str | None, @@ -443,17 +448,22 @@ def discover( port: int | None, allow_private_ips: bool, ) -> None: - """Resolve a TEI to product release UUID(s). + """Resolve a TEI, or a PURL (--purl, TEA 1.x), to product release UUID(s). \b Examples: - tea-cli discover 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --domain example.com - tea-cli discover 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --quiet + tea-cli discover 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --domain example.com + tea-cli discover 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --quiet + tea-cli discover --purl 'pkg:pypi/requests@2.31.0' --base-url https://tea.example.com/tea/v1.0.0 """ + if (tei is None) == (purl is None): + raise click.UsageError("Provide exactly one of TEI or --purl") + if purl is not None and not base_url: + _error("--purl requires --base-url") with _client_session( base_url, token, domain, timeout, use_http, port, auth, tei=tei, allow_private_ips=allow_private_ips ) as client: - result = client.discover(tei) + result = client.discover(tei, purl=purl) if quiet: for d in result: print(d.product_release_uuid) @@ -464,13 +474,13 @@ def discover( @app.command("search-products") @click.option("--id-type", required=True, help="Identifier type (CPE, TEI, PURL)") @click.option("--id-value", required=True, help="Identifier value") -@click.option("--page-offset", type=int, default=0, help="Page offset") +@click.option("--page-token", default=None, help="Token from the previous page (an offset on TEA 0.x servers)") @click.option("--page-size", type=int, default=100, help="Page size") @shared_options def search_products( id_type: str, id_value: str, - page_offset: int, + page_token: str | None, page_size: int, base_url: str | None, token: str | None, @@ -491,20 +501,20 @@ def search_products( with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips ) as client: - result = client.search_products(id_type, id_value, page_offset=page_offset, page_size=page_size) + result = client.search_products(id_type, id_value, page_token=page_token, page_size=page_size) _output(result) @app.command("search-releases") @click.option("--id-type", required=True, help="Identifier type (CPE, TEI, PURL)") @click.option("--id-value", required=True, help="Identifier value") -@click.option("--page-offset", type=int, default=0, help="Page offset") +@click.option("--page-token", default=None, help="Token from the previous page (an offset on TEA 0.x servers)") @click.option("--page-size", type=int, default=100, help="Page size") @shared_options def search_releases( id_type: str, id_value: str, - page_offset: int, + page_token: str | None, page_size: int, base_url: str | None, token: str | None, @@ -520,12 +530,12 @@ def search_releases( \b Examples: tea-cli search-releases --id-type PURL --id-value 'pkg:pypi/requests' --domain example.com - tea-cli search-releases --id-type TEI --id-value 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --json + tea-cli search-releases --id-type TEI --id-value 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --json """ with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips ) as client: - result = client.search_product_releases(id_type, id_value, page_offset=page_offset, page_size=page_size) + result = client.search_product_releases(id_type, id_value, page_token=page_token, page_size=page_size) _output(result) @@ -634,12 +644,12 @@ def get_collection( @app.command("get-product-releases") @click.argument("uuid") -@click.option("--page-offset", type=int, default=0, help="Page offset") +@click.option("--page-token", default=None, help="Token from the previous page (an offset on TEA 0.x servers)") @click.option("--page-size", type=int, default=100, help="Page size") @shared_options def get_product_releases( uuid: str, - page_offset: int, + page_token: str | None, page_size: int, base_url: str | None, token: str | None, @@ -660,7 +670,7 @@ def get_product_releases( with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips ) as client: - result = client.get_product_releases(uuid, page_offset=page_offset, page_size=page_size) + result = client.get_product_releases(uuid, page_token=page_token, page_size=page_size) _output(result) @@ -797,13 +807,13 @@ def get_cle( @app.command("search-components") @click.option("--id-type", required=True, help="Identifier type (CPE, TEI, PURL)") @click.option("--id-value", required=True, help="Identifier value") -@click.option("--page-offset", type=int, default=0, help="Page offset") +@click.option("--page-token", default=None, help="Token from the previous page (an offset on TEA 0.x servers)") @click.option("--page-size", type=int, default=100, help="Page size") @shared_options def search_components( id_type: str, id_value: str, - page_offset: int, + page_token: str | None, page_size: int, base_url: str | None, token: str | None, @@ -824,20 +834,20 @@ def search_components( with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips ) as client: - result = client.search_components(id_type, id_value, page_offset=page_offset, page_size=page_size) + result = client.search_components(id_type, id_value, page_token=page_token, page_size=page_size) _output(result) @app.command("search-component-releases") @click.option("--id-type", required=True, help="Identifier type (CPE, TEI, PURL)") @click.option("--id-value", required=True, help="Identifier value") -@click.option("--page-offset", type=int, default=0, help="Page offset") +@click.option("--page-token", default=None, help="Token from the previous page (an offset on TEA 0.x servers)") @click.option("--page-size", type=int, default=100, help="Page size") @shared_options def search_component_releases( id_type: str, id_value: str, - page_offset: int, + page_token: str | None, page_size: int, base_url: str | None, token: str | None, @@ -853,12 +863,12 @@ def search_component_releases( \b Examples: tea-cli search-component-releases --id-type PURL --id-value 'pkg:pypi/requests' --domain example.com - tea-cli search-component-releases --id-type TEI --id-value 'urn:tei:...' --json + tea-cli search-component-releases --id-type TEI --id-value 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --json """ with _client_session( base_url, token, domain, timeout, use_http, port, auth, allow_private_ips=allow_private_ips ) as client: - result = client.search_component_releases(id_type, id_value, page_offset=page_offset, page_size=page_size) + result = client.search_component_releases(id_type, id_value, page_token=page_token, page_size=page_size) _output(result) @@ -1052,12 +1062,12 @@ def _download_from_tei( collection = client.get_product_release_collection_latest(disc.product_release_uuid) for art_idx, artifact in enumerate(collection.artifacts): for fmt in artifact.formats: - if not fmt.url: + if not fmt.url and client.is_legacy: continue filename = _artifact_filename(fmt, artifact, art_idx) filename = _deduplicate_filename(filename, seen_filenames) dest_path = dest_dir / filename - checksums = [cs for cs in fmt.checksums if cs.algorithm_type and cs.algorithm_value] + checksums = list(fmt.checksums) attempted += 1 if dry_run: if not quiet: @@ -1065,12 +1075,22 @@ def _download_from_tei( downloaded += 1 continue try: - client.download_artifact( - fmt.url, - dest_path, - verify_checksums=checksums or None, - max_download_bytes=max_download_bytes, - ) + if fmt.url: + client.download_artifact( + fmt.url, + dest_path, + verify_checksums=checksums or None, + max_download_bytes=max_download_bytes, + ) + else: # TEA 1.x: the server hosts formats without a url + client.download_artifact_content( + artifact.uuid, + dest_path, + media_type=cast(str, fmt.media_type), # required on TEA 1.x + version=artifact.version, + verify_checksums=checksums or None, + max_download_bytes=max_download_bytes, + ) if not quiet: checksum_note = " (checksum OK)" if checksums else "" print(f"Downloaded {filename}{checksum_note}", file=sys.stderr) @@ -1115,9 +1135,9 @@ def download( port: int | None, allow_private_ips: bool, ) -> None: - """Fetch artifact(s) from a URL or TEI URN. + """Fetch artifact(s) from a URL or TEI. - SOURCE is a direct artifact URL or a TEI URN (urn:tei:...). + SOURCE is a direct artifact URL or a TEI (tei://... or urn:tei:...). \b URL mode: download @@ -1128,7 +1148,7 @@ def download( \b Examples: - tea-cli download 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' ./sboms/ + tea-cli download 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' ./sboms/ tea-cli download https://tea.example.com/artifacts/abc/download output.json --checksum SHA-256:abcdef... """ ctx = click.get_current_context() @@ -1136,7 +1156,7 @@ def download( if output_file: raise click.UsageError("--output is not supported by 'download'; use DESTINATION to specify the output path") - if source.startswith("urn:tei:"): + if source.startswith(_TEI_PREFIXES): if checksum: raise click.UsageError("--checksum is not supported in TEI mode (checksums come from server metadata)") if destination is not None and Path(destination).is_file(): @@ -1162,7 +1182,7 @@ def download( # URL mode: existing direct download behavior if dry_run: - raise click.UsageError("--dry-run is only supported in TEI mode (urn:tei:... source)") + raise click.UsageError("--dry-run is only supported in TEI mode (tei://... or urn:tei:... source)") if destination is None: raise click.UsageError("DESTINATION is required when downloading from a URL") dest_path = Path(destination) @@ -1183,7 +1203,10 @@ def download( _error( f"Unknown checksum algorithm: {alg!r}. Supported: {', '.join(e.value for e in ChecksumAlgorithm)}" ) - checksums.append(Checksum(algorithm_type=alg_enum, algorithm_value=value)) + # The user supplied this hash; it is not server data, so TEA 1.0.0 response rules (e.g. no MD5) don't apply. + checksums.append( + Checksum.model_validate({"algType": alg_enum, "algValue": value}, context={"tea_legacy": True}) + ) try: with _client_session( @@ -1220,8 +1243,8 @@ def inspect( \b Examples: - tea-cli inspect 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --domain example.com - tea-cli inspect 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' --json + tea-cli inspect 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --domain example.com + tea-cli inspect 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' --json """ with _client_session( base_url, token, domain, timeout, use_http, port, auth, tei=tei, allow_private_ips=allow_private_ips @@ -1242,7 +1265,8 @@ def inspect( try: releases = client.get_component_releases(comp_ref.uuid) if releases: - latest = releases[0] + # TEA 1.0.0 lists releases oldest first; pick by createdDate, not position. + latest = max(releases, key=lambda r: r.created_date) cr = client.get_component_release(latest.uuid) comp_data["resolvedRelease"] = cr.model_dump(mode="json", by_alias=True) comp_data["resolvedNote"] = "latest release (not pinned)" @@ -1319,7 +1343,7 @@ def conformance( \b Examples: - tea-cli conformance --base-url https://tea.example.com --tei 'urn:tei:purl:example.com:pkg:pypi/requests@2.31.0' + tea-cli conformance --base-url https://tea.example.com --tei 'tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1' tea-cli conformance --base-url https://tea.example.com --verbose """ ctx.ensure_object(dict) diff --git a/src/libtea/client.py b/src/libtea/client.py index 9c5c6a8..1b34811 100644 --- a/src/libtea/client.py +++ b/src/libtea/client.py @@ -14,24 +14,28 @@ from pathlib import Path from types import TracebackType from typing import Self, TypeVar +from urllib.parse import urlsplit + +from semver import Version from libtea._hashing import WEAK_HASH_ALGORITHMS from libtea._http import TeaHttpClient, probe_endpoint from libtea._validation import ( + _MAX_PAGE_SIZE_V1, _validate, _validate_artifact_version, _validate_collection_version, _validate_list, - _validate_page_offset, _validate_page_size, _validate_path_segment, ) -from libtea.discovery import fetch_well_known, select_best_endpoint, select_endpoints +from libtea.discovery import _advertised_version, fetch_well_known, select_best_endpoint, select_endpoints from libtea.exceptions import ( TeaChecksumError, TeaConnectionError, TeaDiscoveryError, TeaServerError, + TeaValidationError, ) from libtea.models import ( CLE, @@ -41,21 +45,51 @@ Component, ComponentReleaseWithCollection, DiscoveryInfo, + PaginatedCollectionResponse, PaginatedComponentReleaseResponse, PaginatedComponentResponse, PaginatedProductReleaseResponse, PaginatedProductResponse, Product, ProductRelease, + ProductReleaseWithCollection, Release, + _PaginatedResponse, ) logger = logging.getLogger("libtea") -TEA_SPEC_VERSION = "0.4.0" +TEA_SPEC_VERSION = "1.0.0" +# Highest first. Major 0 is the legacy (0.4.0) wire format; major 1 is current. +SUPPORTED_SPEC_VERSIONS: tuple[str, ...] = ("1.0.0", "0.4.0") +_SUPPORTED_MAJORS = frozenset(Version.parse(v).major for v in SUPPORTED_SPEC_VERSIONS) _T = TypeVar("_T") +# ponytail: hard ceiling of 10,000 pages per pagination run (1M results at pageSize 100) so a server +# minting endless distinct tokens cannot loop forever; make it a parameter if real catalogs get that big. +_MAX_PAGES = 10_000 + + +def _resolve_spec_version(base_url: str, explicit: str | None) -> Version: + """Resolve the TEA spec version: explicit argument, then a ``/v`` URL suffix, then the default.""" + candidate = explicit + if candidate is None: + last_segment = urlsplit(base_url).path.rstrip("/").rpartition("/")[2] + if last_segment.startswith("v") and Version.is_valid(last_segment[1:]): + candidate = last_segment[1:] + if candidate is None: + return Version.parse(TEA_SPEC_VERSION) + try: + version = Version.parse(candidate) + except ValueError: + version = None + if version is None or version.major not in _SUPPORTED_MAJORS: + raise ValueError( + f"Unsupported TEA spec version {candidate!r}; supported versions: {', '.join(SUPPORTED_SPEC_VERSIONS)}" + ) + return version + class TeaClient: """Synchronous client for the Transparency Exchange API (consumer / read-only). @@ -74,6 +108,13 @@ class TeaClient: timeout: Request timeout in seconds (default 30). max_retries: Number of automatic retries on 5xx responses (default 3). backoff_factor: Exponential backoff multiplier between retries (default 0.5). + spec_version: TEA spec SemVer the server speaks. Defaults to the ``/v`` + suffix of ``base_url`` when present, else :data:`TEA_SPEC_VERSION`. Major 0 + (``0.4.0``) selects the legacy wire format; major 1 selects 1.0.0. + + Raises: + ValueError: If the resolved spec version is not valid SemVer or its major + version is not supported. """ def __init__( @@ -87,7 +128,11 @@ def __init__( backoff_factor: float = 0.5, allow_private_ips: bool = False, cache_ttl: float | None = None, + spec_version: str | None = None, ): + resolved = _resolve_spec_version(base_url, spec_version) + self.spec_version: str = str(resolved) + self.is_legacy: bool = resolved.major == 0 self._allow_private_ips = allow_private_ips self._http = TeaHttpClient( base_url=base_url, @@ -107,7 +152,7 @@ def from_well_known( token: str | None = None, basic_auth: tuple[str, str] | None = None, timeout: float = 30.0, - version: str = TEA_SPEC_VERSION, + version: str | None = None, scheme: str = "https", port: int | None = None, max_retries: int = 3, @@ -121,13 +166,20 @@ def from_well_known( with the requested ``version`` (SemVer match), and probes each in priority order. If an endpoint is unreachable or returns a server error, the next candidate is tried (per TEA spec: "MUST retry ... with the next endpoint"). + The API path uses each endpoint's version string exactly as advertised + (build metadata included, e.g. ``/v1.0.0+build.5``). + With the default ``version=None`` the candidates span every supported version, + highest first, so failover can also fall back from ``1.0.0`` to ``0.4.0``. Args: domain: Domain name to resolve (e.g. ``tea.example.com``). token: Optional bearer token. basic_auth: Optional ``(username, password)`` tuple. timeout: Request timeout in seconds (default 30). - version: TEA spec SemVer to match against (default: library's built-in version). + version: TEA spec SemVer to match against. The default (``None``) negotiates + the highest of :data:`SUPPORTED_SPEC_VERSIONS` the server advertises: + ``1.0.0`` first, then ``0.4.0``. Only when neither is advertised exactly does it + downgrade to a compatible version (e.g. ``0.3.0``), with a warning. scheme: URL scheme for discovery — ``"https"`` (default) or ``"http"``. port: Optional port for ``.well-known`` resolution. max_retries: Retry count on 5xx (default 3). @@ -142,17 +194,51 @@ def from_well_known( Raises: TeaDiscoveryError: If no compatible or reachable endpoint is found - (wraps the last probe failure as ``__cause__``). + (wraps the last probe failure as ``__cause__``), or the negotiated + version is not one this client supports. """ well_known = fetch_well_known(domain, timeout=timeout, scheme=scheme, port=port) - best = select_best_endpoint(well_known, version) - matched_version = best.matched_version - # Get all endpoints that advertise the matched version (exact match) for failover - candidates = select_endpoints(well_known, matched_version) + # Ordered (endpoint URL, advertised version) attempts: highest version first, then endpoint priority. + # Without an explicit ``version`` every supported version contributes, so failover can cross versions. + wanted_versions = (version,) if version is not None else SUPPORTED_SPEC_VERSIONS + attempts: dict[tuple[str, str], None] = {} # insertion-ordered set + if version is None: + # Exact matches first: a prerelease such as ``1.0.0-rc.1`` is not a version this client supports, + # so a compatible downgrade is only a fallback when no supported version is advertised as-is. + for wanted in SUPPORTED_SPEC_VERSIONS: + try: + exact = select_endpoints(well_known, wanted) + except TeaDiscoveryError: + continue + for endpoint in exact: + attempts[(endpoint.url, _advertised_version(endpoint, wanted))] = None + if not attempts: + for wanted in wanted_versions: + try: + best = select_best_endpoint(well_known, wanted) + except TeaDiscoveryError: + if version is not None: + raise + continue + matched_version = best.matched_version + try: + # Rejects an unsupported version (possible with an explicit ``version``) before any probe. + _resolve_spec_version(best.endpoint.url, matched_version) + except ValueError as exc: + raise TeaDiscoveryError(f"Cannot use negotiated version {matched_version!r}: {exc}") from exc + for endpoint in select_endpoints(well_known, matched_version): + # Build metadata does not affect precedence, so each endpoint may spell the version differently. + attempts[(endpoint.url, _advertised_version(endpoint, matched_version))] = None + if not attempts: + offered = sorted({v for ep in well_known.endpoints for v in ep.versions}) + raise TeaDiscoveryError( + f"No compatible endpoint found for any version tried {list(SUPPORTED_SPEC_VERSIONS)}. " + f"Available versions: {offered}" + ) errors: list[tuple[str, Exception]] = [] - for endpoint in candidates: - base_url = f"{endpoint.url.rstrip('/')}/v{matched_version}" + for url, advertised in attempts: + base_url = f"{url.rstrip('/')}/v{advertised}" try: probe_endpoint(base_url, timeout=min(timeout, 5.0)) except (TeaConnectionError, TeaServerError) as exc: @@ -168,75 +254,109 @@ def from_well_known( backoff_factor=backoff_factor, allow_private_ips=allow_private_ips, cache_ttl=cache_ttl, + spec_version=advertised, ) - if errors: - summary = "; ".join(f"{url}: {exc}" for url, exc in errors) - raise TeaDiscoveryError( - f"All {len(errors)} endpoint(s) failed for version {version!r}: {summary}" - ) from errors[-1][1] - raise TeaDiscoveryError(f"No reachable endpoint found for version {version!r}") # pragma: no cover + summary = "; ".join(f"{url}: {exc}" for url, exc in errors) + raise TeaDiscoveryError(f"All {len(errors)} endpoint(s) failed: {summary}") from errors[-1][1] # --- Discovery --- - def discover(self, tei: str) -> list[DiscoveryInfo]: - """Resolve a TEI to product release UUID(s) via the discovery endpoint. + def discover(self, tei: str | None = None, *, purl: str | None = None) -> list[DiscoveryInfo]: + """Resolve a TEI, or a PURL within this server's inventory, to product release UUID(s). + + Exactly one of ``tei`` and ``purl`` must be given. Discovery by PURL needs an API base the + client already knows and is only available on TEA 1.x servers. Args: - tei: TEI URN string (e.g. ``urn:tei:purl:example.com:pkg:pypi/lib@1.0``). + tei: TEI string, sent verbatim (e.g. ``tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1``). + Use the form the server speaks: the ``tei://`` URI on TEA 1.x, the legacy URN on 0.4.0. + purl: Package URL, sent verbatim (e.g. ``pkg:maven/org.apache.logging.log4j/log4j-core@2.24.3``). Returns: List of discovery results, each with a product release UUID and servers. Raises: - TeaValidationError: If the response is malformed. + TeaValidationError: If not exactly one of ``tei`` and ``purl`` is given, if ``purl`` is used on a + TEA 0.x server, or if the response is malformed, or empty on a TEA 1.x server. TeaConnectionError: On network failure. """ + if (tei is None) == (purl is None): + raise TeaValidationError("Provide exactly one of tei or purl") + if purl is not None and self.is_legacy: + raise TeaValidationError("Discovery by PURL requires a TEA 1.x server") # requests auto-encodes query params — do NOT pre-encode with quote() - data = self._http.get_json("/discovery", params={"tei": tei}) - return _validate_list(DiscoveryInfo, data) + data = self._http.get_json("/discovery", params={"tei": tei} if tei is not None else {"purl": purl}) + results = _validate_list(DiscoveryInfo, data, legacy=self.is_legacy) + if not results and not self.is_legacy: + raise TeaValidationError("Invalid discovery response: expected a non-empty array") + return results + + # --- Pagination parameters --- + + def _page_params(self, page_token: str | None, page_size: int) -> dict[str, typing.Any]: + """Build the paging query: an opaque ``pageToken`` on TEA 1.x, an integer ``pageOffset`` on 0.x.""" + if page_token is not None and not isinstance(page_token, str): + raise TeaValidationError( + f"page_token must be a string (a previous page's nextPageToken), got {type(page_token).__name__}" + ) + if not self.is_legacy: + _validate_page_size(page_size, _MAX_PAGE_SIZE_V1) + return {"pageSize": page_size} if page_token is None else {"pageSize": page_size, "pageToken": page_token} + _validate_page_size(page_size) + if page_token is not None and not (page_token.isascii() and page_token.isdigit()): + raise TeaValidationError( + f"page_token for a TEA 0.x server must be a non-negative integer offset, got {page_token!r}" + ) + return {"pageOffset": int(page_token or 0), "pageSize": page_size} + + def _all_pages(self, path: str, model_cls: type[_PaginatedResponse[_T]]) -> list[_T]: + """Fetch every page of a TEA 1.x list endpoint that 0.4.0 served as a bare array.""" + + def fetch_page(*, page_token: str | None, page_size: int) -> _PaginatedResponse[_T]: + data = self._http.get_json(path, params=self._page_params(page_token, page_size)) + return _validate(model_cls, data, legacy=self.is_legacy) + + # ponytail: drains every page into memory; expose an iterator if lists get large + return list(self._paginate(fetch_page, _MAX_PAGE_SIZE_V1)) # --- Products --- - def list_products(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedProductResponse: + def list_products(self, *, page_token: str | None = None, page_size: int = 100) -> PaginatedProductResponse: """List all products (no identifier filter). Args: - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing products. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/products", - params={"pageOffset": page_offset, "pageSize": page_size}, + params=self._page_params(page_token, page_size), ) - return _validate(PaginatedProductResponse, data) + return _validate(PaginatedProductResponse, data, legacy=self.is_legacy) def search_products( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductResponse: """Search for products by identifier (e.g. PURL, CPE, TEI). Args: id_type: Identifier type (e.g. ``"PURL"``, ``"CPE"``, ``"TEI"``). id_value: Identifier value to search for. - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing matching products. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/products", - params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, + params={"idType": id_type, "idValue": id_value, **self._page_params(page_token, page_size)}, ) - return _validate(PaginatedProductResponse, data) + return _validate(PaginatedProductResponse, data, legacy=self.is_legacy) def get_product(self, uuid: str) -> Product: """Get a TEA product by UUID. @@ -248,82 +368,79 @@ def get_product(self, uuid: str) -> Product: The product with its identifiers. """ data = self._http.get_json(f"/product/{_validate_path_segment(uuid)}") - return _validate(Product, data) + return _validate(Product, data, legacy=self.is_legacy) def get_product_releases( - self, uuid: str, *, page_offset: int = 0, page_size: int = 100 + self, uuid: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: """Get paginated releases for a product. Args: uuid: Product UUID. - page_offset: Zero-based page offset. - page_size: Number of results per page. + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing product releases. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( f"/product/{_validate_path_segment(uuid)}/releases", - params={"pageOffset": page_offset, "pageSize": page_size}, + params=self._page_params(page_token, page_size), ) - return _validate(PaginatedProductReleaseResponse, data) + return _validate(PaginatedProductReleaseResponse, data, legacy=self.is_legacy) # --- Product Releases --- - def list_product_releases(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedProductReleaseResponse: + def list_product_releases( + self, *, page_token: str | None = None, page_size: int = 100 + ) -> PaginatedProductReleaseResponse: """List all product releases (no identifier filter). Args: - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing product releases. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/productReleases", - params={"pageOffset": page_offset, "pageSize": page_size}, + params=self._page_params(page_token, page_size), ) - return _validate(PaginatedProductReleaseResponse, data) + return _validate(PaginatedProductReleaseResponse, data, legacy=self.is_legacy) def search_product_releases( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: """Search for product releases by identifier (e.g. PURL, CPE, TEI). Args: id_type: Identifier type (e.g. ``"PURL"``, ``"CPE"``, ``"TEI"``). id_value: Identifier value to search for. - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing matching product releases. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/productReleases", - params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, + params={"idType": id_type, "idValue": id_value, **self._page_params(page_token, page_size)}, ) - return _validate(PaginatedProductReleaseResponse, data) + return _validate(PaginatedProductReleaseResponse, data, legacy=self.is_legacy) - def get_product_release(self, uuid: str) -> ProductRelease: + def get_product_release(self, uuid: str) -> ProductReleaseWithCollection: """Get a product release by UUID. Args: uuid: Product release UUID. Returns: - The product release with its component references. + The product release with its component references and, on TEA 1.x + servers, its latest collection. """ data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}") - return _validate(ProductRelease, data) + return _validate(ProductReleaseWithCollection, data, legacy=self.is_legacy) def get_product_release_collection_latest(self, uuid: str) -> Collection: """Get the latest collection for a product release. @@ -335,7 +452,7 @@ def get_product_release_collection_latest(self, uuid: str) -> Collection: The latest collection with its artifacts. """ data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}/collection/latest") - return _validate(Collection, data) + return _validate(Collection, data, legacy=self.is_legacy) def get_product_release_collections(self, uuid: str) -> list[Collection]: """Get all collection versions for a product release. @@ -346,8 +463,10 @@ def get_product_release_collections(self, uuid: str) -> list[Collection]: Returns: List of all collection versions. """ - data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}/collections") - return _validate_list(Collection, data) + path = f"/productRelease/{_validate_path_segment(uuid)}/collections" + if not self.is_legacy: + return self._all_pages(path, PaginatedCollectionResponse) + return _validate_list(Collection, self._http.get_json(path), legacy=self.is_legacy) def get_product_release_collection(self, uuid: str, version: int) -> Collection: """Get a specific collection version for a product release. @@ -361,7 +480,7 @@ def get_product_release_collection(self, uuid: str, version: int) -> Collection: """ _validate_collection_version(version) data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}/collection/{version}") - return _validate(Collection, data) + return _validate(Collection, data, legacy=self.is_legacy) # --- Components --- @@ -375,12 +494,12 @@ def get_component(self, uuid: str) -> Component: The component with its identifiers. """ data = self._http.get_json(f"/component/{_validate_path_segment(uuid)}") - return _validate(Component, data) + return _validate(Component, data, legacy=self.is_legacy) def get_component_releases(self, uuid: str) -> list[Release]: """Get all releases for a component. - Unlike product releases, component releases are not paginated. + TEA 1.x paginates this endpoint and every page is fetched; TEA 0.4.0 returns a bare array. Args: uuid: Component UUID. @@ -388,48 +507,46 @@ def get_component_releases(self, uuid: str) -> list[Release]: Returns: List of component releases. """ - data = self._http.get_json(f"/component/{_validate_path_segment(uuid)}/releases") - return _validate_list(Release, data) + path = f"/component/{_validate_path_segment(uuid)}/releases" + if not self.is_legacy: + return self._all_pages(path, PaginatedComponentReleaseResponse) + return _validate_list(Release, self._http.get_json(path), legacy=self.is_legacy) - def list_components(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedComponentResponse: + def list_components(self, *, page_token: str | None = None, page_size: int = 100) -> PaginatedComponentResponse: """List all components (no identifier filter). Args: - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing components. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/components", - params={"pageOffset": page_offset, "pageSize": page_size}, + params=self._page_params(page_token, page_size), ) - return _validate(PaginatedComponentResponse, data) + return _validate(PaginatedComponentResponse, data, legacy=self.is_legacy) def search_components( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentResponse: """Search for components by identifier (e.g. PURL, CPE, TEI). Args: id_type: Identifier type (e.g. ``"PURL"``, ``"CPE"``, ``"TEI"``). id_value: Identifier value to search for. - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing matching components. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/components", - params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, + params={"idType": id_type, "idValue": id_value, **self._page_params(page_token, page_size)}, ) - return _validate(PaginatedComponentResponse, data) + return _validate(PaginatedComponentResponse, data, legacy=self.is_legacy) # --- Component Releases --- @@ -443,7 +560,7 @@ def get_component_release(self, uuid: str) -> ComponentReleaseWithCollection: The release bundled with its latest collection of artifacts. """ data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}") - return _validate(ComponentReleaseWithCollection, data) + return _validate(ComponentReleaseWithCollection, data, legacy=self.is_legacy) def get_component_release_collection_latest(self, uuid: str) -> Collection: """Get the latest collection for a component release. @@ -455,7 +572,7 @@ def get_component_release_collection_latest(self, uuid: str) -> Collection: The latest collection with its artifacts. """ data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}/collection/latest") - return _validate(Collection, data) + return _validate(Collection, data, legacy=self.is_legacy) def get_component_release_collections(self, uuid: str) -> list[Collection]: """Get all collection versions for a component release. @@ -466,8 +583,10 @@ def get_component_release_collections(self, uuid: str) -> list[Collection]: Returns: List of all collection versions. """ - data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}/collections") - return _validate_list(Collection, data) + path = f"/componentRelease/{_validate_path_segment(uuid)}/collections" + if not self.is_legacy: + return self._all_pages(path, PaginatedCollectionResponse) + return _validate_list(Collection, self._http.get_json(path), legacy=self.is_legacy) def get_component_release_collection(self, uuid: str, version: int) -> Collection: """Get a specific collection version for a component release. @@ -481,49 +600,45 @@ def get_component_release_collection(self, uuid: str, version: int) -> Collectio """ _validate_collection_version(version) data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}/collection/{version}") - return _validate(Collection, data) + return _validate(Collection, data, legacy=self.is_legacy) def list_component_releases( - self, *, page_offset: int = 0, page_size: int = 100 + self, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentReleaseResponse: """List all component releases (no identifier filter). Args: - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing component releases. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/componentReleases", - params={"pageOffset": page_offset, "pageSize": page_size}, + params=self._page_params(page_token, page_size), ) - return _validate(PaginatedComponentReleaseResponse, data) + return _validate(PaginatedComponentReleaseResponse, data, legacy=self.is_legacy) def search_component_releases( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentReleaseResponse: """Search for component releases by identifier (e.g. PURL, CPE, TEI). Args: id_type: Identifier type (e.g. ``"PURL"``, ``"CPE"``, ``"TEI"``). id_value: Identifier value to search for. - page_offset: Zero-based page offset (default 0). - page_size: Number of results per page (default 100, max 10000). + page_token: ``nextPageToken`` from the previous page, or ``None`` for the first page. + page_size: Number of results per page (default 100; max 100 on TEA 1.x, 10000 on 0.x). Returns: Paginated response containing matching component releases. """ - _validate_page_size(page_size) - _validate_page_offset(page_offset) data = self._http.get_json( "/componentReleases", - params={"idType": id_type, "idValue": id_value, "pageOffset": page_offset, "pageSize": page_size}, + params={"idType": id_type, "idValue": id_value, **self._page_params(page_token, page_size)}, ) - return _validate(PaginatedComponentReleaseResponse, data) + return _validate(PaginatedComponentReleaseResponse, data, legacy=self.is_legacy) # --- CLE --- @@ -537,7 +652,7 @@ def get_product_cle(self, uuid: str) -> CLE: The CLE document with lifecycle events and optional definitions. """ data = self._http.get_json(f"/product/{_validate_path_segment(uuid)}/cle") - return _validate(CLE, data) + return _validate(CLE, data, legacy=self.is_legacy) def get_product_release_cle(self, uuid: str) -> CLE: """Get CLE data for a product release. @@ -549,7 +664,7 @@ def get_product_release_cle(self, uuid: str) -> CLE: The CLE document with lifecycle events and optional definitions. """ data = self._http.get_json(f"/productRelease/{_validate_path_segment(uuid)}/cle") - return _validate(CLE, data) + return _validate(CLE, data, legacy=self.is_legacy) def get_component_cle(self, uuid: str) -> CLE: """Get CLE data for a component. @@ -561,7 +676,7 @@ def get_component_cle(self, uuid: str) -> CLE: The CLE document with lifecycle events and optional definitions. """ data = self._http.get_json(f"/component/{_validate_path_segment(uuid)}/cle") - return _validate(CLE, data) + return _validate(CLE, data, legacy=self.is_legacy) def get_component_release_cle(self, uuid: str) -> CLE: """Get CLE data for a component release. @@ -573,21 +688,24 @@ def get_component_release_cle(self, uuid: str) -> CLE: The CLE document with lifecycle events and optional definitions. """ data = self._http.get_json(f"/componentRelease/{_validate_path_segment(uuid)}/cle") - return _validate(CLE, data) + return _validate(CLE, data, legacy=self.is_legacy) # --- Artifacts --- def get_artifact(self, uuid: str) -> Artifact: """Get the latest revision of artifact metadata by UUID. + TEA 1.x serves it at ``/artifact/{uuid}/latest``; TEA 0.4.0 at ``/artifact/{uuid}``. + Args: uuid: Artifact UUID. Returns: The artifact with its formats and download URLs. """ - data = self._http.get_json(f"/artifact/{_validate_path_segment(uuid)}/latest") - return _validate(Artifact, data) + path = f"/artifact/{_validate_path_segment(uuid)}" + data = self._http.get_json(path if self.is_legacy else f"{path}/latest") + return _validate(Artifact, data, legacy=self.is_legacy) def get_artifact_version(self, uuid: str, version: int) -> Artifact: """Get a specific version of artifact metadata by UUID. @@ -598,10 +716,15 @@ def get_artifact_version(self, uuid: str, version: int) -> Artifact: Returns: The artifact with its formats and download URLs. + + Raises: + TeaValidationError: On a TEA 0.4.0 server, which has no versioned artifact endpoint. """ _validate_artifact_version(version) + if self.is_legacy: + raise TeaValidationError("Artifact versions require a TEA 1.x server") data = self._http.get_json(f"/artifact/{_validate_path_segment(uuid)}/{version}") - return _validate(Artifact, data) + return _validate(Artifact, data, legacy=self.is_legacy) def download_artifact( self, @@ -631,25 +754,81 @@ def download_artifact( TeaConnectionError: On network failure. TeaValidationError: If download exceeds max_download_bytes. """ - if verify_checksums: - # Skip checksums with missing algorithm_type or algorithm_value (optional per spec) - verify_checksums = [cs for cs in verify_checksums if cs.algorithm_type and cs.algorithm_value] - alg_names = {cs.algorithm_type.value for cs in verify_checksums if cs.algorithm_type} - weak = alg_names & WEAK_HASH_ALGORITHMS + return self._download(url, dest, verify_checksums, max_download_bytes) + + def download_artifact_content( + self, + uuid: str, + dest: Path, + *, + media_type: str, + version: int | None = None, + verify_checksums: list[Checksum] | None = None, + max_download_bytes: int | None = None, + ) -> Path: + """Download artifact content hosted by the TEA server itself (a format without ``url``). + + Fetches ``/artifact/{uuid}/{version}/download?mediaType=...`` (``latest`` when + ``version`` is omitted) with the client's credentials. A redirect is followed + without credentials, so the token is only ever sent to the API base. When the API base is a + private address and its download endpoint redirects to its own host, the redirect hop is + SSRF-checked, so it needs ``allow_private_ips=True``. + + Args: + uuid: Artifact UUID. + dest: Local file path to write to. + media_type: Media type of the format to download. + version: Artifact version number (starts at 1), or ``None`` for the latest. + verify_checksums: Optional list of checksums to verify after download. + On mismatch the downloaded file is deleted. + max_download_bytes: Optional maximum download size in bytes. + + Returns: + The destination path. + + Raises: + TeaValidationError: On a TEA 0.x server, or if download exceeds max_download_bytes. + TeaRequestError: If the server has no format for ``media_type`` (``NO_ACCEPTABLE_FORMAT``). + TeaChecksumError: If checksum verification fails. + TeaConnectionError: On network failure. + """ + if self.is_legacy: + raise TeaValidationError("Server-hosted artifact download requires a TEA 1.x server") + if version is not None: + _validate_artifact_version(version) + revision = "latest" if version is None else version + url = f"{self._http._base_url}/artifact/{_validate_path_segment(uuid)}/{revision}/download" + return self._download( + url, dest, verify_checksums, max_download_bytes, params={"mediaType": media_type}, authenticated=True + ) + + def _download( + self, + url: str, + dest: Path, + verify_checksums: list[Checksum] | None, + max_download_bytes: int | None, + *, + params: dict[str, str] | None = None, + authenticated: bool = False, + ) -> Path: + """Download with on-the-fly hashing, warning on weak algorithms and verifying checksums.""" + algorithms = [cs.algorithm_type.value for cs in verify_checksums] if verify_checksums else None + if algorithms: + weak = set(algorithms) & WEAK_HASH_ALGORITHMS if weak: warnings.warn( f"Verifying with weak hash algorithm(s): {', '.join(sorted(weak))}. Prefer SHA-256 or stronger.", - stacklevel=2, + stacklevel=3, ) - algorithms = ( - [cs.algorithm_type.value for cs in verify_checksums if cs.algorithm_type] if verify_checksums else None - ) computed = self._http.download_with_hashes( url, dest, algorithms=algorithms, max_download_bytes=max_download_bytes, allow_private_ips=self._allow_private_ips, + params=params, + authenticated=authenticated, ) if verify_checksums: @@ -668,8 +847,6 @@ def _verify_checksums(checksums: list[Checksum], computed: dict[str, str], url: TeaChecksumError: If any checksum does not match. """ for cs in checksums: - if cs.algorithm_type is None or cs.algorithm_value is None: - continue # skip incomplete checksums (fields optional per spec) alg_name = cs.algorithm_type.value expected = cs.algorithm_value.lower() if alg_name not in computed: @@ -701,15 +878,19 @@ def _verify_checksums(checksums: list[Checksum], computed: dict[str, str], url: @staticmethod def _paginate(fetch_page: typing.Callable[..., typing.Any], page_size: int, **kwargs: typing.Any) -> Iterator[_T]: - """Generic paginator: calls *fetch_page* with page_offset/page_size, yields results.""" - _validate_page_size(page_size) - offset = 0 - while True: - page = fetch_page(**kwargs, page_offset=offset, page_size=page_size) + """Generic paginator: calls *fetch_page* with page_token/page_size, following ``nextPageToken``.""" + token: str | None = None + seen: set[str] = set() + for _ in range(_MAX_PAGES): + page = fetch_page(**kwargs, page_token=token, page_size=page_size) yield from page.results - offset += len(page.results) - if offset >= page.total_results or not page.results: - break + if not page.has_next: # an empty page may still carry a token; seen-set and cap bound the loop + return + token = page.next_page_token + if token in seen: + raise TeaServerError(f"Server repeated pageToken {token!r}; stopping pagination") + seen.add(token) + raise TeaServerError(f"Pagination exceeded {_MAX_PAGES} pages; stopping") def iter_products(self, id_type: str, id_value: str, *, page_size: int = 100) -> Iterator[Product]: """Iterate over all products matching an identifier, auto-paginating. diff --git a/src/libtea/conformance/__init__.py b/src/libtea/conformance/__init__.py index 482cc81..b210e22 100644 --- a/src/libtea/conformance/__init__.py +++ b/src/libtea/conformance/__init__.py @@ -3,16 +3,21 @@ Usage (programmatic):: from libtea.conformance import run_conformance - result = run_conformance(base_url="https://tea.example.com/v1", tei="urn:tei:...") + result = run_conformance( + base_url="https://tea.example.com/v1", + tei="tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1", + ) print(f"{result.passed} passed, {result.failed} failed, {result.warned} warned, {result.skipped} skipped") Usage (CLI):: - tea-cli conformance --base-url https://tea.example.com/v1 --tei "urn:tei:..." + tea-cli conformance --base-url https://tea.example.com/v1 \\ + --tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" Usage (pytest plugin):: - pytest --tea-base-url https://tea.example.com/v1 --tea-tei "urn:tei:..." + pytest --tea-base-url https://tea.example.com/v1 \\ + --tea-tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" """ from __future__ import annotations diff --git a/src/libtea/conformance/_checks.py b/src/libtea/conformance/_checks.py index 4ef1926..16b7471 100644 --- a/src/libtea/conformance/_checks.py +++ b/src/libtea/conformance/_checks.py @@ -13,13 +13,15 @@ from libtea.client import TeaClient from libtea.conformance._types import CheckResult, CheckStatus -from libtea.exceptions import TeaError, TeaNotFoundError +from libtea.exceptions import TeaError, TeaNotFoundError, TeaValidationError # Canonical zero UUID used for 404 tests. _ZERO_UUID = "00000000-0000-0000-0000-000000000000" _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +_EMPTY_DISCOVERY = "a successful discovery response must be a non-empty array (TEA 1.0.0)" + # --------------------------------------------------------------------------- # Context @@ -64,6 +66,11 @@ def _warn(name: str, msg: str) -> CheckResult: return CheckResult(name=name, status=CheckStatus.WARN, message=msg) +def _is_empty_discovery(exc: TeaError) -> bool: + """True when ``discover()`` rejected an empty array, which TEA 1.x forbids in a successful response.""" + return isinstance(exc, TeaValidationError) and "non-empty array" in str(exc) + + def _collect(ctx: CheckContext, uuid: str | None) -> None: """Append a UUID to the collected list if it looks non-empty.""" if uuid: @@ -83,6 +90,8 @@ def check_discovery(client: TeaClient, ctx: CheckContext) -> CheckResult: try: results = client.discover(ctx.tei) except TeaError as exc: + if _is_empty_discovery(exc): + return _fail(name, f"discover() returned an empty array: {_EMPTY_DISCOVERY}") return _fail(name, f"discover() failed: {exc}", details=str(exc)) if not results: return _fail(name, "discover() returned empty list") @@ -93,18 +102,26 @@ def check_discovery(client: TeaClient, ctx: CheckContext) -> CheckResult: def check_discovery_404(client: TeaClient, ctx: CheckContext) -> CheckResult: - """Discover a fake TEI and expect TeaNotFoundError or empty list.""" + """Discover a fake TEI and expect a 404: with a TEA error body on TEA 1.x; on TEA 0.x an empty list also passes.""" name = "discovery_404" - fake_tei = "urn:tei:uuid:nonexistent.example.com:00000000-0000-0000-0000-000000000000" + if client.is_legacy: + fake_tei = f"urn:tei:uuid:nonexistent.example.com:{_ZERO_UUID}" + else: + fake_tei = f"tei://nonexistent.example.com/uuid/{_ZERO_UUID}" try: results = client.discover(fake_tei) - except TeaNotFoundError: + except TeaNotFoundError as exc: + if not client.is_legacy and exc.error_type is None: + return _fail(name, "Server returned 404 without a TEA error body; TEA 1.0.0 requires a TEA error body") return _pass(name, "Server returned 404 for unknown TEI") except TeaError as exc: + if _is_empty_discovery(exc): + return _fail(name, f"Expected 404 for unknown TEI, got an empty array: {_EMPTY_DISCOVERY}") return _fail(name, f"Unexpected error: {exc}", details=str(exc)) if not results: return _pass(name, "Server returned empty list for unknown TEI") - return _fail(name, f"Expected 404 or empty list, got {len(results)} result(s)") + expected = "404 or empty list" if client.is_legacy else "404" + return _fail(name, f"Expected {expected}, got {len(results)} result(s)") # --------------------------------------------------------------------------- @@ -124,7 +141,7 @@ def check_list_products(client: TeaClient, ctx: CheckContext) -> CheckResult: first = resp.results[0] ctx.product_uuid = ctx.product_uuid or first.uuid _collect(ctx, first.uuid) - return _pass(name, f"Listed {resp.total_results} product(s)") + return _pass(name, f"Listed {len(resp.results)} product(s)") def check_search_products(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -142,12 +159,13 @@ def check_search_products(client: TeaClient, ctx: CheckContext) -> CheckResult: if not ident.id_type or not ident.id_value: return _skip(name, "First identifier missing type or value") try: - resp = client.search_products(ident.id_type, ident.id_value) + # The iterator follows page tokens: a TEA 1.x page may be empty and still have more after it. + found = next(client.iter_products(ident.id_type, ident.id_value), None) except TeaError as exc: return _fail(name, f"search_products() failed: {exc}", details=str(exc)) - if resp.total_results < 1: + if found is None: return _fail(name, "Search returned no results for a known identifier") - return _pass(name, f"Search returned {resp.total_results} product(s)") + return _pass(name, f"Search found product {found.uuid} for a known identifier") def check_get_product(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -189,7 +207,7 @@ def check_product_releases(client: TeaClient, ctx: CheckContext) -> CheckResult: first = resp.results[0] ctx.product_release_uuid = ctx.product_release_uuid or first.uuid _collect(ctx, first.uuid) - return _pass(name, f"Got {resp.total_results} release(s)") + return _pass(name, f"Got {len(resp.results)} release(s)") # --------------------------------------------------------------------------- @@ -209,7 +227,7 @@ def check_list_product_releases(client: TeaClient, ctx: CheckContext) -> CheckRe first = resp.results[0] ctx.product_release_uuid = ctx.product_release_uuid or first.uuid _collect(ctx, first.uuid) - return _pass(name, f"Listed {resp.total_results} product release(s)") + return _pass(name, f"Listed {len(resp.results)} product release(s)") def check_search_product_releases(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -227,12 +245,13 @@ def check_search_product_releases(client: TeaClient, ctx: CheckContext) -> Check if not ident.id_type or not ident.id_value: return _skip(name, "First identifier missing type or value") try: - resp = client.search_product_releases(ident.id_type, ident.id_value) + # The iterator follows page tokens: a TEA 1.x page may be empty and still have more after it. + found = next(client.iter_product_releases(ident.id_type, ident.id_value), None) except TeaError as exc: return _fail(name, f"search_product_releases() failed: {exc}", details=str(exc)) - if resp.total_results < 1: + if found is None: return _fail(name, "Search returned no results for a known identifier") - return _pass(name, f"Search returned {resp.total_results} release(s)") + return _pass(name, f"Search found release {found.uuid} for a known identifier") def check_get_product_release(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -261,11 +280,9 @@ def check_product_release_collection_latest(client: TeaClient, ctx: CheckContext except TeaError as exc: return _fail(name, f"get_product_release_collection_latest() failed: {exc}", details=str(exc)) if collection.artifacts: - for artifact in collection.artifacts: - if artifact.uuid: - ctx.artifact_uuid = ctx.artifact_uuid or artifact.uuid - _collect(ctx, artifact.uuid) - break + artifact = collection.artifacts[0] + ctx.artifact_uuid = ctx.artifact_uuid or artifact.uuid + _collect(ctx, artifact.uuid) return _pass(name, f"Got collection v{collection.version} with {len(collection.artifacts)} artifact(s)") @@ -341,11 +358,9 @@ def check_component_release(client: TeaClient, ctx: CheckContext) -> CheckResult return _fail(name, f"get_component_release() failed: {exc}", details=str(exc)) _collect(ctx, result.release.uuid) if result.latest_collection and result.latest_collection.artifacts: - for artifact in result.latest_collection.artifacts: - if artifact.uuid: - ctx.artifact_uuid = ctx.artifact_uuid or artifact.uuid - _collect(ctx, artifact.uuid) - break + artifact = result.latest_collection.artifacts[0] + ctx.artifact_uuid = ctx.artifact_uuid or artifact.uuid + _collect(ctx, artifact.uuid) return _pass(name, f"Got release v{result.release.version}") @@ -489,7 +504,7 @@ def check_uuid_format(client: TeaClient, ctx: CheckContext) -> CheckResult: def check_pagination_fields(client: TeaClient, ctx: CheckContext) -> CheckResult: - """Verify that the /products listing exposes expected pagination metadata fields.""" + """Verify that the /products listing honours ``pageSize`` (and, on TEA 0.x, exposes offset metadata).""" name = "pagination_fields" try: result = client.list_products(page_size=10) @@ -497,17 +512,44 @@ def check_pagination_fields(client: TeaClient, ctx: CheckContext) -> CheckResult return _fail(name, f"list_products() failed: {exc}", details=str(exc)) issues: list[str] = [] - if result.page_size != 10: - issues.append(f"page_size: requested 10, got {result.page_size!r}") - if result.page_start_index < 0: - issues.append(f"page_start_index is negative: {result.page_start_index}") - if result.total_results < 0: - issues.append(f"total_results is negative: {result.total_results}") - if not result.timestamp: - issues.append("timestamp is empty") + if client.is_legacy: + passed = "Pagination metadata fields present and valid" + if result.page_size != 10: + issues.append(f"page_size: requested 10, got {result.page_size!r}") + if result.page_start_index is None or result.page_start_index < 0: + issues.append(f"page_start_index is missing or negative: {result.page_start_index!r}") + if result.total_results is None or result.total_results < 0: + issues.append(f"total_results is missing or negative: {result.total_results!r}") + if not result.timestamp: + issues.append("timestamp is missing") + else: + # TEA 1.x pages carry no totals; hasNext/nextPageToken consistency is enforced by the model. + passed = f"Page size respected ({len(result.results)} result(s) for pageSize=10)" + if len(result.results) > 10: + issues.append(f"page_size: requested 10, got {len(result.results)} results") if issues: return _fail(name, "Pagination metadata issues", details="; ".join(issues)) - return _pass(name, "Pagination metadata fields present and valid") + return _pass(name, passed) + + +def check_pagination_token(client: TeaClient, ctx: CheckContext) -> CheckResult: + """Follow ``nextPageToken`` from a one-item page and expect a different page back (TEA 1.x only).""" + name = "pagination_token" + if client.is_legacy: + return _skip(name, "pageToken pagination applies to TEA 1.x only") + try: + first = client.list_products(page_size=1) + if not first.next_page_token: + return _skip(name, "Fewer than two products on server") + second = client.list_products(page_token=first.next_page_token, page_size=1) + except TeaError as exc: + return _fail(name, f"list_products() failed: {exc}", details=str(exc)) + repeated = {p.uuid for p in first.results} & {p.uuid for p in second.results} + if repeated: + return _fail( + name, "pageToken did not advance: page 2 repeated results from page 1", details=", ".join(repeated) + ) + return _pass(name, "pageToken advanced to a different page") def check_camel_case_fields(client: TeaClient, ctx: CheckContext) -> CheckResult: @@ -558,5 +600,6 @@ def check_camel_case_fields(client: TeaClient, ctx: CheckContext) -> CheckResult # Cross-cutting check_uuid_format, check_pagination_fields, + check_pagination_token, check_camel_case_fields, ] diff --git a/src/libtea/conformance/plugin.py b/src/libtea/conformance/plugin.py index dcf4486..89ae53a 100644 --- a/src/libtea/conformance/plugin.py +++ b/src/libtea/conformance/plugin.py @@ -5,7 +5,8 @@ Usage:: - pytest --tea-base-url https://tea.example.com/v1 --tea-tei "urn:tei:..." + pytest --tea-base-url https://tea.example.com/v1 \\ + --tea-tei "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" All conformance module imports are deferred to function bodies so that ``pytest-cov`` can track them. The ``pytest11`` entry point causes this diff --git a/src/libtea/discovery.py b/src/libtea/discovery.py index 7f1dab0..7da5808 100644 --- a/src/libtea/discovery.py +++ b/src/libtea/discovery.py @@ -1,11 +1,12 @@ """TEI parsing, .well-known/tea fetching, and SemVer-based endpoint selection. -Implements the TEA discovery flow: parse a TEI URN, fetch the ``.well-known/tea`` +Implements the TEA discovery flow: parse a TEI, fetch the ``.well-known/tea`` document from the TEI's domain, and select the best-matching endpoint using SemVer 2.0.0 comparison and priority-based ordering. """ import logging +import re import warnings from dataclasses import dataclass from typing import Any @@ -24,6 +25,12 @@ _VALID_TEI_TYPES = frozenset(e.value for e in TeiType) +_TEI_URI_PREFIX = "tei://" +_TEI_URI_TYPE = re.compile(r"[a-z][a-z0-9]*") +_TEI_EXPECTED_FORMAT = ( + "Expected format: tei://// (or legacy urn:tei:::)" +) + def _endpoint_sort_key(pair: tuple[_SemVer, TeaEndpoint]) -> tuple[_SemVer, float]: """Sort key: highest SemVer version desc, then priority desc (default 1.0 per TEA spec).""" @@ -51,13 +58,35 @@ def _is_valid_domain(domain: str) -> bool: return True +def _parse_tei_uri(tei: str) -> tuple[str, str, str]: + """Parse ``tei:////``; the type is checked syntactically, not against ``TeiType``.""" + parts = tei[len(_TEI_URI_PREFIX) :].split("/") + if len(parts) == 3: + domain, tei_type, identifier = parts + # _is_valid_domain also rejects ":" (no port allowed) + if ( + _is_valid_domain(domain) + and _TEI_URI_TYPE.fullmatch(tei_type) + and identifier + and not {"?", "#"} & set(identifier) + and "%2f" not in identifier.lower() + ): + return tei_type, domain, identifier + raise TeaDiscoveryError(f"Invalid TEI: {tei!r}. {_TEI_EXPECTED_FORMAT}") + + def parse_tei(tei: str) -> tuple[str, str, str]: - """Parse a TEI URN into (type, domain, identifier). + """Parse a TEI into (type, domain, identifier). - TEI format: ``urn:tei:::`` + Two forms are accepted: + + - ``tei:////`` (TEA 1.0.0). The scheme is case-sensitive, the domain is + returned unchanged, any lowercase alphanumeric ``type`` is accepted (the type registry may grow), + and the identifier may contain ``:`` but not ``/``, ``%2F``, ``?`` or ``#``. + - ``urn:tei:::`` (TEA 0.4.0), where ``type`` must be a known :class:`TeiType`. Args: - tei: TEI URN string. + tei: TEI string. Returns: Tuple of (type, domain, identifier). @@ -65,9 +94,11 @@ def parse_tei(tei: str) -> tuple[str, str, str]: Raises: TeaDiscoveryError: If the TEI format is invalid. """ + if tei.startswith(_TEI_URI_PREFIX): + return _parse_tei_uri(tei) parts = tei.split(":") if len(parts) < 5 or parts[0] != "urn" or parts[1] != "tei": - raise TeaDiscoveryError(f"Invalid TEI: {tei!r}. Expected format: urn:tei:::") + raise TeaDiscoveryError(f"Invalid TEI: {tei!r}. {_TEI_EXPECTED_FORMAT}") tei_type = parts[2] if tei_type not in _VALID_TEI_TYPES: @@ -186,7 +217,7 @@ def fetch_well_known( try: data = response.json() - except ValueError as exc: + except (ValueError, RecursionError) as exc: # RecursionError: deep nesting raise TeaDiscoveryError(f"Invalid JSON in .well-known/tea response from {domain}") from exc finally: if response is not None: @@ -277,13 +308,30 @@ class VersionedEndpoint: """An endpoint paired with the server version it was matched on. Returned by :func:`select_best_endpoint` to let callers know which - version to use in the URL path (``v{matched_version}``). + version to use in the URL path (``v{matched_version}``). ``matched_version`` + is the string exactly as the endpoint advertises it, build metadata included. """ endpoint: TeaEndpoint matched_version: str +def _advertised_version(ep: TeaEndpoint, version: str) -> str: + """Return the spelling ``ep`` advertises for ``version``. + + SemVer equality ignores build metadata, so ``1.0.0`` equals ``1.0.0+build.5``; the spec requires + the path to use the string exactly as advertised, so recover it from the endpoint. + """ + target = _SemVer.parse(version) + for v_str in ep.versions: + try: + if _SemVer.parse(v_str) == target: + return v_str + except ValueError: + continue + return version # pragma: no cover — callers only pass a version the endpoint advertises + + def _is_compatible_version(client: _SemVer, server: _SemVer) -> bool: """Check if a client version is backward-compatible with a server version. @@ -327,7 +375,7 @@ def select_best_endpoint(well_known: TeaWellKnown, supported_version: str) -> Ve # Phase 1: exact match — delegate to select_endpoints try: exact = select_endpoints(well_known, supported_version) - return VersionedEndpoint(endpoint=exact[0], matched_version=supported_version) + return VersionedEndpoint(endpoint=exact[0], matched_version=_advertised_version(exact[0], supported_version)) except TeaDiscoveryError: pass # fall through to Phase 2 (compatible match) diff --git a/src/libtea/exceptions.py b/src/libtea/exceptions.py index 8ac4564..4d08b15 100644 --- a/src/libtea/exceptions.py +++ b/src/libtea/exceptions.py @@ -26,8 +26,9 @@ class TeaNotFoundError(TeaError): """HTTP 404 response from the TEA server. Attributes: - error_type: Optional TEA error type from the JSON response body - (e.g. ``"OBJECT_UNKNOWN"`` or ``"OBJECT_NOT_SHAREABLE"``). + error_type: Optional TEA ``error`` value from the JSON response body + (e.g. ``"OBJECT_UNKNOWN"``; ``"OBJECT_NOT_SHAREABLE"`` on 0.4.0). + Servers may send values not in :class:`~libtea.models.ErrorType`. """ def __init__(self, message: str, *, error_type: str | None = None): @@ -36,7 +37,17 @@ def __init__(self, message: str, *, error_type: str | None = None): class TeaRequestError(TeaError): - """Unexpected HTTP redirect (3xx) or client error (4xx other than 401/403/404).""" + """Unexpected HTTP redirect (3xx) or client error (4xx other than 401/403/404). + + Attributes: + error_type: Optional TEA ``error`` value from the JSON response body + (e.g. ``"INVALID_PAGE_TOKEN"``). Servers may send values not in + :class:`~libtea.models.ErrorType`. + """ + + def __init__(self, message: str, *, error_type: str | None = None): + super().__init__(message) + self.error_type = error_type class TeaServerError(TeaError): diff --git a/src/libtea/models.py b/src/libtea/models.py index d9e17b1..19772ca 100644 --- a/src/libtea/models.py +++ b/src/libtea/models.py @@ -3,16 +3,25 @@ All models use camelCase aliases for JSON serialization (matching the TEA wire format), are frozen (immutable after creation), and silently ignore unknown fields for forward-compatibility with future TEA spec versions. + +Fields required by both TEA 0.4.0 and 1.0.0 are non-optional types. Fields +required only by 1.0.0 are optional types that are rejected when missing, +unless the model is validated with ``context={"tea_legacy": True}``. """ from datetime import datetime from enum import StrEnum -from typing import Literal, Self +from typing import Any, ClassVar, Generic, Literal, Self, TypeVar -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator from pydantic.alias_generators import to_camel +def _is_legacy(info: ValidationInfo) -> bool: + """Return ``True`` when validating in the TEA 0.4.0 compatibility context.""" + return bool(info.context and info.context.get("tea_legacy")) + + class _TeaModel(BaseModel): """Base model for all TEA API objects. @@ -21,6 +30,11 @@ class _TeaModel(BaseModel): - ``populate_by_name=True``: Fields can be set by Python name or alias. - ``extra="ignore"``: Unknown fields from newer spec versions are silently dropped. - ``frozen=True``: Instances are immutable (hashable, safe to cache). + + TEA 1.0.0 rules: fields named in ``_v1_required`` must not be ``None``. + Subclasses add other 1.0.0-only checks by overriding :meth:`_v1_problems` + and extending ``super()._v1_problems()``. The checks are skipped under the + legacy validation context. """ model_config = ConfigDict( @@ -30,6 +44,17 @@ class _TeaModel(BaseModel): frozen=True, ) + _v1_required: ClassVar[tuple[str, ...]] = () + + def _v1_problems(self) -> list[str]: + return [f"missing required field {n!r}" for n in self._v1_required if getattr(self, n) is None] + + @model_validator(mode="after") + def _enforce_v1(self, info: ValidationInfo) -> Self: + if not _is_legacy(info) and (problems := self._v1_problems()): + raise ValueError(f"{type(self).__name__}: {'; '.join(problems)} (TEA 1.0.0)") + return self + # --- Enums --- @@ -60,7 +85,7 @@ class ComplianceDocumentType(StrEnum): ISO_42001 = "ISO_42001" PCI_DSS = "PCI_DSS" HIPAA = "HIPAA" - FEDRAMP = "FedRAMP" + FEDRAMP = "FEDRAMP" GDPR = "GDPR" CSA_STAR = "CSA_STAR" NIST_800_53 = "NIST_800_53" @@ -70,13 +95,15 @@ class ComplianceDocumentType(StrEnum): TISAX = "TISAX" CYBER_ESSENTIALS = "CYBER_ESSENTIALS" CYBER_ESSENTIALS_PLUS = "CYBER_ESSENTIALS_PLUS" + EU_DECLARATION_OF_CONFORMITY = "EU_DECLARATION_OF_CONFORMITY" class TeiType(StrEnum): - """TEI URN scheme types per TEA discovery specification. + """TEI types per TEA discovery specification. - These are the valid ```` values in a TEI URN - (``urn:tei:::``). + These are the ```` values of a TEI. The 0.4.0 URN form + (``urn:tei:::``) accepts only these; the 1.0.0 URI form + (``tei:////``) also accepts other lowercase types. """ UUID = "uuid" @@ -94,6 +121,9 @@ class ChecksumAlgorithm(StrEnum): Values use hyphen form (e.g. ``SHA-256``). The Checksum model's validator normalizes underscore form (``SHA_256``) to hyphen form automatically. + + ``MD5`` is 0.4.0 only: TEA 1.0.0 dropped it, so it is rejected unless the + model is validated with the legacy context. """ MD5 = "MD5" @@ -157,20 +187,33 @@ class CollectionUpdateReasonType(StrEnum): class ErrorType(StrEnum): - """TEA API error types returned in 404 responses.""" + """TEA API error types carried in the ``error`` property of a 4xx response body. + + Servers may send values not listed here; clients must not reject them, so + :attr:`ErrorResponse.error` is a plain ``str``. ``OBJECT_NOT_SHAREABLE`` is + 0.4.0 only. + """ OBJECT_UNKNOWN = "OBJECT_UNKNOWN" - OBJECT_NOT_SHAREABLE = "OBJECT_NOT_SHAREABLE" + OBJECT_NOT_SHAREABLE = "OBJECT_NOT_SHAREABLE" # 0.4.0 only + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + NO_ACCEPTABLE_FORMAT = "NO_ACCEPTABLE_FORMAT" + SIGNATURE_NOT_FOUND = "SIGNATURE_NOT_FOUND" + INVALID_REQUEST = "INVALID_REQUEST" + INVALID_PAGE_TOKEN = "INVALID_PAGE_TOKEN" class ErrorResponse(_TeaModel): - """TEA API 404 error response body. + """TEA API error response body. - Per spec, 404 responses return ``{"error": "OBJECT_UNKNOWN"}`` or - ``{"error": "OBJECT_NOT_SHAREABLE"}`` with no additional fields. + ``error`` is typically one of :class:`ErrorType` (compare with ``==``) but + is kept as ``str``: the spec forbids rejecting a response over an + ``error`` value the client does not know. ``message`` is an optional + human-readable explanation for diagnostics only. """ - error: ErrorType + error: str + message: str | None = None # --- Shared types --- @@ -183,9 +226,11 @@ class Identifier(_TeaModel): future TEA spec versions. Compare against :class:`IdentifierType` members for known types (e.g. ``ident.id_type == IdentifierType.PURL``). - Per spec, neither field is required. + Both fields are required by TEA 1.0.0; 0.4.0 servers may omit either. """ + _v1_required = ("id_type", "id_value") + id_type: str | None = None id_value: str | None = None @@ -194,14 +239,15 @@ class Checksum(_TeaModel): """A checksum with algorithm type and hex value. The ``algorithm_type`` validator normalizes both hyphen form (``SHA-256``) and - underscore form (``SHA_256``) to the canonical hyphen form. + underscore form (``SHA_256``) to the canonical hyphen form. TEA 1.0.0 does not + allow ``MD5``; 0.4.0 servers may still send it. """ # Manual aliases because the TEA spec wire format uses abbreviated names # (algType/algValue) rather than the full algorithmType/algorithmValue # that to_camel would generate from the Python field names. - algorithm_type: ChecksumAlgorithm | None = Field(default=None, alias="algType") - algorithm_value: str | None = Field(default=None, alias="algValue") + algorithm_type: ChecksumAlgorithm = Field(alias="algType") + algorithm_value: str = Field(alias="algValue") @field_validator("algorithm_type", mode="before") @classmethod @@ -217,21 +263,20 @@ def normalize_algorithm_type(cls, v: str) -> str: return mapped return v + def _v1_problems(self) -> list[str]: + problems = super()._v1_problems() + if self.algorithm_type is ChecksumAlgorithm.MD5: + problems.append("MD5 is not a TEA 1.0.0 checksum algorithm") + return problems + # --- Domain objects --- class ReleaseDistribution(_TeaModel): - """A distribution format for a component release (e.g. binary, source). - - As of spec v0.4.0, ``distribution_id`` is required and ``distribution_type`` - has been removed. Both fields are kept as **optional** in the model for - backward compatibility with older servers that still send - ``distributionType`` instead. - """ + """A distribution format for a component release (e.g. binary, source).""" - distribution_id: str | None = None - distribution_type: str | None = None + distribution_id: str description: str | None = None identifiers: tuple[Identifier, ...] = () url: str | None = None @@ -240,7 +285,13 @@ class ReleaseDistribution(_TeaModel): class ArtifactFormat(_TeaModel): - """A TEA artifact in a specific format with download URL and checksums.""" + """A TEA artifact in a specific format with download URL and checksums. + + TEA 1.0.0 requires ``media_type``, and at least one checksum when ``url`` is set. + ``checksums`` may be omitted but, when present, must not be empty. + """ + + _v1_required = ("media_type",) media_type: str | None = None description: str | None = None @@ -248,40 +299,44 @@ class ArtifactFormat(_TeaModel): signature_url: str | None = None checksums: tuple[Checksum, ...] = () - @model_validator(mode="before") - @classmethod - def _normalize_mime_type(cls, data: dict) -> dict: # type: ignore[type-arg] - """Accept ``mimeType`` as an alias for ``mediaType`` (older server compat).""" - if isinstance(data, dict) and "mimeType" in data and "mediaType" not in data: - data["mediaType"] = data.pop("mimeType") - return data + def _v1_problems(self) -> list[str]: + problems = super()._v1_problems() + if not self.checksums and (self.url is not None or "checksums" in self.model_fields_set): + problems.append("'checksums' must not be empty") + return problems class Artifact(_TeaModel): """A security-related artifact (e.g. SBOM, VEX, attestation) with available formats. - As of spec v0.4.0, ``uuid`` is required and ``distribution_ids`` replaces - ``distribution_types``. All fields are kept as **optional** in the model - for backward compatibility with older servers that may omit ``uuid`` or - still send ``distributionTypes``. + TEA 1.0.0 also requires ``version``, ``created_date`` and at least one format. """ - uuid: str | None = None + _v1_required = ("version", "created_date") + + uuid: str name: str | None = None - type: ArtifactType | None = None + type: ArtifactType version: int | None = Field(default=None, ge=1) created_date: datetime | None = None distribution_ids: tuple[str, ...] | None = None - distribution_types: tuple[str, ...] | None = None - formats: tuple[ArtifactFormat, ...] = () + formats: tuple[ArtifactFormat, ...] + + def _v1_problems(self) -> list[str]: + problems = super()._v1_problems() + if not self.formats: + problems.append("'formats' must not be empty") + return problems class CollectionUpdateReason(_TeaModel): """Reason for a collection version update, with optional comment. - Per spec, no fields are required. + TEA 1.0.0 requires ``type``. """ + _v1_required = ("type",) + type: CollectionUpdateReasonType | None = None comment: str | None = None @@ -291,24 +346,42 @@ class Collection(_TeaModel): The UUID matches the owning component or product release. The version integer starts at 1 and increments on each content change. - Per spec, all fields are optional. + TEA 1.0.0 requires ``uuid``, ``version``, ``created_date``, ``belongs_to``, + ``update_reason`` and the ``artifacts`` key (an empty list is valid); 0.4.0 + requires none and names the creation timestamp ``date``. Attributes: uuid: UUID of the owning component or product release. version: Collection version number (starts at 1, increments on change). - date: Timestamp when this collection version was created. + created_date: Timestamp when this collection version was created. belongs_to: Whether this collection belongs to a component or product release. update_reason: Why this collection version was created. artifacts: The artifacts (SBOMs, VEX documents, etc.) in this collection. """ + _v1_required = ("uuid", "version", "created_date", "belongs_to", "update_reason") + uuid: str | None = None version: int | None = Field(default=None, ge=1) - date: datetime | None = None + created_date: datetime | None = None belongs_to: CollectionBelongsTo | None = None update_reason: CollectionUpdateReason | None = None artifacts: tuple[Artifact, ...] = () + def _v1_problems(self) -> list[str]: + problems = super()._v1_problems() + if "artifacts" not in self.model_fields_set: + problems.append("missing required field 'artifacts'") + return problems + + @model_validator(mode="before") + @classmethod + def _map_legacy_date(cls, data: Any, info: ValidationInfo) -> Any: + """Map the 0.4.0 ``date`` key to ``createdDate`` (legacy context only).""" + if _is_legacy(info) and isinstance(data, dict) and "date" in data: + return {"createdDate": data["date"], **data} + return data + class ComponentRef(_TeaModel): """Reference to a TEA component, optionally pinned to a specific release.""" @@ -330,7 +403,7 @@ class Release(_TeaModel): Attributes: uuid: Server-assigned UUID. - component: UUID of the parent component (set when returned in context). + component: UUID of the parent component (required by TEA 1.0.0). component_name: Human-readable name of the parent component. version: Version string (e.g. ``"1.2.3"``). created_date: When the release record was created on the TEA server. @@ -340,6 +413,8 @@ class Release(_TeaModel): distributions: Available distribution formats (binary, source, etc.). """ + _v1_required = ("component",) + uuid: str component: str | None = None component_name: str | None = None @@ -354,12 +429,29 @@ class Release(_TeaModel): class ComponentReleaseWithCollection(_TeaModel): """A component release bundled with its latest collection. - Returned by ``GET /componentRelease/{uuid}``. + Returned by ``GET /componentRelease/{uuid}``. TEA 1.0.0 flattens the release + fields next to ``latestCollection``; TEA 0.4.0 nests them under ``release``. + Both parse into the same ``release`` / ``latest_collection`` shape. """ release: Release latest_collection: Collection + @model_validator(mode="before") + @classmethod + def _unflatten(cls, data: Any, info: ValidationInfo) -> Any: + """Wrap the 1.0.0 flattened wire object into ``release`` + ``latestCollection``. + + Skipped in the legacy context (already nested) and for Python-side + construction from a ``Release`` instance. + """ + if _is_legacy(info) or not isinstance(data, dict) or isinstance(data.get("release"), Release): + return data + return { + "release": {k: v for k, v in data.items() if k != "latestCollection"}, + "latestCollection": data.get("latestCollection"), + } + class Product(_TeaModel): """A TEA product (optional grouping of components).""" @@ -377,7 +469,7 @@ class ProductRelease(_TeaModel): Attributes: uuid: Server-assigned UUID. - product: UUID of the parent product (set when returned in context). + product: UUID of the parent product (required by TEA 1.0.0). product_name: Human-readable name of the parent product. version: Version string (e.g. ``"2.0.0"``). created_date: When the release record was created on the TEA server. @@ -387,6 +479,8 @@ class ProductRelease(_TeaModel): components: References to the components included in this product release. """ + _v1_required: ClassVar[tuple[str, ...]] = ("product",) # widened so subclasses can extend it + uuid: str product: str | None = None product_name: str | None = None @@ -398,6 +492,21 @@ class ProductRelease(_TeaModel): components: tuple[ComponentRef, ...] +class ProductReleaseWithCollection(ProductRelease): + """A product release bundled with its latest collection. + + Returned by ``GET /productRelease/{uuid}``. TEA 1.0.0 requires + ``latest_collection``; 0.4.0 servers omit it. + + Attributes: + latest_collection: The latest collection for this product release. + """ + + _v1_required = ProductRelease._v1_required + ("latest_collection",) + + latest_collection: Collection | None = None + + # --- CLE (Common Lifecycle Enumeration) --- @@ -497,45 +606,73 @@ class CLE(_TeaModel): # --- Pagination --- +_R = TypeVar("_R") +_LEGACY_PAGE_KEYS = ("timestamp", "pageStartIndex", "pageSize", "totalResults") -class PaginatedProductResponse(_TeaModel): - """Paginated response containing a list of products.""" - timestamp: datetime - page_start_index: int - page_size: int - total_results: int - results: tuple[Product, ...] = () +class _PaginatedResponse(_TeaModel, Generic[_R]): + """Common fields of a paginated TEA response. + TEA 1.0.0 pages carry ``has_next`` and an opaque ``next_page_token``. 0.4.0 + pages carry offset metadata instead; under the legacy context ``has_next`` + and ``next_page_token`` are derived from it, the token being the next + result offset as a string. Outside the legacy context the offset metadata + is dropped, so ``total_results is None`` exactly on TEA 1.x pages. + """ -class PaginatedProductReleaseResponse(_TeaModel): - """Paginated response containing a list of product releases.""" + has_next: bool + next_page_token: str | None = None + # TEA 0.4.0 only. + timestamp: datetime | None = None + page_start_index: int | None = None + page_size: int | None = None + total_results: int | None = None + results: tuple[_R, ...] = () + + def _v1_problems(self) -> list[str]: + problems = super()._v1_problems() + if "results" not in self.model_fields_set: + problems.append("missing required field 'results'") + if self.has_next and not self.next_page_token: + problems.append("hasNext is true but nextPageToken is missing") + return problems - timestamp: datetime - page_start_index: int - page_size: int - total_results: int - results: tuple[ProductRelease, ...] = () + @model_validator(mode="before") + @classmethod + def _map_legacy_offsets(cls, data: Any, info: ValidationInfo) -> Any: + """Derive ``hasNext``/``nextPageToken`` from 0.4.0 offsets; drop the offsets outside the legacy context.""" + if not isinstance(data, dict): + return data + if not _is_legacy(info): + return {k: v for k, v in data.items() if k not in _LEGACY_PAGE_KEYS} + if "totalResults" not in data: + return data + start, total, results = data.get("pageStartIndex"), data["totalResults"], data.get("results", []) + if not (isinstance(start, int) and isinstance(total, int) and isinstance(results, list)): + return data # malformed offsets: leave hasNext missing so validation reports it + next_offset = start + len(results) + has_next = bool(results) and next_offset < total + return {**data, "hasNext": has_next, **({"nextPageToken": str(next_offset)} if has_next else {})} + + +class PaginatedProductResponse(_PaginatedResponse[Product]): + """Paginated response containing a list of products.""" -class PaginatedComponentResponse(_TeaModel): - """Paginated response containing a list of components.""" +class PaginatedProductReleaseResponse(_PaginatedResponse[ProductRelease]): + """Paginated response containing a list of product releases.""" - timestamp: datetime - page_start_index: int - page_size: int - total_results: int - results: tuple[Component, ...] = () + +class PaginatedComponentResponse(_PaginatedResponse[Component]): + """Paginated response containing a list of components.""" -class PaginatedComponentReleaseResponse(_TeaModel): +class PaginatedComponentReleaseResponse(_PaginatedResponse[Release]): """Paginated response containing a list of component releases.""" - timestamp: datetime - page_start_index: int - page_size: int - total_results: int - results: tuple[Release, ...] = () + +class PaginatedCollectionResponse(_PaginatedResponse[Collection]): + """Paginated response containing a list of collections (TEA 1.0.0).""" # --- Discovery types --- @@ -610,12 +747,14 @@ class DiscoveryInfo(_TeaModel): "DiscoveryInfo", "ErrorResponse", "Identifier", + "PaginatedCollectionResponse", "PaginatedComponentReleaseResponse", "PaginatedComponentResponse", "PaginatedProductReleaseResponse", "PaginatedProductResponse", "Product", "ProductRelease", + "ProductReleaseWithCollection", "Release", "ReleaseDistribution", "TeaEndpoint", diff --git a/src/libtea/protocols.py b/src/libtea/protocols.py index 1989310..05be037 100644 --- a/src/libtea/protocols.py +++ b/src/libtea/protocols.py @@ -25,6 +25,7 @@ PaginatedProductResponse, Product, ProductRelease, + ProductReleaseWithCollection, Release, ) @@ -42,35 +43,38 @@ class TeaClientProtocol(Protocol): :func:`~libtea._http.probe_endpoint` instead. """ + spec_version: str + is_legacy: bool + # --- Discovery --- - def discover(self, tei: str) -> list[DiscoveryInfo]: ... + def discover(self, tei: str | None = None, *, purl: str | None = None) -> list[DiscoveryInfo]: ... # --- Products --- - def list_products(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedProductResponse: ... + def list_products(self, *, page_token: str | None = None, page_size: int = 100) -> PaginatedProductResponse: ... def search_products( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductResponse: ... def get_product(self, uuid: str) -> Product: ... def get_product_releases( - self, uuid: str, *, page_offset: int = 0, page_size: int = 100 + self, uuid: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: ... # --- Product Releases --- def list_product_releases( - self, *, page_offset: int = 0, page_size: int = 100 + self, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: ... def search_product_releases( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedProductReleaseResponse: ... - def get_product_release(self, uuid: str) -> ProductRelease: ... + def get_product_release(self, uuid: str) -> ProductReleaseWithCollection: ... def get_product_release_collection_latest(self, uuid: str) -> Collection: ... @@ -84,10 +88,10 @@ def get_component(self, uuid: str) -> Component: ... def get_component_releases(self, uuid: str) -> list[Release]: ... - def list_components(self, *, page_offset: int = 0, page_size: int = 100) -> PaginatedComponentResponse: ... + def list_components(self, *, page_token: str | None = None, page_size: int = 100) -> PaginatedComponentResponse: ... def search_components( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentResponse: ... # --- Component Releases --- @@ -101,11 +105,11 @@ def get_component_release_collections(self, uuid: str) -> list[Collection]: ... def get_component_release_collection(self, uuid: str, version: int) -> Collection: ... def list_component_releases( - self, *, page_offset: int = 0, page_size: int = 100 + self, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentReleaseResponse: ... def search_component_releases( - self, id_type: str, id_value: str, *, page_offset: int = 0, page_size: int = 100 + self, id_type: str, id_value: str, *, page_token: str | None = None, page_size: int = 100 ) -> PaginatedComponentReleaseResponse: ... # --- CLE --- @@ -133,6 +137,17 @@ def download_artifact( max_download_bytes: int | None = None, ) -> Path: ... + def download_artifact_content( + self, + uuid: str, + dest: Path, + *, + media_type: str, + version: int | None = None, + verify_checksums: list[Checksum] | None = None, + max_download_bytes: int | None = None, + ) -> Path: ... + # --- Pagination iterators --- def iter_products(self, id_type: str, id_value: str, *, page_size: int = 100) -> Iterator[Product]: ... diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 29dcf53..458d98c 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -6,12 +6,25 @@ import pytest import requests import responses +from responses import matchers click = pytest.importorskip("click", reason="click not installed (install libtea[cli])") from click.testing import CliRunner # noqa: E402 from libtea.cli import app # noqa: E402 +from tests.spec_data import ( # noqa: E402 + ARTIFACT, + COMPONENT, + COMPONENT_RELEASE, + LEGACY, + PRODUCT_RELEASE, + V1_ARTIFACT, + V1_BASE_URL, + V1_COLLECTION, + V1_PRODUCT_RELEASE, + V1_RELEASE, +) runner = CliRunner() @@ -24,7 +37,7 @@ def _all_output(result): return out -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") @@ -108,7 +121,7 @@ def test_discover(self): def test_get_artifact(self): uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" responses.get( - f"{BASE_URL}/artifact/{uuid}/latest", + f"{BASE_URL}/artifact/{uuid}", json={"uuid": uuid, "name": "SBOM", "type": "BOM", "formats": []}, ) result = runner.invoke(app, ["--json", "get-artifact", uuid, "--base-url", BASE_URL]) @@ -223,14 +236,29 @@ def test_search_component_releases(self): def test_get_artifact_version(self): uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" responses.get( - f"{BASE_URL}/artifact/{uuid}/2", - json={"uuid": uuid, "name": "SBOM", "type": "BOM", "version": 2, "formats": []}, + f"{V1_BASE_URL}/artifact/{uuid}/2", + json={ + "uuid": uuid, + "name": "SBOM", + "type": "BOM", + "version": 2, + "createdDate": "2024-12-13T00:00:00Z", + "formats": [{"mediaType": "application/json"}], + }, ) - result = runner.invoke(app, ["--json", "get-artifact-version", uuid, "2", "--base-url", BASE_URL]) + result = runner.invoke(app, ["--json", "get-artifact-version", uuid, "2", "--base-url", V1_BASE_URL]) assert result.exit_code == 0 data = json.loads(result.output) assert data["name"] == "SBOM" + @responses.activate + def test_get_artifact_version_needs_tea_1x(self): + uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + result = runner.invoke(app, ["get-artifact-version", uuid, "2", "--base-url", BASE_URL]) + assert result.exit_code != 0 + assert "TEA 1.x" in result.output + assert len(responses.calls) == 0 + @responses.activate def test_get_release_product(self): uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" @@ -332,6 +360,28 @@ def test_download_with_checksum(self, tmp_path): assert result.exit_code == 0 assert dest.exists() + @responses.activate + def test_download_with_md5_checksum(self, tmp_path): + """A user-supplied MD5 is accepted (it is not server data) and still warns as a weak hash.""" + artifact_url = "https://cdn.example.com/sbom.json" + responses.get(artifact_url, body=b"{}") + dest = tmp_path / "sbom.json" + with pytest.warns(UserWarning, match="weak hash"): + result = runner.invoke( + app, + [ + "download", + artifact_url, + str(dest), + "--checksum", + "MD5:99914b932bd37a50b983c5e7c90ae93b", + "--base-url", + BASE_URL, + ], + ) + assert result.exit_code == 0 + assert dest.read_bytes() == b"{}" + def test_download_invalid_checksum_format(self, tmp_path): dest = tmp_path / "sbom.json" result = runner.invoke( @@ -437,6 +487,40 @@ def test_discover_not_found(self): assert result.exit_code == 1 +class TestCLIDiscoverPurl: + @responses.activate + def test_discover_purl(self): + purl = "pkg:x/y" + responses.get( + f"{V1_BASE_URL}/discovery", + match=[matchers.query_param_matcher({"purl": purl})], + json=[ + { + "productReleaseUuid": PRODUCT_RELEASE, + "servers": [{"rootUrl": "https://api.example.com", "versions": ["1.0.0"]}], + } + ], + ) + result = runner.invoke(app, ["--json", "discover", "--purl", purl, "--base-url", V1_BASE_URL]) + assert result.exit_code == 0 + assert json.loads(result.output)[0]["productReleaseUuid"] == PRODUCT_RELEASE + + @responses.activate + def test_purl_requires_base_url(self): + result = runner.invoke(app, ["discover", "--purl", "pkg:x/y", "--domain", "example.com"]) + assert result.exit_code == 1 + assert "--purl requires --base-url" in _all_output(result) + assert len(responses.calls) == 0 + + @responses.activate + @pytest.mark.parametrize("args", [[], ["tei://example.com/uuid/x", "--purl", "pkg:x/y"]]) + def test_exactly_one_of_tei_or_purl(self, args): + result = runner.invoke(app, ["discover", *args, "--base-url", V1_BASE_URL]) + assert result.exit_code == 2 + assert "exactly one of TEI or --purl" in _all_output(result) + assert len(responses.calls) == 0 + + class TestCLIDiscoveryPath: """P2-3: Tests for --domain discovery path.""" @@ -513,7 +597,7 @@ def test_token_over_http_shows_clean_error(self): "get-product", "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", "--base-url", - "http://example.com/v1", + "http://example.com/v0.4.0", "--token", "t", ], @@ -611,7 +695,7 @@ def test_get_collection_error(self): @responses.activate def test_get_artifact_error(self): uuid = "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" - responses.get(f"{BASE_URL}/artifact/{uuid}/latest", status=500) + responses.get(f"{BASE_URL}/artifact/{uuid}", status=500) result = runner.invoke(app, ["get-artifact", uuid, "--base-url", BASE_URL]) assert result.exit_code == 1 @@ -768,6 +852,59 @@ def test_inspect_component_ref_release_resolution_error(self): assert "Warning: could not resolve releases for component" in result.stderr +class TestCLIInspectV1: + """``inspect`` against a TEA 1.0.0 server.""" + + TEI = "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + + def _discovery_and_release(self, components: list[dict]) -> None: + responses.get( + f"{V1_BASE_URL}/discovery", + json=[ + {"productReleaseUuid": PRODUCT_RELEASE, "servers": [{"rootUrl": V1_BASE_URL, "versions": ["1.0.0"]}]} + ], + ) + collection = {**V1_COLLECTION, "uuid": PRODUCT_RELEASE, "belongsTo": "PRODUCT_RELEASE", "artifacts": []} + responses.get( + f"{V1_BASE_URL}/productRelease/{PRODUCT_RELEASE}", + json={**V1_PRODUCT_RELEASE, "components": components, "latestCollection": collection}, + ) + + @responses.activate + def test_rich_output_with_unnamed_artifact_and_server_hosted_format(self): + """1.0.0 artifacts may omit ``name`` and formats may omit ``url`` (server-hosted).""" + self._discovery_and_release([{"uuid": COMPONENT, "release": COMPONENT_RELEASE}]) + artifact = {**V1_ARTIFACT, "formats": [{"mediaType": "application/vnd.cyclonedx+json"}]} + responses.get( + f"{V1_BASE_URL}/componentRelease/{COMPONENT_RELEASE}", + json={**V1_RELEASE, "latestCollection": {**V1_COLLECTION, "artifacts": [artifact]}}, + ) + result = runner.invoke(app, ["inspect", self.TEI, "--base-url", V1_BASE_URL]) + assert result.exit_code == 0, _all_output(result) + assert ARTIFACT in _strip_ansi(result.output) + + @responses.activate + def test_unpinned_component_resolves_newest_release(self): + """1.0.0 sorts component releases by ``createdDate`` ascending, so the last one is the newest.""" + newer = "e5f6a7b8-c9d0-1234-efab-567890123456" + self._discovery_and_release([{"uuid": COMPONENT}]) + responses.get(f"{V1_BASE_URL}/component/{COMPONENT}", json={"uuid": COMPONENT, "name": "C", "identifiers": []}) + newer_release = {**V1_RELEASE, "uuid": newer, "version": "2.25.0", "createdDate": "2025-01-10T10:51:00Z"} + responses.get( + f"{V1_BASE_URL}/component/{COMPONENT}/releases", + json={"hasNext": False, "results": [V1_RELEASE, newer_release]}, + ) + for release in (V1_RELEASE, newer_release): + responses.get( + f"{V1_BASE_URL}/componentRelease/{release['uuid']}", + json={**release, "latestCollection": {**V1_COLLECTION, "uuid": release["uuid"]}}, + ) + result = runner.invoke(app, ["--json", "inspect", self.TEI, "--base-url", V1_BASE_URL]) + assert result.exit_code == 0, _all_output(result) + resolved = json.loads(result.stdout)[0]["components"][0]["resolvedRelease"] + assert resolved["release"]["version"] == "2.25.0" + + class TestCLITeiAutoDiscovery: """Test TEI auto-discovery: when neither --base-url nor --domain is given.""" @@ -796,6 +933,31 @@ def test_discover_auto_extracts_domain_from_tei(self): data = json.loads(result.output) assert len(data) == 1 + @responses.activate + def test_discover_auto_extracts_domain_from_tei_uri(self): + tei = "tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1" + responses.get( + "https://example.com/.well-known/tea", + json={ + "schemaVersion": 1, + "endpoints": [{"url": "https://api.example.com", "versions": ["0.4.0"]}], + }, + ) + responses.head("https://api.example.com/v0.4.0", status=200) + responses.get( + "https://api.example.com/v0.4.0/discovery", + match=[matchers.query_param_matcher({"tei": tei})], + json=[ + { + "productReleaseUuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "servers": [{"rootUrl": "https://tea.example.com", "versions": ["1.0.0"]}], + } + ], + ) + result = runner.invoke(app, ["--json", "discover", tei]) + assert result.exit_code == 0 + assert len(json.loads(result.output)) == 1 + class TestCLIEntryPointErrors: """Test _cli_entry.py error handling.""" @@ -1144,7 +1306,7 @@ def test_get_product_releases_with_pagination(self): ) result = runner.invoke( app, - ["--json", "get-product-releases", uuid, "--page-offset", "10", "--page-size", "5", "--base-url", BASE_URL], + ["--json", "get-product-releases", uuid, "--page-token", "10", "--page-size", "5", "--base-url", BASE_URL], ) assert result.exit_code == 0 data = json.loads(result.output) @@ -1514,19 +1676,19 @@ def test_conformance_json_output(self): "totalResults": 0, "results": [], } - responses.get("https://tea.example.com/v1/products", json=paginated_empty) - responses.get("https://tea.example.com/v1/productReleases", json=paginated_empty) + responses.get("https://tea.example.com/v0.4.0/products", json=paginated_empty) + responses.get("https://tea.example.com/v0.4.0/productReleases", json=paginated_empty) responses.get( - "https://tea.example.com/v1/product/00000000-0000-0000-0000-000000000000", + "https://tea.example.com/v0.4.0/product/00000000-0000-0000-0000-000000000000", status=404, json={"error": "OBJECT_UNKNOWN"}, ) responses.get( - "https://tea.example.com/v1/discovery", + "https://tea.example.com/v0.4.0/discovery", status=404, json={"error": "OBJECT_UNKNOWN"}, ) - result = runner.invoke(app, ["conformance", "--base-url", "https://tea.example.com/v1", "--json"]) + result = runner.invoke(app, ["conformance", "--base-url", "https://tea.example.com/v0.4.0", "--json"]) assert result.exit_code in (0, 1) combined = _all_output(result) assert "base_url" in combined or "checks" in combined @@ -1610,10 +1772,14 @@ def test_tei_mode_artifact_download_failure_warns(self, tmp_path): "version": 1, "artifacts": [ { + "uuid": "art-uuid-1", + "type": "BOM", "name": "BadArt", "formats": [{"url": bad_url, "checksums": []}], }, { + "uuid": "art-uuid-2", + "type": "BOM", "name": "GoodArt", "formats": [{"url": good_url, "checksums": []}], }, @@ -1648,7 +1814,14 @@ def test_tei_mode_all_downloads_fail(self, tmp_path): json={ "uuid": pr_uuid, "version": 1, - "artifacts": [{"name": "SBOM", "formats": [{"url": artifact_url, "checksums": []}]}], + "artifacts": [ + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": artifact_url, "checksums": []}], + } + ], }, ) responses.get(artifact_url, status=500) @@ -1657,6 +1830,39 @@ def test_tei_mode_all_downloads_fail(self, tmp_path): assert result.exit_code == 1 assert "All 1 artifact download(s) failed" in _all_output(result) + @responses.activate + def test_tei_mode_empty_checksum_value_fails_closed(self, tmp_path): + """A checksum with an empty value must fail verification, not be skipped.""" + tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0" + pr_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + artifact_url = "https://cdn.example.com/sbom.json" + responses.get( + f"{BASE_URL}/discovery", + json=[{"productReleaseUuid": pr_uuid, "servers": [{"rootUrl": BASE_URL, "versions": ["0.4.0"]}]}], + ) + responses.get( + f"{BASE_URL}/productRelease/{pr_uuid}/collection/latest", + json={ + "uuid": pr_uuid, + "version": 1, + "artifacts": [ + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": artifact_url, "checksums": [{"algType": "SHA-256", "algValue": ""}]}], + } + ], + }, + ) + responses.get(artifact_url, body=b'{"bomFormat": "CycloneDX"}') + dest = tmp_path / "output" + result = runner.invoke(app, ["download", tei, str(dest), "--base-url", BASE_URL]) + assert result.exit_code == 1 + assert "Checksum FAILED for sbom.json" in _all_output(result) + assert "Downloaded sbom.json" not in _all_output(result) + assert not (dest / "sbom.json").exists() + @responses.activate def test_url_mode_still_works(self, tmp_path): """Direct URL mode is unchanged.""" @@ -1725,6 +1931,8 @@ def test_tei_mode_skips_formats_without_url(self, tmp_path): "version": 1, "artifacts": [ { + "uuid": "art-uuid-1", + "type": "BOM", "name": "SBOM", "formats": [ {"mediaType": "application/xml", "checksums": []}, @@ -1740,6 +1948,32 @@ def test_tei_mode_skips_formats_without_url(self, tmp_path): assert result.exit_code == 0 assert (dest / "sbom.json").exists() + @responses.activate + def test_tei_mode_v1_downloads_server_hosted_format(self, tmp_path): + """On TEA 1.x a format without url is fetched from the artifact download endpoint.""" + tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0" + media = "application/vnd.cyclonedx+json" + empty_json_sha256 = "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + fmt = {"mediaType": media, "checksums": [{"algType": "SHA-256", "algValue": empty_json_sha256}]} + collection = {**V1_COLLECTION, "artifacts": [{**V1_ARTIFACT, "name": "SBOM", "formats": [fmt]}]} + responses.get( + f"{V1_BASE_URL}/discovery", + json=[ + {"productReleaseUuid": PRODUCT_RELEASE, "servers": [{"rootUrl": V1_BASE_URL, "versions": ["1.0.0"]}]} + ], + ) + responses.get(f"{V1_BASE_URL}/productRelease/{PRODUCT_RELEASE}/collection/latest", json=collection) + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/2/download", + match=[matchers.query_param_matcher({"mediaType": media})], + body=b"{}", + ) + dest = tmp_path / "output" + result = runner.invoke(app, ["download", tei, str(dest), "--base-url", V1_BASE_URL]) + assert result.exit_code == 0, _all_output(result) + assert (dest / "SBOM.cdx.json").read_bytes() == b"{}" + assert "Downloaded SBOM.cdx.json (checksum OK)" in _all_output(result) + def test_tei_mode_rejects_checksum_flag(self, tmp_path): """--checksum is rejected in TEI mode.""" tei = "urn:tei:purl:example.com:pkg:pypi/test@1.0" @@ -1769,6 +2003,8 @@ def test_tei_mode_checksum_verification(self, tmp_path): "version": 1, "artifacts": [ { + "uuid": "art-uuid-1", + "type": "BOM", "name": "SBOM", "formats": [ { @@ -1803,6 +2039,8 @@ def test_tei_mode_checksum_failure_shown_without_error_prefix(self, tmp_path): "version": 1, "artifacts": [ { + "uuid": "art-uuid-1", + "type": "BOM", "name": "SBOM", "formats": [ { @@ -1857,11 +2095,23 @@ def test_tei_mode_multiple_discoveries(self, tmp_path): ) responses.get( f"{BASE_URL}/productRelease/{pr1}/collection/latest", - json={"uuid": pr1, "version": 1, "artifacts": [{"name": "A", "formats": [{"url": url1, "checksums": []}]}]}, + json={ + "uuid": pr1, + "version": 1, + "artifacts": [ + {"uuid": "art-uuid-1", "type": "BOM", "name": "A", "formats": [{"url": url1, "checksums": []}]} + ], + }, ) responses.get( f"{BASE_URL}/productRelease/{pr2}/collection/latest", - json={"uuid": pr2, "version": 1, "artifacts": [{"name": "B", "formats": [{"url": url2, "checksums": []}]}]}, + json={ + "uuid": pr2, + "version": 1, + "artifacts": [ + {"uuid": "art-uuid-2", "type": "BOM", "name": "B", "formats": [{"url": url2, "checksums": []}]} + ], + }, ) responses.get(url1, body=b"one") responses.get(url2, body=b"two") @@ -1888,8 +2138,8 @@ def test_tei_mode_filename_collision_deduplicates(self, tmp_path): "uuid": pr_uuid, "version": 1, "artifacts": [ - {"name": "A", "formats": [{"url": url1, "checksums": []}]}, - {"name": "B", "formats": [{"url": url2, "checksums": []}]}, + {"uuid": "art-uuid-1", "type": "BOM", "name": "A", "formats": [{"url": url1, "checksums": []}]}, + {"uuid": "art-uuid-2", "type": "BOM", "name": "B", "formats": [{"url": url2, "checksums": []}]}, ], }, ) @@ -1904,66 +2154,71 @@ def test_tei_mode_filename_collision_deduplicates(self, tmp_path): assert (dest / "sbom-1.json").read_bytes() == b"second" +def _legacy_format(**fields): + from libtea.models import ArtifactFormat + + return ArtifactFormat.model_validate(fields, context=LEGACY) + + +def _legacy_artifact(**fields): + from libtea.models import Artifact + + return Artifact.model_validate({"uuid": "art-uuid-1", "type": "BOM", "formats": [], **fields}, context=LEGACY) + + class TestArtifactFilename: """Tests for _artifact_filename derivation logic.""" def test_filename_from_url(self): from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(url="https://cdn.example.com/path/to/sbom.cdx.json") - art = Artifact(name="SBOM") + fmt = _legacy_format(url="https://cdn.example.com/path/to/sbom.cdx.json") + art = _legacy_artifact(name="SBOM") assert _artifact_filename(fmt, art, 0) == "sbom.cdx.json" def test_filename_fallback_to_artifact_name(self): from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(media_type="application/json") - art = Artifact(name="my-sbom") + fmt = _legacy_format(media_type="application/json") + art = _legacy_artifact(name="my-sbom") assert _artifact_filename(fmt, art, 0) == "my-sbom.json" def test_filename_fallback_no_name(self): from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(media_type="application/vnd.cyclonedx+json") - art = Artifact() + fmt = _legacy_format(media_type="application/vnd.cyclonedx+json") + art = _legacy_artifact() assert _artifact_filename(fmt, art, 3) == "artifact-3.cdx.json" def test_filename_url_without_extension_falls_back(self): """URLs ending in generic segments like /download use artifact name instead.""" from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(url="https://cdn.example.com/artifacts/uuid/download", media_type="application/json") - art = Artifact(name="my-sbom") + fmt = _legacy_format(url="https://cdn.example.com/artifacts/uuid/download", media_type="application/json") + art = _legacy_artifact(name="my-sbom") assert _artifact_filename(fmt, art, 0) == "my-sbom.json" def test_filename_unknown_media_type(self): from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(media_type="application/octet-stream") - art = Artifact(name="blob") + fmt = _legacy_format(media_type="application/octet-stream") + art = _legacy_artifact(name="blob") assert _artifact_filename(fmt, art, 0) == "blob" def test_filename_path_traversal_sanitized(self): """Malicious artifact names with path traversal are sanitized.""" from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(media_type="application/json") - art = Artifact(name="../../etc/passwd") + fmt = _legacy_format(media_type="application/json") + art = _legacy_artifact(name="../../etc/passwd") assert _artifact_filename(fmt, art, 0) == "passwd.json" def test_filename_absolute_path_sanitized(self): """Absolute path in artifact name is sanitized to basename.""" from libtea.cli import _artifact_filename - from libtea.models import Artifact, ArtifactFormat - fmt = ArtifactFormat(media_type="application/json") - art = Artifact(name="/etc/cron.d/malicious") + fmt = _legacy_format(media_type="application/json") + art = _legacy_artifact(name="/etc/cron.d/malicious") assert _artifact_filename(fmt, art, 0) == "malicious.json" def test_ext_from_media_type(self): @@ -2112,7 +2367,12 @@ def test_dry_run_shows_would_download(self, tmp_path): "uuid": pr_uuid, "version": 1, "artifacts": [ - {"name": "SBOM", "formats": [{"url": artifact_url, "checksums": []}]}, + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": artifact_url, "checksums": []}], + }, ], }, ) @@ -2124,6 +2384,74 @@ def test_dry_run_shows_would_download(self, tmp_path): assert "sbom.cdx.json" in combined assert not dest.exists() + @responses.activate + def test_tei_uri_dry_run_enters_tei_mode(self, tmp_path): + """A tei:// source is TEI mode: --dry-run lists artifacts instead of being rejected as URL mode.""" + tei = "tei://example.com/uuid/a1b2c3d4-e5f6-7890-abcd-ef1234567890" + pr_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + responses.get( + f"{BASE_URL}/discovery", + match=[matchers.query_param_matcher({"tei": tei})], + json=[{"productReleaseUuid": pr_uuid, "servers": [{"rootUrl": BASE_URL, "versions": ["1.0.0"]}]}], + ) + responses.get( + f"{BASE_URL}/productRelease/{pr_uuid}/collection/latest", + json={ + "uuid": pr_uuid, + "version": 1, + "artifacts": [ + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": "https://cdn.example.com/sbom.cdx.json", "checksums": []}], + }, + ], + }, + ) + dest = tmp_path / "output" + result = runner.invoke(app, ["download", tei, str(dest), "--dry-run", "--base-url", BASE_URL]) + assert result.exit_code == 0 + assert "Would download:" in _all_output(result) + assert not dest.exists() + + @responses.activate + def test_tei_uri_download_reaches_discovery(self, tmp_path): + """Without --base-url, a tei:// source auto-discovers via its domain's .well-known/tea and downloads.""" + tei = "tei://example.com/uuid/a1b2c3d4-e5f6-7890-abcd-ef1234567890" + pr_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + artifact_url = "https://cdn.example.com/sbom.cdx.json" + responses.get( + "https://example.com/.well-known/tea", + json={"schemaVersion": 1, "endpoints": [{"url": "https://api.example.com", "versions": ["0.4.0"]}]}, + ) + responses.head("https://api.example.com/v0.4.0", status=200) + responses.get( + "https://api.example.com/v0.4.0/discovery", + match=[matchers.query_param_matcher({"tei": tei})], + json=[{"productReleaseUuid": pr_uuid, "servers": [{"rootUrl": BASE_URL, "versions": ["1.0.0"]}]}], + ) + responses.get( + f"https://api.example.com/v0.4.0/productRelease/{pr_uuid}/collection/latest", + json={ + "uuid": pr_uuid, + "version": 1, + "artifacts": [ + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": artifact_url, "checksums": []}], + }, + ], + }, + ) + responses.get(artifact_url, body=b'{"bomFormat": "CycloneDX"}') + dest = tmp_path / "output" + result = runner.invoke(app, ["download", tei, str(dest)]) + assert result.exit_code == 0, _all_output(result) + assert (dest / "sbom.cdx.json").exists() + def test_dry_run_rejected_in_url_mode(self): """--dry-run is only valid in TEI mode.""" result = runner.invoke( @@ -2149,7 +2477,12 @@ def test_quiet_suppresses_download_progress(self, tmp_path): "uuid": pr_uuid, "version": 1, "artifacts": [ - {"name": "SBOM", "formats": [{"url": artifact_url, "checksums": []}]}, + { + "uuid": "art-uuid-1", + "type": "BOM", + "name": "SBOM", + "formats": [{"url": artifact_url, "checksums": []}], + }, ], }, ) diff --git a/tests/cli/test_cli_fmt.py b/tests/cli/test_cli_fmt.py index f561a35..a154da8 100644 --- a/tests/cli/test_cli_fmt.py +++ b/tests/cli/test_cli_fmt.py @@ -53,10 +53,17 @@ PaginatedProductResponse, Product, ProductRelease, + ProductReleaseWithCollection, Release, ReleaseDistribution, TeaServerInfo, ) +from tests.spec_data import LEGACY, product # noqa: E402 + + +def _legacy(model_cls, **fields): + """Build a model from 0.4.0-shaped data, skipping the TEA 1.0.0 required-field checks.""" + return model_cls.model_validate(fields, context=LEGACY) def _capture(fn, *args, **kwargs) -> str: @@ -128,6 +135,7 @@ def test_renders_pagination_and_table(self): timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=1, results=[Product(uuid=UUID, name="Test Product", identifiers=[])], ) @@ -140,23 +148,69 @@ def test_empty_results(self): timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=0, results=[], ) output = _capture(fmt_search_products, data) assert "No results (total: 0)" in output + def test_v1_page_shows_count_and_next_token(self): + data = PaginatedProductResponse( + has_next=True, + next_page_token="t2", + results=[Product(uuid=UUID, name="Test Product", identifiers=[])], + ) + output = _capture(fmt_search_products, data) + assert "1 result(s)" in output + assert "More results: --page-token t2" in output + + def test_v1_page_with_totals_still_shows_next_token(self): + data = PaginatedProductResponse.model_validate( + {"hasNext": True, "nextPageToken": "t2", "totalResults": 5, "results": [product(UUID, "P1")]} + ) + assert data.total_results is None + assert "More results: --page-token t2" in _capture(fmt_search_products, data) + + def test_v1_next_token_is_shell_quoted(self): + data = PaginatedProductResponse(has_next=True, next_page_token="a b", results=[]) + assert "More results: --page-token 'a b'" in _capture(fmt_search_products, data) + + def test_v1_last_page_has_no_next_hint(self): + data = PaginatedProductResponse(has_next=False, results=[]) + output = _capture(fmt_search_products, data) + assert "0 result(s)" in output + assert "--page-token" not in output + + def test_legacy_page_keeps_offset_range(self): + data = _legacy( + PaginatedProductResponse, + timestamp="2024-01-01T00:00:00Z", + pageStartIndex=0, + pageSize=2, + totalResults=3, + results=[product(UUID, "P1"), product(UUID2, "P2")], + ) + assert "Results 1-2 of 3" in _capture(fmt_search_products, data) + class TestFmtSearchReleases: def test_renders_table(self): - data = PaginatedProductReleaseResponse( + data = _legacy( + PaginatedProductReleaseResponse, timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=1, results=[ - ProductRelease( - uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", components=[], pre_release=True + _legacy( + ProductRelease, + uuid=UUID, + version="1.0.0", + created_date="2024-01-01T00:00:00Z", + components=[], + pre_release=True, ) ], ) @@ -190,7 +244,8 @@ def test_markup_escape(self): class TestFmtProductRelease: def test_renders_panel_and_components(self): - data = ProductRelease( + data = _legacy( + ProductRelease, uuid=UUID, version="2.0.0", product_name="My Product", @@ -208,7 +263,7 @@ def test_renders_panel_and_components(self): assert UUID2 in output def test_no_components(self): - data = ProductRelease(uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", components=[]) + data = _legacy(ProductRelease, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", components=[]) output = _capture(fmt_product_release, data) assert "Product Release" in output assert "Components" not in output @@ -216,15 +271,17 @@ def test_no_components(self): class TestFmtComponentRelease: def test_renders_release_and_collection(self): - data = ComponentReleaseWithCollection( - release=Release( + data = _legacy( + ComponentReleaseWithCollection, + release=_legacy( + Release, uuid=UUID, version="1.0.0", component_name="libfoo", created_date="2024-01-01T00:00:00Z", identifiers=[], ), - latest_collection=Collection(uuid=UUID, version=1, artifacts=[]), + latest_collection=_legacy(Collection, uuid=UUID, version=1, artifacts=[]), ) output = _capture(fmt_component_release, data) assert "Component Release" in output @@ -232,12 +289,14 @@ def test_renders_release_and_collection(self): assert "Latest Collection" in output def test_renders_artifacts(self): - data = ComponentReleaseWithCollection( - release=Release(uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), - latest_collection=Collection( + data = _legacy( + ComponentReleaseWithCollection, + release=_legacy(Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), + latest_collection=_legacy( + Collection, uuid=UUID, version=1, - artifacts=[Artifact(uuid=UUID2, name="SBOM", type="BOM", formats=[])], + artifacts=[_legacy(Artifact, uuid=UUID2, name="SBOM", type="BOM", formats=[])], ), ) output = _capture(fmt_component_release, data) @@ -247,7 +306,8 @@ def test_renders_artifacts(self): class TestFmtCollection: def test_renders_panel(self): - data = Collection( + data = _legacy( + Collection, uuid=UUID, version=3, belongs_to=CollectionBelongsTo.PRODUCT_RELEASE, @@ -263,14 +323,15 @@ def test_renders_panel(self): assert "CVE-2024-1234 fixed" in output def test_no_update_reason(self): - data = Collection(uuid=UUID, version=1, artifacts=[]) + data = _legacy(Collection, uuid=UUID, version=1, artifacts=[]) output = _capture(fmt_collection, data) assert "Collection" in output class TestFmtArtifact: def test_renders_panel_and_formats(self): - data = Artifact( + data = _legacy( + Artifact, uuid=UUID, name="SBOM", type="BOM", @@ -291,7 +352,7 @@ def test_renders_panel_and_formats(self): assert "abcdef123456" in output def test_no_formats(self): - data = Artifact(uuid=UUID, name="VEX", type="VULNERABILITIES", formats=[]) + data = _legacy(Artifact, uuid=UUID, name="VEX", type="VULNERABILITIES", formats=[]) output = _capture(fmt_artifact, data) assert "Artifact" in output assert "Formats" not in output @@ -472,6 +533,12 @@ def test_dispatch_product(self): output = _capture(format_output, product) assert "Product" in output + def test_dispatch_product_release_with_collection(self): + data = _legacy( + ProductReleaseWithCollection, uuid=UUID, version="1.0.0", created_date="2024-01-01", components=[] + ) + assert "Product Release" in _capture(format_output, data) + def test_dispatch_discover_via_command(self): data = [ DiscoveryInfo( @@ -528,15 +595,17 @@ class TestDistributionsTable: """Test display of release-distribution fields.""" def test_component_release_with_distributions(self): - data = ComponentReleaseWithCollection( - release=Release( + data = _legacy( + ComponentReleaseWithCollection, + release=_legacy( + Release, uuid=UUID, version="11.0.7", component_name="tomcat", created_date="2024-01-01T00:00:00Z", distributions=[ ReleaseDistribution( - distribution_type="zip", + distribution_id="zip", description="Core binary distribution, zip archive", url="https://repo.example.com/tomcat-11.0.7.zip", signature_url="https://repo.example.com/tomcat-11.0.7.zip.asc", @@ -545,13 +614,13 @@ def test_component_release_with_distributions(self): ], ), ReleaseDistribution( - distribution_type="tar.gz", + distribution_id="tar.gz", description="Core binary distribution, tar.gz archive", url="https://repo.example.com/tomcat-11.0.7.tar.gz", ), ], ), - latest_collection=Collection(uuid=UUID, version=1, artifacts=[]), + latest_collection=_legacy(Collection, uuid=UUID, version=1, artifacts=[]), ) output = _capture(fmt_component_release, data) assert "Distributions" in output @@ -563,9 +632,10 @@ def test_component_release_with_distributions(self): assert "SHA-256:abcdef123456" in output def test_component_release_without_distributions(self): - data = ComponentReleaseWithCollection( - release=Release(uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), - latest_collection=Collection(uuid=UUID, version=1, artifacts=[]), + data = _legacy( + ComponentReleaseWithCollection, + release=_legacy(Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), + latest_collection=_legacy(Collection, uuid=UUID, version=1, artifacts=[]), ) output = _capture(fmt_component_release, data) assert "Distributions" not in output @@ -584,7 +654,7 @@ def test_inspect_component_with_distributions(self): "componentName": "tomcat", "distributions": [ { - "distributionType": "zip", + "distributionId": "zip", "description": "Zip archive", "url": "https://repo.example.com/tomcat.zip", "signatureUrl": "https://repo.example.com/tomcat.zip.asc", @@ -607,10 +677,11 @@ def test_inspect_component_with_distributions(self): class TestArtifactFormatDetails: - """Test display of artifact-format description, signatureUrl, and distributionTypes.""" + """Test display of artifact-format description, signatureUrl, and distributionIds.""" def test_formats_table_shows_description_and_signature(self): - data = Artifact( + data = _legacy( + Artifact, uuid=UUID, name="Build SBOM", type="BOM", @@ -629,35 +700,20 @@ def test_formats_table_shows_description_and_signature(self): assert "sbom.xml.asc" in output assert "application/vnd.cyclonedx+xml" in output - def test_artifacts_table_shows_distribution_types(self): - data = Collection( - uuid=UUID, - version=1, - artifacts=[ - Artifact( - uuid=UUID, - name="Build SBOM", - type="BOM", - distribution_types=["zip", "tar.gz"], - formats=[ArtifactFormat(media_type="application/xml", url="https://example.com/sbom.xml")], - ) - ], - ) - output = _capture(fmt_collection, data) - assert "zip, tar.gz" in output - def test_artifacts_table_prefers_distribution_ids(self): """v0.4.0: distribution_ids should be displayed when present.""" - data = Collection( + data = _legacy( + Collection, uuid=UUID, version=1, artifacts=[ - Artifact( + _legacy( + Artifact, uuid=UUID, name="Build SBOM", type="BOM", distribution_ids=["dist-1", "dist-2"], - formats=[ArtifactFormat(media_type="application/xml", url="https://example.com/sbom.xml")], + formats=[_legacy(ArtifactFormat, media_type="application/xml", url="https://example.com/sbom.xml")], ) ], ) @@ -666,8 +722,10 @@ def test_artifacts_table_prefers_distribution_ids(self): def test_distributions_table_prefers_distribution_id(self): """v0.4.0: distribution_id should be displayed when present.""" - data = ComponentReleaseWithCollection( - release=Release( + data = _legacy( + ComponentReleaseWithCollection, + release=_legacy( + Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", @@ -675,46 +733,11 @@ def test_distributions_table_prefers_distribution_id(self): ReleaseDistribution(distribution_id="dist-abc", description="Primary"), ], ), - latest_collection=Collection(uuid=UUID, version=1, artifacts=[]), + latest_collection=_legacy(Collection, uuid=UUID, version=1, artifacts=[]), ) output = _capture(fmt_component_release, data) assert "dist-abc" in output - def test_distributions_table_falls_back_to_type(self): - """Legacy servers: distribution_type shown when distribution_id absent.""" - data = ComponentReleaseWithCollection( - release=Release( - uuid=UUID, - version="1.0.0", - created_date="2024-01-01T00:00:00Z", - distributions=[ - ReleaseDistribution(distribution_type="tar.gz", description="Legacy"), - ], - ), - latest_collection=Collection(uuid=UUID, version=1, artifacts=[]), - ) - output = _capture(fmt_component_release, data) - assert "tar.gz" in output - - def test_artifacts_table_empty_distribution_ids_no_fallback(self): - """Empty distribution_ids=() must not fall back to legacy distribution_types.""" - data = Collection( - uuid=UUID, - version=1, - artifacts=[ - Artifact( - uuid=UUID, - name="Build SBOM", - type="BOM", - distribution_ids=(), - distribution_types=("binary",), - formats=[ArtifactFormat(media_type="application/xml", url="https://example.com/sbom.xml")], - ) - ], - ) - output = _capture(fmt_collection, data) - assert "binary" not in output - def test_inspect_artifact_shows_description_and_signature(self): """Inspect output should show description and signatureUrl for artifact formats.""" data = [ @@ -735,7 +758,7 @@ def test_inspect_artifact_shows_description_and_signature(self): "uuid": UUID2, "name": "VDR", "type": "VULNERABILITIES", - "distributionTypes": ["zip"], + "distributionIds": ["zip"], "formats": [ { "mediaType": "application/vnd.cyclonedx+xml", @@ -779,7 +802,8 @@ def test_format_output_dispatches_component(self): class TestReleasesFormatter: def test_fmt_releases(self): releases = [ - Release( + _legacy( + Release, uuid=UUID, version="1.0.0", component_name="App", @@ -787,7 +811,8 @@ def test_fmt_releases(self): release_date="2024-02-01T00:00:00Z", pre_release=False, ), - Release( + _legacy( + Release, uuid=UUID2, version="2.0.0", component_name="App", @@ -802,7 +827,7 @@ def test_fmt_releases(self): def test_format_output_releases_command(self): releases = [ - Release(uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), + _legacy(Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z"), ] output = _capture(format_output, releases, command="releases") assert "Component Releases" in output @@ -812,13 +837,21 @@ def test_format_output_releases_command(self): class TestCollectionsFormatter: def test_fmt_collections(self): cols = [ - Collection(uuid=UUID, version=1, date="2024-01-01T00:00:00Z", belongs_to="COMPONENT_RELEASE", artifacts=[]), - Collection( + _legacy( + Collection, + uuid=UUID, + version=1, + created_date="2024-01-01T00:00:00Z", + belongs_to="COMPONENT_RELEASE", + artifacts=[], + ), + _legacy( + Collection, uuid=UUID2, version=2, - date="2024-06-01T00:00:00Z", + created_date="2024-06-01T00:00:00Z", belongs_to="COMPONENT_RELEASE", - artifacts=[Artifact(uuid=UUID, name="SBOM", type="BOM", formats=[])], + artifacts=[_legacy(Artifact, uuid=UUID, name="SBOM", type="BOM", formats=[])], ), ] output = _capture(fmt_collections, cols) @@ -829,7 +862,7 @@ def test_fmt_collections(self): assert "1" in output def test_format_output_collections_command(self): - cols = [Collection(uuid=UUID, version=1, artifacts=[])] + cols = [_legacy(Collection, uuid=UUID, version=1, artifacts=[])] output = _capture(format_output, cols, command="collections") assert "Collections" in output @@ -926,7 +959,8 @@ class TestCollectionUpdateReasonComment: """Cover the update_reason.comment branch at _cli_fmt.py:239.""" def test_update_reason_without_comment(self): - data = Collection( + data = _legacy( + Collection, uuid=UUID, version=2, update_reason=CollectionUpdateReason(type=CollectionUpdateReasonType.VEX_UPDATED), @@ -1030,7 +1064,8 @@ class TestArtifactVersionRendering: """Test that fmt_artifact renders version and created_date when present.""" def test_artifact_with_version(self): - data = Artifact( + data = _legacy( + Artifact, uuid=UUID, name="SBOM", type="BOM", @@ -1042,7 +1077,7 @@ def test_artifact_with_version(self): assert "3" in output def test_artifact_without_version(self): - data = Artifact(uuid=UUID, name="SBOM", type="BOM", formats=[]) + data = _legacy(Artifact, uuid=UUID, name="SBOM", type="BOM", formats=[]) output = _capture(fmt_artifact, data) assert "Version" not in output @@ -1053,6 +1088,7 @@ def test_renders_pagination_and_table(self): timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=1, results=[Component(uuid=UUID, name="Test Component", identifiers=[])], ) @@ -1065,6 +1101,7 @@ def test_empty_results(self): timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=0, results=[], ) @@ -1074,12 +1111,16 @@ def test_empty_results(self): class TestFmtSearchComponentReleases: def test_renders_table(self): - data = PaginatedComponentReleaseResponse( + data = _legacy( + PaginatedComponentReleaseResponse, timestamp="2024-01-01T00:00:00Z", page_start_index=0, page_size=100, + has_next=False, total_results=1, - results=[Release(uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", pre_release=True)], + results=[ + _legacy(Release, uuid=UUID, version="1.0.0", created_date="2024-01-01T00:00:00Z", pre_release=True) + ], ) output = _capture(fmt_search_component_releases, data) assert "Component Releases" in output diff --git a/tests/client/test_cache.py b/tests/client/test_cache.py index ef9486c..02de7e5 100644 --- a/tests/client/test_cache.py +++ b/tests/client/test_cache.py @@ -8,7 +8,7 @@ from libtea.client import TeaClient -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" UUID1 = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" UUID2 = "b2c3d4e5-f6a7-8901-bcde-f12345678901" diff --git a/tests/client/test_client.py b/tests/client/test_client.py index a48e283..bad3ced 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -21,6 +21,7 @@ ProductRelease, Release, ) +from tests.spec_data import LEGACY class TestSearchProducts: @@ -62,7 +63,7 @@ def test_search_products_pagination(self, client, base_url): "results": [], }, ) - resp = client.search_products("CPE", "cpe:2.3:a:vendor:product", page_offset=10, page_size=25) + resp = client.search_products("CPE", "cpe:2.3:a:vendor:product", page_token="10", page_size=25) request = responses.calls[0].request assert "pageOffset=10" in str(request.url) assert "pageSize=25" in str(request.url) @@ -129,7 +130,7 @@ def test_list_products_with_pagination(self, client, base_url): "results": [], }, ) - resp = client.list_products(page_offset=10, page_size=25) + resp = client.list_products(page_token="10", page_size=25) request = responses.calls[0].request assert "pageOffset=10" in str(request.url) assert "pageSize=25" in str(request.url) @@ -141,7 +142,7 @@ def test_list_products_rejects_invalid_page_size(self, client): def test_list_products_rejects_negative_offset(self, client): with pytest.raises(TeaValidationError): - client.list_products(page_offset=-1) + client.list_products(page_token="-1") class TestSearchProductReleases: @@ -211,7 +212,7 @@ def test_list_product_releases_with_pagination(self, client, base_url): "results": [], }, ) - client.list_product_releases(page_offset=5, page_size=50) + client.list_product_releases(page_token="5", page_size=50) request = responses.calls[0].request assert "pageOffset=5" in str(request.url) assert "pageSize=50" in str(request.url) @@ -222,7 +223,7 @@ def test_list_product_releases_invalid_page_size(self, client): def test_list_product_releases_invalid_page_offset(self, client): with pytest.raises(TeaValidationError): - client.list_product_releases(page_offset=-1) + client.list_product_releases(page_token="-1") class TestSearchComponents: @@ -264,7 +265,7 @@ def test_search_components_pagination(self, client, base_url): "results": [], }, ) - resp = client.search_components("CPE", "cpe:2.3:a:vendor:product", page_offset=10, page_size=25) + resp = client.search_components("CPE", "cpe:2.3:a:vendor:product", page_token="10", page_size=25) request = responses.calls[0].request assert "pageOffset=10" in str(request.url) assert "pageSize=25" in str(request.url) @@ -331,7 +332,7 @@ def test_list_components_with_pagination(self, client, base_url): "results": [], }, ) - resp = client.list_components(page_offset=10, page_size=25) + resp = client.list_components(page_token="10", page_size=25) request = responses.calls[0].request assert "pageOffset=10" in str(request.url) assert "pageSize=25" in str(request.url) @@ -343,7 +344,7 @@ def test_list_components_rejects_invalid_page_size(self, client): def test_list_components_rejects_negative_offset(self, client): with pytest.raises(TeaValidationError): - client.list_components(page_offset=-1) + client.list_components(page_token="-1") class TestSearchComponentReleases: @@ -421,7 +422,7 @@ def test_list_component_releases_invalid_page_size(self, client): def test_list_component_releases_invalid_offset(self, client): with pytest.raises(TeaValidationError): - client.list_component_releases(page_offset=-1) + client.list_component_releases(page_token="-1") class TestProduct: @@ -595,8 +596,9 @@ def test_get_component_release_collection_by_version(self, client, base_url): class TestArtifact: @responses.activate def test_get_artifact(self, client, base_url): + # TEA 0.4.0 has no /latest suffix: artifact metadata lives at /artifact/{uuid} responses.get( - f"{base_url}/artifact/e5f6a7b8-c9d0-1234-efab-345678901234/latest", + f"{base_url}/artifact/e5f6a7b8-c9d0-1234-efab-345678901234", json={ "uuid": "e5f6a7b8-c9d0-1234-efab-345678901234", "name": "SBOM", @@ -615,20 +617,10 @@ def test_get_artifact(self, client, base_url): assert artifact.name == "SBOM" @responses.activate - def test_get_artifact_version(self, client, base_url): - responses.get( - f"{base_url}/artifact/e5f6a7b8-c9d0-1234-efab-345678901234/2", - json={ - "uuid": "e5f6a7b8-c9d0-1234-efab-345678901234", - "name": "SBOM", - "type": "BOM", - "version": 2, - "formats": [], - }, - ) - artifact = client.get_artifact_version("e5f6a7b8-c9d0-1234-efab-345678901234", 2) - assert isinstance(artifact, Artifact) - assert artifact.version == 2 + def test_get_artifact_version_requires_tea_1x(self, client): + with pytest.raises(TeaValidationError, match="TEA 1.x"): + client.get_artifact_version("e5f6a7b8-c9d0-1234-efab-345678901234", 2) + assert len(responses.calls) == 0 def test_get_artifact_version_rejects_zero(self, client): with pytest.raises(TeaValidationError, match="Artifact version must be >= 1"): @@ -648,62 +640,32 @@ class TestSpecV040Fields: def test_artifact_distribution_ids(self): art = Artifact.model_validate( - {"uuid": self._ART_UUID, "distributionIds": [self._DIST_UUID, self._DIST_UUID2], "formats": []} + { + "uuid": self._ART_UUID, + "type": "BOM", + "distributionIds": [self._DIST_UUID, self._DIST_UUID2], + "formats": [], + }, + context=LEGACY, ) assert art.distribution_ids == (self._DIST_UUID, self._DIST_UUID2) def test_artifact_distribution_ids_absent(self): - art = Artifact.model_validate({"uuid": self._ART_UUID, "formats": []}) + art = Artifact.model_validate({"uuid": self._ART_UUID, "type": "BOM", "formats": []}, context=LEGACY) assert art.distribution_ids is None - def test_artifact_backward_compat_distribution_types(self): - """Older servers may still send distributionTypes — must not break.""" - art = Artifact.model_validate( - {"uuid": self._ART_UUID, "distributionTypes": ["binary", "source"], "formats": []} - ) - assert art.distribution_types == ("binary", "source") - def test_release_distribution_id(self): from libtea.models import ReleaseDistribution dist = ReleaseDistribution.model_validate({"distributionId": self._DIST_UUID, "description": "core binary"}) assert dist.distribution_id == self._DIST_UUID - def test_release_distribution_backward_compat(self): - """Older servers may send distributionType instead of distributionId.""" - from libtea.models import ReleaseDistribution - - dist = ReleaseDistribution.model_validate({"distributionType": "binary", "description": "old format"}) - assert dist.distribution_type == "binary" - assert dist.distribution_id is None - def test_artifact_distribution_ids_empty_array(self): """Empty distributionIds array should parse to empty tuple, not None.""" - art = Artifact.model_validate({"uuid": self._ART_UUID, "distributionIds": [], "formats": []}) - assert art.distribution_ids == () - - def test_artifact_coexistence_old_and_new_fields(self): - """A server sending both old and new fields should preserve both.""" art = Artifact.model_validate( - { - "uuid": self._ART_UUID, - "distributionIds": [self._DIST_UUID], - "distributionTypes": ["binary"], - "formats": [], - } - ) - assert art.distribution_ids == (self._DIST_UUID,) - assert art.distribution_types == ("binary",) - - def test_release_distribution_coexistence_old_and_new(self): - """Both distributionId and distributionType present — both preserved.""" - from libtea.models import ReleaseDistribution - - dist = ReleaseDistribution.model_validate( - {"distributionId": self._DIST_UUID, "distributionType": "binary", "description": "both fields"} + {"uuid": self._ART_UUID, "type": "BOM", "distributionIds": [], "formats": []}, context=LEGACY ) - assert dist.distribution_id == self._DIST_UUID - assert dist.distribution_type == "binary" + assert art.distribution_ids == () class TestDiscovery: @@ -805,93 +767,93 @@ def test_from_well_known_passes_token(self, base_url): class TestProbeEndpoint: @responses.activate def test_probe_success(self): - responses.head("https://api.example.com/v1", status=200) - probe_endpoint("https://api.example.com/v1") # should not raise + responses.head("https://api.example.com/v0.4.0", status=200) + probe_endpoint("https://api.example.com/v0.4.0") # should not raise @responses.activate def test_probe_404_triggers_failover(self): """404 means wrong path/version — probe should fail to trigger failover.""" - responses.head("https://api.example.com/v1", status=404) + responses.head("https://api.example.com/v0.4.0", status=404) with pytest.raises(TeaConnectionError, match="HTTP 404"): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_410_triggers_failover(self): """410 Gone means endpoint removed — probe should fail to trigger failover.""" - responses.head("https://api.example.com/v1", status=410) + responses.head("https://api.example.com/v0.4.0", status=410) with pytest.raises(TeaConnectionError, match="HTTP 410"): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_401_is_ok(self): """401 means auth required but server is reachable — probe should succeed.""" - responses.head("https://api.example.com/v1", status=401) - probe_endpoint("https://api.example.com/v1") # should not raise + responses.head("https://api.example.com/v0.4.0", status=401) + probe_endpoint("https://api.example.com/v0.4.0") # should not raise @responses.activate def test_probe_405_is_ok(self): """405 means HEAD not allowed but server is reachable — probe should succeed.""" - responses.head("https://api.example.com/v1", status=405) - probe_endpoint("https://api.example.com/v1") # should not raise + responses.head("https://api.example.com/v0.4.0", status=405) + probe_endpoint("https://api.example.com/v0.4.0") # should not raise @responses.activate def test_probe_trailing_slash_redirect_is_ok(self): """Trailing-slash redirect (e.g. Django APPEND_SLASH) should not fail the probe.""" - responses.head("https://api.example.com/v1", status=301, headers={"Location": "/v1/"}) - probe_endpoint("https://api.example.com/v1") # should not raise + responses.head("https://api.example.com/v0.4.0", status=301, headers={"Location": "/v0.4.0/"}) + probe_endpoint("https://api.example.com/v0.4.0") # should not raise @responses.activate def test_probe_trailing_slash_redirect_absolute_url(self): """Trailing-slash redirect with absolute Location header.""" responses.head( - "https://api.example.com/v1", + "https://api.example.com/v0.4.0", status=301, - headers={"Location": "https://api.example.com/v1/"}, + headers={"Location": "https://api.example.com/v0.4.0/"}, ) - probe_endpoint("https://api.example.com/v1") # should not raise + probe_endpoint("https://api.example.com/v0.4.0") # should not raise @responses.activate def test_probe_cross_path_redirect_raises_connection_error(self): """Redirect to a different path should fail.""" - responses.head("https://api.example.com/v1", status=302, headers={"Location": "/elsewhere"}) + responses.head("https://api.example.com/v0.4.0", status=302, headers={"Location": "/elsewhere"}) with pytest.raises(TeaConnectionError, match="redirect"): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_cross_origin_redirect_raises_connection_error(self): """Redirect to a different host should fail.""" responses.head( - "https://api.example.com/v1", + "https://api.example.com/v0.4.0", status=301, - headers={"Location": "https://evil.example.com/v1/"}, + headers={"Location": "https://evil.example.com/v0.4.0/"}, ) with pytest.raises(TeaConnectionError, match="redirect"): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_500_raises_server_error(self): - responses.head("https://api.example.com/v1", status=500) + responses.head("https://api.example.com/v0.4.0", status=500) with pytest.raises(TeaServerError): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_connection_error_raises(self): - responses.head("https://api.example.com/v1", body=requests.ConnectionError("refused")) + responses.head("https://api.example.com/v0.4.0", body=requests.ConnectionError("refused")) with pytest.raises(TeaConnectionError): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_timeout_raises(self): - responses.head("https://api.example.com/v1", body=requests.Timeout("timed out")) + responses.head("https://api.example.com/v0.4.0", body=requests.Timeout("timed out")) with pytest.raises(TeaConnectionError): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") @responses.activate def test_probe_request_exception_raises(self): """Generic RequestException (not ConnectionError/Timeout) also raises TeaConnectionError.""" - responses.head("https://api.example.com/v1", body=requests.exceptions.TooManyRedirects("too many")) + responses.head("https://api.example.com/v0.4.0", body=requests.exceptions.TooManyRedirects("too many")) with pytest.raises(TeaConnectionError): - probe_endpoint("https://api.example.com/v1") + probe_endpoint("https://api.example.com/v0.4.0") class TestVersionNegotiation: @@ -1062,7 +1024,7 @@ def test_get_product_releases_pagination_params(self, client, base_url): "results": [], }, ) - resp = client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890", page_offset=50, page_size=25) + resp = client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890", page_token="50", page_size=25) request = responses.calls[0].request assert "pageOffset=50" in str(request.url) assert "pageSize=25" in str(request.url) @@ -1202,7 +1164,7 @@ def test_md5_checksum_warns(self, client, tmp_path): content = b"test content" responses.get("https://artifacts.example.com/sbom.json", body=content) md5 = hashlib.md5(content).hexdigest() - checksums = [Checksum(algorithm_type=ChecksumAlgorithm.MD5, algorithm_value=md5)] + checksums = [Checksum.model_validate({"algType": "MD5", "algValue": md5}, context=LEGACY)] dest = tmp_path / "sbom.json" with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") diff --git a/tests/client/test_client_v1.py b/tests/client/test_client_v1.py new file mode 100644 index 0000000..c87dc85 --- /dev/null +++ b/tests/client/test_client_v1.py @@ -0,0 +1,521 @@ +from unittest.mock import patch + +import pytest +import responses +from responses import matchers + +from libtea._validation import _validate, _validate_list +from libtea.client import TeaClient +from libtea.exceptions import TeaChecksumError, TeaRequestError, TeaServerError, TeaValidationError +from libtea.models import Checksum +from tests.spec_data import ( + ARTIFACT, + COMPONENT, + COMPONENT_RELEASE, + LEGACY_BASE_URL, + MD5_HEX, + PRODUCT, + PRODUCT_RELEASE, + V1_ARTIFACT, + V1_BASE_URL, + V1_COLLECTION, + V1_PRODUCT_RELEASE, + V1_RELEASE, + product, +) + + +class TestSpecVersion: + def test_inferred_from_base_url(self): + c = TeaClient(LEGACY_BASE_URL) + assert (c.spec_version, c.is_legacy) == ("0.4.0", True) + + def test_build_metadata_kept(self): + assert TeaClient("https://api.example.com/v1.0.0+build.5").spec_version == "1.0.0+build.5" + + def test_defaults_to_1_0_0_without_semver_suffix(self): + c = TeaClient("https://api.example.com/tea/v1") + assert (c.spec_version, c.is_legacy) == ("1.0.0", False) + + def test_explicit_overrides_url(self): + assert TeaClient(V1_BASE_URL, spec_version="0.4.0").is_legacy is True + + @pytest.mark.parametrize("v", ["2.0.0", "not-semver"]) + def test_rejects_unsupported(self, v): + with pytest.raises(ValueError, match="Unsupported TEA spec version"): + TeaClient("https://api.example.com", spec_version=v) + + +CASES = [("legacy", LEGACY_BASE_URL, True), ("v1", V1_BASE_URL, False)] + + +class TestLegacyFlagReachesValidation: + @responses.activate + @pytest.mark.parametrize("fixture, base_url, expected", CASES) + def test_validate(self, request, fixture, base_url, expected): + client = request.getfixturevalue(fixture) + responses.get(f"{base_url}/product/{PRODUCT}", json=product(PRODUCT, "Widget")) + with patch("libtea.client._validate", wraps=_validate) as spy: + client.get_product(PRODUCT) + assert spy.call_args.kwargs == {"legacy": expected} + + @responses.activate + @pytest.mark.parametrize("fixture, base_url, expected", CASES) + def test_validate_list(self, request, fixture, base_url, expected): + client = request.getfixturevalue(fixture) + responses.get( + f"{base_url}/discovery", + json=[ + { + "productReleaseUuid": PRODUCT_RELEASE, + "servers": [{"rootUrl": "https://api.example.com", "versions": ["1.0.0"]}], + } + ], + ) + with patch("libtea.client._validate_list", wraps=_validate_list) as spy: + client.discover("urn:tei:purl:example.com:pkg:pypi/lib@1.0") + assert spy.call_args.kwargs == {"legacy": expected} + + +@responses.activate +def test_discover_passes_tei_verbatim(v1): + tei = "tei://Example.com/uuid/x" + responses.get( + f"{V1_BASE_URL}/discovery", + match=[matchers.query_param_matcher({"tei": tei})], + json=[ + { + "productReleaseUuid": PRODUCT_RELEASE, + "servers": [{"rootUrl": "https://api.example.com", "versions": ["1.0.0"]}], + } + ], + ) + assert v1.discover(tei)[0].product_release_uuid == PRODUCT_RELEASE + + +@responses.activate +def test_discover_by_purl(v1): + purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.24.3" + responses.get( + f"{V1_BASE_URL}/discovery", + match=[matchers.query_param_matcher({"purl": purl})], + json=[ + { + "productReleaseUuid": PRODUCT_RELEASE, + "servers": [{"rootUrl": "https://api.example.com", "versions": ["1.0.0"]}], + } + ], + ) + assert v1.discover(purl=purl)[0].product_release_uuid == PRODUCT_RELEASE + + +@responses.activate +@pytest.mark.parametrize("kwargs", [{}, {"tei": "tei://example.com/uuid/x", "purl": "pkg:x/y"}]) +def test_discover_needs_exactly_one(v1, kwargs): + with pytest.raises(TeaValidationError, match="exactly one"): + v1.discover(**kwargs) + assert len(responses.calls) == 0 + + +@responses.activate +def test_purl_rejected_on_legacy(legacy): + with pytest.raises(TeaValidationError, match="TEA 1.x"): + legacy.discover(purl="pkg:x/y") + assert len(responses.calls) == 0 + + +@responses.activate +def test_discover_empty_array_rejected_on_v1(v1): + responses.get(f"{V1_BASE_URL}/discovery", json=[]) + with pytest.raises(TeaValidationError, match="non-empty"): + v1.discover("tei://example.com/uuid/x") + + +@responses.activate +def test_v1_client_rejects_incomplete_collection(): + responses.get( + f"{V1_BASE_URL}/componentRelease/{COMPONENT_RELEASE}/collection/latest", + json={"uuid": COMPONENT_RELEASE, "version": 1}, + ) + with pytest.raises(TeaValidationError, match="TEA 1.0.0"): + TeaClient(V1_BASE_URL).get_component_release_collection_latest(COMPONENT_RELEASE) + + +@responses.activate +def test_v1_artifact_with_md5_rejected(): + art = { + **V1_ARTIFACT, + "formats": [{**V1_ARTIFACT["formats"][0], "checksums": [{"algType": "MD5", "algValue": MD5_HEX}]}], + } + responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest", json=art) + with pytest.raises(TeaValidationError, match="MD5"): + TeaClient(V1_BASE_URL).get_artifact(ARTIFACT) + + +@responses.activate +def test_get_artifact_uses_latest_path_on_v1(v1): + responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest", json=V1_ARTIFACT) + assert v1.get_artifact(ARTIFACT).version == 2 + assert len(responses.calls) == 1 + + +@responses.activate +def test_get_artifact_version_on_v1(v1): + responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/2", json=V1_ARTIFACT) + assert v1.get_artifact_version(ARTIFACT, 2).version == 2 + assert len(responses.calls) == 1 + + +# --- Token pagination --- + + +@responses.activate +def test_first_page_omits_token(v1): + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "100"})], + json={"hasNext": False, "results": []}, + ) + assert v1.list_products().has_next is False + assert len(responses.calls) == 1 + + +@responses.activate +def test_page_token_sent(v1): + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "100", "pageToken": "tok-1"})], + json={"hasNext": False, "results": []}, + ) + v1.list_products(page_token="tok-1") + assert len(responses.calls) == 1 + + +def test_page_size_capped_at_100_on_v1(v1): + with pytest.raises(TeaValidationError, match="between 1 and 100"): + v1.list_products(page_size=101) + + +@responses.activate +def test_iter_follows_tokens(v1): + responses.get( + f"{V1_BASE_URL}/productReleases", + match=[matchers.query_param_matcher({"idType": "PURL", "idValue": "pkg:x", "pageSize": "100"})], + json={"hasNext": True, "nextPageToken": "t2", "results": [V1_PRODUCT_RELEASE]}, + ) + responses.get( + f"{V1_BASE_URL}/productReleases", + match=[ + matchers.query_param_matcher({"idType": "PURL", "idValue": "pkg:x", "pageSize": "100", "pageToken": "t2"}) + ], + json={"hasNext": False, "results": [V1_PRODUCT_RELEASE]}, + ) + assert len(list(v1.iter_product_releases("PURL", "pkg:x"))) == 2 + + +@responses.activate +def test_iter_raises_on_repeated_token(v1): # Review Focus 1 + responses.get( + f"{V1_BASE_URL}/products", + json={"hasNext": True, "nextPageToken": "same", "results": [product(PRODUCT, "P")]}, + ) + with pytest.raises(TeaServerError, match="repeated"): + list(v1.iter_products("PURL", "pkg:x")) + + +@responses.activate +def test_iter_raises_on_token_cycle(v1): + url = f"{V1_BASE_URL}/products" + for sent, nxt in [(None, "A"), ("A", "B"), ("B", "A")]: + query = {"idType": "PURL", "idValue": "pkg:x", "pageSize": "100", **({"pageToken": sent} if sent else {})} + responses.get( + url, + match=[matchers.query_param_matcher(query)], + json={"hasNext": True, "nextPageToken": nxt, "results": [product(PRODUCT, "P")]}, + ) + with pytest.raises(TeaServerError, match="repeated"): + list(v1.iter_products("PURL", "pkg:x")) + assert len(responses.calls) == 3 + + +@responses.activate +def test_iter_stops_at_page_cap(v1, monkeypatch): + monkeypatch.setattr("libtea.client._MAX_PAGES", 2) + url = f"{V1_BASE_URL}/products" + for sent, nxt in [(None, "t1"), ("t1", "t2")]: + query = {"idType": "PURL", "idValue": "pkg:x", "pageSize": "100", **({"pageToken": sent} if sent else {})} + responses.get( + url, + match=[matchers.query_param_matcher(query)], + json={"hasNext": True, "nextPageToken": nxt, "results": [product(PRODUCT, "P")]}, + ) + with pytest.raises(TeaServerError, match="Pagination exceeded 2 pages"): + list(v1.iter_products("PURL", "pkg:x")) + assert len(responses.calls) == 2 + + +@responses.activate +def test_iter_follows_token_past_empty_page(v1): + """An empty page with ``hasNext`` and a fresh token is not the end of the results.""" + url = f"{V1_BASE_URL}/products" + pages = [ + (None, {"hasNext": True, "nextPageToken": "t1", "results": [product(PRODUCT, "P1")]}), + ("t1", {"hasNext": True, "nextPageToken": "t2", "results": []}), + ("t2", {"hasNext": False, "results": [product(PRODUCT, "P2"), product(PRODUCT, "P3")]}), + ] + for sent, page in pages: + query = {"idType": "PURL", "idValue": "pkg:x", "pageSize": "100", **({"pageToken": sent} if sent else {})} + responses.get(url, match=[matchers.query_param_matcher(query)], json=page) + assert [p.name for p in v1.iter_products("PURL", "pkg:x")] == ["P1", "P2", "P3"] + assert len(responses.calls) == 3 + + +@responses.activate +def test_iter_raises_on_repeated_token_with_empty_pages(v1): + responses.get(f"{V1_BASE_URL}/products", json={"hasNext": True, "nextPageToken": "t", "results": []}) + with pytest.raises(TeaServerError, match="repeated"): + list(v1.iter_products("PURL", "pkg:x")) + assert len(responses.calls) == 2 + + +@responses.activate +def test_component_releases_collects_pages_on_v1(v1): + url = f"{V1_BASE_URL}/component/{COMPONENT}/releases" + responses.get( + url, + match=[matchers.query_param_matcher({"pageSize": "100"})], + json={"hasNext": True, "nextPageToken": "n", "results": [V1_RELEASE]}, + ) + responses.get( + url, + match=[matchers.query_param_matcher({"pageSize": "100", "pageToken": "n"})], + json={"hasNext": False, "results": [V1_RELEASE]}, + ) + assert len(v1.get_component_releases(COMPONENT)) == 2 + + +@responses.activate +@pytest.mark.parametrize( + ("method", "path", "uuid"), + [ + ("get_product_release_collections", "productRelease", PRODUCT_RELEASE), + ("get_component_release_collections", "componentRelease", COMPONENT_RELEASE), + ], +) +def test_collections_collects_pages_on_v1(v1, method, path, uuid): + url = f"{V1_BASE_URL}/{path}/{uuid}/collections" + responses.get( + url, + match=[matchers.query_param_matcher({"pageSize": "100"})], + json={"hasNext": True, "nextPageToken": "n", "results": [V1_COLLECTION]}, + ) + responses.get( + url, + match=[matchers.query_param_matcher({"pageSize": "100", "pageToken": "n"})], + json={"hasNext": False, "results": [V1_COLLECTION]}, + ) + assert len(getattr(v1, method)(uuid)) == 2 + + +@responses.activate +def test_legacy_token_maps_to_offset(legacy): + responses.get( + f"{LEGACY_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageOffset": "200", "pageSize": "100"})], + json={ + "timestamp": "2024-03-20T15:30:00Z", + "pageStartIndex": 200, + "pageSize": 100, + "totalResults": 200, + "results": [], + }, + ) + legacy.list_products(page_token="200") + assert len(responses.calls) == 1 + + +@responses.activate +@pytest.mark.parametrize("token", ["abc", "-1", "²", ""]) +def test_legacy_non_numeric_token_rejected(legacy, token): # Review Focus 2 + with pytest.raises(TeaValidationError, match="integer offset"): + legacy.list_products(page_token=token) + assert len(responses.calls) == 0 + + +@responses.activate +@pytest.mark.parametrize("fixture", ["v1", "legacy"]) +def test_non_string_page_token_rejected(request, fixture): + """An int carried over from the old ``page_offset`` argument fails cleanly before any request.""" + client = request.getfixturevalue(fixture) + with pytest.raises(TeaValidationError, match="page_token must be a string"): + client.list_products(page_token=200) + assert len(responses.calls) == 0 + + +def test_legacy_page_size_allows_10000(legacy): + with pytest.raises(TeaValidationError, match="between 1 and 10000"): + legacy.list_products(page_size=10001) + + +@responses.activate +def test_legacy_page_size_10000_accepted(legacy): + responses.get( + f"{LEGACY_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageOffset": "0", "pageSize": "10000"})], + json={"timestamp": "2024-03-20T15:30:00Z", "pageStartIndex": 0, "pageSize": 10000, "totalResults": 0}, + ) + legacy.list_products(page_size=10000) + assert len(responses.calls) == 1 + + +@responses.activate +def test_get_product_release_v1(v1): + responses.get( + f"{V1_BASE_URL}/productRelease/{PRODUCT_RELEASE}", + json={**V1_PRODUCT_RELEASE, "latestCollection": {**V1_COLLECTION, "belongsTo": "PRODUCT_RELEASE"}}, + ) + assert v1.get_product_release(PRODUCT_RELEASE).latest_collection.belongs_to == "PRODUCT_RELEASE" + + +@responses.activate +def test_get_product_release_v1_without_collection_rejected(v1): + responses.get(f"{V1_BASE_URL}/productRelease/{PRODUCT_RELEASE}", json=V1_PRODUCT_RELEASE) + with pytest.raises(TeaValidationError, match="latest_collection"): + v1.get_product_release(PRODUCT_RELEASE) + + +@responses.activate +def test_get_component_release_v1_flattened(v1): + responses.get( + f"{V1_BASE_URL}/componentRelease/{COMPONENT_RELEASE}", + json={**V1_RELEASE, "latestCollection": V1_COLLECTION}, + ) + result = v1.get_component_release(COMPONENT_RELEASE) + assert (result.release.component, result.latest_collection.version) == (COMPONENT, 2) + + +@responses.activate +def test_get_component_release_legacy_nested(legacy): + responses.get( + f"{LEGACY_BASE_URL}/componentRelease/{COMPONENT_RELEASE}", + json={"release": V1_RELEASE, "latestCollection": V1_COLLECTION}, + ) + assert legacy.get_component_release(COMPONENT_RELEASE).release.uuid == COMPONENT_RELEASE + + +# --- Server-hosted artifact download (TEA 1.0.0) --- + +MEDIA = "application/vnd.cyclonedx+json" +EMPTY_JSON_SHA256 = "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" # sha256(b"{}") + + +@responses.activate +def test_token_not_forwarded_on_redirect(tmp_path): # Review Focus 4 + c = TeaClient(V1_BASE_URL, token="secret") + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/2/download", + match=[matchers.query_param_matcher({"mediaType": MEDIA})], + status=302, + headers={"Location": "https://cdn.example.com/blob/abc"}, + ) + responses.get("https://cdn.example.com/blob/abc", body=b"{}") + c.download_artifact_content( + ARTIFACT, + tmp_path / "a.json", + media_type=MEDIA, + version=2, + verify_checksums=[Checksum(algorithm_type="SHA-256", algorithm_value=EMPTY_JSON_SHA256)], + ) + assert responses.calls[0].request.headers["Authorization"] == "Bearer secret" + assert "Authorization" not in responses.calls[1].request.headers + + +@responses.activate +def test_token_not_forwarded_on_redirect_to_api_host(tmp_path): + c = TeaClient(V1_BASE_URL, token="secret") + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/2/download", + status=302, + headers={"Location": "/blobs/abc"}, + ) + responses.get("https://api.example.com/blobs/abc", body=b"{}") + c.download_artifact_content(ARTIFACT, tmp_path / "a.json", media_type=MEDIA, version=2) + assert responses.calls[0].request.headers["Authorization"] == "Bearer secret" + assert responses.calls[1].request.url == "https://api.example.com/blobs/abc" + assert "Authorization" not in responses.calls[1].request.headers + + +@responses.activate +def test_basic_auth_not_forwarded_on_redirect(tmp_path): + c = TeaClient(V1_BASE_URL, basic_auth=("u", "p")) + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/2/download", + status=302, + headers={"Location": "https://cdn.example.com/blob/abc"}, + ) + responses.get("https://cdn.example.com/blob/abc", body=b"{}") + c.download_artifact_content(ARTIFACT, tmp_path / "a.json", media_type=MEDIA, version=2) + assert responses.calls[0].request.headers["Authorization"] == "Basic dTpw" # base64("u:p") + assert "Authorization" not in responses.calls[1].request.headers + + +@responses.activate +def test_download_rejects_https_to_http_redirect(tmp_path): + c = TeaClient(V1_BASE_URL, token="secret") + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/2/download", + status=302, + headers={"Location": "http://cdn.example.com/blob/abc"}, + ) + dest = tmp_path / "a.json" + with pytest.raises(TeaValidationError, match="HTTPS to HTTP"): + c.download_artifact_content(ARTIFACT, dest, media_type=MEDIA, version=2) + assert len(responses.calls) == 1 + assert not dest.exists() + + +@responses.activate +def test_latest_when_version_omitted(v1, tmp_path): + responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest/download", body=b"{}") + assert v1.download_artifact_content(ARTIFACT, tmp_path / "a", media_type=MEDIA).read_bytes() == b"{}" + + +@responses.activate +def test_server_download_rejected_on_legacy(legacy, tmp_path): + with pytest.raises(TeaValidationError, match="TEA 1.x"): + legacy.download_artifact_content(ARTIFACT, tmp_path / "a", media_type=MEDIA) + assert len(responses.calls) == 0 + + +@responses.activate +def test_server_download_no_acceptable_format(v1, tmp_path): + responses.get( + f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest/download", status=406, json={"error": "NO_ACCEPTABLE_FORMAT"} + ) + dest = tmp_path / "a" + with pytest.raises(TeaRequestError) as exc_info: + v1.download_artifact_content(ARTIFACT, dest, media_type=MEDIA) + assert exc_info.value.error_type == "NO_ACCEPTABLE_FORMAT" + assert not dest.exists() + + +@responses.activate +def test_server_download_checksum_mismatch_deletes_file(v1, tmp_path): + responses.get(f"{V1_BASE_URL}/artifact/{ARTIFACT}/latest/download", body=b"[]") + dest = tmp_path / "a" + with pytest.raises(TeaChecksumError): + v1.download_artifact_content( + ARTIFACT, + dest, + media_type=MEDIA, + verify_checksums=[Checksum(algorithm_type="SHA-256", algorithm_value=EMPTY_JSON_SHA256)], + ) + assert not dest.exists() + + +@responses.activate +def test_server_download_trusts_private_api_base(tmp_path): + """The first hop is the configured API base, exempt from SSRF checks like every other API call.""" + c = TeaClient("https://10.0.0.5/tea/v1.0.0") + responses.get(f"https://10.0.0.5/tea/v1.0.0/artifact/{ARTIFACT}/latest/download", body=b"{}") + assert c.download_artifact_content(ARTIFACT, tmp_path / "a", media_type=MEDIA).read_bytes() == b"{}" diff --git a/tests/client/test_discovery.py b/tests/client/test_discovery.py index 7f08fdb..049da76 100644 --- a/tests/client/test_discovery.py +++ b/tests/client/test_discovery.py @@ -3,6 +3,7 @@ import responses from pydantic import ValidationError +from libtea.client import TeaClient from libtea.discovery import ( VersionedEndpoint, _is_compatible_version, @@ -94,6 +95,50 @@ def test_tei_with_slash_in_purl_identifier(self): assert identifier == "pkg:maven/org.apache/log4j@2.24.3" +class TestParseTeiUri: + def test_uuid_tei(self): + assert parse_tei("tei://products.example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1") == ( + "uuid", + "products.example.com", + "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", + ) + + def test_hash_identifier_keeps_colon(self): + tei = "tei://example.com/hash/SHA-256:fd44efd601f651c8865acf0dfeacb0df19a2b50ec69ead0262096fd2f67197b9" + assert parse_tei(tei)[2].startswith("SHA-256:") + + def test_unregistered_type_accepted(self): + assert parse_tei("tei://example.com/newtype2/x")[0] == "newtype2" + + def test_domain_not_rewritten(self): + assert parse_tei("tei://Example.com/uuid/x")[1] == "Example.com" + + @pytest.mark.parametrize( + "bad", + [ + "tei://example.com:8443/uuid/x", + "tei://example.com/uuid/a/b", + "tei://example.com/uuid/a%2Fb", + "tei://example.com/uuid/a%2fb", + "tei://example.com/uuid/", + "tei://example.com/UUID/x", + "tei://example.com/1uuid/x", + "tei://example.com/uuid/x?y=1", + "tei://example.com/uuid/x#frag", + "tei://example.com/uuid", + "tei:///uuid/x", + "tei://bad_domain.com/uuid/x", + "TEI://example.com/uuid/x", + ], + ) + def test_rejects_malformed(self, bad): # Review Focus 5 + with pytest.raises(TeaDiscoveryError, match="tei:////"): + parse_tei(bad) + + def test_urn_form_still_parsed(self): + assert parse_tei("urn:tei:uuid:example.com:abc") == ("uuid", "example.com", "abc") + + class TestFetchWellKnown: @responses.activate def test_fetch_well_known_success(self): @@ -245,6 +290,12 @@ def test_fetch_well_known_non_json_raises_discovery_error(self): with pytest.raises(TeaDiscoveryError, match="Invalid JSON"): fetch_well_known("example.com") + @responses.activate + def test_fetch_well_known_deeply_nested_json_raises_discovery_error(self): + responses.get("https://example.com/.well-known/tea", body=b"[" * 100_000) + with pytest.raises(TeaDiscoveryError, match="Invalid JSON"): + fetch_well_known("example.com") + @responses.activate def test_fetch_well_known_invalid_schema_raises_discovery_error(self): responses.get("https://example.com/.well-known/tea", json={"bad": "data"}) @@ -762,3 +813,160 @@ def test_negotiation_emits_warning(self): warnings.simplefilter("always") select_best_endpoint(wk, "0.4.0") assert any("negotiated to server version" in str(warning.message) for warning in w) + + +class TestEndpointNegotiation: + """``TeaClient.from_well_known`` negotiates 1.0.0, then 0.4.0, and keeps the advertised spelling.""" + + API = "https://api.example.com" + + def _discover(self, *endpoints: dict, **kwargs) -> TeaClient: + responses.get("https://example.com/.well-known/tea", json={"schemaVersion": 1, "endpoints": list(endpoints)}) + return TeaClient.from_well_known("example.com", **kwargs) + + @responses.activate + def test_negotiates_highest_supported(self): + responses.head(f"{self.API}/v1.0.0", status=200) + client = self._discover({"url": self.API, "versions": ["0.4.0", "1.0.0"]}) + assert client.spec_version == "1.0.0" + assert client.is_legacy is False + assert responses.calls[1].request.url == f"{self.API}/v1.0.0" + + @responses.activate + def test_falls_back_to_0_4_0(self): + responses.head(f"{self.API}/v0.4.0", status=200) + client = self._discover({"url": self.API, "versions": ["0.4.0"]}) + assert client.spec_version == "0.4.0" + assert client.is_legacy is True + assert responses.calls[1].request.url == f"{self.API}/v0.4.0" + + @responses.activate + def test_build_metadata_spelling_used(self): + responses.head(f"{self.API}/v1.0.0+build.5", status=200) + responses.get( + f"{self.API}/v1.0.0+build.5/discovery", + json=[ + { + "productReleaseUuid": "d4d9f54a-abcf-11ee-ac79-1a52914d44b1", + "servers": [{"rootUrl": self.API, "versions": ["1.0.0+build.5"]}], + } + ], + ) + client = self._discover({"url": self.API, "versions": ["1.0.0+build.5"]}) + assert client.spec_version == "1.0.0+build.5" + assert responses.calls[1].request.url == f"{self.API}/v1.0.0+build.5" + client.discover("tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1") + assert responses.calls[2].request.url.startswith(f"{self.API}/v1.0.0+build.5/discovery?tei=") + + @responses.activate + def test_each_failover_candidate_uses_its_own_spelling(self): + responses.head("https://a.example.com/v1.0.0+a", status=500) + responses.head("https://b.example.com/v1.0.0+b", status=200) + client = self._discover( + {"url": "https://a.example.com", "versions": ["1.0.0+a"], "priority": 1.0}, + {"url": "https://b.example.com", "versions": ["1.0.0+b"], "priority": 0.5}, + ) + assert client.spec_version == "1.0.0+b" + assert [c.request.url for c in responses.calls[1:]] == [ + "https://a.example.com/v1.0.0+a", + "https://b.example.com/v1.0.0+b", + ] + + @responses.activate + def test_fails_over_across_supported_versions(self): + responses.head("https://a.example.com/v1.0.0", status=500) + responses.head("https://b.example.com/v0.4.0", status=200) + client = self._discover( + {"url": "https://a.example.com", "versions": ["1.0.0"]}, + {"url": "https://b.example.com", "versions": ["0.4.0"]}, + ) + assert client.spec_version == "0.4.0" + assert client.is_legacy is True + assert [c.request.url for c in responses.calls[1:]] == [ + "https://a.example.com/v1.0.0", + "https://b.example.com/v0.4.0", + ] + + @responses.activate + def test_exact_supported_version_beats_prerelease(self): + """A 1.0.0 prerelease is not 1.0.0; the exact 0.4.0 match wins and nothing warns.""" + import warnings + + responses.head(f"{self.API}/v1.0.0-rc.1", status=200) + responses.head(f"{self.API}/v0.4.0", status=200) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client = self._discover({"url": self.API, "versions": ["0.4.0", "1.0.0-rc.1"]}) + assert client.spec_version == "0.4.0" + assert client.is_legacy is True + assert [c.request.url for c in responses.calls[1:]] == [f"{self.API}/v0.4.0"] + assert [str(w.message) for w in caught] == [] + + @responses.activate + def test_downgrades_when_no_supported_version_matches_exactly(self): + responses.head(f"{self.API}/v0.3.0", status=200) + with pytest.warns(UserWarning, match="negotiated to server version 0.3.0"): + client = self._discover({"url": self.API, "versions": ["0.3.0"]}) + assert client.spec_version == "0.3.0" + assert client.is_legacy is True + assert [c.request.url for c in responses.calls[1:]] == [f"{self.API}/v0.3.0"] + + @responses.activate + def test_higher_version_beats_higher_priority(self): + responses.head("https://new.example.com/v1.0.0", status=200) + client = self._discover( + {"url": "https://old.example.com", "versions": ["0.4.0"], "priority": 1.0}, + {"url": "https://new.example.com", "versions": ["1.0.0"], "priority": 0.1}, + ) + assert client.spec_version == "1.0.0" + assert [c.request.url for c in responses.calls[1:]] == ["https://new.example.com/v1.0.0"] + + @responses.activate + def test_all_attempts_fail_lists_every_url_tried(self): + responses.head("https://a.example.com/v1.0.0", status=500) + responses.head("https://b.example.com/v0.4.0", body=requests.ConnectionError("refused")) + with pytest.raises(TeaDiscoveryError, match="All 2 endpoint") as exc_info: + self._discover( + {"url": "https://a.example.com", "versions": ["1.0.0"]}, + {"url": "https://b.example.com", "versions": ["0.4.0"]}, + ) + assert "https://a.example.com/v1.0.0" in str(exc_info.value) + assert "https://b.example.com/v0.4.0" in str(exc_info.value) + + @responses.activate + def test_explicit_version_kept(self): + responses.head(f"{self.API}/v0.4.0", status=200) + client = self._discover({"url": self.API, "versions": ["1.0.0", "0.4.0"]}, version="0.4.0") + assert client.spec_version == "0.4.0" + assert client.is_legacy is True + assert responses.calls[1].request.url == f"{self.API}/v0.4.0" + + @responses.activate + def test_explicit_version_without_match_does_not_try_other_versions(self): + with pytest.raises(TeaDiscoveryError, match="No .*endpoint found for version '1.0.0'"): + self._discover({"url": self.API, "versions": ["0.4.0"]}, version="1.0.0") + assert len(responses.calls) == 1 + + @responses.activate + def test_unsupported_explicit_version_raises_discovery_error_without_probing(self): + with pytest.raises(TeaDiscoveryError, match="Unsupported TEA spec version") as exc_info: + self._discover({"url": self.API, "versions": ["2.0.0"]}, version="2.0.0") + assert isinstance(exc_info.value.__cause__, ValueError) + assert len(responses.calls) == 1 # only .well-known/tea was fetched + + @responses.activate + def test_no_supported_version_lists_tried_and_offered(self): + with pytest.raises(TeaDiscoveryError) as exc_info: + self._discover({"url": self.API, "versions": ["2.0.0", "0.9.0-x"]}) + message = str(exc_info.value) + assert "'1.0.0', '0.4.0'" in message + assert "'2.0.0'" in message + + def test_select_best_endpoint_returns_advertised_spelling(self): + wk = TeaWellKnown( + schema_version=1, + endpoints=[TeaEndpoint(url="https://api.example.com", versions=["not-semver", "1.0.0+build.5"])], + ) + assert select_best_endpoint(wk, "1.0.0").matched_version == "1.0.0+build.5" + with pytest.warns(UserWarning, match="negotiated to server version"): + assert select_best_endpoint(wk, "1.1.0").matched_version == "1.0.0+build.5" diff --git a/tests/client/test_download.py b/tests/client/test_download.py index 9363419..cbe84bb 100644 --- a/tests/client/test_download.py +++ b/tests/client/test_download.py @@ -3,8 +3,10 @@ import pytest import responses +from libtea._http import TeaHttpClient from libtea.exceptions import TeaChecksumError from libtea.models import Checksum, ChecksumAlgorithm +from tests.spec_data import V1_BASE_URL ARTIFACT_URL = "https://artifacts.example.com/sbom.json" ARTIFACT_CONTENT = b'{"bomFormat": "CycloneDX", "specVersion": "1.5"}' @@ -118,3 +120,18 @@ def test_multi_checksum_partial_failure(self, client, tmp_path): with pytest.raises(TeaChecksumError, match="SHA-256"): client.download_artifact(ARTIFACT_URL, dest, verify_checksums=checksums) assert not dest.exists() + + +@responses.activate +def test_authenticated_off_base_rejected(tmp_path): + with TeaHttpClient(base_url=V1_BASE_URL, token="secret") as h, pytest.raises(ValueError, match="API base"): + h.download_with_hashes("https://evil.example.com/x", tmp_path / "x", authenticated=True) + assert len(responses.calls) == 0 + + +@responses.activate +@pytest.mark.parametrize("url", ["https://api.example.com.evil.com/x", "https://api.example.com@evil.com/x"]) +def test_authenticated_lookalike_host_rejected(tmp_path, url): + with TeaHttpClient(base_url="https://api.example.com", token="secret") as h, pytest.raises(ValueError): + h.download_with_hashes(url, tmp_path / "x", authenticated=True) + assert len(responses.calls) == 0 diff --git a/tests/client/test_http.py b/tests/client/test_http.py index 1d63ef8..d103025 100644 --- a/tests/client/test_http.py +++ b/tests/client/test_http.py @@ -20,6 +20,7 @@ TeaServerError, TeaValidationError, ) +from tests.spec_data import LEGACY_BASE_URL class TestTeaHttpClient: @@ -128,6 +129,13 @@ def test_get_json_non_json_response_raises_validation_error(self, http_client, b with pytest.raises(TeaValidationError, match="Invalid JSON"): http_client.get_json("/product/abc") + @responses.activate + def test_get_json_deeply_nested_body_raises_validation_error(self, http_client, base_url): + """json.loads raises RecursionError, not ValueError, when nesting exceeds the recursion limit.""" + responses.get(f"{base_url}/product/abc", body=b"[" * 100_000, status=200) + with pytest.raises(TeaValidationError, match="Invalid JSON"): + http_client.get_json("/product/abc") + @responses.activate def test_download_creates_parent_directories(self, http_client, tmp_path): content = b"nested file" @@ -314,7 +322,85 @@ def test_404_with_json_array_body(self, http_client, base_url): assert exc_info.value.error_type is None -BASE_URL = "https://api.example.com/tea/v1" +class TestErrorTypeFromBody: + """Every 4xx surfaces the TEA ``error`` value when the body is a JSON object with a string ``error``.""" + + @responses.activate + @pytest.mark.parametrize("status, error", [(400, "INVALID_PAGE_TOKEN"), (406, "NO_ACCEPTABLE_FORMAT")]) + def test_4xx_carries_error_type(self, http_client, status, error): + responses.get(f"{LEGACY_BASE_URL}/products", status=status, json={"error": error}) + with pytest.raises(TeaRequestError) as exc: + http_client.get_json("/products") + assert exc.value.error_type == error + assert error in str(exc.value) + + @responses.activate + def test_400_deeply_nested_body_has_no_error_type(self, http_client): + """json.loads raises RecursionError (not ValueError) on deep nesting under Python 3.11.""" + responses.get(f"{LEGACY_BASE_URL}/products", status=400, body=b"[" * 4000) + with pytest.raises(TeaRequestError) as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + + @responses.activate + def test_unknown_error_value_passes_through(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=404, json={"error": "SOMETHING_NEW"}) + with pytest.raises(TeaNotFoundError) as exc: + http_client.get_json("/products") + assert exc.value.error_type == "SOMETHING_NEW" + + @responses.activate + def test_404_non_string_error_ignored(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=404, json={"error": 5}) + with pytest.raises(TeaNotFoundError) as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + + @responses.activate + def test_400_non_string_error_ignored(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=400, json={"error": ["INVALID_REQUEST"]}) + with pytest.raises(TeaRequestError) as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + + @responses.activate + def test_400_non_json_body_has_no_error_type(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=400, body="Bad Request") + with pytest.raises(TeaRequestError, match="Bad Request") as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + + @responses.activate + @pytest.mark.parametrize("status, exc_type", [(400, TeaRequestError), (404, TeaNotFoundError)]) + def test_long_message_keeps_error_type(self, http_client, status, exc_type): + """A body past the 200-char snippet limit still yields its error type; the message stays truncated.""" + body = {"error": "INVALID_PAGE_TOKEN", "message": "m" * 500} + responses.get(f"{LEGACY_BASE_URL}/products", status=status, json=body) + with pytest.raises(exc_type) as exc: + http_client.get_json("/products") + assert exc.value.error_type == "INVALID_PAGE_TOKEN" + if exc_type is TeaRequestError: + assert "(truncated)" in str(exc.value) + assert "m" * 300 not in str(exc.value) + + @responses.activate + def test_error_key_past_read_bound_not_found(self, http_client): + """Only the first 4 KiB of a 4xx body is read, so an ``error`` key after it is not seen.""" + body = '{"padding": "' + "x" * 5000 + '", "error": "INVALID_REQUEST"}' + responses.get(f"{LEGACY_BASE_URL}/products", status=400, body=body) + with pytest.raises(TeaRequestError) as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + + @responses.activate + def test_3xx_has_no_error_type(self, http_client): + responses.get(f"{LEGACY_BASE_URL}/products", status=301) + with pytest.raises(TeaRequestError) as exc: + http_client.get_json("/products") + assert exc.value.error_type is None + + +BASE_URL = "https://api.example.com/tea/v0.4.0" class TestBasicAuth: @@ -492,10 +578,10 @@ class TestResponseSizeLimit: @responses.activate def test_rejects_oversized_content_length(self): """Content-Length header advertising oversized body triggers rejection.""" - client = TeaHttpClient("https://api.example.com/v1") + client = TeaHttpClient("https://api.example.com/v0.4.0") client._max_response_bytes = 5 # Very small limit responses.get( - "https://api.example.com/v1/product/abc", + "https://api.example.com/v0.4.0/product/abc", json={"uuid": "abc"}, status=200, ) @@ -506,10 +592,10 @@ def test_rejects_oversized_content_length(self): @responses.activate def test_rejects_oversized_body(self): """Body exceeding limit is rejected even without Content-Length.""" - client = TeaHttpClient("https://api.example.com/v1") + client = TeaHttpClient("https://api.example.com/v0.4.0") client._max_response_bytes = 100 large_body = b'{"data": "' + b"x" * 200 + b'"}' - responses.get("https://api.example.com/v1/product/abc", body=large_body, status=200) + responses.get("https://api.example.com/v0.4.0/product/abc", body=large_body, status=200) with pytest.raises(TeaValidationError, match="exceeds limit"): client.get_json("/product/abc") client.close() @@ -517,8 +603,8 @@ def test_rejects_oversized_body(self): @responses.activate def test_normal_response_passes(self): """Normal-sized responses pass the size check.""" - client = TeaHttpClient("https://api.example.com/v1") - responses.get("https://api.example.com/v1/product/abc", json={"uuid": "abc"}, status=200) + client = TeaHttpClient("https://api.example.com/v0.4.0") + responses.get("https://api.example.com/v0.4.0/product/abc", json={"uuid": "abc"}, status=200) result = client.get_json("/product/abc") assert result == {"uuid": "abc"} client.close() @@ -536,12 +622,12 @@ def test_gzip_response_decoded(self): payload = json.dumps({"uuid": "abc", "name": "Gzipped"}).encode() compressed = gzip.compress(payload) responses.get( - "https://api.example.com/v1/product/abc", + "https://api.example.com/v0.4.0/product/abc", body=compressed, headers={"Content-Encoding": "gzip", "Content-Type": "application/json"}, status=200, ) - client = TeaHttpClient("https://api.example.com/v1") + client = TeaHttpClient("https://api.example.com/v0.4.0") result = client.get_json("/product/abc") assert result["name"] == "Gzipped" client.close() diff --git a/tests/client/test_iterators.py b/tests/client/test_iterators.py index a3897c1..6137c08 100644 --- a/tests/client/test_iterators.py +++ b/tests/client/test_iterators.py @@ -2,11 +2,13 @@ import pytest import responses +from responses import matchers from libtea.client import TeaClient +from libtea.exceptions import TeaServerError from libtea.models import Component, Product, ProductRelease, Release -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" def _product_json(uuid: str, name: str) -> dict: @@ -22,6 +24,42 @@ def _release_json(uuid: str, version: str) -> dict: } +def _legacy_page(start: int, total: int, names: list[str]) -> dict: + return { + "timestamp": "2024-03-20T15:30:00Z", + "pageStartIndex": start, + "pageSize": 2, + "totalResults": total, + "results": [_product_json("a1b2c3d4-e5f6-7890-abcd-ef1234567890", n) for n in names], + } + + +class TestLegacyOffsetTokens: + @responses.activate + def test_second_page_requested_at_next_offset(self): + query = {"idType": "PURL", "idValue": "pkg:pypi/foo", "pageSize": "2"} + responses.get( + f"{BASE_URL}/products", + match=[matchers.query_param_matcher({**query, "pageOffset": "0"})], + json=_legacy_page(0, 3, ["P1", "P2"]), + ) + responses.get( + f"{BASE_URL}/products", + match=[matchers.query_param_matcher({**query, "pageOffset": "2"})], + json=_legacy_page(2, 3, ["P3"]), + ) + with TeaClient(BASE_URL) as client: + names = [p.name for p in client.iter_products("PURL", "pkg:pypi/foo", page_size=2)] + assert names == ["P1", "P2", "P3"] + assert len(responses.calls) == 2 + + @responses.activate + def test_server_ignoring_offset_raises(self): + responses.get(f"{BASE_URL}/products", json=_legacy_page(0, 3, ["P1", "P2"])) + with TeaClient(BASE_URL) as client, pytest.raises(TeaServerError, match="repeated"): + list(client.iter_products("PURL", "pkg:pypi/foo", page_size=2)) + + class TestIterProducts: @responses.activate def test_iter_products_single_page(self): @@ -343,7 +381,7 @@ def test_get_artifacts_bulk(self): ] for uid in uuids: responses.get( - f"{BASE_URL}/artifact/{uid}/latest", + f"{BASE_URL}/artifact/{uid}", json={"uuid": uid, "name": "SBOM", "type": "BOM", "formats": []}, ) with TeaClient(BASE_URL) as client: diff --git a/tests/conftest.py b/tests/conftest.py index 7518e12..acb13b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,8 +5,9 @@ from libtea._http import TeaHttpClient from libtea.client import TeaClient +from tests.spec_data import LEGACY_BASE_URL, V1_BASE_URL -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" # Public IP used by getaddrinfo mock so SSRF validation passes for test hostnames. _FAKE_PUBLIC_ADDR = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))] @@ -38,6 +39,20 @@ def client(): c.close() +@pytest.fixture +def v1(): + c = TeaClient(V1_BASE_URL) + yield c + c.close() + + +@pytest.fixture +def legacy(): + c = TeaClient(LEGACY_BASE_URL) + yield c + c.close() + + @pytest.fixture def http_client(): c = TeaHttpClient(base_url=BASE_URL) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index de5bb4c..ce0e72a 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -23,7 +23,7 @@ "identifiers": [{"idType": "PURL", "idValue": "pkg:maven/org.apache.tomcat/tomcat@11.0.7"}], "distributions": [ { - "distributionType": "zip", + "distributionId": "6a0d58e1-4896-4f1e-83f7-5feb6c032537", "description": "Core binary distribution, zip archive", "identifiers": [{"idType": "PURL", "idValue": "pkg:maven/org.apache.tomcat/tomcat@11.0.7?type=zip"}], "checksums": [ @@ -81,8 +81,7 @@ } -# v0.4.0 payload variants: distributionId replaces distributionType, -# distributionIds replaces distributionTypes on artifacts. +# v0.4.0 payload variants with distributionId and artifact distributionIds. TOMCAT_RELEASE_V040 = { "uuid": "605d0ecb-1057-40e4-9abf-c400b10f0345", "version": "11.0.7", @@ -150,7 +149,7 @@ def test_full_consumer_flow(self, base_url): # Step 2: Get component release with collection cr = client.get_component_release(release_uuid) assert cr.release.version == "11.0.7" - assert cr.release.distributions[0].distribution_type == "zip" + assert cr.release.distributions[0].distribution_id == "6a0d58e1-4896-4f1e-83f7-5feb6c032537" assert cr.release.distributions[0].checksums[0].algorithm_type == ChecksumAlgorithm.SHA_256 # Step 3: Get latest collection @@ -168,7 +167,7 @@ def test_full_consumer_flow(self, base_url): @responses.activate def test_v040_server_payload(self, base_url): - """v0.4.0 server sends distributionId and distributionIds instead of legacy fields.""" + """v0.4.0 server sends distributionId and distributionIds.""" release_uuid = TOMCAT_RELEASE_V040["uuid"] responses.get( @@ -182,42 +181,6 @@ def test_v040_server_payload(self, base_url): with TeaClient(base_url=base_url) as client: cr = client.get_component_release(release_uuid) - # New v0.4.0 field assert cr.release.distributions[0].distribution_id == "d7a8e9f0-1234-5678-abcd-ef0123456789" - # Legacy field absent - assert cr.release.distributions[0].distribution_type is None - - # Artifact uses distributionIds (new) not distributionTypes (legacy) sbom = cr.latest_collection.artifacts[0] assert sbom.distribution_ids == ("d7a8e9f0-1234-5678-abcd-ef0123456789",) - assert sbom.distribution_types is None - - @responses.activate - def test_mixed_server_payload(self, base_url): - """Transitional server sends both old and new fields — both preserved.""" - mixed_release = { - **TOMCAT_RELEASE_V040, - "distributions": [ - { - "distributionId": "d7a8e9f0-1234-5678-abcd-ef0123456789", - "distributionType": "zip", - "description": "Both fields present", - } - ], - } - release_uuid = mixed_release["uuid"] - - responses.get( - f"{base_url}/componentRelease/{release_uuid}", - json={ - "release": mixed_release, - "latestCollection": LOG4J_COLLECTION, - }, - ) - - with TeaClient(base_url=base_url) as client: - cr = client.get_component_release(release_uuid) - dist = cr.release.distributions[0] - # Both fields preserved - assert dist.distribution_id == "d7a8e9f0-1234-5678-abcd-ef0123456789" - assert dist.distribution_type == "zip" diff --git a/tests/spec_data.py b/tests/spec_data.py new file mode 100644 index 0000000..f66c6b1 --- /dev/null +++ b/tests/spec_data.py @@ -0,0 +1,50 @@ +LEGACY_BASE_URL = "https://api.example.com/tea/v0.4.0" +V1_BASE_URL = "https://api.example.com/tea/v1.0.0" +LEGACY = {"tea_legacy": True} + +PRODUCT = "09e8c73b-ac45-4475-acac-33e6a7314e6d" +PRODUCT_RELEASE = "123e4567-e89b-12d3-a456-426614174000" +COMPONENT = "b844c9bd-55d6-478c-af59-954a932b6ad3" +COMPONENT_RELEASE = "da89e38e-95e7-44ca-aa7d-f3b6b34c7fab" +ARTIFACT = "1cb47b95-8bf8-3bad-a5a4-0d54d86e10ce" +SHA256_HEX = "e04c9d55986d7194822eaa4f8115a77f801844d807ad6e0d454ac31dd41861e5" +MD5_HEX = "d41d8cd98f00b204e9800998ecf8427e" + +V1_ARTIFACT = { + "uuid": ARTIFACT, + "version": 2, + "type": "BOM", + "createdDate": "2024-12-13T00:00:00Z", + "formats": [ + { + "mediaType": "application/vnd.cyclonedx+xml", + "url": "https://repo.example.com/sbom.xml", + "checksums": [{"algType": "SHA-256", "algValue": SHA256_HEX}], + } + ], +} +V1_COLLECTION = { + "uuid": COMPONENT_RELEASE, + "version": 2, + "createdDate": "2025-05-12T18:08:00Z", + "belongsTo": "COMPONENT_RELEASE", + "updateReason": {"type": "VEX_UPDATED"}, + "artifacts": [V1_ARTIFACT], +} +V1_RELEASE = { + "uuid": COMPONENT_RELEASE, + "version": "2.24.3", + "createdDate": "2024-12-10T10:51:00Z", + "component": COMPONENT, +} +V1_PRODUCT_RELEASE = { + "uuid": PRODUCT_RELEASE, + "version": "2.24.3", + "createdDate": "2025-04-01T15:43:00Z", + "product": PRODUCT, + "components": [{"uuid": COMPONENT}], +} + + +def product(uuid: str, name: str) -> dict: + return {"uuid": uuid, "name": name, "identifiers": []} diff --git a/tests/unit/test_conformance_checks.py b/tests/unit/test_conformance_checks.py index 892c4f1..d736d8a 100644 --- a/tests/unit/test_conformance_checks.py +++ b/tests/unit/test_conformance_checks.py @@ -1,4 +1,5 @@ import responses +from responses import matchers from libtea.client import TeaClient from libtea.conformance._checks import ( @@ -23,6 +24,7 @@ check_list_product_releases, check_list_products, check_pagination_fields, + check_pagination_token, check_product_cle, check_product_release_cle, check_product_release_collection_latest, @@ -34,8 +36,17 @@ check_uuid_format, ) from libtea.conformance._types import CheckStatus +from tests.spec_data import ( + COMPONENT, + PRODUCT, + PRODUCT_RELEASE, + V1_BASE_URL, + V1_COLLECTION, + V1_PRODUCT_RELEASE, + product, +) -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" # Reusable response payloads --------------------------------------------------- @@ -120,7 +131,7 @@ _COLLECTION_JSON = { "version": 1, - "artifacts": [{"uuid": _ARTIFACT_UUID, "name": "sbom.json", "formats": []}], + "artifacts": [{"uuid": _ARTIFACT_UUID, "name": "sbom.json", "type": "BOM", "formats": []}], } _COLLECTION_NO_ARTIFACTS_JSON = { @@ -154,6 +165,7 @@ _ARTIFACT_JSON = { "uuid": _ARTIFACT_UUID, "name": "sbom.json", + "type": "BOM", "formats": [], } @@ -255,6 +267,14 @@ def test_fail_empty_list(self): result = check_discovery(client, ctx) assert result.status == CheckStatus.FAIL + @responses.activate + def test_v1_fail_empty_array(self, v1): + responses.get(f"{V1_BASE_URL}/discovery", json=[]) + result = check_discovery(v1, CheckContext(tei="tei://example.com/uuid/d4d9f54a-abcf-11ee-ac79-1a52914d44b1")) + assert result.status == CheckStatus.FAIL + assert "must be a non-empty array" in result.message + assert "Unexpected error" not in result.message + @responses.activate def test_does_not_overwrite_existing_uuid(self): responses.get(f"{BASE_URL}/discovery", json=_DISCOVERY_RESULT) @@ -274,6 +294,50 @@ def test_fail_on_server_error(self): class TestCheckDiscovery404: + @responses.activate + def test_v1_uses_tei_uri(self, v1): + responses.get(f"{V1_BASE_URL}/discovery", status=404, json={"error": "OBJECT_UNKNOWN"}) + result = check_discovery_404(v1, CheckContext()) + assert result.status == CheckStatus.PASS + assert len(responses.calls) == 1 + assert "tei%3A%2F%2Fnonexistent.example.com%2Fuuid%2F" + _ZERO_UUID in responses.calls[0].request.url + + @responses.activate + def test_v1_fail_on_404_without_tea_error_body(self, v1): + """A bare 404 (e.g. an unmounted path) is not a TEA "not found" answer on 1.0.0.""" + responses.get(f"{V1_BASE_URL}/discovery", status=404, body="nginx") + result = check_discovery_404(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert "TEA 1.0.0 requires a TEA error body" in result.message + + @responses.activate + def test_legacy_passes_on_404_without_tea_error_body(self, legacy): + responses.get(f"{BASE_URL}/discovery", status=404, body="nginx") + assert check_discovery_404(legacy, CheckContext()).status == CheckStatus.PASS + + @responses.activate + def test_legacy_keeps_urn(self, legacy): + responses.get(f"{BASE_URL}/discovery", status=404, json={"error": "OBJECT_UNKNOWN"}) + result = check_discovery_404(legacy, CheckContext()) + assert result.status == CheckStatus.PASS + assert len(responses.calls) == 1 + assert "urn%3Atei%3Auuid%3Anonexistent.example.com" in responses.calls[0].request.url + + @responses.activate + def test_v1_fail_on_empty_array(self, v1): + responses.get(f"{V1_BASE_URL}/discovery", json=[]) + result = check_discovery_404(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert "must be a non-empty array" in result.message + assert "Unexpected error" not in result.message + + @responses.activate + def test_v1_fail_when_results_returned(self, v1): + responses.get(f"{V1_BASE_URL}/discovery", json=_DISCOVERY_RESULT) + result = check_discovery_404(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert result.message == "Expected 404, got 1 result(s)" + @responses.activate def test_pass_on_404(self): responses.get(f"{BASE_URL}/discovery", json={"error": "OBJECT_UNKNOWN"}, status=404) @@ -494,6 +558,25 @@ def test_fail_no_results(self): assert result.status == CheckStatus.FAIL assert "no results" in result.message + @responses.activate + def test_v1_follows_token_past_empty_first_page(self, v1): + known = {**product(PRODUCT, "A"), "identifiers": [{"idType": "PURL", "idValue": "pkg:x"}]} + responses.get(f"{V1_BASE_URL}/product/{PRODUCT}", json=known) + search = {"idType": "PURL", "idValue": "pkg:x", "pageSize": "100"} + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher(search)], + json={"hasNext": True, "nextPageToken": "t2", "results": []}, + ) + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({**search, "pageToken": "t2"})], + json={"hasNext": False, "results": [known]}, + ) + result = check_search_products(v1, CheckContext(product_uuid=PRODUCT)) + assert result.status == CheckStatus.PASS + assert len(responses.calls) == 3 + class TestCheckProductReleases: @responses.activate @@ -638,6 +721,28 @@ def test_fail_no_results(self): assert result.status == CheckStatus.FAIL assert "no results" in result.message + @responses.activate + def test_v1_follows_token_past_empty_first_page(self, v1): + release = {**V1_PRODUCT_RELEASE, "identifiers": [{"idType": "PURL", "idValue": "pkg:x@1"}]} + responses.get( + f"{V1_BASE_URL}/productRelease/{PRODUCT_RELEASE}", + json={**release, "latestCollection": {**V1_COLLECTION, "belongsTo": "PRODUCT_RELEASE"}}, + ) + search = {"idType": "PURL", "idValue": "pkg:x@1", "pageSize": "100"} + responses.get( + f"{V1_BASE_URL}/productReleases", + match=[matchers.query_param_matcher(search)], + json={"hasNext": True, "nextPageToken": "t2", "results": []}, + ) + responses.get( + f"{V1_BASE_URL}/productReleases", + match=[matchers.query_param_matcher({**search, "pageToken": "t2"})], + json={"hasNext": False, "results": [release]}, + ) + result = check_search_product_releases(v1, CheckContext(product_release_uuid=PRODUCT_RELEASE)) + assert result.status == CheckStatus.PASS + assert len(responses.calls) == 3 + class TestCheckGetProductRelease: @responses.activate @@ -734,43 +839,6 @@ def test_does_not_overwrite_existing_artifact_uuid(self): check_product_release_collection_latest(client, ctx) assert ctx.artifact_uuid == "existing-uuid" - @responses.activate - def test_skips_artifact_with_no_uuid(self): - collection_json = { - "version": 1, - "artifacts": [ - {"name": "no-uuid.json", "formats": []}, - {"uuid": _ARTIFACT_UUID, "name": "sbom.json", "formats": []}, - ], - } - responses.get( - f"{BASE_URL}/productRelease/{_RELEASE_UUID}/collection/latest", - json=collection_json, - ) - client = _make_client() - ctx = CheckContext(product_release_uuid=_RELEASE_UUID) - check_product_release_collection_latest(client, ctx) - assert ctx.artifact_uuid == _ARTIFACT_UUID - - @responses.activate - def test_all_artifacts_without_uuid(self): - collection_json = { - "version": 1, - "artifacts": [ - {"name": "no-uuid-1.json", "formats": []}, - {"name": "no-uuid-2.json", "formats": []}, - ], - } - responses.get( - f"{BASE_URL}/productRelease/{_RELEASE_UUID}/collection/latest", - json=collection_json, - ) - client = _make_client() - ctx = CheckContext(product_release_uuid=_RELEASE_UUID) - result = check_product_release_collection_latest(client, ctx) - assert result.status == CheckStatus.PASS - assert ctx.artifact_uuid is None - class TestCheckProductReleaseCollections: @responses.activate @@ -966,49 +1034,6 @@ def test_does_not_overwrite_existing_artifact_uuid(self): check_component_release(client, ctx) assert ctx.artifact_uuid == "existing-uuid" - @responses.activate - def test_skips_artifact_with_no_uuid(self): - json_data = { - "release": _RELEASE_JSON, - "latestCollection": { - "version": 1, - "artifacts": [ - {"name": "no-uuid.json", "formats": []}, - {"uuid": _ARTIFACT_UUID, "name": "sbom.json", "formats": []}, - ], - }, - } - responses.get( - f"{BASE_URL}/componentRelease/{_COMPONENT_RELEASE_UUID}", - json=json_data, - ) - client = _make_client() - ctx = CheckContext(component_release_uuid=_COMPONENT_RELEASE_UUID) - check_component_release(client, ctx) - assert ctx.artifact_uuid == _ARTIFACT_UUID - - @responses.activate - def test_all_artifacts_without_uuid(self): - json_data = { - "release": _RELEASE_JSON, - "latestCollection": { - "version": 1, - "artifacts": [ - {"name": "no-uuid-1.json", "formats": []}, - {"name": "no-uuid-2.json", "formats": []}, - ], - }, - } - responses.get( - f"{BASE_URL}/componentRelease/{_COMPONENT_RELEASE_UUID}", - json=json_data, - ) - client = _make_client() - ctx = CheckContext(component_release_uuid=_COMPONENT_RELEASE_UUID) - result = check_component_release(client, ctx) - assert result.status == CheckStatus.PASS - assert ctx.artifact_uuid is None - class TestCheckComponentReleaseCollections: @responses.activate @@ -1043,7 +1068,7 @@ def test_fail_on_server_error(self): class TestCheckGetArtifact: @responses.activate def test_pass(self): - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", json=_ARTIFACT_JSON) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", json=_ARTIFACT_JSON) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_get_artifact(client, ctx) @@ -1058,7 +1083,7 @@ def test_skip_no_uuid(self): @responses.activate def test_fail_on_server_error(self): - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", status=500) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", status=500) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_get_artifact(client, ctx) @@ -1066,8 +1091,8 @@ def test_fail_on_server_error(self): @responses.activate def test_pass_artifact_without_name(self): - artifact_no_name = {"uuid": _ARTIFACT_UUID, "formats": []} - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", json=artifact_no_name) + artifact_no_name = {"uuid": _ARTIFACT_UUID, "type": "BOM", "formats": []} + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", json=artifact_no_name) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_get_artifact(client, ctx) @@ -1339,6 +1364,76 @@ def test_fail_on_page_size_mismatch(self): assert result.status == CheckStatus.FAIL assert "page_size" in result.details + @responses.activate + def test_v1_pass_within_page_size(self, v1): + responses.get( + f"{V1_BASE_URL}/products", json={"hasNext": True, "nextPageToken": "t", "results": [_PRODUCT_JSON]} + ) + result = check_pagination_fields(v1, CheckContext()) + assert result.status == CheckStatus.PASS + assert result.message == "Page size respected (1 result(s) for pageSize=10)" + + @responses.activate + def test_v1_fail_when_page_overflows(self, v1): + responses.get(f"{V1_BASE_URL}/products", json={"hasNext": False, "results": [_PRODUCT_JSON] * 11}) + result = check_pagination_fields(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert "got 11 results" in result.details + + +class TestCheckPaginationToken: + @responses.activate + def test_pass(self, v1): + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "1"})], + json={"hasNext": True, "nextPageToken": "t", "results": [product(PRODUCT, "A")]}, + ) + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "1", "pageToken": "t"})], + json={"hasNext": False, "results": [product(COMPONENT, "B")]}, + ) + result = check_pagination_token(v1, CheckContext()) + assert result.status == CheckStatus.PASS + assert len(responses.calls) == 2 + + @responses.activate + def test_same_page_fails(self, v1): + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "1"})], + json={"hasNext": True, "nextPageToken": "t", "results": [product(PRODUCT, "A")]}, + ) + responses.get( + f"{V1_BASE_URL}/products", + match=[matchers.query_param_matcher({"pageSize": "1", "pageToken": "t"})], + json={"hasNext": False, "results": [product(PRODUCT, "A")]}, + ) + result = check_pagination_token(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert len(responses.calls) == 2 + + @responses.activate + def test_single_page_skips(self, v1): + responses.get(f"{V1_BASE_URL}/products", json={"hasNext": False, "results": []}) + result = check_pagination_token(v1, CheckContext()) + assert result.status == CheckStatus.SKIP + assert len(responses.calls) == 1 + + @responses.activate + def test_skips_legacy(self, legacy): + result = check_pagination_token(legacy, CheckContext()) + assert result.status == CheckStatus.SKIP + assert len(responses.calls) == 0 + + @responses.activate + def test_fail_on_server_error(self, v1): + responses.get(f"{V1_BASE_URL}/products", status=500) + result = check_pagination_token(v1, CheckContext()) + assert result.status == CheckStatus.FAIL + assert "list_products() failed" in result.message + class TestCheckCamelCaseFields: def test_pass_with_collected_uuids(self): @@ -1366,9 +1461,10 @@ def test_pass_with_formats(self): artifact_with_formats = { "uuid": _ARTIFACT_UUID, "name": "sbom.json", + "type": "BOM", "formats": [{"mediaType": "application/json", "url": "https://example.com/sbom.json"}], } - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", json=artifact_with_formats) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", json=artifact_with_formats) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_artifact_formats_required(client, ctx) @@ -1377,7 +1473,7 @@ def test_pass_with_formats(self): @responses.activate def test_warn_no_formats(self): - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", json=_ARTIFACT_JSON) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", json=_ARTIFACT_JSON) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_artifact_formats_required(client, ctx) @@ -1392,7 +1488,7 @@ def test_skip_no_uuid(self): @responses.activate def test_fail_on_server_error(self): - responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}/latest", status=500) + responses.get(f"{BASE_URL}/artifact/{_ARTIFACT_UUID}", status=500) client = _make_client() ctx = CheckContext(artifact_uuid=_ARTIFACT_UUID) result = check_artifact_formats_required(client, ctx) @@ -1431,7 +1527,9 @@ def test_discovery_checks_come_first(self): assert ALL_CHECKS[1].__name__ == "check_discovery_404" def test_cross_cutting_checks_come_last(self): - last_three = [c.__name__ for c in ALL_CHECKS[-3:]] - assert "check_uuid_format" in last_three - assert "check_pagination_fields" in last_three - assert "check_camel_case_fields" in last_three + assert [c.__name__ for c in ALL_CHECKS[-4:]] == [ + "check_uuid_format", + "check_pagination_fields", + "check_pagination_token", + "check_camel_case_fields", + ] diff --git a/tests/unit/test_conformance_plugin.py b/tests/unit/test_conformance_plugin.py index 192c442..087bb8a 100644 --- a/tests/unit/test_conformance_plugin.py +++ b/tests/unit/test_conformance_plugin.py @@ -144,8 +144,8 @@ def test_client_created_with_correct_params(self) -> None: # The fixture extracts base_url, token, and timeout from config. # We test the same logic directly. - client = TeaClient(base_url="https://tea.example.com/v1", token="secret", timeout=15.0) - assert client._http._base_url == "https://tea.example.com/v1" + client = TeaClient(base_url="https://tea.example.com/v0.4.0", token="secret", timeout=15.0) + assert client._http._base_url == "https://tea.example.com/v0.4.0" client.close() diff --git a/tests/unit/test_conformance_runner.py b/tests/unit/test_conformance_runner.py index 89db020..ecca43c 100644 --- a/tests/unit/test_conformance_runner.py +++ b/tests/unit/test_conformance_runner.py @@ -2,7 +2,7 @@ from libtea.conformance import ConformanceResult, run_conformance -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" _PAGINATED_EMPTY = { "timestamp": "2024-01-01T00:00:00Z", diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 8f375fb..c2f8495 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -1,8 +1,11 @@ +from datetime import datetime, timezone + import pytest from pydantic import ValidationError from libtea.models import ( CLE, + Artifact, ArtifactFormat, ArtifactType, Checksum, @@ -15,17 +18,32 @@ CollectionUpdateReasonType, ComplianceDocumentType, Component, + ComponentReleaseWithCollection, ErrorResponse, ErrorType, Identifier, IdentifierType, + PaginatedCollectionResponse, PaginatedComponentReleaseResponse, PaginatedComponentResponse, PaginatedProductResponse, Product, + ProductRelease, + ProductReleaseWithCollection, Release, TeiType, ) +from tests.spec_data import ( + COMPONENT_RELEASE, + LEGACY, + MD5_HEX, + PRODUCT, + V1_ARTIFACT, + V1_COLLECTION, + V1_PRODUCT_RELEASE, + V1_RELEASE, + product, +) class TestEnums: @@ -138,7 +156,7 @@ def test_underscore_to_value(self, raw, expected_member): def test_valid_values_pass_through(self): for member in ChecksumAlgorithm: - cs = Checksum.model_validate({"algType": member.value, "algValue": "aabbcc"}) + cs = Checksum.model_validate({"algType": member.value, "algValue": "aabbcc"}, context=LEGACY) assert cs.algorithm_type == member @@ -202,6 +220,17 @@ def test_error_type_values(self): assert ErrorType.OBJECT_NOT_SHAREABLE == "OBJECT_NOT_SHAREABLE" assert isinstance(ErrorType.OBJECT_UNKNOWN, str) + def test_error_type_1_0_0_members(self): + assert {e.value for e in ErrorType} == { + "OBJECT_UNKNOWN", + "OBJECT_NOT_SHAREABLE", + "NOT_IMPLEMENTED", + "NO_ACCEPTABLE_FORMAT", + "SIGNATURE_NOT_FOUND", + "INVALID_REQUEST", + "INVALID_PAGE_TOKEN", + } + class TestProduct: def test_product_from_json(self): @@ -241,7 +270,7 @@ def test_release_from_json(self): "identifiers": [{"idType": "PURL", "idValue": "pkg:maven/org.apache.tomcat/tomcat@11.0.7"}], "distributions": [ { - "distributionType": "zip", + "distributionId": "zip", "description": "Core binary distribution", "checksums": [ { @@ -253,9 +282,9 @@ def test_release_from_json(self): } ], } - release = Release.model_validate(data) + release = Release.model_validate(data, context=LEGACY) assert release.version == "11.0.7" - assert release.distributions[0].distribution_type == "zip" + assert release.distributions[0].distribution_id == "zip" assert release.distributions[0].checksums[0].algorithm_type == ChecksumAlgorithm.SHA_256 @@ -283,7 +312,7 @@ def test_collection_from_json(self): } ], } - collection = Collection.model_validate(data) + collection = Collection.model_validate(data, context=LEGACY) assert collection.version == 3 assert collection.belongs_to == CollectionBelongsTo.COMPONENT_RELEASE assert collection.update_reason.type == CollectionUpdateReasonType.ARTIFACT_UPDATED @@ -298,7 +327,7 @@ def test_release_minimal_fields(self): "version": "1.0.0", "createdDate": "2024-01-01T00:00:00Z", } - release = Release.model_validate(data) + release = Release.model_validate(data, context=LEGACY) assert release.release_date is None assert release.pre_release is None assert release.component is None @@ -307,15 +336,15 @@ def test_release_minimal_fields(self): def test_collection_minimal_fields(self): data = {"uuid": "c-1", "version": 1} - collection = Collection.model_validate(data) - assert collection.date is None + collection = Collection.model_validate(data, context=LEGACY) + assert collection.created_date is None assert collection.belongs_to is None assert collection.update_reason is None assert collection.artifacts == () def test_collection_all_fields_optional(self): - """Per TEA spec, all Collection fields are optional.""" - collection = Collection.model_validate({}) + """Per TEA 0.4.0, all Collection fields are optional.""" + collection = Collection.model_validate({}, context=LEGACY) assert collection.uuid is None assert collection.version is None assert collection.artifacts == () @@ -323,18 +352,18 @@ def test_collection_all_fields_optional(self): def test_collection_version_rejects_zero(self): """TEA spec says versions start with 1.""" with pytest.raises(ValidationError): - Collection.model_validate({"version": 0}) + Collection.model_validate({"version": 0}, context=LEGACY) def test_collection_version_rejects_negative(self): with pytest.raises(ValidationError): - Collection.model_validate({"version": -1}) + Collection.model_validate({"version": -1}, context=LEGACY) def test_artifact_format_minimal_fields(self): data = { "mediaType": "application/json", "url": "https://example.com/sbom.json", } - fmt = ArtifactFormat.model_validate(data) + fmt = ArtifactFormat.model_validate(data, context=LEGACY) assert fmt.description is None assert fmt.signature_url is None assert fmt.checksums == () @@ -347,7 +376,7 @@ def test_paginated_product_response_empty_results(self): "totalResults": 0, "results": [], } - resp = PaginatedProductResponse.model_validate(data) + resp = PaginatedProductResponse.model_validate(data, context=LEGACY) assert resp.total_results == 0 assert resp.results == () @@ -367,11 +396,79 @@ def test_paginated_product_response(self): } ], } - resp = PaginatedProductResponse.model_validate(data) + resp = PaginatedProductResponse.model_validate(data, context=LEGACY) assert resp.total_results == 1 assert resp.results[0].name == "Apache Log4j 2" +class TestPagination: + def test_v1_page(self): + p = PaginatedProductResponse.model_validate( + {"hasNext": True, "nextPageToken": "eyJwYWdlIjoiY29udGludWF0aW9uIn0", "results": []} + ) + assert (p.has_next, p.next_page_token, p.total_results) == (True, "eyJwYWdlIjoiY29udGludWF0aW9uIn0", None) + + def test_v1_drops_legacy_offsets(self): + p = PaginatedProductResponse.model_validate( + {"hasNext": False, "totalResults": 5, "pageStartIndex": 0, "pageSize": 2, "timestamp": "x", "results": []} + ) + assert (p.total_results, p.page_start_index, p.page_size, p.timestamp) == (None, None, None, None) + + def test_non_object_rejected(self): + with pytest.raises(ValidationError): + PaginatedProductResponse.model_validate([]) + + def test_v1_missing_results_rejected(self): + with pytest.raises(ValidationError, match="missing required field 'results'"): + PaginatedProductResponse.model_validate({"hasNext": False}) + + def test_legacy_missing_results_still_accepted(self): + data = {"timestamp": "2024-03-20T15:30:00Z", "pageStartIndex": 0, "pageSize": 2, "totalResults": 0} + p = PaginatedProductResponse.model_validate(data, context=LEGACY) + assert (p.results, p.has_next) == ((), False) + + def test_v1_has_next_without_token_rejected(self): + with pytest.raises(ValidationError, match="nextPageToken"): + PaginatedProductResponse.model_validate({"hasNext": True, "results": []}) + + def test_legacy_shape_rejected_on_v1(self): + with pytest.raises(ValidationError, match="hasNext"): # Pydantic reports missing fields by alias + PaginatedProductResponse.model_validate( + { + "timestamp": "2024-03-20T15:30:00Z", + "pageStartIndex": 0, + "pageSize": 2, + "totalResults": 3, + "results": [], + } + ) + + @pytest.mark.parametrize( + ("start", "n", "total", "has_next", "token"), + [(0, 2, 3, True, "2"), (2, 1, 3, False, None), (0, 0, 5, False, None)], + ) + def test_legacy_offsets_synthesize_token(self, start, n, total, has_next, token): + data = { + "timestamp": "2024-03-20T15:30:00Z", + "pageStartIndex": start, + "pageSize": 2, + "totalResults": total, + "results": [product(PRODUCT, f"P{i}") for i in range(n)], + } + p = PaginatedProductResponse.model_validate(data, context=LEGACY) + assert (p.has_next, p.next_page_token) == (has_next, token) + + def test_legacy_bad_offsets_rejected(self): + with pytest.raises(ValidationError): + PaginatedProductResponse.model_validate( + {"pageStartIndex": "x", "totalResults": 3, "results": []}, context=LEGACY + ) + + def test_collection_page(self): + p = PaginatedCollectionResponse.model_validate({"hasNext": False, "results": [V1_COLLECTION]}) + assert p.results[0].version == 2 + + class TestCLEEventType: @pytest.mark.parametrize( "value", @@ -577,25 +674,9 @@ def test_version_specifier_empty_rejected(self): class TestInteropCompat: """Tests for interoperability with older/non-standard server responses.""" - def test_artifact_format_accepts_mime_type_as_alias(self): - """Servers may send ``mimeType`` instead of ``mediaType``.""" - fmt = ArtifactFormat.model_validate({"mimeType": "application/json", "url": "https://example.com/sbom.json"}) - assert fmt.media_type == "application/json" - - def test_artifact_format_prefers_media_type_over_mime_type(self): - """When both are present, ``mediaType`` takes precedence.""" - fmt = ArtifactFormat.model_validate( - { - "mediaType": "application/xml", - "mimeType": "text/plain", - "url": "https://example.com/sbom.xml", - } - ) - assert fmt.media_type == "application/xml" - def test_artifact_format_media_type_optional(self): - """mediaType is not required by the spec.""" - fmt = ArtifactFormat.model_validate({"url": "https://example.com/sbom.json"}) + """mediaType is not required by the 0.4.0 spec.""" + fmt = ArtifactFormat.model_validate({"url": "https://example.com/sbom.json"}, context=LEGACY) assert fmt.media_type is None def test_artifact_format_url_optional(self): @@ -603,78 +684,31 @@ def test_artifact_format_url_optional(self): fmt = ArtifactFormat.model_validate({"mediaType": "application/json"}) assert fmt.url is None - def test_release_distribution_type_optional(self): - """distributionType can be null (not required by spec).""" - from libtea.models import ReleaseDistribution - - dist = ReleaseDistribution.model_validate({"distributionType": None}) - assert dist.distribution_type is None - - def test_release_distribution_type_absent(self): - """distributionType can be entirely absent.""" - from libtea.models import ReleaseDistribution - - dist = ReleaseDistribution.model_validate({}) - assert dist.distribution_type is None - def test_identifier_all_fields_optional(self): - """Per spec, Identifier has no required fields.""" - ident = Identifier.model_validate({}) + """Per TEA 0.4.0, Identifier has no required fields.""" + ident = Identifier.model_validate({}, context=LEGACY) assert ident.id_type is None assert ident.id_value is None def test_identifier_partial_fields(self): - """Per spec, only idType without idValue is valid.""" - ident = Identifier.model_validate({"idType": "PURL"}) + """Per TEA 0.4.0, only idType without idValue is valid.""" + ident = Identifier.model_validate({"idType": "PURL"}, context=LEGACY) assert ident.id_type == "PURL" assert ident.id_value is None - def test_checksum_all_fields_optional(self): - """Per spec, Checksum has no required fields.""" - cs = Checksum.model_validate({}) - assert cs.algorithm_type is None - assert cs.algorithm_value is None - - def test_checksum_partial_algorithm_type_only(self): - """Per spec, algType without algValue is valid.""" - cs = Checksum.model_validate({"algType": "SHA-256"}) - assert cs.algorithm_type == ChecksumAlgorithm.SHA_256 - assert cs.algorithm_value is None - - def test_artifact_all_fields_optional(self): - """Per spec, Artifact has no required fields.""" - from libtea.models import Artifact - - art = Artifact.model_validate({}) - assert art.uuid is None - assert art.name is None - assert art.type is None - assert art.version is None - assert art.created_date is None - assert art.formats == () - - def test_artifact_partial_fields(self): - """Per spec, uuid alone is valid.""" - from libtea.models import Artifact - - art = Artifact.model_validate({"uuid": "abc-123"}) - assert art.uuid == "abc-123" - assert art.name is None - assert art.type is None - def test_collection_update_reason_all_fields_optional(self): - """Per spec, CollectionUpdateReason has no required fields.""" + """Per TEA 0.4.0, CollectionUpdateReason has no required fields.""" from libtea.models import CollectionUpdateReason - reason = CollectionUpdateReason.model_validate({}) + reason = CollectionUpdateReason.model_validate({}, context=LEGACY) assert reason.type is None assert reason.comment is None def test_collection_update_reason_comment_only(self): - """Per spec, comment without type is valid.""" + """Per TEA 0.4.0, comment without type is valid.""" from libtea.models import CollectionUpdateReason - reason = CollectionUpdateReason.model_validate({"comment": "Updated manually"}) + reason = CollectionUpdateReason.model_validate({"comment": "Updated manually"}, context=LEGACY) assert reason.type is None assert reason.comment == "Updated manually" @@ -692,12 +726,23 @@ def test_from_json_object_not_shareable(self): def test_to_json(self): resp = ErrorResponse(error=ErrorType.OBJECT_UNKNOWN) - data = resp.model_dump(by_alias=True) + data = resp.model_dump(by_alias=True, exclude_none=True) assert data == {"error": "OBJECT_UNKNOWN"} - def test_rejects_unknown_error_type(self): + def test_unknown_error_value_accepted(self): + resp = ErrorResponse.model_validate({"error": "SOMETHING_NEW", "message": "x"}) + assert resp.error == "SOMETHING_NEW" + assert resp.message == "x" + + def test_message_optional(self): + assert ErrorResponse.model_validate({"error": "INVALID_REQUEST"}).message is None + + def test_known_error_compares_equal_to_enum(self): + assert ErrorResponse.model_validate({"error": "INVALID_PAGE_TOKEN"}).error == ErrorType.INVALID_PAGE_TOKEN + + def test_non_string_error_rejected(self): with pytest.raises(ValidationError): - ErrorResponse.model_validate({"error": "INVALID_TYPE"}) + ErrorResponse.model_validate({"error": 5}) def test_extra_fields_tolerated(self): """_TeaModel uses extra='ignore' — tolerant parsing for robustness.""" @@ -706,11 +751,8 @@ def test_extra_fields_tolerated(self): class TestComplianceDocumentType: - def test_fedramp_value(self): - assert ComplianceDocumentType.FEDRAMP == "FedRAMP" - def test_all_members_count(self): - assert len(ComplianceDocumentType) == 20 + assert len(ComplianceDocumentType) == 21 class TestPaginatedComponentResponse: @@ -728,7 +770,7 @@ def test_basic(self): } ], } - resp = PaginatedComponentResponse.model_validate(data) + resp = PaginatedComponentResponse.model_validate(data, context=LEGACY) assert resp.total_results == 1 assert isinstance(resp.results[0], Component) assert resp.results[0].name == "Test Component" @@ -741,7 +783,7 @@ def test_empty_results(self): "totalResults": 0, "results": [], } - resp = PaginatedComponentResponse.model_validate(data) + resp = PaginatedComponentResponse.model_validate(data, context=LEGACY) assert resp.total_results == 0 assert resp.results == () @@ -761,7 +803,7 @@ def test_basic(self): } ], } - resp = PaginatedComponentReleaseResponse.model_validate(data) + resp = PaginatedComponentReleaseResponse.model_validate(data, context=LEGACY) assert resp.total_results == 1 assert isinstance(resp.results[0], Release) assert resp.results[0].version == "1.0.0" @@ -774,6 +816,120 @@ def test_empty_results(self): "totalResults": 0, "results": [], } - resp = PaginatedComponentReleaseResponse.model_validate(data) + resp = PaginatedComponentReleaseResponse.model_validate(data, context=LEGACY) assert resp.total_results == 0 assert resp.results == () + + +class TestV1RequiredFields: + def test_collection_missing_created_date_rejected(self): + data = {k: v for k, v in V1_COLLECTION.items() if k != "createdDate"} + with pytest.raises(ValidationError, match="created_date"): + Collection.model_validate(data) + + def test_collection_legacy_date_mapped(self): + c = Collection.model_validate({"uuid": COMPONENT_RELEASE, "date": "2024-12-15T00:00:00Z"}, context=LEGACY) + assert c.created_date == datetime(2024, 12, 15, tzinfo=timezone.utc) + + def test_collection_date_key_not_mapped_on_v1(self): + data = {**{k: v for k, v in V1_COLLECTION.items() if k != "createdDate"}, "date": "2024-12-15T00:00:00Z"} + with pytest.raises(ValidationError, match="created_date"): + Collection.model_validate(data) + + def test_nested_artifact_enforced(self): + bad = {**V1_COLLECTION, "artifacts": [{k: v for k, v in V1_ARTIFACT.items() if k != "version"}]} + with pytest.raises(ValidationError) as exc: + Collection.model_validate(bad) + assert exc.value.errors()[0]["loc"][:2] == ("artifacts", 0) + + def test_identifier_both_fields_v1_only(self): + with pytest.raises(ValidationError, match="id_value"): + Identifier.model_validate({"idType": "PURL"}) + assert Identifier.model_validate({"idType": "PURL"}, context=LEGACY).id_value is None + + def test_checksum_fields_required_in_both(self): + with pytest.raises(ValidationError): + Checksum.model_validate({"algType": "SHA-256"}, context=LEGACY) + + def test_format_with_url_requires_checksums(self): + with pytest.raises(ValidationError, match="checksums"): + ArtifactFormat.model_validate({"mediaType": "application/json", "url": "https://x.example.com/a"}) + + def test_artifact_requires_formats_entry(self): + with pytest.raises(ValidationError, match="formats"): + Artifact.model_validate({**V1_ARTIFACT, "formats": []}) + + def test_collection_requires_artifacts_key(self): + data = {k: v for k, v in V1_COLLECTION.items() if k != "artifacts"} + with pytest.raises(ValidationError, match="missing required field 'artifacts'"): + Collection.model_validate(data) + assert Collection.model_validate({**data, "artifacts": []}).artifacts == () + assert Collection.model_validate(data, context=LEGACY).artifacts == () + + def test_format_checksums_present_must_not_be_empty(self): + for extra in ({}, {"url": "https://x.example.com/a"}): + with pytest.raises(ValidationError, match="'checksums' must not be empty"): + ArtifactFormat.model_validate({"mediaType": "application/json", "checksums": [], **extra}) + assert ArtifactFormat.model_validate({"mediaType": "application/json"}).checksums == () + + def test_valid_v1_documents_pass(self): + Collection.model_validate(V1_COLLECTION) + Release.model_validate(V1_RELEASE) + ProductRelease.model_validate(V1_PRODUCT_RELEASE) + + def test_md5_rejected_on_v1(self): + with pytest.raises(ValidationError, match="MD5"): + Checksum.model_validate({"algType": "MD5", "algValue": MD5_HEX}) + + def test_md5_accepted_legacy(self): + cs = Checksum.model_validate({"algType": "MD5", "algValue": MD5_HEX}, context=LEGACY) + assert cs.algorithm_type is ChecksumAlgorithm.MD5 + + def test_fedramp_value(self): + assert ComplianceDocumentType.FEDRAMP == "FEDRAMP" + + +class TestReleaseWithCollection: + def test_component_release_flattened_on_v1(self): + r = ComponentReleaseWithCollection.model_validate({**V1_RELEASE, "latestCollection": V1_COLLECTION}) + assert (r.release.version, r.latest_collection.version) == ("2.24.3", 2) + + def test_component_release_nested_rejected_on_v1(self): + with pytest.raises(ValidationError): + ComponentReleaseWithCollection.model_validate({"release": V1_RELEASE, "latestCollection": V1_COLLECTION}) + + def test_component_release_nested_legacy(self): + r = ComponentReleaseWithCollection.model_validate( + {"release": V1_RELEASE, "latestCollection": V1_COLLECTION}, context=LEGACY + ) + assert r.release.uuid == COMPONENT_RELEASE + + def test_component_release_requires_latest_collection_on_v1(self): + with pytest.raises(ValidationError, match="latestCollection"): + ComponentReleaseWithCollection.model_validate(V1_RELEASE) + + def test_component_release_built_from_instances(self): + built = ComponentReleaseWithCollection( + release=Release.model_validate(V1_RELEASE), latest_collection=Collection.model_validate(V1_COLLECTION) + ) + assert built.latest_collection.version == 2 + + def test_product_release_requires_latest_collection_on_v1(self): + with pytest.raises(ValidationError, match="latest_collection"): + ProductReleaseWithCollection.model_validate(V1_PRODUCT_RELEASE) + + def test_product_release_with_collection_on_v1(self): + r = ProductReleaseWithCollection.model_validate({**V1_PRODUCT_RELEASE, "latestCollection": V1_COLLECTION}) + assert isinstance(r, ProductRelease) + assert (r.product, r.latest_collection.version) == (V1_PRODUCT_RELEASE["product"], 2) + + def test_product_release_still_requires_product_on_v1(self): + data = {k: v for k, v in V1_PRODUCT_RELEASE.items() if k != "product"} + with pytest.raises(ValidationError, match="'product'"): + ProductReleaseWithCollection.model_validate({**data, "latestCollection": V1_COLLECTION}) + + def test_product_release_without_collection_legacy(self): + r = ProductReleaseWithCollection.model_validate( + {k: v for k, v in V1_PRODUCT_RELEASE.items() if k != "product"}, context=LEGACY + ) + assert r.latest_collection is None diff --git a/tests/unit/test_protocols.py b/tests/unit/test_protocols.py index 72265e2..33a2782 100644 --- a/tests/unit/test_protocols.py +++ b/tests/unit/test_protocols.py @@ -3,7 +3,7 @@ from libtea.client import TeaClient from libtea.protocols import TeaClientProtocol -BASE_URL = "https://api.example.com/tea/v1" +BASE_URL = "https://api.example.com/tea/v0.4.0" class TestProtocol: diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 19a2d36..ae8c3b8 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -1,16 +1,47 @@ +from typing import Any + import pytest +from pydantic import BaseModel, ValidationInfo, field_validator from libtea._validation import ( _MAX_PAGE_SIZE, + _validate, _validate_artifact_version, _validate_collection_version, - _validate_page_offset, + _validate_list, _validate_page_size, _validate_path_segment, ) from libtea.exceptions import TeaValidationError +class _EchoContext(BaseModel): + """Stores the validation context it was validated with in ``n``.""" + + n: Any = None + + @field_validator("n", mode="before") + @classmethod + def _echo(cls, _value: Any, info: ValidationInfo) -> Any: + return info.context + + +class TestValidationContext: + def test_validate_passes_no_context_by_default(self): + assert _validate(_EchoContext, {"n": 1}).n is None + + def test_validate_passes_legacy_context(self): + assert _validate(_EchoContext, {"n": 1}, legacy=True).n == {"tea_legacy": True} + + def test_validate_list_passes_no_context_by_default(self): + assert [m.n for m in _validate_list(_EchoContext, [{"n": 1}, {"n": 2}])] == [None, None] + + def test_validate_list_passes_legacy_context(self): + assert [m.n for m in _validate_list(_EchoContext, [{"n": 1}, {"n": 2}], legacy=True)] == [ + {"tea_legacy": True} + ] * 2 + + class TestValidatePathSegment: def test_accepts_uuid(self): assert _validate_path_segment("d4d9f54a-abcf-11ee-ac79-1a52914d44b1") == "d4d9f54a-abcf-11ee-ac79-1a52914d44b1" @@ -74,30 +105,20 @@ def test_search_product_releases_rejects_bad_page_size(self, client): client.search_product_releases("PURL", "pkg:pypi/foo", page_size=_MAX_PAGE_SIZE + 1) -class TestPageOffsetValidation: - """page_offset parameter is validated in search/paginated methods.""" - - def test_validate_page_offset_rejects_negative(self): - with pytest.raises(TeaValidationError, match="page_offset must be >= 0"): - _validate_page_offset(-1) - - def test_validate_page_offset_accepts_zero(self): - _validate_page_offset(0) # should not raise - - def test_validate_page_offset_accepts_positive(self): - _validate_page_offset(100) # should not raise +class TestPageTokenValidation: + """page_token must be an integer offset on TEA 0.x servers.""" def test_search_products_rejects_negative_offset(self, client): - with pytest.raises(TeaValidationError, match="page_offset"): - client.search_products("PURL", "pkg:pypi/foo", page_offset=-1) + with pytest.raises(TeaValidationError, match="integer offset"): + client.search_products("PURL", "pkg:pypi/foo", page_token="-1") def test_get_product_releases_rejects_negative_offset(self, client): - with pytest.raises(TeaValidationError, match="page_offset"): - client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890", page_offset=-1) + with pytest.raises(TeaValidationError, match="integer offset"): + client.get_product_releases("a1b2c3d4-e5f6-7890-abcd-ef1234567890", page_token="-1") def test_search_product_releases_rejects_negative_offset(self, client): - with pytest.raises(TeaValidationError, match="page_offset"): - client.search_product_releases("PURL", "pkg:pypi/foo", page_offset=-1) + with pytest.raises(TeaValidationError, match="integer offset"): + client.search_product_releases("PURL", "pkg:pypi/foo", page_token="-1") class TestCollectionVersionValidation: