From 81612ea146c0d181e1f1b6288a2e376e3fb65dab Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Mon, 13 Jul 2026 11:13:59 +0100 Subject: [PATCH] feat(ctl): add --dependencies flag to infrahubctl marketplace get (#1118) ## feat(ctl): add marketplace --dependencies flag with server-side resolution Add `--dependencies` flag to `marketplace get` for collections and schemas, recursively downloading transitive dependencies resolved server-side via new `/dependencies` endpoints. ### Key features - **Collections:** prerequisite collections grouped into subdirectories, members and standalone schemas in output root - **Schemas:** dependencies grouped by owning collection (or root if standalone) - **Soft-fail** for unresolved/missing schema dependencies; strict for collection members - **Deduplication, cycle safety,** and reconciliation with existing schemas (keep by default, overwrite with `-y`) - **Path validation** (`_safe_segment`) to prevent directory traversal attacks - **Versioned** schema downloads resolve the pinned version's dependencies ### Technical changes - Replaced client-side dependency walk with single server-side `/dependencies` endpoint call - Report visibility-hidden dependencies in summary **Refs:** IHS-246, opsmill/infrahub-sdk-python#1117 --- changelog/1117.added.md | 1 + .../infrahubctl/infrahubctl-marketplace.mdx | 2 + infrahub_sdk/ctl/marketplace.py | 523 +++++++++++++++- tests/unit/ctl/test_marketplace_app.py | 563 +++++++++++++++++- 4 files changed, 1055 insertions(+), 34 deletions(-) create mode 100644 changelog/1117.added.md diff --git a/changelog/1117.added.md b/changelog/1117.added.md new file mode 100644 index 000000000..89c4e94b7 --- /dev/null +++ b/changelog/1117.added.md @@ -0,0 +1 @@ +Added a `--dependencies` flag to `infrahubctl marketplace get`. When downloading a schema or a collection, it now also resolves and downloads the schemas they depend on, via the marketplace API. Dependencies are grouped by the collection they belong to: prerequisite collections (and, for a single schema, dependencies that are members of a collection) are placed in their own `/` directory, while dependencies that belong to no collection land in the output root. Referenced kinds the marketplace cannot resolve are reported as unresolved dependencies. A schema that already exists in the output directory is reconciled to a single file rather than duplicated across directories — kept by default, or overwritten with the new `-y`/`--yes` flag. diff --git a/docs/docs/infrahubctl/infrahubctl-marketplace.mdx b/docs/docs/infrahubctl/infrahubctl-marketplace.mdx index 63791550a..46bcaca23 100644 --- a/docs/docs/infrahubctl/infrahubctl-marketplace.mdx +++ b/docs/docs/infrahubctl/infrahubctl-marketplace.mdx @@ -106,6 +106,8 @@ $ infrahubctl marketplace get [OPTIONS] IDENTIFIER * `-v, --version TEXT`: Specific schema version, for example 1.2.0. Default: latest published. * `-c, --collection`: Force collection download. Default: auto-detect whether the identifier is a schema or collection. +* `--dependencies`: Also download the schemas this schema or collection depends on. +* `-y, --yes`: Overwrite schemas that already exist in the output directory without prompting. * `-s, --stdout`: Print content to stdout instead of writing to disk. Status messages go to stderr. * `-o, --output-dir PATH`: Directory to save downloaded files. [default: schemas] * `--marketplace-url TEXT`: Base URL of the Infrahub Marketplace. Overrides configuration and environment. diff --git a/infrahub_sdk/ctl/marketplace.py b/infrahub_sdk/ctl/marketplace.py index f84cec371..9ca958458 100644 --- a/infrahub_sdk/ctl/marketplace.py +++ b/infrahub_sdk/ctl/marketplace.py @@ -2,6 +2,7 @@ import asyncio import sys +from collections import Counter from enum import Enum from pathlib import Path from typing import Any, Literal, NamedTuple, NoReturn @@ -80,6 +81,10 @@ def _collection_url(base_url: str, namespace: str, name: str) -> str: return f"{base_url}/api/v1/collections/{namespace}/{name}" +def _dependencies_url(base_url: str, item_type: MarketplaceItemType, namespace: str, name: str) -> str: + return f"{base_url}/api/v1/{item_type}s/{namespace}/{name}/dependencies" + + def _list_url(base_url: str, item_type: MarketplaceItemType) -> str: return f"{base_url}/api/v1/{item_type}s" @@ -192,6 +197,18 @@ def _is_transport_failure(r: object) -> bool: return isinstance(r, httpx.Response) and r.status_code >= 500 +def _safe_segment(segment: str) -> str: + """Reject a path component (from user input or the marketplace API) that could escape output. + + Namespaces, schema names, and collection names all become directory or file components on + disk, so a value containing a path separator, ``.``/``..``, an absolute-path root, or a NUL + is refused rather than allowed to traverse outside the output directory. + """ + if not segment or segment in {".", ".."} or "/" in segment or "\\" in segment or "\x00" in segment: + _fail(_ErrorClass.INVALID_INPUT, f"Refusing unsafe path component from marketplace: {segment!r}") + return segment + + def _classify_http_error(exc: httpx.HTTPError) -> _ErrorClass: """Map an httpx error to an error class for consistent exit-code assignment. @@ -233,6 +250,53 @@ def _mkdir_or_fail(path: Path) -> None: _fail(_ErrorClass.INVALID_INPUT, f"Cannot write to '{path}': {exc}") +class _WriteContext(NamedTuple): + """Controls overwriting when a schema already exists in the output tree. + + ``preexisting`` maps a schema filename (``.yml``) to the path where it was found in + the output directory *before* this command ran, so a schema being written now that already + exists elsewhere (e.g. a dependency at the root vs. a copy under a collection directory) is + reconciled to a single file instead of duplicated. Only files present before the run are + considered, so schemas written during this same run never trigger a prompt. + """ + + assume_yes: bool + preexisting: dict[str, Path] + + +def _snapshot_existing_schemas(output_root: Path) -> dict[str, Path]: + """Map ``.yml`` -> existing path for every schema already under ``output_root``.""" + existing: dict[str, Path] = {} + if output_root.exists(): + for path in sorted(output_root.rglob("*.yml")): + if path.is_file(): + existing.setdefault(path.name, path) + return existing + + +def _print_unresolved(status: Console, unresolved: set[str] | list[str]) -> None: + """Report referenced kinds the marketplace could not resolve to a schema, if any.""" + if unresolved: + status.print( + "[yellow]Unresolved dependencies (referenced kinds the marketplace could not resolve to a schema): " + + ", ".join(sorted(unresolved)) + ) + + +def _confirm_overwrite(prompt: str, *, assume_yes: bool) -> bool: + """Return whether to overwrite an existing schema file. + + ``--yes`` overwrites unconditionally; an interactive terminal is prompted; a + non-interactive run without ``--yes`` declines (keep the existing file) so scripts and CI + never block or clobber. + """ + if assume_yes: + return True + if not sys.stdin.isatty(): + return False + return typer.confirm(prompt, default=False) + + def _make_http_client(sdk_cfg: _SdkConfig) -> httpx.AsyncClient: """Build an httpx client that inherits the SDK's proxy and TLS configuration.""" proxy_kwargs: dict[str, Any] = {} @@ -329,9 +393,14 @@ async def _download_schema( prefetched: httpx.Response | None = None, schema_confirmed_exists: bool = False, needs_separator: bool = False, -) -> None: + soft_fail: bool = False, + write_ctx: _WriteContext | None = None, +) -> bool: """Download a single schema and write it to disk or stdout. + Returns ``True`` when the schema was written/streamed and ``False`` when it was skipped + (only possible with ``soft_fail``). + When ``prefetched`` is supplied and ``version`` is None, reuses the response instead of re-fetching the unversioned download URL. ``schema_confirmed_exists`` signals that the schema is known to exist (e.g. from @@ -340,13 +409,36 @@ async def _download_schema( ``needs_separator`` inserts a ``---`` document separator before the content in stdout mode when it is missing, so multiple schemas streamed back-to-back (e.g. from a collection) form a valid multi-document YAML stream. + ``soft_fail`` downgrades a 404 to an informational note and a ``False`` return instead of + aborting — used for resolved dependencies so one missing dependency does not fail the + whole download. ``write_ctx`` reconciles against schemas already present on disk: an + already-present schema is overwritten (``--yes``/prompt) or kept, decided before fetching + so a kept schema costs no download. """ + filename = f"{_safe_segment(name)}.yml" + + # Reconcile with a pre-existing copy before fetching (disk mode only). + existing_path = write_ctx.preexisting.get(filename) if write_ctx is not None and not stdout else None + if existing_path is not None and not _confirm_overwrite( + f"{namespace}/{name} already exists at {existing_path}. Overwrite?", + assume_yes=write_ctx.assume_yes if write_ctx else False, + ): + _status_console(stdout).print( + f"[yellow]Kept existing {existing_path}; skipped {namespace}/{name} (pass --yes to overwrite)." + ) + return False + if prefetched is not None and version is None: resp = prefetched else: resp = await client.get(_schema_url(base_url, namespace, name, version=version)) if resp.status_code == 404: + if soft_fail: + _status_console(stdout).print( + f"[yellow]Note: dependency {namespace}/{name} could not be downloaded (not found); skipping." + ) + return False if version and schema_confirmed_exists: _fail( _ErrorClass.NOT_FOUND, @@ -368,14 +460,372 @@ async def _download_schema( if not resp.text.endswith("\n"): sys.stdout.write("\n") err_console.print(f"[green]Fetched schema {namespace}/{name} v{resolved_version}") - return + return True + + if existing_path is not None: + existing_path.write_text(resp.text, encoding="utf-8") + console.print(f"[green]Updated schema {namespace}/{name} v{resolved_version} -> {existing_path}") + return True - filename = f"{name}.yml" _mkdir_or_fail(output_dir) file_path = output_dir / filename file_path.write_text(resp.text, encoding="utf-8") console.print(f"[green]Downloaded schema {namespace}/{name} v{resolved_version} -> {file_path}") + return True + + +def _collection_members(payload: Any, status: Console) -> list[dict[str, Any]]: + """Extract downloadable members from a collection metadata payload. + + Returns a list of ``{"namespace", "name", "version"}`` dicts. Members missing a namespace + or name are skipped with a warning rather than aborting the download. + """ + items = payload.get("items", []) if isinstance(payload, dict) else [] + schemas = [item.get("schema") for item in items if isinstance(item, dict)] + members: list[dict[str, Any]] = [] + for schema in schemas: + if not isinstance(schema, dict): + continue + member_namespace = schema.get("namespace") + member_name = schema.get("name") + if not member_namespace or not member_name: + status.print("[yellow]Warning: skipping a collection member with missing namespace or name.") + continue + version = (schema.get("latest_version") or {}).get("semver") + members.append({"namespace": member_namespace, "name": member_name, "version": version}) + return members + + +class _ResolvedDependencies(NamedTuple): + """A schema's or collection's fully resolved dependency closure, grouped by source. + + ``collection_groups`` are prerequisite collections as ``(namespace, name, member_schemas)``; + each group's schemas land under ``output_dir//``. ``standalone`` are dependency schemas + that belong to no collection and land in the output root. ``unresolved`` are referenced kinds + the marketplace could not resolve to a schema; ``hidden_count`` counts dependencies omitted + because they are not visible to the caller. + """ + + collection_groups: list[tuple[str, str, list[dict[str, Any]]]] + standalone: list[dict[str, Any]] + unresolved: list[str] + hidden_count: int + + +def _dependency_schemas(entries: Any) -> list[dict[str, Any]]: + """Build member-shaped ``{namespace, name, version}`` dicts from dependency schema entries.""" + return [ + {"namespace": str(entry["namespace"]), "name": str(entry["name"]), "version": None} + for entry in entries or [] + if isinstance(entry, dict) and entry.get("namespace") and entry.get("name") + ] + + +def _parse_dependencies(payload: Any) -> _ResolvedDependencies: + """Parse the marketplace ``/dependencies`` response into a grouped download plan.""" + data = payload if isinstance(payload, dict) else {} + groups: list[tuple[str, str, list[dict[str, Any]]]] = [] + for collection in data.get("collections") or []: + if not isinstance(collection, dict) or not collection.get("name"): + continue + groups.append( + ( + str(collection.get("namespace") or ""), + str(collection["name"]), + _dependency_schemas(collection.get("schemas")), + ) + ) + hidden = data.get("hidden_count") or 0 + return _ResolvedDependencies( + collection_groups=groups, + standalone=_dependency_schemas(data.get("schemas")), + unresolved=[str(kind) for kind in data.get("unresolved_kinds") or [] if kind], + hidden_count=hidden if isinstance(hidden, int) else 0, + ) + + +async def _fetch_dependencies( + client: httpx.AsyncClient, + base_url: str, + item_type: MarketplaceItemType, + namespace: str, + name: str, + *, + version: str | None = None, + status: Console, +) -> _ResolvedDependencies | None: + """Fetch a schema's or collection's resolved dependency closure from the marketplace. + + The marketplace resolves the full transitive closure server-side (``GET .../dependencies``) + and returns it grouped by source: prerequisite collections with their members, plus + standalone schemas. When ``version`` is given (schemas only), dependencies are resolved for + that version. A read failure is reported as an informational note and treated as "no + dependencies" so the requested item still downloads. + """ + url = _dependencies_url(base_url, item_type, namespace, name) + params = {"version": version} if version else None + try: + resp = await client.get(url, params=params) + resp.raise_for_status() + payload = resp.json() + except (httpx.HTTPError, ValueError) as exc: + detail = str(exc) or type(exc).__name__ + status.print(f"[yellow]Note: could not resolve dependencies for {namespace}/{name}: {detail}") + return None + return _parse_dependencies(payload) + + +async def _download_schema_set( + client: httpx.AsyncClient, + base_url: str, + schemas: list[dict[str, Any]], + target_dir: Path, + *, + stdout: bool, + seen: set[tuple[str, str]] | None = None, + already_written: int = 0, + soft_fail: bool = False, + reserved_names: set[str] | None = None, + write_ctx: _WriteContext | None = None, +) -> int: + """Download a resolved set of schemas into ``target_dir``, returning the count written. + + Schemas sharing a name across namespaces are disambiguated into per-namespace + subdirectories so they do not overwrite each other. ``soft_fail`` downgrades a missing + schema to a note instead of aborting — used for loose schema dependencies so one missing + dependency does not fail the whole download (collection members download strictly). + ``seen`` deduplicates ``(namespace, name)`` across multiple calls so a schema already + downloaded (e.g. as a member of another collection) is skipped. ``already_written`` is the + running total written by prior calls, used so the ``---`` stdout separator is inserted + before every document except the very first across the whole download. ``reserved_names`` + are names already written into ``target_dir`` by a prior call (e.g. the requested schema), + so a pending schema sharing one is disambiguated into a namespace subdirectory rather than + overwriting it. + """ + if seen is None: + seen = set() + pending = [] + for schema in schemas: + key = (schema["namespace"], schema["name"]) + if key in seen: + continue + seen.add(key) + pending.append(schema) + + name_counts = Counter(schema["name"] for schema in pending) + for reserved in reserved_names or (): + name_counts[reserved] += 1 + + written_here = 0 + for schema in pending: + member_name = schema["name"] + member_dir = target_dir / _safe_segment(schema["namespace"]) if name_counts[member_name] > 1 else target_dir + written = await _download_schema( + client=client, + base_url=base_url, + namespace=schema["namespace"], + name=member_name, + version=schema.get("version"), + output_dir=member_dir, + stdout=stdout, + schema_confirmed_exists=True, + needs_separator=already_written + written_here > 0, + soft_fail=soft_fail, + write_ctx=write_ctx, + ) + if written: + written_here += 1 + return written_here + + +def _print_hidden(status: Console, hidden_count: int) -> None: + """Report dependencies the marketplace omitted because they are not visible to the caller.""" + if hidden_count: + noun = "dependency" if hidden_count == 1 else "dependencies" + status.print(f"[yellow]{hidden_count} {noun} hidden due to visibility and not downloaded.") + + +def _report_collection_tree( + status: Console, + namespace: str, + name: str, + total_written: int, + requested_member_count: int, + prerequisites: list[str], + unresolved: set[str], + hidden_count: int, +) -> None: + dependency_count = total_written - requested_member_count + noun = "dependency" if dependency_count == 1 else "dependencies" + status.print( + f"\n[green]Collection {namespace}/{name}: {total_written} schemas downloaded " + f"({dependency_count} {noun} resolved)" + ) + if prerequisites: + status.print("[green]Prerequisite collections: " + ", ".join(prerequisites)) + _print_unresolved(status, unresolved) + _print_hidden(status, hidden_count) + + +async def _download_collection_tree( + client: httpx.AsyncClient, + base_url: str, + namespace: str, + name: str, + payload: Any, + output_dir: Path, + *, + stdout: bool, + write_ctx: _WriteContext | None = None, +) -> None: + """Download a collection together with its dependencies, grouped by source collection. + + Layout: + + - requested collection members -> ``output_dir//`` + - each prerequisite collection -> ``output_dir//`` + - standalone dependency schemas (not part of any prerequisite collection) -> ``output_dir/`` + + The marketplace resolves the full transitive closure (``GET .../dependencies``); ``seen`` + ensures each schema is written once even when it appears in several groups. + """ + status = _status_console(stdout) + seen: set[tuple[str, str]] = set() + + # Requested collection members download strictly (a curated collection that lists a missing + # member is an error, not something to skip), into the collection's own directory. + members = _collection_members(payload, status) + # Count members actually written (not kept pre-existing ones) as the dependency-count + # baseline, so keeping members never yields a negative dependency count in the report. + requested_written = await _download_schema_set( + client, + base_url, + members, + output_dir / _safe_segment(name), + stdout=stdout, + seen=seen, + soft_fail=False, + write_ctx=write_ctx, + ) + total_written = requested_written + + prerequisites: list[str] = [] + unresolved: set[str] = set() + hidden_count = 0 + deps = await _fetch_dependencies(client, base_url, "collection", namespace, name, status=status) + if deps is not None: + unresolved.update(deps.unresolved) + hidden_count = deps.hidden_count + # Prerequisite collection members also download strictly, each into its own directory. + for dep_namespace, dep_name, dep_members in deps.collection_groups: + prerequisites.append(f"{dep_namespace}/{dep_name}" if dep_namespace else dep_name) + total_written += await _download_schema_set( + client, + base_url, + dep_members, + output_dir / _safe_segment(dep_name), + stdout=stdout, + seen=seen, + already_written=total_written, + soft_fail=False, + write_ctx=write_ctx, + ) + # Standalone dependency schemas soft-fail so one missing dependency does not abort. + total_written += await _download_schema_set( + client, + base_url, + deps.standalone, + output_dir, + stdout=stdout, + seen=seen, + already_written=total_written, + soft_fail=True, + write_ctx=write_ctx, + ) + + _report_collection_tree( + status, namespace, name, total_written, requested_written, prerequisites, unresolved, hidden_count + ) + + +async def _download_schema_tree( + client: httpx.AsyncClient, + base_url: str, + namespace: str, + name: str, + version: str | None, + output_dir: Path, + *, + stdout: bool, + prefetched: httpx.Response | None = None, + schema_confirmed_exists: bool = False, + write_ctx: _WriteContext | None = None, +) -> None: + """Download a single schema together with its resolved dependencies. + + The requested schema downloads strictly (the primary target) to the output root. Its + dependencies — resolved server-side (``GET .../dependencies``) — soft-fail and are grouped by + the collection they belong to (``output_dir//``); dependencies in no collection + go to the output root. When ``version`` is set, dependencies are resolved for that version. + """ + status = _status_console(stdout) + requested_written = await _download_schema( + client=client, + base_url=base_url, + namespace=namespace, + name=name, + version=version, + output_dir=output_dir, + stdout=stdout, + prefetched=prefetched, + schema_confirmed_exists=schema_confirmed_exists, + write_ctx=write_ctx, + ) + total_written = 1 if requested_written else 0 + seen: set[tuple[str, str]] = {(namespace, name)} + + unresolved: set[str] = set() + hidden_count = 0 + deps = await _fetch_dependencies(client, base_url, "schema", namespace, name, version=version, status=status) + if deps is not None: + unresolved.update(deps.unresolved) + hidden_count = deps.hidden_count + # Dependencies that belong to a collection are grouped under its directory. + for _dep_namespace, dep_name, dep_members in deps.collection_groups: + total_written += await _download_schema_set( + client, + base_url, + dep_members, + output_dir / _safe_segment(dep_name), + stdout=stdout, + seen=seen, + already_written=total_written, + soft_fail=True, + write_ctx=write_ctx, + ) + # Standalone dependencies go to the root; reserve the requested name so a same-named + # dependency is disambiguated into a namespace subdir rather than overwriting it. + total_written += await _download_schema_set( + client, + base_url, + deps.standalone, + output_dir, + stdout=stdout, + seen=seen, + already_written=total_written, + soft_fail=True, + reserved_names={name} if requested_written else None, + write_ctx=write_ctx, + ) + + dependency_count = total_written - (1 if requested_written else 0) + noun = "dependency" if dependency_count == 1 else "dependencies" + status.print( + f"\n[green]Schema {namespace}/{name}: {total_written} schemas downloaded ({dependency_count} {noun} resolved)" + ) + _print_unresolved(status, unresolved) + _print_hidden(status, hidden_count) async def _download_collection( @@ -387,6 +837,8 @@ async def _download_collection( *, stdout: bool, prefetched: httpx.Response | None = None, + with_dependencies: bool = False, + write_ctx: _WriteContext | None = None, ) -> None: """Fetch every schema in a collection, writing to disk or stdout. @@ -419,37 +871,16 @@ async def _download_collection( f"Response from {_collection_url(base_url, namespace, name)} is not valid JSON", ) - items = payload.get("items", []) if isinstance(payload, dict) else [] - schemas = [item.get("schema") for item in items if isinstance(item, dict)] - members: list[dict[str, Any]] = [schema for schema in schemas if isinstance(schema, dict)] status = _status_console(stdout) - target_dir = output_dir / name - - member_names = [schema.get("name") for schema in members if schema.get("namespace") and schema.get("name")] - duplicated_names = {member_name for member_name in member_names if member_names.count(member_name) > 1} - downloaded = 0 - for schema in members: - member_namespace = schema.get("namespace") - member_name = schema.get("name") - if not member_namespace or not member_name: - status.print("[yellow]Warning: skipping a collection member with missing namespace or name.") - continue - version = (schema.get("latest_version") or {}).get("semver") - member_dir = target_dir / member_namespace if member_name in duplicated_names else target_dir - await _download_schema( - client=client, - base_url=base_url, - namespace=member_namespace, - name=member_name, - version=version, - output_dir=member_dir, - stdout=stdout, - schema_confirmed_exists=True, - needs_separator=downloaded > 0, + if with_dependencies: + await _download_collection_tree( + client, base_url, namespace, name, payload, output_dir, stdout=stdout, write_ctx=write_ctx ) - downloaded += 1 + return + members = _collection_members(payload, status) + downloaded = await _download_schema_set(client, base_url, members, output_dir / _safe_segment(name), stdout=stdout) status.print(f"\n[green]Collection {namespace}/{name}: {downloaded} schemas downloaded") @@ -631,6 +1062,17 @@ async def get( is_flag=True, help="Force collection download. Default: auto-detect whether the identifier is a schema or collection.", ), + dependencies: bool = typer.Option( + False, + "--dependencies", + help="Also download the schemas this schema or collection depends on.", + ), + yes: bool = typer.Option( + False, + "--yes", + "-y", + help="Overwrite schemas that already exist in the output directory without prompting.", + ), stdout: bool = typer.Option( False, "--stdout", @@ -657,6 +1099,12 @@ async def get( sdk_cfg = _SdkConfig() resolved_url = (marketplace_url or SETTINGS.active.marketplace_url).rstrip("/") + # When resolving dependencies to disk, reconcile against schemas already present so a + # dependency is not duplicated across directories; overwriting is gated by --yes/prompt. + write_ctx: _WriteContext | None = None + if dependencies and not stdout: + write_ctx = _WriteContext(assume_yes=yes, preexisting=_snapshot_existing_schemas(output_dir)) + async with _make_http_client(sdk_cfg) as client: prefetched: httpx.Response | None = None schema_confirmed_exists = False @@ -682,6 +1130,21 @@ async def get( output_dir=output_dir, stdout=stdout, prefetched=prefetched, + with_dependencies=dependencies, + write_ctx=write_ctx, + ) + elif dependencies: + await _download_schema_tree( + client=client, + base_url=resolved_url, + namespace=namespace, + name=name, + version=version, + output_dir=output_dir, + stdout=stdout, + prefetched=prefetched, + schema_confirmed_exists=schema_confirmed_exists, + write_ctx=write_ctx, ) else: await _download_schema( diff --git a/tests/unit/ctl/test_marketplace_app.py b/tests/unit/ctl/test_marketplace_app.py index 99fce762b..ff3715299 100644 --- a/tests/unit/ctl/test_marketplace_app.py +++ b/tests/unit/ctl/test_marketplace_app.py @@ -1,4 +1,5 @@ import json as _json +from itertools import starmap from pathlib import Path import httpx @@ -33,6 +34,53 @@ def _collection_json(members: list[tuple[str, str, str]]) -> dict: } +def _dep_schema(namespace: str, name: str) -> dict: + """Build a dependency schema entry as the marketplace ``/dependencies`` endpoint returns it.""" + return {"id": f"s-{namespace}-{name}", "namespace": namespace, "name": name, "display_name": name.upper()} + + +def _dep_collection(namespace: str, name: str, members: list[tuple[str, str]]) -> dict: + """Build a prerequisite-collection group (with its member schemas embedded).""" + return { + "namespace": namespace, + "name": name, + "display_name": name, + "schemas": list(starmap(_dep_schema, members)), + } + + +def _deps_response( + *, + collections: list[tuple[str, str, list[tuple[str, str]]]] | None = None, + schemas: list[tuple[str, str]] | None = None, + unresolved: list[str] | None = None, + hidden: int = 0, +) -> dict: + """Build a marketplace ``/dependencies`` response: the resolved closure grouped by source.""" + return { + "collections": list(starmap(_dep_collection, collections or [])), + "schemas": list(starmap(_dep_schema, schemas or [])), + "unresolved_kinds": unresolved or [], + "hidden_count": hidden, + } + + +def _stub_deps( + httpx_mock: HTTPXMock, + item_type: str, + namespace: str, + name: str, + response: dict, + *, + version: str | None = None, +) -> None: + """Stub GET /{item_type}s/{ns}/{name}/dependencies (optionally pinned to a version).""" + url = f"https://marketplace.infrahub.app/api/v1/{item_type}s/{namespace}/{name}/dependencies" + if version: + url += f"?version={version}" + httpx_mock.add_response(method="GET", url=url, json=response) + + def test_download_schema_specific_version(httpx_mock: HTTPXMock, tmp_path: Path) -> None: # Auto-detect probes httpx_mock.add_response( @@ -747,7 +795,7 @@ def test_list_json_output_is_parseable(httpx_mock: HTTPXMock) -> None: assert parsed[0]["latest_version"]["semver"] == "1.2.0" -def _schema_detail() -> dict: +def _show_schema_detail() -> dict: return { "namespace": "infrahub", "name": "vlan", @@ -771,7 +819,7 @@ def test_show_schema_autodetect(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( method="GET", url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/vlan", - json=_schema_detail(), + json=_show_schema_detail(), ) httpx_mock.add_response( method="GET", @@ -794,7 +842,7 @@ def test_show_schema_json(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( method="GET", url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/vlan", - json=_schema_detail(), + json=_show_schema_detail(), ) httpx_mock.add_response( method="GET", @@ -914,6 +962,7 @@ async def test_collection_false_autodetects_schema(httpx_mock: HTTPXMock, tmp_pa identifier="acme/network-base", version=None, collection=False, + dependencies=False, stdout=False, output_dir=tmp_path, marketplace_url="https://marketplace.infrahub.app", @@ -923,6 +972,512 @@ async def test_collection_false_autodetects_schema(httpx_mock: HTTPXMock, tmp_pa assert (tmp_path / "network-base.yml").read_text() == SCHEMA_YAML +def test_dependencies_groups_prerequisite_collections_and_standalone(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """C1: members, prerequisite collections, and standalone dependency schemas land in the right dirs.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack", + json=_collection_json([("acme", "app", "1.0.0")]), + ) + # The marketplace resolves the full closure: base-schemas (with members) plus standalone extra/more. + _stub_deps( + httpx_mock, + "collection", + "acme", + "starter-pack", + _deps_response( + collections=[("acme", "base", [("acme", "dcim"), ("acme", "ipam")])], + schemas=[("acme", "extra"), ("acme", "more")], + ), + ) + # Members download at their pinned version; dependency schemas at latest (unversioned). + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + for dep in ("dcim", "ipam", "extra", "more"): + httpx_mock.add_response( + method="GET", + url=f"https://marketplace.infrahub.app/api/v1/schemas/acme/{dep}/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/starter-pack", "-c", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "5 schemas downloaded" in result.output + assert "4 dependencies resolved" in result.output + assert "Prerequisite collections: acme/base" in result.output + # Requested collection members in its own dir. + assert (tmp_path / "starter-pack" / "app.yml").exists() + # Prerequisite collection members in their collection's dir. + assert (tmp_path / "base" / "dcim.yml").exists() + assert (tmp_path / "base" / "ipam.yml").exists() + # Standalone dependency schemas at the output root. + assert (tmp_path / "extra.yml").exists() + assert (tmp_path / "more.yml").exists() + + +def test_dependencies_not_requested_skips_resolution(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """C2: without --dependencies, the dependencies endpoint is never called (backward compatible).""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack", + json=_collection_json([("acme", "app", "1.0.0")]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + # No /dependencies mock — pytest-httpx fails if dependency resolution runs. + result = runner.invoke(app, ["get", "acme/starter-pack", "-c", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "1 schemas downloaded" in result.output + assert "dependencies resolved" not in result.output + assert (tmp_path / "starter-pack" / "app.yml").exists() + + +def test_dependencies_member_also_standalone_downloaded_once(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """A schema that is both a member and a standalone dependency is written once (as a member).""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/pair", + json=_collection_json([("acme", "a", "1.0.0"), ("acme", "b", "2.0.0")]), + ) + # b is also listed as a standalone dependency, but it is already a member so it is not re-downloaded. + _stub_deps(httpx_mock, "collection", "acme", "pair", _deps_response(schemas=[("acme", "b")])) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/a/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/b/versions/2.0.0/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/pair", "-c", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "2 schemas downloaded" in result.output + assert "0 dependencies resolved" in result.output + assert (tmp_path / "pair" / "b.yml").exists() + + +def test_dependencies_stdout_streams_all_documents(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """C4: --dependencies --stdout streams members, prerequisite-collection, and standalone schemas; no files.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack", + json=_collection_json([("acme", "a", "1.0.0")]), + ) + _stub_deps( + httpx_mock, + "collection", + "acme", + "starter-pack", + _deps_response(collections=[("acme", "base", [("acme", "bb")])], schemas=[("acme", "ext")]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/a/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + for dep in ("bb", "ext"): + httpx_mock.add_response( + method="GET", + url=f"https://marketplace.infrahub.app/api/v1/schemas/acme/{dep}/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/starter-pack", "-c", "--dependencies", "--stdout", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert result.output.count(SCHEMA_YAML) == 3 + assert "Fetched schema acme/a v1.0.0" in result.output + assert "Fetched schema acme/bb" in result.output + assert "Fetched schema acme/ext" in result.output + assert "2 dependencies resolved" in result.output + assert not any(tmp_path.iterdir()) + + +def test_dependencies_unresolved_kinds_reported(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """C6: referenced kinds the marketplace cannot resolve are listed as unresolved dependencies.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack", + json=_collection_json([("acme", "app", "1.0.0")]), + ) + _stub_deps( + httpx_mock, + "collection", + "acme", + "starter-pack", + _deps_response(unresolved=["BuiltinTag", "BuiltinIPHost"]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/starter-pack", "-c", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "0 dependencies resolved" in result.output + assert "Unresolved dependencies" in result.output + assert "BuiltinTag" in result.output + assert "BuiltinIPHost" in result.output + + +def test_dependencies_hidden_count_reported(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """Dependencies hidden by visibility are reported and not counted as downloaded.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack", + json=_collection_json([("acme", "app", "1.0.0")]), + ) + _stub_deps(httpx_mock, "collection", "acme", "starter-pack", _deps_response(hidden=2)) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/starter-pack", "-c", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "2 dependencies hidden due to visibility" in result.output + + +def test_dependencies_missing_standalone_download_is_soft(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """C7: a standalone dependency that 404s on download is skipped with a note; the rest succeed.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/needy", + json=_collection_json([("acme", "app", "1.0.0")]), + ) + _stub_deps(httpx_mock, "collection", "acme", "needy", _deps_response(schemas=[("acme", "gone")])) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/gone/download", + status_code=404, + json={"detail": "Schema not found"}, + ) + result = runner.invoke(app, ["get", "acme/needy", "-c", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "dependency acme/gone could not be downloaded" in result.output + assert "1 schemas downloaded" in result.output + assert (tmp_path / "needy" / "app.yml").exists() + assert not (tmp_path / "gone.yml").exists() + + +def test_dependencies_missing_prerequisite_member_fails_strictly(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """A prerequisite collection member that cannot be downloaded is a hard error.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/app-pack", + json=_collection_json([("acme", "app", "1.0.0")]), + ) + _stub_deps( + httpx_mock, + "collection", + "acme", + "app-pack", + _deps_response(collections=[("acme", "base", [("acme", "missing")])]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + # The prerequisite collection's member cannot be fetched. + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/missing/download", + status_code=404, + json={"detail": "Schema not found"}, + ) + result = runner.invoke(app, ["get", "acme/app-pack", "-c", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 1 + assert "acme/missing" in result.output + + +def test_dependencies_on_schema_resolves_dependencies(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """--dependencies on a single schema downloads it plus the dependency schemas the marketplace resolves.""" + # Auto-detect: identifier resolves to a schema. + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.0.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/app", + status_code=404, + json={"detail": "Collection not found"}, + ) + _stub_deps(httpx_mock, "schema", "acme", "app", _deps_response(schemas=[("acme", "ipam"), ("acme", "location")])) + for dep in ("ipam", "location"): + httpx_mock.add_response( + method="GET", + url=f"https://marketplace.infrahub.app/api/v1/schemas/acme/{dep}/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/app", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "3 schemas downloaded" in result.output + assert "2 dependencies resolved" in result.output + assert (tmp_path / "app.yml").exists() + assert (tmp_path / "ipam.yml").exists() + assert (tmp_path / "location.yml").exists() + + +def test_dependencies_on_schema_groups_deps_by_collection(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """A dependency that belongs to a collection is placed under that collection's directory.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.0.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/app", + status_code=404, + json={"detail": "Collection not found"}, + ) + # dcim belongs to the base-schemas collection → grouped under base-schemas/. + _stub_deps( + httpx_mock, + "schema", + "acme", + "app", + _deps_response(collections=[("acme", "base-schemas", [("acme", "dcim")])]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/dcim/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/app", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "1 dependency resolved" in result.output + assert (tmp_path / "app.yml").exists() # requested schema at the root + assert (tmp_path / "base-schemas" / "dcim.yml").exists() # dependency grouped by collection + assert not (tmp_path / "dcim.yml").exists() + + +def test_dependencies_on_schema_with_no_dependencies(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """--dependencies on a leaf schema downloads just the schema and reports zero dependencies.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/leaf/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.0.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/leaf", + status_code=404, + json={"detail": "Collection not found"}, + ) + _stub_deps(httpx_mock, "schema", "acme", "leaf", _deps_response()) + result = runner.invoke(app, ["get", "acme/leaf", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "1 schemas downloaded" in result.output + assert "0 dependencies resolved" in result.output + assert (tmp_path / "leaf.yml").exists() + + +def test_dependencies_on_schema_disambiguates_same_name_dependency(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """A dependency sharing the requested schema's name (different namespace) does not overwrite it.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/thing/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.0.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/thing", + status_code=404, + json={"detail": "Collection not found"}, + ) + _stub_deps(httpx_mock, "schema", "acme", "thing", _deps_response(schemas=[("other", "thing")])) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/other/thing/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/thing", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "2 schemas downloaded" in result.output + # Requested schema at the root; the same-named dependency disambiguated into its namespace. + assert (tmp_path / "thing.yml").exists() + assert (tmp_path / "other" / "thing.yml").exists() + + +def test_dependencies_existing_file_kept_without_yes(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """A schema already present in the output tree is kept (not overwritten/duplicated) without --yes.""" + # Pre-existing copy from a prior download, in a collection subdirectory. + (tmp_path / "base-schemas").mkdir() + existing = tmp_path / "base-schemas" / "dcim.yml" + existing.write_text("OLD CONTENT\n") + + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.0.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/app", + status_code=404, + json={"detail": "Collection not found"}, + ) + _stub_deps( + httpx_mock, + "schema", + "acme", + "app", + _deps_response(collections=[("acme", "base-schemas", [("acme", "dcim")])]), + ) + # Non-interactive (CliRunner stdin is not a tty) and no --yes → dcim is kept, not re-fetched. + result = runner.invoke(app, ["get", "acme/app", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Kept existing" in result.output + assert existing.read_text() == "OLD CONTENT\n" # untouched + assert not (tmp_path / "dcim.yml").exists() # no duplicate created at the root + + +def test_dependencies_existing_file_overwritten_with_yes(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """With --yes, an existing schema is overwritten in place rather than duplicated.""" + (tmp_path / "base-schemas").mkdir() + existing = tmp_path / "base-schemas" / "dcim.yml" + existing.write_text("OLD CONTENT\n") + + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.0.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/app", + status_code=404, + json={"detail": "Collection not found"}, + ) + _stub_deps( + httpx_mock, + "schema", + "acme", + "app", + _deps_response(collections=[("acme", "base-schemas", [("acme", "dcim")])]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/dcim/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/app", "--dependencies", "--yes", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Updated schema acme/dcim" in result.output + assert existing.read_text() == SCHEMA_YAML # overwritten in place + assert not (tmp_path / "dcim.yml").exists() # still no duplicate at the root + + +def test_dependencies_of_pinned_version_forwarded_to_endpoint(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """--version with --dependencies resolves deps for that version (the version is sent to the endpoint).""" + # Auto-detect probes (version is applied to the download URL, not the detect probe). + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "2.0.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/app", + status_code=404, + json={"detail": "Collection not found"}, + ) + # Pinned download of the requested version. + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/app/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + # The dependencies endpoint is stubbed only for ?version=1.0.0; if the SDK omitted the version + # the request would not match and pytest-httpx would fail. + _stub_deps(httpx_mock, "schema", "acme", "app", _deps_response(schemas=[("acme", "olddep")]), version="1.0.0") + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/olddep/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/app", "-v", "1.0.0", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "1 dependency resolved" in result.output + assert (tmp_path / "olddep.yml").exists() + + +def test_dependencies_kept_members_do_not_skew_dependency_count(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """Keeping a pre-existing requested member must not yield a wrong/negative dependency count.""" + # The single requested member is already on disk and will be kept (non-interactive, no --yes). + (tmp_path / "pack").mkdir() + (tmp_path / "pack" / "app.yml").write_text("OLD CONTENT\n") + + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/pack", + json=_collection_json([("acme", "app", "1.0.0")]), + ) + _stub_deps(httpx_mock, "collection", "acme", "pack", _deps_response(schemas=[("acme", "extra")])) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/extra/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/pack", "-c", "--dependencies", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Kept existing" in result.output + # One dependency was downloaded; the kept member must not push the count to 0 or negative. + assert "1 schemas downloaded (1 dependency resolved)" in result.output + assert (tmp_path / "extra.yml").exists() + + +def test_dependencies_refuses_path_traversal_in_member_name(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """A member/collection name from the marketplace that would escape the output dir is refused.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/pack", + json={"items": [{"schema": {"namespace": "acme", "name": "../evil", "latest_version": {"semver": "1.0.0"}}}]}, + ) + result = runner.invoke(app, ["get", "acme/pack", "-c", "-o", str(tmp_path)]) + + assert result.exit_code == 1 + assert "unsafe path component" in result.output.lower() + assert not (tmp_path.parent / "evil.yml").exists() + + # --------------------------------------------------------------------------- # Fix #1 — 4xx errors must exit 1 (not 2) # --------------------------------------------------------------------------- @@ -974,7 +1529,7 @@ def test_show_collision_json_stdout_is_clean(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( method="GET", url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/vlan", - json=_schema_detail(), + json=_show_schema_detail(), ) httpx_mock.add_response( method="GET",