From 5a1720b1f4e7d75ff13608bae9b1683821a1af09 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Thu, 30 Apr 2026 15:44:41 +0100 Subject: [PATCH 1/3] add metadata API client and client test --- cloudsmith_cli/core/api/metadata.py | 247 +++++++++++++ cloudsmith_cli/core/tests/test_metadata.py | 397 +++++++++++++++++++++ 2 files changed, 644 insertions(+) create mode 100644 cloudsmith_cli/core/api/metadata.py create mode 100644 cloudsmith_cli/core/tests/test_metadata.py diff --git a/cloudsmith_cli/core/api/metadata.py b/cloudsmith_cli/core/api/metadata.py new file mode 100644 index 00000000..ce884b69 --- /dev/null +++ b/cloudsmith_cli/core/api/metadata.py @@ -0,0 +1,247 @@ +"""API - Package metadata (v2) endpoints.""" + +import json +from typing import Any, Optional, Union + +import cloudsmith_api + +from .. import ratelimits, utils +from ..pagination import PageInfo +from ..rest import RestClient +from .exceptions import catch_raise_api_exception + +SOURCE_KIND_VALUES = { + "unknown": 0, + "system": 1, + "ecosystem": 2, + "customer": 3, + "third_party": 4, +} + +CLASSIFICATION_VALUES = { + "unknown": 0, + "intrinsic": 1, + "upstream": 2, + "security": 3, + "provenance": 4, + "sbom": 5, + "generic": 6, +} + + +def _normalise_enum(value, mapping, name): + if value is None: + return None + if isinstance(value, bool): + raise ValueError(f"Invalid {name} value: {value!r}") + if isinstance(value, int): + return value + if isinstance(value, str): + text = value.strip() + if not text: + raise ValueError(f"Invalid {name} value: {value!r}") + try: + return int(text) + except ValueError: + pass + key = text.lower().replace("-", "_") + try: + return mapping[key] + except KeyError: + valid = ", ".join(sorted(mapping)) + raise ValueError( + f"Invalid {name} {value!r}. Expected an integer or one of: {valid}." + ) + raise ValueError(f"Invalid {name} type: {type(value).__name__}") + + +def normalise_source_kind(value): + """Coerce a MetadataSourceKind name or integer to its integer value.""" + return _normalise_enum(value, SOURCE_KIND_VALUES, "source_kind") + + +def normalise_classification(value): + """Coerce a MetadataClassification name or integer to its integer value.""" + return _normalise_enum(value, CLASSIFICATION_VALUES, "classification") + + +class _MetadataApi: + """Small client for metadata endpoints not yet present in cloudsmith_api.""" + + def __init__(self): + self.config = cloudsmith_api.Configuration() + self.rest_client = RestClient( + error_retry_cb=getattr(self.config, "error_retry_cb", None), + respect_retry_after_header=getattr(self.config, "rate_limit", True), + ) + + +def get_metadata_api(): + """Get the metadata API client.""" + return _MetadataApi() + + +def _build_url(config, *parts): + host = (config.host or "").rstrip("/") + suffix = "/".join(p.strip("/") for p in parts if p) + return f"{host}/v2/{suffix}/" + + +def _build_headers(config): + """Build request headers from the configured cloudsmith_api.Configuration. + + Mirrors the auth resolution performed by core/api/init.py: an Authorization + header (SSO bearer or Basic) takes precedence; otherwise we fall back to + the X-Api-Key header. + """ + headers = {"Accept": "application/json", "Content-Type": "application/json"} + + user_agent = getattr(config, "user_agent", None) + if user_agent: + headers["User-Agent"] = user_agent + + extra = getattr(config, "headers", None) or {} + auth_header = extra.get("Authorization") + if auth_header: + headers["Authorization"] = auth_header + else: + api_key = (config.api_key or {}).get("X-Api-Key") + if api_key: + headers["X-Api-Key"] = api_key + + return headers + + +def _request(client, method, *path_parts, query_params=None, body=None): + url = _build_url(client.config, *path_parts) + + with catch_raise_api_exception(): + response = client.rest_client.request( + method, + url, + query_params=query_params, + headers=_build_headers(client.config), + body=body, + ) + + ratelimits.maybe_rate_limit(client, response.getheaders()) + return response + + +def _response_json(response): + if not response.data: + return {} + return json.loads(response.data) + + +def list_metadata( + package_slug_perm: str, + *, + source_kind: Optional[Union[int, str]] = None, + classification: Optional[Union[int, str]] = None, + page: Optional[int] = None, + page_size: Optional[int] = None, +): + """List metadata entries attached to a package. + + `source_kind` and `classification` may be supplied as either an integer + or the matching enum name (case-insensitive); both are converted to the + integer the v2 API expects before the request is issued. + + Returns a (results, PageInfo) tuple. + """ + client = get_metadata_api() + api_kwargs = {} + + source_kind_value = normalise_source_kind(source_kind) + if source_kind_value is not None: + api_kwargs["source_kind"] = source_kind_value + + classification_value = normalise_classification(classification) + if classification_value is not None: + api_kwargs["classification"] = classification_value + + api_kwargs.update(utils.get_page_kwargs(page=page, page_size=page_size)) + + response = _request( + client, + "GET", + "packages", + package_slug_perm, + "metadata", + query_params=api_kwargs or None, + ) + + payload = _response_json(response) + results = payload.get("results", payload) if isinstance(payload, dict) else payload + page_info = PageInfo.from_headers(response.getheaders()) + return results, page_info + + +def create_metadata( + package_slug_perm: str, + *, + content: Any, + content_type: str, + source_identity: str, +): + """Attach a new metadata entry to a package.""" + client = get_metadata_api() + body = { + "content": content, + "content_type": content_type, + "source_identity": source_identity, + } + response = _request( + client, "POST", "packages", package_slug_perm, "metadata", body=body + ) + return _response_json(response) + + +def update_metadata( + package_slug_perm: str, + metadata_slug_perm: str, + *, + content: Any = None, + source_identity: Optional[str] = None, +): + """Patch an existing customer-owned metadata entry. + + Only `content` and `source_identity` are mutable; the v2 API rejects + attempts to change `content_type`. Fields left as None are omitted from + the patch body so existing values are preserved. + """ + client = get_metadata_api() + body = {} + if content is not None: + body["content"] = content + if source_identity is not None: + body["source_identity"] = source_identity + if not body: + raise ValueError( + "update_metadata requires at least one of content or source_identity" + ) + + response = _request( + client, + "PATCH", + "packages", + package_slug_perm, + "metadata", + metadata_slug_perm, + body=body, + ) + return _response_json(response) + + +def delete_metadata(package_slug_perm: str, metadata_slug_perm: str): + """Remove a customer-owned metadata entry from a package.""" + client = get_metadata_api() + _request( + client, + "DELETE", + "packages", + package_slug_perm, + "metadata", + metadata_slug_perm, + ) diff --git a/cloudsmith_cli/core/tests/test_metadata.py b/cloudsmith_cli/core/tests/test_metadata.py new file mode 100644 index 00000000..e35c159d --- /dev/null +++ b/cloudsmith_cli/core/tests/test_metadata.py @@ -0,0 +1,397 @@ +"""Tests for the v2 package metadata API client.""" + +import json + +import httpretty +import httpretty.core +import pytest + +from .. import keyring +from ..api import metadata +from ..api.exceptions import ApiException +from ..api.init import initialise_api + +API_HOST = "https://api.cloudsmith.io" +PKG = "pkg-slug" +META = "meta-slug" +LIST_URL = f"{API_HOST}/v2/packages/{PKG}/metadata/" +DETAIL_URL = f"{API_HOST}/v2/packages/{PKG}/metadata/{META}/" + + +@pytest.fixture(autouse=True) +def _setup_api(monkeypatch): + """Initialise the SDK Configuration and stub keyring lookups. + + initialise_api() registers custom retry attributes on cloudsmith_api.Configuration + that create_requests_session expects, and the metadata module reads host/auth + off the same Configuration singleton. Keyring is stubbed so we never touch the + user's real SSO tokens during a test run. + """ + monkeypatch.setattr(keyring, "get_access_token", lambda host: None) + monkeypatch.setattr(keyring, "get_refresh_token", lambda host: None) + monkeypatch.setattr(keyring, "should_refresh_access_token", lambda host: False) + monkeypatch.setattr( + httpretty.core.fakesock.socket, + "shutdown", + lambda self, how: None, + raising=False, + ) + initialise_api(host=API_HOST, key="test-api-key") + + +def _last_request(): + return httpretty.last_request() + + +class TestNormalisers: + @pytest.mark.parametrize( + "value, expected", + [ + (None, None), + (3, 3), + ("3", 3), + ("customer", 3), + ("CUSTOMER", 3), + ("Third-Party", 4), + ("third_party", 4), + ], + ) + def test_source_kind(self, value, expected): + assert metadata.normalise_source_kind(value) == expected + + @pytest.mark.parametrize( + "value, expected", + [ + (None, None), + (6, 6), + ("generic", 6), + ("GENERIC", 6), + ("PROVENANCE", 4), + ], + ) + def test_classification(self, value, expected): + assert metadata.normalise_classification(value) == expected + + def test_invalid_source_kind_name(self): + with pytest.raises(ValueError, match="Invalid source_kind"): + metadata.normalise_source_kind("not-a-kind") + + def test_invalid_classification_name(self): + with pytest.raises(ValueError, match="Invalid classification"): + metadata.normalise_classification("nope") + + def test_invalid_type(self): + with pytest.raises(ValueError): + metadata.normalise_source_kind(3.14) + + def test_bool_rejected(self): + with pytest.raises(ValueError): + metadata.normalise_source_kind(True) + + +class TestListMetadata: + @httpretty.activate(allow_net_connect=False) + def test_success_returns_results_and_page_info(self): + body = {"results": [{"slug_perm": "abc", "content_type": "application/json"}]} + httpretty.register_uri( + httpretty.GET, + LIST_URL, + body=json.dumps(body), + status=200, + content_type="application/json", + adding_headers={ + "X-Pagination-Count": "1", + "X-Pagination-Page": "1", + "X-Pagination-PageSize": "30", + "X-Pagination-PageTotal": "1", + }, + ) + + results, page_info = metadata.list_metadata(PKG) + + assert results == body["results"] + assert page_info.is_valid + assert page_info.count == 1 + + sent = _last_request() + assert sent.headers.get("X-Api-Key") == "test-api-key" + assert sent.headers.get("Accept") == "application/json" + + @httpretty.activate(allow_net_connect=False) + def test_filters_normalised_to_integers(self): + httpretty.register_uri( + httpretty.GET, + LIST_URL, + body=json.dumps({"results": []}), + status=200, + content_type="application/json", + ) + + metadata.list_metadata( + PKG, source_kind="customer", classification="GENERIC", page=2, page_size=50 + ) + + qs = _last_request().querystring # pylint: disable=no-member + assert qs["source_kind"] == ["3"] + assert qs["classification"] == ["6"] + assert qs["page"] == ["2"] + assert qs["page_size"] == ["50"] + + @httpretty.activate(allow_net_connect=False) + def test_non_positive_page_options_omitted(self): + httpretty.register_uri( + httpretty.GET, + LIST_URL, + body=json.dumps({"results": []}), + status=200, + content_type="application/json", + ) + + metadata.list_metadata(PKG, page=0, page_size=0) + + qs = _last_request().querystring # pylint: disable=no-member + assert "page" not in qs + assert "page_size" not in qs + + @httpretty.activate(allow_net_connect=False) + def test_404_raises_api_exception(self): + httpretty.register_uri( + httpretty.GET, + LIST_URL, + body=json.dumps({"detail": "Not found."}), + status=404, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.list_metadata(PKG) + + assert exc_info.value.status == 404 + assert exc_info.value.detail == "Not found." + + @httpretty.activate(allow_net_connect=False) + def test_422_raises_with_fields(self): + body = { + "detail": "Invalid query parameters.", + "fields": {"source_kind": ["Not a valid choice."]}, + } + httpretty.register_uri( + httpretty.GET, + LIST_URL, + body=json.dumps(body), + status=422, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.list_metadata(PKG, source_kind=3) + + assert exc_info.value.status == 422 + assert exc_info.value.fields == {"source_kind": ["Not a valid choice."]} + + +class TestCreateMetadata: + @httpretty.activate(allow_net_connect=False) + def test_success_posts_required_fields(self): + created = { + "slug_perm": "new-slug", + "content_type": "application/json", + "source_identity": "cloudsmith-cli@1.16.0", + } + httpretty.register_uri( + httpretty.POST, + LIST_URL, + body=json.dumps(created), + status=201, + content_type="application/json", + ) + + result = metadata.create_metadata( + PKG, + content={"foo": "bar"}, + content_type="application/json", + source_identity="cloudsmith-cli@1.16.0", + ) + + assert result == created + sent_body = json.loads(_last_request().body) + assert sent_body == { + "content": {"foo": "bar"}, + "content_type": "application/json", + "source_identity": "cloudsmith-cli@1.16.0", + } + + @httpretty.activate(allow_net_connect=False) + def test_404_when_package_unknown(self): + httpretty.register_uri( + httpretty.POST, + LIST_URL, + body=json.dumps({"detail": "Not found."}), + status=404, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.create_metadata( + PKG, + content={"x": 1}, + content_type="application/json", + source_identity="customer:test", + ) + assert exc_info.value.status == 404 + + @httpretty.activate(allow_net_connect=False) + def test_422_carries_field_errors(self): + body = { + "detail": "Validation failed.", + "fields": {"content": ["Content must be a JSON object."]}, + } + httpretty.register_uri( + httpretty.POST, + LIST_URL, + body=json.dumps(body), + status=422, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.create_metadata( + PKG, + content="not-an-object", + content_type="application/json", + source_identity="customer:test", + ) + + assert exc_info.value.status == 422 + assert exc_info.value.detail == "Validation failed." + assert exc_info.value.fields == {"content": ["Content must be a JSON object."]} + + +class TestUpdateMetadata: + @httpretty.activate(allow_net_connect=False) + def test_success_sends_only_provided_fields(self): + updated = {"slug_perm": META, "source_identity": "customer:new"} + httpretty.register_uri( + httpretty.PATCH, + DETAIL_URL, + body=json.dumps(updated), + status=200, + content_type="application/json", + ) + + result = metadata.update_metadata(PKG, META, source_identity="customer:new") + + assert result == updated + assert json.loads(_last_request().body) == {"source_identity": "customer:new"} + + @httpretty.activate(allow_net_connect=False) + def test_rejects_empty_patch(self): + with pytest.raises(ValueError): + metadata.update_metadata(PKG, META) + + @httpretty.activate(allow_net_connect=False) + def test_404_on_unknown_metadata(self): + httpretty.register_uri( + httpretty.PATCH, + DETAIL_URL, + body=json.dumps({"detail": "Not found."}), + status=404, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.update_metadata(PKG, META, content={"k": "v"}) + assert exc_info.value.status == 404 + + @httpretty.activate(allow_net_connect=False) + def test_422_when_changing_content_type(self): + body = { + "detail": "Validation failed.", + "fields": { + "content_type": "content_type cannot be changed after creation." + }, + } + httpretty.register_uri( + httpretty.PATCH, + DETAIL_URL, + body=json.dumps(body), + status=422, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.update_metadata(PKG, META, content={"k": "v"}) + + assert exc_info.value.status == 422 + assert exc_info.value.fields == { + "content_type": "content_type cannot be changed after creation." + } + + +class TestDeleteMetadata: + @httpretty.activate(allow_net_connect=False) + def test_success_returns_none(self): + httpretty.register_uri(httpretty.DELETE, DETAIL_URL, status=204) + + assert metadata.delete_metadata(PKG, META) is None + assert _last_request().method == "DELETE" + + @httpretty.activate(allow_net_connect=False) + def test_404_raises(self): + httpretty.register_uri( + httpretty.DELETE, + DETAIL_URL, + body=json.dumps({"detail": "Not found."}), + status=404, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.delete_metadata(PKG, META) + assert exc_info.value.status == 404 + + @httpretty.activate(allow_net_connect=False) + def test_422_raises(self): + body = { + "detail": "Cannot delete.", + "fields": {"non_field_errors": ["Metadata is read-only."]}, + } + httpretty.register_uri( + httpretty.DELETE, + DETAIL_URL, + body=json.dumps(body), + status=422, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.delete_metadata(PKG, META) + + assert exc_info.value.status == 422 + assert exc_info.value.fields == {"non_field_errors": ["Metadata is read-only."]} + + +class TestAuthHeaders: + @httpretty.activate(allow_net_connect=False) + def test_sso_authorization_header_takes_precedence(self): + # initialise_api with no key, then mutate Configuration to mimic post-SSO state + import cloudsmith_api + + cfg = cloudsmith_api.Configuration() + cfg.api_key = {} + cfg.headers = {"Authorization": "Bearer sso-token"} + cloudsmith_api.Configuration.set_default(cfg) + + httpretty.register_uri( + httpretty.GET, + LIST_URL, + body=json.dumps({"results": []}), + status=200, + content_type="application/json", + ) + + metadata.list_metadata(PKG) + + sent = _last_request() + assert sent.headers.get("Authorization") == "Bearer sso-token" + assert sent.headers.get("X-Api-Key") is None From 76bc280a0221a7e78a0560f7fb8e8adf650ad507 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Thu, 30 Apr 2026 16:14:19 +0100 Subject: [PATCH 2/3] copilot feedback --- cloudsmith_cli/core/api/metadata.py | 7 ++-- cloudsmith_cli/core/tests/test_metadata.py | 41 +++++++++++++++++----- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/cloudsmith_cli/core/api/metadata.py b/cloudsmith_cli/core/api/metadata.py index ce884b69..3e06b73d 100644 --- a/cloudsmith_cli/core/api/metadata.py +++ b/cloudsmith_cli/core/api/metadata.py @@ -95,15 +95,14 @@ def _build_headers(config): the X-Api-Key header. """ headers = {"Accept": "application/json", "Content-Type": "application/json"} + headers.update(getattr(config, "headers", None) or {}) user_agent = getattr(config, "user_agent", None) if user_agent: headers["User-Agent"] = user_agent - extra = getattr(config, "headers", None) or {} - auth_header = extra.get("Authorization") - if auth_header: - headers["Authorization"] = auth_header + if headers.get("Authorization"): + headers.pop("X-Api-Key", None) else: api_key = (config.api_key or {}).get("X-Api-Key") if api_key: diff --git a/cloudsmith_cli/core/tests/test_metadata.py b/cloudsmith_cli/core/tests/test_metadata.py index e35c159d..479c1a86 100644 --- a/cloudsmith_cli/core/tests/test_metadata.py +++ b/cloudsmith_cli/core/tests/test_metadata.py @@ -2,6 +2,7 @@ import json +import cloudsmith_api import httpretty import httpretty.core import pytest @@ -372,16 +373,20 @@ def test_422_raises(self): class TestAuthHeaders: - @httpretty.activate(allow_net_connect=False) - def test_sso_authorization_header_takes_precedence(self): - # initialise_api with no key, then mutate Configuration to mimic post-SSO state - import cloudsmith_api - + @staticmethod + def _override_config(monkeypatch, *, api_key=None, headers=None): cfg = cloudsmith_api.Configuration() - cfg.api_key = {} - cfg.headers = {"Authorization": "Bearer sso-token"} - cloudsmith_api.Configuration.set_default(cfg) + cfg.api_key = api_key if api_key is not None else cfg.api_key + cfg.headers = headers if headers is not None else cfg.headers + monkeypatch.setattr(cloudsmith_api.Configuration, "_default", cfg) + @httpretty.activate(allow_net_connect=False) + def test_sso_authorization_header_takes_precedence(self, monkeypatch): + self._override_config( + monkeypatch, + api_key={"X-Api-Key": "test-api-key"}, + headers={"Authorization": "Bearer sso-token"}, + ) httpretty.register_uri( httpretty.GET, LIST_URL, @@ -395,3 +400,23 @@ def test_sso_authorization_header_takes_precedence(self): sent = _last_request() assert sent.headers.get("Authorization") == "Bearer sso-token" assert sent.headers.get("X-Api-Key") is None + + @httpretty.activate(allow_net_connect=False) + def test_extra_config_headers_are_preserved(self, monkeypatch): + self._override_config( + monkeypatch, + headers={"X-Custom-Header": "custom-value"}, + ) + httpretty.register_uri( + httpretty.GET, + LIST_URL, + body=json.dumps({"results": []}), + status=200, + content_type="application/json", + ) + + metadata.list_metadata(PKG) + + sent = _last_request() + assert sent.headers.get("X-Custom-Header") == "custom-value" + assert sent.headers.get("X-Api-Key") == "test-api-key" From 45636c3f6834295651f123b31601ae73ce6c3f56 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Thu, 30 Apr 2026 17:22:57 +0100 Subject: [PATCH 3/3] Add single fetch GET metadata endpoint --- cloudsmith_cli/core/api/metadata.py | 14 +++++ cloudsmith_cli/core/tests/test_metadata.py | 68 ++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/cloudsmith_cli/core/api/metadata.py b/cloudsmith_cli/core/api/metadata.py index 3e06b73d..4f1595b9 100644 --- a/cloudsmith_cli/core/api/metadata.py +++ b/cloudsmith_cli/core/api/metadata.py @@ -177,6 +177,20 @@ def list_metadata( return results, page_info +def get_metadata(package_slug_perm: str, metadata_slug_perm: str): + """Retrieve a single metadata entry attached to a package.""" + client = get_metadata_api() + response = _request( + client, + "GET", + "packages", + package_slug_perm, + "metadata", + metadata_slug_perm, + ) + return _response_json(response) + + def create_metadata( package_slug_perm: str, *, diff --git a/cloudsmith_cli/core/tests/test_metadata.py b/cloudsmith_cli/core/tests/test_metadata.py index 479c1a86..b0c10a65 100644 --- a/cloudsmith_cli/core/tests/test_metadata.py +++ b/cloudsmith_cli/core/tests/test_metadata.py @@ -191,6 +191,74 @@ def test_422_raises_with_fields(self): assert exc_info.value.fields == {"source_kind": ["Not a valid choice."]} +class TestGetMetadata: + @httpretty.activate(allow_net_connect=False) + def test_success_returns_metadata_entry(self): + body = { + "slug_perm": META, + "content": {"foo": "bar"}, + "content_type": "application/json", + "classification": "generic", + "source_kind": "customer", + "source_identity": "cloudsmith-cli@1.16.0", + "is_canonical": True, + "source_table": "package_metadata", + "created_at": "2026-04-30T12:34:56Z", + } + httpretty.register_uri( + httpretty.GET, + DETAIL_URL, + body=json.dumps(body), + status=200, + content_type="application/json", + ) + + result = metadata.get_metadata(PKG, META) + + assert result == body + sent = _last_request() + assert sent.method == "GET" + assert sent.headers.get("X-Api-Key") == "test-api-key" + + @httpretty.activate(allow_net_connect=False) + def test_404_on_unknown_metadata(self): + httpretty.register_uri( + httpretty.GET, + DETAIL_URL, + body=json.dumps({"detail": "Not found."}), + status=404, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.get_metadata(PKG, META) + + assert exc_info.value.status == 404 + assert exc_info.value.detail == "Not found." + + @httpretty.activate(allow_net_connect=False) + def test_422_raises_with_fields(self): + body = { + "detail": "Validation failed.", + "fields": {"metadata_slug_perm": ["Invalid metadata slug."]}, + } + httpretty.register_uri( + httpretty.GET, + DETAIL_URL, + body=json.dumps(body), + status=422, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.get_metadata(PKG, META) + + assert exc_info.value.status == 422 + assert exc_info.value.fields == { + "metadata_slug_perm": ["Invalid metadata slug."] + } + + class TestCreateMetadata: @httpretty.activate(allow_net_connect=False) def test_success_posts_required_fields(self):