Skip to content
Closed
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1fe44f3
feat(evalhub): Update EvalHub provider tests and add RBAC fixtures
ruivieira Mar 6, 2026
eb77148
Merge remote-tracking branch 'upstream/main' into evalhub-collections
ruivieira Mar 6, 2026
783e4f6
refactor(certificates): Change CA bundle creation by using IngressCon…
ruivieira Mar 6, 2026
55b31df
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 6, 2026
d297049
Merge branch 'main' into evalhub-collections
kpunwatk Mar 6, 2026
6fc160b
Merge branch 'main' into evalhub-collections
kpunwatk Mar 6, 2026
c26a84f
test(evalhub): add assertion to ensure providers are registered befor…
ruivieira Mar 6, 2026
121348f
fix(certs): ensure existence of router cert secret in IngressControll…
ruivieira Mar 6, 2026
c9c8790
test(evalhub): enhance error handling in provider ID retrieval test t…
ruivieira Mar 6, 2026
04a3b59
Merge branch 'main' into evalhub-collections
ruivieira Mar 6, 2026
3771ceb
refactor(test(evalhub)): change variable naming in provider ID
ruivieira Mar 8, 2026
03dc1d4
Merge remote-tracking branch 'origin/evalhub-collections' into evalhu…
ruivieira Mar 8, 2026
663ce8d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 8, 2026
a5812e4
test(evalhub): add fixture to fetch providers list per test class and…
ruivieira Mar 8, 2026
4e7a02d
Merge remote-tracking branch 'origin/evalhub-collections' into evalhu…
ruivieira Mar 8, 2026
2017d59
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 8, 2026
8a19c7d
refactor(test(evalhub)): rename test method for clarity on provider l…
ruivieira Mar 8, 2026
d975393
Merge remote-tracking branch 'origin/evalhub-collections' into evalhu…
ruivieira Mar 8, 2026
cb1b962
fix(tests): update command parameter in token generation functions fo…
ruivieira Mar 8, 2026
056e22c
fix(tests): use dynamic provider ID in unauthorized access test for e…
ruivieira Mar 8, 2026
06d8faf
Merge branch 'main' into evalhub-collections
ruivieira Mar 9, 2026
7ed9fed
Merge branch 'main' into evalhub-collections
dbasunag Mar 9, 2026
d7a0f95
Merge branch 'main' into evalhub-collections
dbasunag Mar 10, 2026
c5772cc
Merge branch 'main' into evalhub-collections
dbasunag Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions tests/model_explainability/evalhub/conftest.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import shlex
from collections.abc import Generator
from typing import Any

import pytest
from kubernetes.dynamic import DynamicClient
from ocp_resources.deployment import Deployment
from ocp_resources.namespace import Namespace
from ocp_resources.role_binding import RoleBinding
from ocp_resources.route import Route
from ocp_resources.service_account import ServiceAccount
from pyhelper_utils.shell import run_command
from simple_logger.logger import get_logger

from tests.model_explainability.evalhub.constants import EVALHUB_PROVIDERS_ACCESS_CLUSTER_ROLE
from tests.model_explainability.evalhub.utils import list_evalhub_providers
from utilities.certificates_utils import create_ca_bundle_file
from utilities.constants import Timeout
from utilities.resources.evalhub import EvalHub
Expand Down Expand Up @@ -67,3 +73,91 @@ def evalhub_ca_bundle_file(
) -> str:
"""Create a CA bundle file for verifying the EvalHub route TLS certificate."""
return create_ca_bundle_file(client=admin_client)


@pytest.fixture(scope="class")
def evalhub_scoped_sa(
admin_client: DynamicClient,
model_namespace: Namespace,
evalhub_deployment: Deployment,
) -> Generator[ServiceAccount, Any, Any]:
"""ServiceAccount with providers access in the test namespace."""
with ServiceAccount(
client=admin_client,
name="evalhub-test-user",
namespace=model_namespace.name,
) as sa:
yield sa


@pytest.fixture(scope="class")
def evalhub_providers_role_binding(
admin_client: DynamicClient,
model_namespace: Namespace,
evalhub_scoped_sa: ServiceAccount,
) -> Generator[RoleBinding, Any, Any]:
"""RoleBinding granting the scoped SA providers access via the ClusterRole."""
with RoleBinding(
client=admin_client,
name="evalhub-test-providers-access",
namespace=model_namespace.name,
role_ref_kind="ClusterRole",
role_ref_name=EVALHUB_PROVIDERS_ACCESS_CLUSTER_ROLE,
subjects_kind="ServiceAccount",
subjects_name=evalhub_scoped_sa.name,
) as rb:
yield rb


@pytest.fixture(scope="class")
def evalhub_scoped_token(
evalhub_scoped_sa: ServiceAccount,
model_namespace: Namespace,
) -> str:
"""Short-lived token for the scoped ServiceAccount."""
return run_command(
command=shlex.split(f"oc create token -n {model_namespace.name} {evalhub_scoped_sa.name} --duration=30m")
)[1].strip()


@pytest.fixture(scope="class")
def evalhub_providers_response(
model_namespace: Namespace,
evalhub_scoped_token: str,
evalhub_providers_role_binding: RoleBinding,
evalhub_ca_bundle_file: str,
evalhub_route: Route,
) -> dict:
"""Fetch the providers list once per test class."""
return list_evalhub_providers(
host=evalhub_route.host,
token=evalhub_scoped_token,
ca_bundle_file=evalhub_ca_bundle_file,
tenant=model_namespace.name,
)


@pytest.fixture(scope="class")
def evalhub_unauthorised_sa(
admin_client: DynamicClient,
model_namespace: Namespace,
evalhub_deployment: Deployment,
) -> Generator[ServiceAccount, Any, Any]:
"""ServiceAccount without any EvalHub RBAC in the test namespace."""
with ServiceAccount(
client=admin_client,
name="evalhub-no-access-user",
namespace=model_namespace.name,
) as sa:
yield sa


@pytest.fixture(scope="class")
def evalhub_unauthorised_token(
evalhub_unauthorised_sa: ServiceAccount,
model_namespace: Namespace,
) -> str:
"""Short-lived token for the unauthorised ServiceAccount."""
return run_command(
command=shlex.split(f"oc create token -n {model_namespace.name} {evalhub_unauthorised_sa.name} --duration=30m")
)[1].strip()
3 changes: 3 additions & 0 deletions tests/model_explainability/evalhub/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@
EVALHUB_API_VERSION: str = "v1alpha1"
EVALHUB_KIND: str = "EvalHub"
EVALHUB_PLURAL: str = "evalhubs"

# RBAC ClusterRole names (must match operator config/rbac/evalhub/ YAML files)
EVALHUB_PROVIDERS_ACCESS_CLUSTER_ROLE: str = "trustyai-service-operator-evalhub-providers-access"
178 changes: 178 additions & 0 deletions tests/model_explainability/evalhub/test_evalhub_providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import pytest
import requests
from ocp_resources.namespace import Namespace
from ocp_resources.role_binding import RoleBinding
from ocp_resources.route import Route

from tests.model_explainability.evalhub.utils import get_evalhub_provider, list_evalhub_providers


@pytest.mark.parametrize(
"model_namespace",
[
pytest.param(
{"name": "test-evalhub-providers"},
),
],
indirect=True,
)
@pytest.mark.sanity
@pytest.mark.model_explainability
class TestEvalHubProviders:
"""Tests for the EvalHub providers API using a scoped non-admin ServiceAccount."""

def test_scoped_user_can_list_providers(
self,
evalhub_providers_response: dict,
) -> None:
"""Verify that a scoped user with providers access can list providers."""
assert "items" in evalhub_providers_response, "Response missing 'items' field"
assert isinstance(evalhub_providers_response["items"], list), "'items' must be a list"
assert "total_count" in evalhub_providers_response, "Response missing 'total_count' field"
assert "limit" in evalhub_providers_response, "Response missing 'limit' field"

def test_list_providers_has_registered_providers(
self,
evalhub_providers_response: dict,
) -> None:
"""Verify that at least one provider is registered."""
assert evalhub_providers_response["total_count"] > 0, "Expected at least one registered provider"
assert len(evalhub_providers_response["items"]) > 0, "Expected at least one provider in items"

def test_provider_has_required_fields(
self,
evalhub_providers_response: dict,
) -> None:
"""Verify that each provider contains the expected resource metadata and config fields."""
for provider in evalhub_providers_response["items"]:
assert "resource" in provider, f"Provider missing 'resource': {provider}"
assert "id" in provider["resource"], f"Provider resource missing 'id': {provider}"
assert provider["resource"]["id"], "Provider ID must not be empty"
assert "name" in provider, f"Provider missing 'name': {provider}"
assert "benchmarks" in provider, f"Provider missing 'benchmarks': {provider}"

def test_provider_benchmarks_have_required_fields(
self,
evalhub_providers_response: dict,
) -> None:
"""Verify that benchmarks within each provider have id, name, and category."""
for provider in evalhub_providers_response["items"]:
provider_name = provider.get("name", "unknown")
for benchmark in provider.get("benchmarks", []):
assert "id" in benchmark, f"Benchmark in provider '{provider_name}' missing 'id'"
assert "name" in benchmark, f"Benchmark in provider '{provider_name}' missing 'name'"
assert "category" in benchmark, f"Benchmark in provider '{provider_name}' missing 'category'"

def test_lm_evaluation_harness_provider_exists(
self,
evalhub_providers_response: dict,
) -> None:
"""Verify that the lm_evaluation_harness provider is registered and has benchmarks."""
provider_ids = [provider["resource"]["id"] for provider in evalhub_providers_response["items"]]
assert "lm_evaluation_harness" in provider_ids, (
f"Expected 'lm_evaluation_harness' in providers, got: {provider_ids}"
)

lmeval_provider = next(
provider
for provider in evalhub_providers_response["items"]
if provider["resource"]["id"] == "lm_evaluation_harness"
)
Copy link
Copy Markdown
Collaborator

@dbasunag dbasunag Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any next() call should be used with an explicit default. We are trying to move to python 3.14 and they tightened this in 3.14

assert len(lmeval_provider["benchmarks"]) > 0, (
"lm_evaluation_harness provider should have at least one benchmark"
)

def test_get_single_provider(
self,
evalhub_providers_response: dict,
evalhub_scoped_token: str,
evalhub_ca_bundle_file: str,
evalhub_route: Route,
model_namespace: Namespace,
) -> None:
"""Verify that a single provider can be retrieved by ID."""
assert evalhub_providers_response.get("items") and len(evalhub_providers_response["items"]) > 0, (
"No providers registered; cannot test single-provider retrieval"
)
first_provider_id = evalhub_providers_response["items"][0]["resource"]["id"]

data = get_evalhub_provider(
host=evalhub_route.host,
token=evalhub_scoped_token,
ca_bundle_file=evalhub_ca_bundle_file,
provider_id=first_provider_id,
tenant=model_namespace.name,
)

assert data["resource"]["id"] == first_provider_id
assert "name" in data
assert "benchmarks" in data

def test_get_nonexistent_provider_returns_error(
self,
model_namespace: Namespace,
evalhub_scoped_token: str,
evalhub_providers_role_binding: RoleBinding,
evalhub_ca_bundle_file: str,
evalhub_route: Route,
) -> None:
"""Verify that requesting a non-existent provider ID returns 404."""
with pytest.raises(requests.exceptions.HTTPError) as excinfo:
get_evalhub_provider(
host=evalhub_route.host,
token=evalhub_scoped_token,
ca_bundle_file=evalhub_ca_bundle_file,
provider_id="nonexistent-provider-id",
tenant=model_namespace.name,
)
assert excinfo.value.response.status_code == 404


@pytest.mark.parametrize(
"model_namespace",
[
pytest.param(
{"name": "test-evalhub-providers"},
),
],
indirect=True,
)
@pytest.mark.sanity
@pytest.mark.model_explainability
class TestEvalHubProvidersUnauthorised:
"""Tests verifying that a user without providers RBAC is denied access."""

def test_list_providers_denied_without_role_binding(
self,
model_namespace: Namespace,
evalhub_unauthorised_token: str,
evalhub_ca_bundle_file: str,
evalhub_route: Route,
) -> None:
"""Verify that a user without providers ClusterRole binding gets 403."""
with pytest.raises(requests.exceptions.HTTPError, match="403"):
list_evalhub_providers(
host=evalhub_route.host,
token=evalhub_unauthorised_token,
ca_bundle_file=evalhub_ca_bundle_file,
tenant=model_namespace.name,
)
Comment on lines +153 to +159
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd tests/model_explainability/evalhub && wc -l test_evalhub_providers.py

Repository: opendatahub-io/opendatahub-tests

Length of output: 105


🏁 Script executed:

cd tests/model_explainability/evalhub && sed -n '175,195p' test_evalhub_providers.py

Repository: opendatahub-io/opendatahub-tests

Length of output: 829


🏁 Script executed:

cd tests/model_explainability/evalhub && sed -n '210,240p' test_evalhub_providers.py

Repository: opendatahub-io/opendatahub-tests

Length of output: 1201


🏁 Script executed:

cd tests/model_explainability/evalhub && sed -n '65,85p' utils.py

Repository: opendatahub-io/opendatahub-tests

Length of output: 764


🏁 Script executed:

cd tests/model_explainability/evalhub && grep -n "raise_for_status\|HTTPError" utils.py

Repository: opendatahub-io/opendatahub-tests

Length of output: 342


🏁 Script executed:

cd tests/model_explainability/evalhub && sed -n '1,30p' test_evalhub_providers.py

Repository: opendatahub-io/opendatahub-tests

Length of output: 829


🏁 Script executed:

cd tests/model_explainability/evalhub && sed -n '85,110p' utils.py

Repository: opendatahub-io/opendatahub-tests

Length of output: 734


🏁 Script executed:

cd tests/model_explainability/evalhub && python3 << 'EOF'
import requests

# Verify that HTTPError from raise_for_status() has a response attribute
try:
    # Create a mock response scenario to understand HTTPError structure
    response = requests.Response()
    response.status_code = 403
    response.url = "http://example.com"
    
    # Simulate what raise_for_status does
    http_error = requests.exceptions.HTTPError()
    http_error.response = response
    
    # Check if we can access response.status_code
    print(f"HTTPError.response accessible: {http_error.response is not None}")
    print(f"HTTPError.response.status_code accessible: {http_error.response.status_code}")
except Exception as e:
    print(f"Error: {e}")
EOF

Repository: opendatahub-io/opendatahub-tests

Length of output: 382


🏁 Script executed:

cd tests/model_explainability/evalhub && grep -B5 -A5 "match=" test_evalhub_providers.py | head -30

Repository: opendatahub-io/opendatahub-tests

Length of output: 1103


Assert the 403 contract on response.status_code, not the exception string.

match="403" only matches formatted text in the exception message. These assertions should pin the HTTP status code explicitly, as Line 180 does for the 404 path.

Suggested change
-        with pytest.raises(requests.exceptions.HTTPError, match="403"):
+        with pytest.raises(requests.exceptions.HTTPError) as excinfo:
             list_evalhub_providers(
                 host=evalhub_route.host,
                 token=evalhub_unauthorised_token,
                 ca_bundle_file=evalhub_ca_bundle_file,
                 tenant=model_namespace.name,
             )
+        assert excinfo.value.response is not None
+        assert excinfo.value.response.status_code == 403
...
-        with pytest.raises(requests.exceptions.HTTPError, match="403"):
+        with pytest.raises(requests.exceptions.HTTPError) as excinfo:
             get_evalhub_provider(
                 host=evalhub_route.host,
                 token=evalhub_unauthorised_token,
                 ca_bundle_file=evalhub_ca_bundle_file,
                 provider_id="lm_evaluation_harness",
                 tenant=model_namespace.name,
             )
+        assert excinfo.value.response is not None
+        assert excinfo.value.response.status_code == 403

Also applies to: Lines 229-236.


def test_get_provider_denied_without_role_binding(
self,
model_namespace: Namespace,
evalhub_unauthorised_token: str,
evalhub_ca_bundle_file: str,
evalhub_route: Route,
evalhub_providers_response: dict,
) -> None:
"""Verify that a user without providers ClusterRole binding cannot get a provider."""
provider_id = evalhub_providers_response["items"][0]["resource"]["id"]
with pytest.raises(requests.exceptions.HTTPError, match="403"):
get_evalhub_provider(
host=evalhub_route.host,
token=evalhub_unauthorised_token,
ca_bundle_file=evalhub_ca_bundle_file,
provider_id=provider_id,
tenant=model_namespace.name,
)
Loading