Skip to content

Commit 17815b9

Browse files
silvi-tcrstrn13
andauthored
test: add extension PipelinePolicy tests (#991)
Signed-off-by: Alexander Cristurean <acristur@redhat.com> Signed-off-by: Silvia Tarabova <starabov@redhat.com> Co-authored-by: Alexander Cristurean <acristur@redhat.com>
1 parent 5682b36 commit 17815b9

26 files changed

Lines changed: 1275 additions & 1 deletion

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@ podmonitors.monitoring.coreos.com,$\
163163
apiservices.apiregistration.k8s.io,$\
164164
horizontalpodautoscalers.autoscaling,$\
165165
oidcpolicies.extensions.kuadrant.io,$\
166-
planpolicies.extensions.kuadrant.io
166+
planpolicies.extensions.kuadrant.io,$\
167+
pipelinepolicies.extensions.kuadrant.io
167168

168169
clean: ## Clean all objects on cluster created by running this testsuite. Set the env variable USER to delete after someone else
169170
@echo "Deleting objects for user: $(USER)"

config/settings.local.yaml.tpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
# password: "testPassword"
1717
# spicedb:
1818
# image: "SPICEDB_IMAGE"
19+
# pipeline_policy_extension_service:
20+
# image: "PIPELINE_POLICY_EXTENSION_SERVICE_IMAGE"
1921
# auth0:
2022
# client_id: "CLIENT_ID"
2123
# client_secret: "CLIENT_SECRET"

config/settings.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ default:
1818
image: "quay.io/rhn_support_azgabur/mockserver:latest"
1919
spicedb:
2020
image: "quay.io/authzed/spicedb:latest"
21+
pipeline_policy_extension_service:
22+
image: "quay.io/kuadrant/threat-assessment-service:latest"
2123
prometheus:
2224
project: "openshift-monitoring"
2325
service: "thanos-querier"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Module containing classes related to PipelinePolicy"""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from functools import cached_property
7+
from typing import Dict, List, Optional
8+
9+
from testsuite.gateway import Referencable
10+
from testsuite.kubernetes import modify
11+
from testsuite.kubernetes.client import KubernetesClient
12+
from testsuite.kuadrant.policy import Policy
13+
14+
15+
class ActionSection:
16+
"""Section for request/response actions in a PipelinePolicy, mirrors ActionSpec from the Go API"""
17+
18+
def __init__(self, obj: "PipelinePolicy", section_name: str) -> None:
19+
self.obj = obj
20+
self.section_name = section_name
21+
22+
def modify_and_apply(self, modifier_func, retries=2, cmd_args=None):
23+
"""Delegates modify_and_apply to the parent PipelinePolicy"""
24+
25+
def _new_modifier(obj):
26+
modifier_func(ActionSection(obj, self.section_name))
27+
28+
return self.obj.modify_and_apply(_new_modifier, retries, cmd_args)
29+
30+
@property
31+
def committed(self):
32+
"""Delegates committed check to the parent PipelinePolicy"""
33+
return self.obj.committed
34+
35+
@property
36+
def section(self):
37+
"""Returns the action list for this section"""
38+
return self.obj.model.spec.setdefault(self.section_name, [])
39+
40+
@modify
41+
def add_grpc_method(self, method: str, var: Optional[str] = None, predicate: Optional[str] = None):
42+
"""Add a grpc_method action that calls an upstream"""
43+
action: Dict = {"type": "grpc_method", "method": method}
44+
if var is not None:
45+
action["var"] = var
46+
if predicate is not None:
47+
action["predicate"] = predicate
48+
self.section.append(action)
49+
50+
@modify
51+
def add_deny(
52+
self,
53+
predicate: Optional[str] = None,
54+
with_status: Optional[int] = None,
55+
with_headers: Optional[str] = None,
56+
with_body: Optional[str] = None,
57+
):
58+
"""Add a deny action"""
59+
action: Dict = {"type": "deny"}
60+
if predicate is not None:
61+
action["predicate"] = predicate
62+
if with_status is not None:
63+
action["withStatus"] = with_status
64+
if with_headers is not None:
65+
action["withHeaders"] = with_headers
66+
if with_body is not None:
67+
action["withBody"] = with_body
68+
self.section.append(action)
69+
70+
@modify
71+
def add_fail(self, log_message: str, predicate: Optional[str] = None):
72+
"""Add a fail action"""
73+
action: Dict = {"type": "fail", "logMessage": log_message}
74+
if predicate is not None:
75+
action["predicate"] = predicate
76+
self.section.append(action)
77+
78+
@modify
79+
def add_headers(self, headers: List[List[str]], predicate: Optional[str] = None):
80+
"""Add an add_headers action"""
81+
action: Dict = {"type": "add_headers", "headersToAdd": json.dumps(headers)}
82+
if predicate is not None:
83+
action["predicate"] = predicate
84+
self.section.append(action)
85+
86+
87+
class PipelinePolicy(Policy):
88+
"""PipelinePolicy for defining declarative action pipelines (request/response actions) on routes"""
89+
90+
@classmethod
91+
def create_instance(
92+
cls,
93+
cluster: KubernetesClient,
94+
name: str,
95+
target: Referencable,
96+
labels: Dict[str, str] = None,
97+
section_name: str = None,
98+
):
99+
"""Creates base instance"""
100+
model: Dict = {
101+
"apiVersion": "extensions.kuadrant.io/v1alpha1",
102+
"kind": "PipelinePolicy",
103+
"metadata": {"name": name, "namespace": cluster.project, "labels": labels},
104+
"spec": {
105+
"targetRef": target.reference,
106+
},
107+
}
108+
if section_name:
109+
model["spec"]["targetRef"]["sectionName"] = section_name
110+
111+
return cls(model, context=cluster.context)
112+
113+
@cached_property
114+
def on_http_request(self) -> ActionSection:
115+
"""Gives access to request actions"""
116+
return ActionSection(self, "request")
117+
118+
@cached_property
119+
def on_http_response(self) -> ActionSection:
120+
"""Gives access to response actions"""
121+
return ActionSection(self, "response")
122+
123+
@modify
124+
def add_action_method(self, name: str, url: str, service: str, method: str, message_template: str):
125+
"""Add a gRPC upstream action method definition"""
126+
self.model.spec.setdefault("actionMethods", []).append(
127+
{
128+
"name": name,
129+
"url": url,
130+
"service": service,
131+
"method": method,
132+
"messageTemplate": message_template,
133+
}
134+
)

testsuite/tests/singlecluster/extensions/pipeline_policy/__init__.py

Whitespace-only changes.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Shared fixtures for PipelinePolicy testing."""
2+
3+
import pytest
4+
5+
from openshift_client import OpenShiftPythonException
6+
7+
from testsuite.kuadrant.extensions.pipeline_policy import PipelinePolicy
8+
9+
10+
@pytest.fixture(scope="session", autouse=True)
11+
def check_pipeline_policy_crd(cluster, skip_or_fail):
12+
"""Skip all PipelinePolicy tests if the CRD is not installed on the cluster."""
13+
try:
14+
cluster.do_action("get", "crd/pipelinepolicies.extensions.kuadrant.io")
15+
except OpenShiftPythonException:
16+
skip_or_fail("PipelinePolicy CRD is not installed on the cluster")
17+
18+
19+
@pytest.fixture(scope="module")
20+
def pipeline_policy(cluster, blame, route, module_label):
21+
"""PipelinePolicy targeting the test HTTPRoute"""
22+
return PipelinePolicy.create_instance(cluster, blame("pipeline"), route, labels={"testRun": module_label})
23+
24+
25+
@pytest.fixture(scope="module", autouse=True)
26+
def commit(request, pipeline_policy):
27+
"""Commit and wait for PipelinePolicy to be ready."""
28+
request.addfinalizer(pipeline_policy.delete)
29+
pipeline_policy.commit()
30+
pipeline_policy.wait_for_ready()

testsuite/tests/singlecluster/extensions/pipeline_policy/grpc/__init__.py

Whitespace-only changes.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Shared fixtures for PipelinePolicy gRPC tests."""
2+
3+
import pytest
4+
5+
from testsuite.kubernetes import Selector
6+
from testsuite.kubernetes.deployment import Deployment
7+
from testsuite.kubernetes.service import Service, ServicePort
8+
from testsuite.utils.constants import HTTP_API_PORT
9+
10+
11+
@pytest.fixture(scope="module")
12+
def threat_assessment_service(request, cluster, blame, module_label, testconfig):
13+
"""Deploys the ThreatAssessmentService gRPC backend"""
14+
testconfig.validators.validate(only="pipeline_policy_extension_service")
15+
name = blame("threat")
16+
match_labels = {"app": module_label, "deployment": name}
17+
18+
deployment = Deployment.create_instance(
19+
cluster,
20+
name,
21+
container_name="threat-assessment",
22+
image=testconfig["pipeline_policy_extension_service"]["image"],
23+
ports={"grpc": HTTP_API_PORT},
24+
selector=Selector(matchLabels=match_labels),
25+
labels={"app": module_label},
26+
readiness_probe={"grpc": {"port": HTTP_API_PORT}, "initialDelaySeconds": 3, "periodSeconds": 5},
27+
)
28+
request.addfinalizer(deployment.delete)
29+
deployment.commit()
30+
deployment.wait_for_ready()
31+
32+
service = Service.create_instance(
33+
cluster,
34+
name,
35+
selector=match_labels,
36+
ports=[ServicePort(name="grpc", port=HTTP_API_PORT, targetPort="grpc")],
37+
labels={"app": module_label},
38+
)
39+
request.addfinalizer(service.delete)
40+
service.commit()
41+
return service
42+
43+
44+
@pytest.fixture(scope="module")
45+
def threat_service_url(threat_assessment_service):
46+
"""gRPC URL for the threat assessment service."""
47+
svc = threat_assessment_service
48+
return f"grpc://{svc.name()}.{svc.namespace()}.svc.cluster.local:{HTTP_API_PORT}"
49+
50+
51+
@pytest.fixture(scope="module")
52+
def pipeline_policy(pipeline_policy, threat_service_url):
53+
"""PipelinePolicy with the threat assessment gRPC action method pre-registered."""
54+
pipeline_policy.add_action_method(
55+
name="assess",
56+
url=threat_service_url,
57+
service="threat.v1.ThreatAssessmentService",
58+
method="AssessRequest",
59+
message_template="threat.v1.ThreatRequest{uri: request.path}",
60+
)
61+
return pipeline_policy
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Tests for PipelinePolicy grpc_method action: upstream calls and conditional execution."""
2+
3+
import pytest
4+
5+
from testsuite.utils.constants import THREAT_ASSESSMENT_THRESHOLD
6+
7+
pytestmark = [pytest.mark.kuadrant_only, pytest.mark.extensions]
8+
9+
10+
@pytest.fixture(scope="module")
11+
def pipeline_policy(pipeline_policy):
12+
"""PipelinePolicy with conditional gRPC execution and threat-level deny."""
13+
pipeline_policy.on_http_request.add_grpc_method(
14+
method="assess",
15+
var="threatResponse",
16+
predicate='"x-assess-threat" in request.headers',
17+
)
18+
pipeline_policy.on_http_request.add_deny(predicate='request.url_path == "/blocked"', with_status=403)
19+
pipeline_policy.on_http_request.add_deny(
20+
predicate=f"threatResponse.threat_level >= {THREAT_ASSESSMENT_THRESHOLD}",
21+
with_status=403,
22+
)
23+
24+
pipeline_policy.on_http_response.add_headers(
25+
[["x-threat-assessed", "true"]],
26+
predicate='"x-assess-threat" in request.headers',
27+
)
28+
pipeline_policy.on_http_response.add_headers(
29+
[["x-threat-assessed", "false"]],
30+
predicate='!("x-assess-threat" in request.headers)',
31+
)
32+
pipeline_policy.on_http_response.add_headers([["x-threat-threshold", str(THREAT_ASSESSMENT_THRESHOLD)]])
33+
34+
return pipeline_policy
35+
36+
37+
def test_basic_grpc_upstream_call(client):
38+
"""gRPC upstream is called when predicate matches, response var is available to subsequent actions."""
39+
response = client.get("/get", headers={"x-assess-threat": "true"})
40+
assert response.status_code == 200
41+
assert response.headers.get("x-threat-assessed") == "true"
42+
assert response.headers.get("x-threat-threshold") == str(THREAT_ASSESSMENT_THRESHOLD)
43+
44+
45+
def test_conditional_grpc_call_skipped(client):
46+
"""gRPC upstream is not called when predicate does not match."""
47+
response = client.get("/get")
48+
assert response.status_code == 200
49+
assert response.headers.get("x-threat-assessed") == "false"
50+
assert response.headers.get("x-threat-threshold") == str(THREAT_ASSESSMENT_THRESHOLD)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Tests for PipelinePolicy composition of gRPC actions with deny and fail."""
2+
3+
import pytest
4+
5+
from testsuite.kuadrant.extensions.pipeline_policy import PipelinePolicy
6+
7+
pytestmark = [pytest.mark.kuadrant_only, pytest.mark.extensions]
8+
9+
10+
@pytest.fixture(scope="module", autouse=True)
11+
def commit():
12+
"""No module-level policy; each test creates its own."""
13+
14+
15+
@pytest.fixture(scope="module")
16+
def create_grpc_policy(cluster, blame, route, threat_service_url, module_label):
17+
"""Factory for creating a PipelinePolicy with the assess gRPC action pre-registered."""
18+
19+
def _create(name):
20+
policy = PipelinePolicy.create_instance(cluster, blame(name), route, labels={"testRun": module_label})
21+
policy.add_action_method(
22+
name="assess",
23+
url=threat_service_url,
24+
service="threat.v1.ThreatAssessmentService",
25+
method="AssessRequest",
26+
message_template="threat.v1.ThreatRequest{uri: request.path}",
27+
)
28+
policy.on_http_request.add_grpc_method(method="assess", var="threat")
29+
return policy
30+
31+
return _create
32+
33+
34+
def test_fail_before_deny(request, create_grpc_policy, client):
35+
"""Fail action terminates the chain before the deny action when gRPC response triggers the fail predicate."""
36+
policy = create_grpc_policy("failord")
37+
policy.on_http_request.add_fail("threat too high", predicate="threat.threat_level >= 4")
38+
policy.on_http_request.add_deny(predicate='request.url_path == "/blocked"', with_status=403)
39+
request.addfinalizer(policy.delete)
40+
policy.commit()
41+
policy.wait_for_ready()
42+
43+
response = client.get("/admin")
44+
assert response.status_code == 500
45+
46+
47+
def test_deny_after_grpc_call(request, create_grpc_policy, client):
48+
"""Deny action after gRPC call works when the deny predicate matches."""
49+
policy = create_grpc_policy("grpc-deny")
50+
policy.on_http_request.add_deny(predicate='request.url_path == "/blocked"', with_status=403)
51+
request.addfinalizer(policy.delete)
52+
policy.commit()
53+
policy.wait_for_ready()
54+
55+
response = client.get("/blocked")
56+
assert response.status_code == 403
57+
58+
59+
def test_deny_based_on_grpc_var(request, create_grpc_policy, client):
60+
"""Deny action using gRPC response variable denies requests when threat level is high."""
61+
policy = create_grpc_policy("grpc-var-deny")
62+
policy.on_http_request.add_deny(predicate="threat.threat_level >= 4", with_status=403)
63+
request.addfinalizer(policy.delete)
64+
policy.commit()
65+
policy.wait_for_ready()
66+
67+
response = client.get("/admin")
68+
assert response.status_code == 403
69+
70+
response = client.get("/get")
71+
assert response.status_code == 200
72+
73+
74+
def test_deny_with_dynamic_body(request, create_grpc_policy, client):
75+
"""Deny action with CEL expression in withBody interpolates gRPC response variable."""
76+
policy = create_grpc_policy("dyn-body")
77+
policy.on_http_request.add_deny(
78+
predicate="threat.threat_level >= 4",
79+
with_status=403,
80+
with_body="'blocked: threat level ' + string(threat.threat_level)",
81+
)
82+
request.addfinalizer(policy.delete)
83+
policy.commit()
84+
policy.wait_for_ready()
85+
86+
response = client.get("/admin")
87+
assert response.status_code == 403
88+
assert "blocked: threat level" in response.text

0 commit comments

Comments
 (0)