Skip to content

Commit c673d4f

Browse files
committed
feat(pulpcore): add tests for JSON content-app listings
Cover Accept negotiation, cache keys, generic listing, plugin JSON handler hook, and content-app JSON vs HTML responses. ref #7887 Assisted-By: Cursor
1 parent 5d5de04 commit c673d4f

3 files changed

Lines changed: 399 additions & 1 deletion

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Tests for content-app Accept negotiation and JSON directory listing."""
2+
3+
import json
4+
from urllib.parse import urljoin
5+
6+
import pytest
7+
import requests
8+
9+
from pulpcore.tests.functional.utils import download_file
10+
11+
JSON_ACCEPT = {"Accept": "application/json"}
12+
HTML_ACCEPT = {"Accept": "text/html"}
13+
14+
15+
def _add_files_to_repo(file_bindings, repo, contents, monitor_task):
16+
monitor_task(
17+
file_bindings.RepositoriesFileApi.modify(
18+
repo.pulp_href,
19+
{"add_content_units": [content.pulp_href for content in contents]},
20+
).task
21+
)
22+
return file_bindings.RepositoriesFileApi.read(repo.pulp_href)
23+
24+
25+
@pytest.mark.parallel
26+
def test_json_vs_html_listing_and_artifact(
27+
file_bindings,
28+
file_repo_with_auto_publish,
29+
file_content_unit_with_name_factory,
30+
file_distribution_factory,
31+
distribution_base_url,
32+
monitor_task,
33+
):
34+
"""JSON listing is recursive; default Accept stays HTML; artifacts stay binary."""
35+
root_file = file_content_unit_with_name_factory("a.iso")
36+
nested_file = file_content_unit_with_name_factory("subdir/b.iso")
37+
repo = _add_files_to_repo(
38+
file_bindings,
39+
file_repo_with_auto_publish,
40+
[root_file, nested_file],
41+
monitor_task,
42+
)
43+
distro = file_distribution_factory(repository=repo.pulp_href)
44+
distro_url = distribution_base_url(distro.base_url)
45+
46+
json_listing = download_file(distro_url, headers=JSON_ACCEPT)
47+
assert "application/json" in json_listing.response_obj.headers["Content-Type"]
48+
assert json_listing.response_obj.headers.get("Vary") == "Accept"
49+
body = json.loads(json_listing.body)
50+
assert body["path"].rstrip("/").endswith(distro.base_path)
51+
listed_paths = [pkg["path"] for pkg in body["packages"]]
52+
assert "a.iso" in listed_paths
53+
assert "subdir/b.iso" in listed_paths
54+
assert "subdir/" not in listed_paths
55+
assert body["count"] == len(body["packages"])
56+
assert body["limit"] == 1000
57+
assert body["offset"] == 0
58+
assert "next_offset" not in body
59+
60+
html_listing = download_file(distro_url, headers=HTML_ACCEPT)
61+
html = html_listing.body.decode("utf-8")
62+
assert "text/html" in html_listing.response_obj.headers["Content-Type"]
63+
assert html_listing.response_obj.headers.get("Vary") == "Accept"
64+
assert '<a href="./a.iso">' in html
65+
assert '<a href="./subdir/">' in html
66+
assert "./subdir/b.iso" not in html
67+
68+
default_listing = download_file(distro_url)
69+
assert "text/html" in default_listing.response_obj.headers["Content-Type"]
70+
71+
artifact = download_file(urljoin(distro_url, "a.iso"), headers=JSON_ACCEPT)
72+
assert "application/json" not in artifact.response_obj.headers.get("Content-Type", "")
73+
assert artifact.body != json_listing.body
74+
75+
76+
@pytest.mark.parallel
77+
def test_json_listing_pagination_and_invalid_params(
78+
file_bindings,
79+
file_repo_with_auto_publish,
80+
file_content_unit_with_name_factory,
81+
file_distribution_factory,
82+
distribution_base_url,
83+
monitor_task,
84+
):
85+
contents = [file_content_unit_with_name_factory(f"{i}.iso") for i in range(3)]
86+
repo = _add_files_to_repo(file_bindings, file_repo_with_auto_publish, contents, monitor_task)
87+
distro = file_distribution_factory(repository=repo.pulp_href)
88+
distro_url = distribution_base_url(distro.base_url)
89+
90+
page = json.loads(download_file(f"{distro_url}?limit=1&offset=0", headers=JSON_ACCEPT).body)
91+
assert page["limit"] == 1
92+
assert page["offset"] == 0
93+
assert len(page["packages"]) == 1
94+
assert page["count"] >= 3
95+
assert page["next_offset"] == 1
96+
97+
next_page = json.loads(
98+
download_file(
99+
f"{distro_url}?limit=1&offset={page['next_offset']}", headers=JSON_ACCEPT
100+
).body
101+
)
102+
assert next_page["offset"] == 1
103+
assert next_page["packages"][0]["path"] != page["packages"][0]["path"]
104+
105+
invalid = json.loads(
106+
download_file(f"{distro_url}?limit=nope&offset=nope", headers=JSON_ACCEPT).body
107+
)
108+
assert invalid["limit"] == 1000
109+
assert invalid["offset"] == 0
110+
111+
112+
@pytest.mark.parallel
113+
def test_json_listing_trailing_slash_redirect(
114+
file_bindings,
115+
file_repo_with_auto_publish,
116+
file_content_unit_with_name_factory,
117+
file_distribution_factory,
118+
distribution_base_url,
119+
monitor_task,
120+
):
121+
nested = file_content_unit_with_name_factory("subdir/b.iso")
122+
repo = _add_files_to_repo(file_bindings, file_repo_with_auto_publish, [nested], monitor_task)
123+
distro = file_distribution_factory(repository=repo.pulp_href)
124+
distro_url = distribution_base_url(distro.base_url)
125+
no_slash_url = urljoin(distro_url, "subdir")
126+
127+
redirect = requests.get(no_slash_url, headers=JSON_ACCEPT, allow_redirects=False, verify=False)
128+
assert redirect.status_code == 301
129+
assert redirect.headers["Location"].endswith("subdir/")
130+
131+
listed = download_file(urljoin(distro_url, "subdir/"), headers=JSON_ACCEPT)
132+
body = json.loads(listed.body)
133+
assert body["packages"]
134+
assert body["packages"][0]["path"] == "b.iso"
135+
136+
137+
@pytest.mark.parallel
138+
def test_json_and_html_listings_are_cached_separately(
139+
file_bindings,
140+
file_repo_with_auto_publish,
141+
file_content_unit_with_name_factory,
142+
file_distribution_factory,
143+
distribution_base_url,
144+
monitor_task,
145+
redis_status,
146+
):
147+
if not redis_status:
148+
pytest.xfail("Could not connect to the Redis server")
149+
150+
content = file_content_unit_with_name_factory("a.iso")
151+
repo = _add_files_to_repo(file_bindings, file_repo_with_auto_publish, [content], monitor_task)
152+
distro = file_distribution_factory(repository=repo.pulp_href)
153+
distro_url = distribution_base_url(distro.base_url)
154+
155+
json_miss = download_file(distro_url, headers=JSON_ACCEPT)
156+
json_hit = download_file(distro_url, headers=JSON_ACCEPT)
157+
html_miss = download_file(distro_url, headers=HTML_ACCEPT)
158+
html_hit = download_file(distro_url, headers=HTML_ACCEPT)
159+
160+
assert json_miss.response_obj.headers.get("X-PULP-CACHE") == "MISS"
161+
assert json_hit.response_obj.headers.get("X-PULP-CACHE") == "HIT"
162+
assert html_miss.response_obj.headers.get("X-PULP-CACHE") == "MISS"
163+
assert html_hit.response_obj.headers.get("X-PULP-CACHE") == "HIT"
164+
assert "application/json" in json_hit.response_obj.headers["Content-Type"]
165+
assert "text/html" in html_hit.response_obj.headers["Content-Type"]
166+
assert json.loads(json_hit.body)["packages"]
167+
assert b"<html" in html_hit.body.lower() or b"<a href=" in html_hit.body.lower()

pulpcore/tests/unit/content/test_handler.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import hashlib
2+
import json
13
import uuid
24
from datetime import timedelta
35
from unittest.mock import AsyncMock, Mock
46

57
import pytest
68
import pytest_asyncio
9+
from aiohttp.web import HTTPOk, StreamResponse
710
from aiohttp.web_exceptions import HTTPMovedPermanently
11+
from asgiref.sync import async_to_sync
812
from django.db import IntegrityError
913
from django_guid import clear_guid, set_guid
1014

@@ -17,6 +21,7 @@
1721
ContentArtifact,
1822
Distribution,
1923
Publication,
24+
PublishedArtifact,
2025
Remote,
2126
RemoteArtifact,
2227
Repository,
@@ -607,3 +612,140 @@ async def test_async_pull_through_add(ca1, monkeypatch, app_status):
607612
await repo.adelete()
608613
if task:
609614
await task.adelete()
615+
616+
617+
def test_content_handler_json_default_returns_none():
618+
assert Distribution().content_handler_json("any/path") is None
619+
620+
621+
@pytest.mark.parametrize(
622+
"accept,expected",
623+
[
624+
(None, False),
625+
("text/html", False),
626+
("*/*", False),
627+
("application/json", True),
628+
("application/vnd.pypi.simple.v1+json", True),
629+
],
630+
)
631+
def test_negotiate_json(accept, expected):
632+
request = Mock(headers={} if accept is None else {"Accept": accept})
633+
assert Handler.negotiate_json(request) is expected
634+
635+
636+
def test_pagination_params_defaults_and_bounds():
637+
request = Mock(query={})
638+
assert Handler._pagination_params(request) == (Handler.DEFAULT_JSON_LIST_LIMIT, 0)
639+
640+
request = Mock(query={"limit": "nope", "offset": "also-nope"})
641+
assert Handler._pagination_params(request) == (Handler.DEFAULT_JSON_LIST_LIMIT, 0)
642+
643+
request = Mock(query={"limit": "0", "offset": "-5"})
644+
assert Handler._pagination_params(request) == (1, 0)
645+
646+
request = Mock(query={"limit": "99999", "offset": "10"})
647+
assert Handler._pagination_params(request) == (Handler.MAX_JSON_LIST_LIMIT, 10)
648+
649+
650+
def test_json_listing_response_envelope_and_next_offset():
651+
entries = [{"path": "a.iso", "size": 1, "date": "2026-01-01T00:00:00"}]
652+
response = Handler._json_listing_response("/pulp/content/foo/", entries, 3, 1, 0)
653+
assert isinstance(response, HTTPOk)
654+
assert "application/json" in response.headers["Content-Type"]
655+
assert response.headers["Vary"] == "Accept"
656+
body = json.loads(response.text)
657+
assert body["path"] == "/pulp/content/foo/"
658+
assert body["packages"] == entries
659+
assert body["count"] == 3
660+
assert body["limit"] == 1
661+
assert body["offset"] == 0
662+
assert body["next_offset"] == 1
663+
664+
last_page = Handler._json_listing_response("/pulp/content/foo/", entries, 1, 1, 0)
665+
assert "next_offset" not in json.loads(last_page.text)
666+
667+
668+
def test_json_response_passes_through_stream_response():
669+
original = StreamResponse()
670+
assert Handler._json_response(original) is original
671+
672+
response = Handler._json_response({"hello": "world"})
673+
assert "application/json" in response.headers["Content-Type"]
674+
assert json.loads(response.text) == {"hello": "world"}
675+
676+
677+
@pytest.mark.asyncio
678+
async def test_content_handler_json_hook_is_used_when_accept_prefers_json():
679+
"""A Distribution.content_handler_json override is returned as the JSON response."""
680+
handler = Handler()
681+
distro = Mock()
682+
distro.base_path = "foo"
683+
distro.checkpoint = False
684+
distro.content_handler.return_value = None
685+
distro.content_handler_json.return_value = {"packages": ["from-plugin"]}
686+
distro.content_headers_for.return_value = {}
687+
handler._match_distribution = Mock(return_value=distro)
688+
handler._permit = Mock(return_value=False)
689+
690+
request = Mock()
691+
request.headers = {"Accept": "application/json"}
692+
request.path = "/pulp/content/foo/"
693+
request.query = {}
694+
695+
response = await handler._match_and_stream("foo/", request)
696+
697+
distro.content_handler_json.assert_called_once_with("")
698+
assert "application/json" in response.headers["Content-Type"]
699+
assert json.loads(response.text) == {"packages": ["from-plugin"]}
700+
701+
702+
@pytest.mark.django_db
703+
def test_list_directory_flat_recursive_leaves_and_pagination():
704+
from pulp_file.app.models import FileContent, FilePublication, FileRepository
705+
706+
repo = FileRepository.objects.create(name=str(uuid.uuid4()))
707+
paths = ["a.iso", "subdir/b.iso"]
708+
with repo.new_version() as repo_version:
709+
for path in paths:
710+
digest = hashlib.sha256(path.encode()).hexdigest()
711+
content = FileContent.objects.create(relative_path=path, digest=digest)
712+
repo_version.add_content(Content.objects.filter(pk=content.pk))
713+
ContentArtifact.objects.create(content=content, relative_path=path)
714+
715+
publication = FilePublication.objects.create(repository_version=repo_version, complete=True)
716+
for ca in ContentArtifact.objects.filter(content__in=repo_version.content.all()):
717+
PublishedArtifact.objects.create(
718+
publication=publication, content_artifact=ca, relative_path=ca.relative_path
719+
)
720+
721+
handler = Handler()
722+
list_flat = async_to_sync(handler.list_directory_flat)
723+
entries, total = list_flat(None, publication, "", 1000, 0)
724+
listed_paths = [entry["path"] for entry in entries]
725+
assert total == 2
726+
assert listed_paths == ["a.iso", "subdir/b.iso"]
727+
assert "subdir/" not in listed_paths
728+
assert all("size" in entry and "date" in entry for entry in entries)
729+
730+
nested, nested_total = list_flat(None, publication, "subdir/", 1000, 0)
731+
assert nested_total == 1
732+
assert nested[0]["path"] == "b.iso"
733+
734+
page, page_total = list_flat(None, publication, "", 1, 0)
735+
assert page_total == 2
736+
assert [entry["path"] for entry in page] == ["a.iso"]
737+
738+
empty, empty_total = list_flat(None, publication, "missing/", 1000, 0)
739+
assert empty_total == 0
740+
assert empty == []
741+
742+
rv_entries, rv_total = list_flat(repo_version, None, "", 1000, 0)
743+
assert rv_total == 2
744+
assert [entry["path"] for entry in rv_entries] == ["a.iso", "subdir/b.iso"]
745+
746+
from django.db import connection
747+
from django.test.utils import CaptureQueriesContext
748+
749+
with CaptureQueriesContext(connection) as ctx:
750+
list_flat(None, publication, "", 1, 0)
751+
assert any("LIMIT" in q["sql"].upper() for q in ctx.captured_queries)

0 commit comments

Comments
 (0)