Skip to content

Commit c5c8525

Browse files
committed
Add tests
1 parent c7d6123 commit c5c8525

1 file changed

Lines changed: 350 additions & 0 deletions

File tree

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import httpx
6+
import pytest
7+
8+
from infrahub_sdk.template import Jinja2Template
9+
from infrahub_sdk.template.exceptions import JinjaFilterError, JinjaTemplateError, JinjaTemplateOperationViolationError
10+
from infrahub_sdk.template.filters import INFRAHUB_FILTERS, ExecutionContext, FilterDefinition
11+
from infrahub_sdk.template.infrahub_filters import from_json, from_yaml, no_client_filter
12+
13+
if TYPE_CHECKING:
14+
from pytest_httpx import HTTPXMock
15+
16+
from infrahub_sdk import InfrahubClient
17+
18+
19+
pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True)
20+
21+
ARTIFACT_CONTENT_URL = "http://mock/api/storage/object"
22+
FILE_OBJECT_CONTENT_URL = "http://mock/api/files/by-storage-id"
23+
24+
CLIENT_FILTER_PARAMS = [
25+
pytest.param(
26+
"artifact_content",
27+
"{{ storage_id | artifact_content }}",
28+
f"{ARTIFACT_CONTENT_URL}/test-id",
29+
{"content-type": "text/plain"},
30+
id="artifact_content",
31+
),
32+
pytest.param(
33+
"file_object_content",
34+
"{{ storage_id | file_object_content }}",
35+
f"{FILE_OBJECT_CONTENT_URL}/test-id",
36+
{"content-type": "text/plain"},
37+
id="file_object_content",
38+
),
39+
]
40+
41+
42+
class TestJinjaFilterError:
43+
def test_instantiation_without_hint(self) -> None:
44+
exc = JinjaFilterError(filter_name="my_filter", message="something broke")
45+
assert exc.filter_name == "my_filter"
46+
assert exc.hint is None
47+
assert exc.message == "Filter 'my_filter': something broke"
48+
49+
def test_instantiation_with_hint(self) -> None:
50+
exc = JinjaFilterError(filter_name="my_filter", message="something broke", hint="try harder")
51+
assert exc.filter_name == "my_filter"
52+
assert exc.hint == "try harder"
53+
assert exc.message == "Filter 'my_filter': something broke — try harder"
54+
55+
56+
class TestFilterDefinition:
57+
def test_trusted_when_all_contexts(self) -> None:
58+
fd = FilterDefinition(name="abs", allowed_contexts=ExecutionContext.ALL, source="jinja2")
59+
assert fd.trusted is True
60+
61+
def test_not_trusted_when_local_only(self) -> None:
62+
fd = FilterDefinition(name="safe", allowed_contexts=ExecutionContext.LOCAL, source="jinja2")
63+
assert fd.trusted is False
64+
65+
def test_not_trusted_when_worker_and_local(self) -> None:
66+
fd = FilterDefinition(
67+
name="artifact_content",
68+
allowed_contexts=ExecutionContext.WORKER | ExecutionContext.LOCAL,
69+
source="infrahub",
70+
)
71+
assert fd.trusted is False
72+
73+
def test_not_trusted_when_core_only(self) -> None:
74+
fd = FilterDefinition(name="custom", allowed_contexts=ExecutionContext.CORE, source="test")
75+
assert fd.trusted is False
76+
77+
def test_infrahub_filters_list_sorted(self) -> None:
78+
"""Infrahub filter names should be in alphabetical order."""
79+
names = [fd.name for fd in INFRAHUB_FILTERS]
80+
assert names == sorted(names)
81+
82+
83+
class TestValidateContext:
84+
def test_restricted_true_blocks_untrusted_filters(self) -> None:
85+
"""restricted=True behaves like ExecutionContext.CORE -- blocks LOCAL-only filters."""
86+
jinja = Jinja2Template(template="{{ network | get_all_host }}")
87+
with pytest.raises(JinjaTemplateOperationViolationError) as exc:
88+
jinja.validate(restricted=True)
89+
assert exc.value.message == "The 'get_all_host' filter isn't allowed to be used"
90+
91+
def test_restricted_false_allows_all_filters(self) -> None:
92+
"""restricted=False behaves like ExecutionContext.LOCAL -- allows everything."""
93+
jinja = Jinja2Template(template="{{ network | get_all_host }}")
94+
jinja.validate(restricted=False)
95+
96+
def test_context_core_blocks_artifact_content(self) -> None:
97+
jinja = Jinja2Template(template="{{ sid | artifact_content }}")
98+
with pytest.raises(JinjaTemplateOperationViolationError) as exc:
99+
jinja.validate(context=ExecutionContext.CORE)
100+
assert exc.value.message == "The 'artifact_content' filter isn't allowed to be used"
101+
102+
def test_context_worker_allows_artifact_content(self) -> None:
103+
jinja = Jinja2Template(template="{{ sid | artifact_content }}")
104+
jinja.validate(context=ExecutionContext.WORKER)
105+
106+
def test_context_worker_blocks_local_only_filters(self) -> None:
107+
"""WORKER context should still block LOCAL-only filters like 'safe'."""
108+
jinja = Jinja2Template(template="{{ data | safe }}")
109+
with pytest.raises(JinjaTemplateOperationViolationError) as exc:
110+
jinja.validate(context=ExecutionContext.WORKER)
111+
assert exc.value.message == "The 'safe' filter isn't allowed to be used"
112+
113+
def test_context_local_allows_local_only_filters(self) -> None:
114+
jinja = Jinja2Template(template="{{ data | safe }}")
115+
jinja.validate(context=ExecutionContext.LOCAL)
116+
117+
def test_context_local_allows_artifact_content(self) -> None:
118+
"""LOCAL context allows artifact_content (WORKER | LOCAL)."""
119+
jinja = Jinja2Template(template="{{ sid | artifact_content }}")
120+
jinja.validate(context=ExecutionContext.LOCAL)
121+
122+
@pytest.mark.parametrize("context", [ExecutionContext.CORE, ExecutionContext.WORKER])
123+
def test_user_filters_always_allowed(self, context: ExecutionContext) -> None:
124+
def my_custom_filter(value: str) -> str:
125+
return value.upper()
126+
127+
jinja = Jinja2Template(template="{{ name | my_custom }}", filters={"my_custom": my_custom_filter})
128+
jinja.validate(context=context)
129+
130+
def test_context_core_allows_from_json(self) -> None:
131+
jinja = Jinja2Template(template="{{ '{\"a\":1}' | from_json }}")
132+
jinja.validate(context=ExecutionContext.CORE)
133+
134+
def test_context_core_blocks_file_object_content(self) -> None:
135+
jinja = Jinja2Template(template="{{ sid | file_object_content }}")
136+
with pytest.raises(JinjaTemplateOperationViolationError) as exc:
137+
jinja.validate(context=ExecutionContext.CORE)
138+
assert exc.value.message == "The 'file_object_content' filter isn't allowed to be used"
139+
140+
def test_context_worker_allows_file_object_content(self) -> None:
141+
jinja = Jinja2Template(template="{{ sid | file_object_content }}")
142+
jinja.validate(context=ExecutionContext.WORKER)
143+
144+
145+
class TestClientDependentFilters:
146+
@pytest.mark.parametrize(("filter_name", "template", "url", "headers"), CLIENT_FILTER_PARAMS)
147+
async def test_happy_path(
148+
self,
149+
filter_name: str,
150+
template: str,
151+
url: str,
152+
headers: dict[str, str],
153+
client: InfrahubClient,
154+
httpx_mock: HTTPXMock,
155+
) -> None:
156+
httpx_mock.add_response(method="GET", url=url, text="rendered content", headers=headers)
157+
jinja = Jinja2Template(template=template, client=client)
158+
result = await jinja.render(variables={"storage_id": "test-id"})
159+
assert result == "rendered content"
160+
161+
@pytest.mark.parametrize(("filter_name", "template", "url", "headers"), CLIENT_FILTER_PARAMS)
162+
@pytest.mark.parametrize(
163+
("storage_id_value", "expected_message"),
164+
[
165+
pytest.param(
166+
None,
167+
"Filter '{filter_name}': storage_id is null"
168+
" — ensure the GraphQL query returns a valid storage_id value",
169+
id="null",
170+
),
171+
pytest.param(
172+
"",
173+
"Filter '{filter_name}': storage_id is empty"
174+
" — ensure the GraphQL query returns a non-empty storage_id value",
175+
id="empty",
176+
),
177+
],
178+
)
179+
async def test_invalid_storage_id(
180+
self,
181+
filter_name: str,
182+
template: str,
183+
url: str,
184+
headers: dict[str, str],
185+
storage_id_value: str | None,
186+
expected_message: str,
187+
client: InfrahubClient,
188+
) -> None:
189+
jinja = Jinja2Template(template=template, client=client)
190+
with pytest.raises(JinjaTemplateError) as exc:
191+
await jinja.render(variables={"storage_id": storage_id_value})
192+
assert exc.value.message == expected_message.format(filter_name=filter_name)
193+
194+
@pytest.mark.parametrize(
195+
("template", "url"),
196+
[
197+
pytest.param(
198+
"{{ storage_id | artifact_content }}",
199+
f"{ARTIFACT_CONTENT_URL}/abc-123",
200+
id="artifact_content",
201+
),
202+
pytest.param(
203+
"{{ storage_id | file_object_content }}",
204+
f"{FILE_OBJECT_CONTENT_URL}/abc-123",
205+
id="file_object_content",
206+
),
207+
],
208+
)
209+
async def test_store_exception_is_wrapped(
210+
self, template: str, url: str, client: InfrahubClient, httpx_mock: HTTPXMock
211+
) -> None:
212+
httpx_mock.add_exception(httpx.ConnectError("connection timeout"), method="GET", url=url)
213+
jinja = Jinja2Template(template=template, client=client)
214+
with pytest.raises(JinjaTemplateError):
215+
await jinja.render(variables={"storage_id": "abc-123"})
216+
217+
@pytest.mark.parametrize(
218+
("template", "url", "storage_id", "expected_message"),
219+
[
220+
pytest.param(
221+
"{{ storage_id | artifact_content }}",
222+
f"{ARTIFACT_CONTENT_URL}/sid-x",
223+
"sid-x",
224+
"Filter 'artifact_content': permission denied for storage_id: sid-x",
225+
id="artifact_content",
226+
),
227+
pytest.param(
228+
"{{ storage_id | file_object_content }}",
229+
f"{FILE_OBJECT_CONTENT_URL}/fid-x",
230+
"fid-x",
231+
"Filter 'file_object_content': permission denied for storage_id: fid-x",
232+
id="file_object_content",
233+
),
234+
],
235+
)
236+
async def test_auth_error(
237+
self,
238+
template: str,
239+
url: str,
240+
storage_id: str,
241+
expected_message: str,
242+
client: InfrahubClient,
243+
httpx_mock: HTTPXMock,
244+
) -> None:
245+
httpx_mock.add_response(method="GET", url=url, status_code=403, json={"errors": [{"message": "forbidden"}]})
246+
jinja = Jinja2Template(template=template, client=client)
247+
with pytest.raises(JinjaTemplateError) as exc:
248+
await jinja.render(variables={"storage_id": storage_id})
249+
assert exc.value.message == expected_message
250+
251+
async def test_file_object_content_binary_content_rejected(
252+
self, client: InfrahubClient, httpx_mock: HTTPXMock
253+
) -> None:
254+
httpx_mock.add_response(
255+
method="GET",
256+
url=f"{FILE_OBJECT_CONTENT_URL}/fid-bin",
257+
content=b"\x00\x01\x02",
258+
headers={"content-type": "application/octet-stream"},
259+
)
260+
jinja = Jinja2Template(template="{{ storage_id | file_object_content }}", client=client)
261+
with pytest.raises(JinjaTemplateError) as exc:
262+
await jinja.render(variables={"storage_id": "fid-bin"})
263+
assert (
264+
exc.value.message == "Filter 'file_object_content': Binary content not supported:"
265+
" content-type 'application/octet-stream' for storage_id 'fid-bin'"
266+
)
267+
268+
269+
class TestFromJsonFilter:
270+
def test_valid_json(self) -> None:
271+
result = from_json('{"key": "value", "num": 42}')
272+
assert result == {"key": "value", "num": 42}
273+
274+
def test_valid_json_list(self) -> None:
275+
result = from_json("[1, 2, 3]")
276+
assert result == [1, 2, 3]
277+
278+
def test_empty_string_returns_empty_dict(self) -> None:
279+
assert from_json("") == {}
280+
281+
def test_malformed_json_raises_error(self) -> None:
282+
with pytest.raises(JinjaFilterError) as exc:
283+
from_json("{not valid json}")
284+
assert exc.value.filter_name == "from_json"
285+
assert exc.value.message.startswith("Filter 'from_json': invalid JSON: Expecting property name")
286+
287+
async def test_render_through_template(self) -> None:
288+
jinja = Jinja2Template(template="{{ data | from_json }}")
289+
result = await jinja.render(variables={"data": '{"a": 1}'})
290+
assert "a" in result
291+
assert "1" in result
292+
293+
294+
class TestFromYamlFilter:
295+
def test_valid_yaml(self) -> None:
296+
result = from_yaml("key: value\nnum: 42")
297+
assert result == {"key": "value", "num": 42}
298+
299+
def test_valid_yaml_list(self) -> None:
300+
result = from_yaml("- one\n- two\n- three")
301+
assert result == ["one", "two", "three"]
302+
303+
def test_empty_string_returns_empty_dict(self) -> None:
304+
assert from_yaml("") == {}
305+
306+
def test_malformed_yaml_raises_error(self) -> None:
307+
with pytest.raises(JinjaFilterError) as exc:
308+
from_yaml("key:\n\t- broken: [unclosed")
309+
assert exc.value.filter_name == "from_yaml"
310+
assert exc.value.message is not None
311+
assert exc.value.message.startswith("Filter 'from_yaml': invalid YAML: while scanning for the next token")
312+
313+
async def test_render_through_template(self) -> None:
314+
jinja = Jinja2Template(template="{{ data | from_yaml }}")
315+
result = await jinja.render(variables={"data": "key: value"})
316+
assert "key" in result
317+
assert "value" in result
318+
319+
320+
class TestFilterChaining:
321+
async def test_artifact_content_piped_to_from_json(self, client: InfrahubClient, httpx_mock: HTTPXMock) -> None:
322+
json_payload = '{"hostname": "router1", "interfaces": ["eth0", "eth1"]}'
323+
httpx_mock.add_response(method="GET", url=f"{ARTIFACT_CONTENT_URL}/store-789", text=json_payload)
324+
jinja = Jinja2Template(template="{{ storage_id | artifact_content | from_json }}", client=client)
325+
result = await jinja.render(variables={"storage_id": "store-789"})
326+
assert "hostname" in result
327+
assert "router1" in result
328+
329+
330+
class TestClientFilter:
331+
@pytest.mark.parametrize("filter_name", ["artifact_content", "file_object_content"])
332+
async def test_no_client_filter_raises(self, filter_name: str) -> None:
333+
fallback = no_client_filter(filter_name)
334+
with pytest.raises(JinjaFilterError) as exc:
335+
await fallback("some-id")
336+
assert exc.value.message == (
337+
f"Filter '{filter_name}': requires an InfrahubClient — pass a client via Jinja2Template(client=...)"
338+
)
339+
assert exc.value.filter_name == filter_name
340+
341+
async def test_set_client_enables_artifact_content(self, client: InfrahubClient, httpx_mock: HTTPXMock) -> None:
342+
httpx_mock.add_response(method="GET", url=f"{ARTIFACT_CONTENT_URL}/abc", text="deferred content")
343+
tpl = Jinja2Template(template="{{ sid | artifact_content }}")
344+
345+
with pytest.raises(JinjaTemplateError):
346+
await tpl.render(variables={"sid": "abc"})
347+
348+
tpl.set_client(client=client)
349+
result = await tpl.render(variables={"sid": "abc"})
350+
assert result == "deferred content"

0 commit comments

Comments
 (0)