From f56324baeca0f76ed173aacb7c739de20365eb47 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 18 Sep 2026 16:07:34 -0500 Subject: [PATCH] docs: improve test suite documentation Add module docstrings, per-test docstrings and developer attribution across tests/, documenting the precise behavior or security invariant each test exercises. Documentation only: no executable test semantics changed (verified via AST comparison against the pre-change baseline for every modified file). Co-Authored-By: Claude Sonnet 5 --- tests/__init__.py | 4 +++ tests/conftest.py | 11 +++++++ tests/test_cache.py | 19 ++++++++++++ tests/test_engine.py | 37 +++++++++++++++++++++++ tests/test_engine_permission_tenancy.py | 28 +++++++++++++++++ tests/test_main.py | 12 +++++++- tests/test_permissions.py | 33 ++++++++++++++++++++ tests/test_policy_boundaries_extra.py | 40 +++++++++++++++++++++++++ tests/test_policy_service.py | 10 +++++++ tests/test_rbac.py | 17 +++++++++++ tests/test_role_hierarchy_fixtures.py | 19 ++++++++++++ tests/test_routes_policy.py | 20 +++++++++++++ tests/test_security_boundaries.py | 35 ++++++++++++++++++++++ tests/test_tenancy.py | 21 +++++++++++++ 14 files changed, 305 insertions(+), 1 deletion(-) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..9141a8f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +"""Test package marker for the omnibioai-policy-engine unit-test suite. + +Developer: Manish Kumar +""" diff --git a/tests/conftest.py b/tests/conftest.py index f1ca918..adc2ed4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,9 @@ +"""Shared pytest fixtures for the policy engine tests: stubs swagger_ui_bundle +when the optional package is not installed, and provides a PolicyEngine wired +to a mock Redis-backed cache so no test connects to a real Redis. + +Developer: Manish Kumar +""" import os import sys import tempfile @@ -18,11 +24,13 @@ @pytest.fixture def mock_redis(): + """Provide a MagicMock standing in for a Redis client.""" return MagicMock() @pytest.fixture def policy_cache(mock_redis): + """Build a PolicyCache with its Redis client replaced by the mock.""" with patch("app.services.cache.redis") as mock_redis_module: mock_redis_module.from_url.return_value = mock_redis from app.services.cache import PolicyCache @@ -33,6 +41,9 @@ def policy_cache(mock_redis): @pytest.fixture def policy_engine(policy_cache): + """Build a PolicyEngine using the mocked policy cache, together with the cache and its mock + Redis client. + """ cache, mock_redis = policy_cache from app.core.engine import PolicyEngine engine = PolicyEngine(cache=cache) diff --git a/tests/test_cache.py b/tests/test_cache.py index 289f249..d5baeae 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,3 +1,9 @@ +"""Unit tests for app/services/cache.py::PolicyCache against a mocked Redis +client: deterministic cache-key derivation, get/set round-tripping through JSON +with a default TTL, and per-user cache invalidation. + +Developer: Manish Kumar +""" import json import hashlib import pytest @@ -6,6 +12,7 @@ @pytest.fixture def cache_with_mock(): + """Build a PolicyCache with its Redis client replaced by a mock.""" mock_redis = MagicMock() with patch("app.services.cache.redis") as mock_redis_module: mock_redis_module.from_url.return_value = mock_redis @@ -20,6 +27,8 @@ def cache_with_mock(): # --------------------------------------------------------------------------- def test_build_key_is_deterministic(cache_with_mock): + """build_key produces the same key for identical user, action, resource and context arguments. + """ cache, _ = cache_with_mock key1 = cache.build_key("u1", "read", "resource", {"env": "prod"}) key2 = cache.build_key("u1", "read", "resource", {"env": "prod"}) @@ -27,12 +36,14 @@ def test_build_key_is_deterministic(cache_with_mock): def test_build_key_starts_with_policy_prefix(cache_with_mock): + """build_key's output starts with the "policy:" prefix.""" cache, _ = cache_with_mock key = cache.build_key("u1", "read", "res", {}) assert key.startswith("policy:") def test_build_key_different_users_different_keys(cache_with_mock): + """build_key produces different keys for different users.""" cache, _ = cache_with_mock key1 = cache.build_key("u1", "read", "res", {}) key2 = cache.build_key("u2", "read", "res", {}) @@ -40,6 +51,7 @@ def test_build_key_different_users_different_keys(cache_with_mock): def test_build_key_context_order_insensitive(cache_with_mock): + """build_key produces the same key regardless of the context dict's key order.""" cache, _ = cache_with_mock key1 = cache.build_key("u1", "read", "res", {"a": 1, "b": 2}) key2 = cache.build_key("u1", "read", "res", {"b": 2, "a": 1}) @@ -51,6 +63,7 @@ def test_build_key_context_order_insensitive(cache_with_mock): # --------------------------------------------------------------------------- def test_get_returns_parsed_dict_on_hit(cache_with_mock): + """get returns the JSON-decoded value stored under the given key.""" cache, mock_redis = cache_with_mock data = {"allowed": True, "reason": "ok", "policy_source": "RBAC"} mock_redis.get.return_value = json.dumps(data) @@ -62,6 +75,7 @@ def test_get_returns_parsed_dict_on_hit(cache_with_mock): def test_get_returns_none_on_miss(cache_with_mock): + """get returns None when the key is not in Redis.""" cache, mock_redis = cache_with_mock mock_redis.get.return_value = None @@ -75,6 +89,7 @@ def test_get_returns_none_on_miss(cache_with_mock): # --------------------------------------------------------------------------- def test_set_stores_json_with_ttl(cache_with_mock): + """set stores the JSON-encoded value under the key with the given TTL via SETEX.""" cache, mock_redis = cache_with_mock data = {"allowed": False, "reason": "denied"} @@ -84,6 +99,7 @@ def test_set_stores_json_with_ttl(cache_with_mock): def test_set_default_ttl(cache_with_mock): + """set uses a default TTL of 300 seconds when none is given.""" cache, mock_redis = cache_with_mock cache.set("policy:key2", {"allowed": True, "reason": "ok"}) @@ -96,6 +112,7 @@ def test_set_default_ttl(cache_with_mock): # --------------------------------------------------------------------------- def test_invalidate_user_deletes_only_matching_keys(cache_with_mock): + """invalidate_user scans all policy keys and deletes only the ones matching the given user.""" cache, mock_redis = cache_with_mock # Keys that literally contain "u1" trigger deletion; others are skipped mock_redis.scan_iter.return_value = iter(["policy:u1_hash", "policy:other_user"]) @@ -107,6 +124,7 @@ def test_invalidate_user_deletes_only_matching_keys(cache_with_mock): def test_invalidate_user_no_matching_keys(cache_with_mock): + """invalidate_user deletes nothing when no scanned key matches the given user.""" cache, mock_redis = cache_with_mock mock_redis.scan_iter.return_value = iter(["policy:other_user"]) @@ -116,6 +134,7 @@ def test_invalidate_user_no_matching_keys(cache_with_mock): def test_invalidate_user_no_keys(cache_with_mock): + """invalidate_user deletes nothing when there are no policy keys at all.""" cache, mock_redis = cache_with_mock mock_redis.scan_iter.return_value = iter([]) diff --git a/tests/test_engine.py b/tests/test_engine.py index ff59b36..eb6b3f1 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,3 +1,10 @@ +"""Unit tests for app/core/engine.py::PolicyEngine: RBAC, ABAC and RULES denial +short-circuit evaluation with the right policy_source and reason, an all-pass +request is allowed, the Redis-backed cache is read on evaluate() and written on +a miss, and a cached decision skips policy checks entirely. + +Developer: Manish Kumar +""" import json import pytest from unittest.mock import MagicMock @@ -7,6 +14,9 @@ def make_engine(mock_redis=None): + """Build a PolicyEngine with its cache backed by the given (or a default empty) mock Redis + client. + """ if mock_redis is None: mock_redis = MagicMock() mock_redis.get.return_value = None @@ -21,6 +31,7 @@ def make_engine(mock_redis=None): def basic_request(**kwargs): + """Build a PolicyRequest with sensible defaults, overridable by keyword.""" defaults = { "user_id": "u1", "email": "u1@test.com", @@ -39,6 +50,9 @@ def basic_request(**kwargs): # --------------------------------------------------------------------------- def test_rbac_deny_stops_evaluation(): + """A request missing the researcher role is denied at the RBAC stage with a reason naming the + role. + """ engine, _ = make_engine() req = basic_request(roles=[], action="tes.submit") # no researcher role @@ -54,6 +68,9 @@ def test_rbac_deny_stops_evaluation(): # --------------------------------------------------------------------------- def test_abac_deny_gpu_access(): + """A GPU-required request from a caller without the gpu_user role is denied at the ABAC stage + with a reason mentioning GPU. + """ engine, _ = make_engine() req = basic_request( roles=["researcher"], @@ -68,6 +85,9 @@ def test_abac_deny_gpu_access(): def test_abac_deny_hpc_access(): + """An HPC-node request from a caller without the hpc_user role is denied at the ABAC stage with + a reason mentioning HPC. + """ engine, _ = make_engine() req = basic_request( roles=["researcher"], @@ -86,6 +106,7 @@ def test_abac_deny_hpc_access(): # --------------------------------------------------------------------------- def test_rules_deny_protected_dataset_delete(): + """Deleting a protected dataset is denied at the RULES stage even after RBAC and ABAC pass.""" engine, _ = make_engine() req = basic_request( roles=["data_scientist"], @@ -100,6 +121,9 @@ def test_rules_deny_protected_dataset_delete(): def test_rules_deny_model_registry_delete(): + """Deleting from the model registry is denied at the RULES stage even for an admin who passes + RBAC and ABAC. + """ engine, _ = make_engine() req = basic_request( roles=["admin"], @@ -118,6 +142,9 @@ def test_rules_deny_model_registry_delete(): # --------------------------------------------------------------------------- def test_all_checks_pass_returns_allowed(): + """A request that passes every stage is allowed with policy_source ALL_PASSED and reason "access + granted". + """ engine, _ = make_engine() req = basic_request() @@ -133,6 +160,9 @@ def test_all_checks_pass_returns_allowed(): # --------------------------------------------------------------------------- def test_evaluate_returns_cached_decision(): + """evaluate() returns a decision reconstructed from a cached, previously stored decision + payload, reporting policy_source CACHE and never calling setex. + """ mock_redis = MagicMock() # A real cached entry is always a previous decision.dict(), which always # includes policy_source (RBAC/ABAC/RULES/ALL_PASSED) -- a payload @@ -163,12 +193,15 @@ class _FakeRedis: used to, before it was corrected above).""" def __init__(self): + """Start with an empty in-memory store.""" self.store: dict = {} def get(self, key): + """Return the stored value for a key, or None.""" return self.store.get(key) def setex(self, key, ttl, value): + """Store a value under a key with a TTL.""" self.store[key] = value @@ -196,6 +229,7 @@ def test_evaluate_cache_hit_after_miss_does_not_raise(): def test_evaluate_cache_miss_computes_and_stores(): + """On a cache miss, evaluate() computes an allowed decision and stores it once via setex.""" mock_redis = MagicMock() mock_redis.get.return_value = None engine, _ = make_engine(mock_redis) @@ -208,6 +242,7 @@ def test_evaluate_cache_miss_computes_and_stores(): def test_evaluate_cache_miss_rbac_deny_stores_denial(): + """On a cache miss, evaluate() computes and stores a denied RBAC decision once via setex.""" mock_redis = MagicMock() mock_redis.get.return_value = None engine, _ = make_engine(mock_redis) @@ -224,6 +259,7 @@ def test_evaluate_cache_miss_rbac_deny_stores_denial(): # --------------------------------------------------------------------------- def test_abac_gpu_with_gpu_user_role_passes(): + """A GPU-required request from a caller with the gpu_user role passes the ABAC stage.""" engine, _ = make_engine() req = basic_request( roles=["researcher", "gpu_user"], @@ -234,6 +270,7 @@ def test_abac_gpu_with_gpu_user_role_passes(): def test_abac_hpc_with_hpc_user_role_passes(): + """An HPC-node request from a caller with the hpc_user role passes the ABAC stage.""" engine, _ = make_engine() req = basic_request( roles=["researcher", "hpc_user"], diff --git a/tests/test_engine_permission_tenancy.py b/tests/test_engine_permission_tenancy.py index 060f3da..54d2d81 100644 --- a/tests/test_engine_permission_tenancy.py +++ b/tests/test_engine_permission_tenancy.py @@ -1,3 +1,12 @@ +"""Unit tests for PolicyEngine's permission and tenancy checks: a required +permission's absence denies at the PERMISSION stage (unless the caller is admin +or held no permissions at all, the legacy role-only shape), a request whose +resource organization differs from the caller's is denied at the TENANCY stage, +and PolicyCache.build_key's cache key varies with organization id and +permissions. + +Developer: Manish Kumar +""" import json from unittest.mock import MagicMock, patch @@ -7,6 +16,7 @@ def make_engine(): + """Build a PolicyEngine with its cache backed by a mock Redis client that always misses.""" mock_redis = MagicMock() mock_redis.get.return_value = None with patch("app.services.cache.redis") as mock_redis_module: @@ -17,6 +27,7 @@ def make_engine(): def basic_request(**kwargs): + """Build a PolicyRequest with sensible defaults, overridable by keyword.""" defaults = { "user_id": "u1", "email": "u1@test.com", @@ -36,6 +47,9 @@ def basic_request(**kwargs): # --------------------------------------------------------------------------- def test_permission_deny_stops_before_abac(): + """A request lacking the required workflow.execute permission is denied at the PERMISSION stage + with a reason naming it. + """ engine, _ = make_engine() req = basic_request( roles=["researcher"], # passes RBAC @@ -52,6 +66,7 @@ def test_permission_deny_stops_before_abac(): def test_permission_allow_falls_through_to_all_passed(): + """A request holding the required workflow.execute permission passes through to ALL_PASSED.""" engine, _ = make_engine() req = basic_request( roles=["researcher"], @@ -87,6 +102,9 @@ def test_permission_deny_for_real_gateway_action_shape_workflow_execute(): def test_permission_allow_for_real_gateway_action_shape_model_use(): + """A request for the real model.use action against the model registry, holding no roles but the + model.use permission, is allowed. + """ engine, _ = make_engine() req = basic_request( roles=[], permissions=["model.use"], action="model.use", resource="model-registry", @@ -115,6 +133,9 @@ def test_permission_check_is_noop_for_legacy_role_only_traffic(): # --------------------------------------------------------------------------- def test_tenancy_deny_cross_org_access(): + """A request whose resource belongs to a different organization than the caller is denied at the + TENANCY stage with reason "cross-tenant access denied". + """ engine, _ = make_engine() req = basic_request( roles=["researcher"], @@ -132,6 +153,8 @@ def test_tenancy_deny_cross_org_access(): def test_tenancy_allow_same_org(): + """A request whose resource belongs to the caller's own organization passes the TENANCY stage. + """ engine, _ = make_engine() req = basic_request( roles=["researcher"], @@ -172,6 +195,9 @@ def test_admin_bypasses_permission_but_not_tenancy(): # --------------------------------------------------------------------------- def test_cache_key_differs_by_org_id(): + """build_key produces different cache keys for the same request under different organization + ids. + """ _, mock_redis = make_engine() cache = PolicyCache(redis_url="redis://localhost") key_org1 = cache.build_key("u1", "tes.submit", "job", {}, org_id="org-1", permissions=[]) @@ -180,6 +206,8 @@ def test_cache_key_differs_by_org_id(): def test_cache_key_differs_by_permissions(): + """build_key produces different cache keys for the same request with different permission sets. + """ cache = PolicyCache(redis_url="redis://localhost") key_a = cache.build_key("u1", "tes.submit", "job", {}, org_id=None, permissions=["workflow.execute"]) key_b = cache.build_key("u1", "tes.submit", "job", {}, org_id=None, permissions=[]) diff --git a/tests/test_main.py b/tests/test_main.py index eb72132..a128aab 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,7 @@ -"""Tests for app/main.py: _invalidation_subscriber and lifespan.""" +"""Tests for app/main.py: _invalidation_subscriber and lifespan. + +Developer: Manish Kumar +""" import asyncio import json import pytest @@ -11,6 +14,7 @@ @pytest.mark.asyncio async def test_subscriber_processes_valid_message(): + """_invalidation_subscriber invalidates the cache for the user id in a valid pubsub message.""" mock_cache = MagicMock() message_data = json.dumps({"user_id": "u1"}) processed = asyncio.Event() @@ -46,6 +50,8 @@ async def mock_listen(): @pytest.mark.asyncio async def test_subscriber_ignores_empty_user_id(): + """_invalidation_subscriber does not invalidate the cache for a message whose user_id is empty. + """ mock_cache = MagicMock() message_data = json.dumps({"user_id": ""}) done = asyncio.Event() @@ -80,6 +86,9 @@ async def mock_listen(): @pytest.mark.asyncio async def test_subscriber_swallows_invalid_json(): + """_invalidation_subscriber does not invalidate the cache and does not raise for a message whose + data is not valid JSON. + """ mock_cache = MagicMock() done = asyncio.Event() @@ -171,6 +180,7 @@ async def long_running(): # --------------------------------------------------------------------------- def test_app_has_evaluate_route(): + """The app registers a policy evaluation route.""" from app.main import app paths = [r.path for r in app.routes] assert any("evaluate" in p for p in paths) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index aa9f490..27a5328 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -1,3 +1,11 @@ +"""Unit tests for app/core/permissions.py: no permissions supplied is a no-op +allow (the legacy role-only shape), admin always overrides, required_permission +maps actions/resources to the permission they need (or None when unrelated), +and evaluate_permission grants or denies based on whether the caller holds that +permission. + +Developer: Manish Kumar +""" import pytest from app.core.permissions import evaluate_permission, required_permission @@ -8,12 +16,18 @@ # --------------------------------------------------------------------------- def test_no_permissions_supplied_is_noop(): + """With no permissions supplied at all, evaluate_permission allows with a reason mentioning that + no permissions were supplied. + """ allowed, reason = evaluate_permission(["researcher"], [], "tes.submit", "job") assert allowed is True assert "no permissions supplied" in reason def test_admin_role_always_overrides_even_with_permissions_supplied(): + """The admin role allows regardless of the caller's actual permissions, with reason "admin + override". + """ allowed, reason = evaluate_permission(["admin"], ["something.else"], "tes.submit", "job") assert allowed is True assert reason == "admin override" @@ -24,6 +38,7 @@ def test_admin_role_always_overrides_even_with_permissions_supplied(): # --------------------------------------------------------------------------- def test_required_permission_for_tes_prefix(): + """required_permission maps a tes.submit action to workflow.execute.""" assert required_permission("tes.submit", "job") == "workflow.execute" @@ -45,10 +60,12 @@ def test_required_permission_for_tes_prefix(): def test_required_permission_for_workflow_execute_action(): + """required_permission maps a workflow.execute action to workflow.execute.""" assert required_permission("workflow.execute", "job") == "workflow.execute" def test_required_permission_for_model_use_action(): + """required_permission maps a model.use action to model.use.""" assert required_permission("model.use", "model") == "model.use" @@ -76,14 +93,18 @@ def test_viewer_denied_workflow_execute_via_gateway_shaped_action(): def test_required_permission_for_dataset_read(): + """required_permission maps a dataset.read action to dataset.read.""" assert required_permission("dataset.read", "human_genome") == "dataset.read" def test_required_permission_for_model_registry_delete(): + """required_permission maps a delete action on the model_registry resource to workflow.manage. + """ assert required_permission("delete", "model_registry") == "workflow.manage" def test_required_permission_none_for_unrelated_action(): + """required_permission returns None for an action with no associated permission.""" assert required_permission("profile.read", "profile") is None @@ -92,6 +113,9 @@ def test_required_permission_none_for_unrelated_action(): # --------------------------------------------------------------------------- def test_scientist_with_workflow_execute_allowed_tes_submit(): + """A caller holding workflow.execute is granted with reason "permission granted" for a + tes.submit action. + """ allowed, reason = evaluate_permission( [], ["workflow.execute", "dataset.read"], "tes.submit", "job" ) @@ -100,6 +124,7 @@ def test_scientist_with_workflow_execute_allowed_tes_submit(): def test_viewer_with_dataset_read_allowed_dataset_read(): + """A caller holding dataset.read is granted for a dataset.read action.""" allowed, reason = evaluate_permission([], ["dataset.read"], "dataset.read", "human_genome") assert allowed is True @@ -109,12 +134,17 @@ def test_viewer_with_dataset_read_allowed_dataset_read(): # --------------------------------------------------------------------------- def test_viewer_without_workflow_execute_denied_tes_submit(): + """A caller without workflow.execute is denied for a tes.submit action, with a reason naming it. + """ allowed, reason = evaluate_permission([], ["dataset.read"], "tes.submit", "job") assert allowed is False assert "workflow.execute" in reason def test_scientist_without_workflow_manage_denied_model_registry_delete(): + """A caller without workflow.manage is denied for a model-registry delete action, with a reason + naming it. + """ allowed, reason = evaluate_permission( [], ["workflow.execute", "dataset.read"], "delete", "model_registry" ) @@ -123,6 +153,9 @@ def test_scientist_without_workflow_manage_denied_model_registry_delete(): def test_unrelated_action_allowed_even_with_permissions_supplied(): + """An action with no required permission is allowed regardless of the caller's permissions, with + reason "no permission required". + """ allowed, reason = evaluate_permission([], ["dataset.read"], "profile.read", "profile") assert allowed is True assert reason == "no permission required" diff --git a/tests/test_policy_boundaries_extra.py b/tests/test_policy_boundaries_extra.py index a9d7cdf..3319e67 100644 --- a/tests/test_policy_boundaries_extra.py +++ b/tests/test_policy_boundaries_extra.py @@ -2,6 +2,8 @@ These tests invoke pure policy functions and route/helper callables directly. They intentionally avoid the environment's hanging FastAPI TestClient path. + +Developer: Manish Kumar """ from __future__ import annotations @@ -32,6 +34,10 @@ ], ) def test_abac_boundary_combinations(context, roles, allowed, reason): + """evaluate_abac allows when no GPU or HPC access is required, denies GPU access without the + gpu_user role and HPC access without the hpc_user role, and denies HPC access when both are + required but only the GPU role is held. + """ assert abac.evaluate_abac(context, roles) == (allowed, reason) @@ -48,10 +54,16 @@ def test_abac_boundary_combinations(context, roles, allowed, reason): def test_permission_resolution_covers_exact_prefix_resource_and_unknown_actions( action, resource, expected ): + """required_permission resolves an action by exact match, by tes.-prefix, by resource-specific + delete, and returns None for an action with no required permission. + """ assert permissions.required_permission(action, resource) == expected def test_permission_gate_admin_override_and_missing_permission_are_distinct(): + """evaluate_permission reports "admin override" for the admin role and a distinct "missing + permission: workflow.manage" message for a caller lacking it. + """ assert permissions.evaluate_permission(["admin"], [], "unknown", "resource") == ( True, "admin override", @@ -63,6 +75,9 @@ def test_permission_gate_admin_override_and_missing_permission_are_distinct(): def test_permission_gate_unknown_action_is_currently_permissive(): + """evaluate_permission currently allows an action with no required permission even when the + caller's own permissions are unrelated, recording this as the actual fail-open behavior. + """ # This records the current fail-open behavior without disguising it as a # secure contract; the pre-existing security-boundary test tracks the # desired fail-closed behavior separately as an expected failure. @@ -83,10 +98,17 @@ def test_permission_gate_unknown_action_is_currently_permissive(): ], ) def test_tenancy_boundary_values(org_id, resource_org_id, expected): + """evaluate_tenancy allows when the resource has no organization context, allows a matching + organization (including non-string ids compared as strings), denies a mismatched + organization, and denies when the caller has no organization at all. + """ assert tenancy.evaluate_tenancy(org_id, {"resource_org_id": resource_org_id}) == expected def test_rules_protect_both_human_genome_and_model_registry_resources(): + """evaluate_rules denies deleting a human-genome dataset and denies deleting from the model + registry, while an ordinary dataset read still passes. + """ assert rules.evaluate_rules("dataset.delete", "human_genome_v1") == ( False, "protected dataset cannot be deleted", @@ -99,6 +121,9 @@ def test_rules_protect_both_human_genome_and_model_registry_resources(): def test_policy_request_defaults_are_independent_and_validate_required_fields(): + """Two PolicyRequest instances' default roles and context lists are independent of each other, + and constructing one without a required field raises ValidationError. + """ first = PolicyRequest(user_id="u1", action="read", resource="r") second = PolicyRequest(user_id="u2", action="read", resource="r") first.roles.append("viewer") @@ -111,6 +136,9 @@ def test_policy_request_defaults_are_independent_and_validate_required_fields(): def test_policy_service_rejects_invalid_request_before_calling_engine(monkeypatch): + """evaluate_policy raises ValidationError for an incomplete payload without ever calling the + engine. + """ engine = MagicMock() monkeypatch.setattr(policy_service, "engine", engine) @@ -120,6 +148,9 @@ def test_policy_service_rejects_invalid_request_before_calling_engine(monkeypatc def test_policy_route_delegates_without_testclient(monkeypatch): + """The evaluate route handler calls through to evaluate_policy with the given payload and + returns its result. + """ evaluate = MagicMock(return_value={"allowed": True}) monkeypatch.setattr(routes_policy, "evaluate_policy", evaluate) payload = {"user_id": "u1", "action": "read", "resource": "dataset"} @@ -129,6 +160,9 @@ def test_policy_route_delegates_without_testclient(monkeypatch): def test_health_and_swagger_helpers_are_deterministic(): + """health returns {"status": "ok"}, swagger_static returns 404 for a missing asset and 200 for + the bundled CSS. + """ assert asyncio.run(health()) == {"status": "ok"} missing = asyncio.run(swagger_static("does-not-exist.js")) assert missing.status_code == 404 @@ -137,6 +171,9 @@ def test_health_and_swagger_helpers_are_deterministic(): def test_custom_swagger_ui_contains_openapi_metadata(): + """custom_swagger_ui returns 200 with HTML mentioning the service name and embedding the OpenAPI + spec. + """ response = asyncio.run(custom_swagger_ui()) body = response.body.decode() assert response.status_code == 200 @@ -145,6 +182,9 @@ def test_custom_swagger_ui_contains_openapi_metadata(): def test_invalidation_subscriber_restarts_after_pubsub_stream_closes(monkeypatch): + """_invalidation_subscriber re-subscribes and calls listen again after the pubsub stream ends + without error, then propagates a CancelledError raised from the second listen call. + """ class PubSub: def __init__(self): self.listen_calls = 0 diff --git a/tests/test_policy_service.py b/tests/test_policy_service.py index d9cc54f..4445bdc 100644 --- a/tests/test_policy_service.py +++ b/tests/test_policy_service.py @@ -1,5 +1,7 @@ """ Tests for evaluate_policy() — patches the module-level engine so no Redis needed. + +Developer: Manish Kumar """ import pytest from unittest.mock import MagicMock, patch @@ -28,6 +30,7 @@ def patched_engine(): def test_evaluate_policy_allow(patched_engine): + """evaluate_policy allows a request that passes every policy stage.""" from app.services.policy_service import evaluate_policy result = evaluate_policy({ @@ -44,6 +47,7 @@ def test_evaluate_policy_allow(patched_engine): def test_evaluate_policy_deny_rbac(patched_engine): + """evaluate_policy denies a request missing its required role, reporting policy_source RBAC.""" from app.services.policy_service import evaluate_policy result = evaluate_policy({ @@ -61,6 +65,9 @@ def test_evaluate_policy_deny_rbac(patched_engine): def test_evaluate_policy_deny_abac(patched_engine): + """evaluate_policy denies a GPU-required request from a caller without the gpu_user role, + reporting policy_source ABAC. + """ from app.services.policy_service import evaluate_policy result = evaluate_policy({ @@ -78,6 +85,9 @@ def test_evaluate_policy_deny_abac(patched_engine): def test_evaluate_policy_deny_rules(patched_engine): + """evaluate_policy denies deleting a protected human-genome dataset, reporting policy_source + RULES. + """ from app.services.policy_service import evaluate_policy result = evaluate_policy({ diff --git a/tests/test_rbac.py b/tests/test_rbac.py index c6e6177..7a5bc00 100644 --- a/tests/test_rbac.py +++ b/tests/test_rbac.py @@ -1,3 +1,10 @@ +"""Unit tests for app/core/rbac.py::evaluate_rbac: admin always overrides, tes.* +actions require the researcher role, dataset actions require data_scientist, +viewer is denied dataset access, and an unrelated action or an empty role list +is allowed since RBAC does not gate it. + +Developer: Manish Kumar +""" import pytest from app.core.rbac import evaluate_rbac @@ -7,12 +14,14 @@ # --------------------------------------------------------------------------- def test_admin_gets_universal_allow(): + """The admin role allows any action with reason "admin override".""" allowed, reason = evaluate_rbac(["admin"], "tes.submit") assert allowed is True assert reason == "admin override" def test_admin_with_other_roles_still_overrides(): + """Admin still overrides even when combined with other roles.""" allowed, reason = evaluate_rbac(["admin", "viewer"], "dataset.delete") assert allowed is True assert reason == "admin override" @@ -23,18 +32,21 @@ def test_admin_with_other_roles_still_overrides(): # --------------------------------------------------------------------------- def test_researcher_can_submit_tes_job(): + """The researcher role passes RBAC for a tes.submit action.""" allowed, reason = evaluate_rbac(["researcher"], "tes.submit") assert allowed is True assert reason == "rbac passed" def test_missing_researcher_denied_tes_action(): + """Missing the researcher role denies a tes.* action with a reason naming it.""" allowed, reason = evaluate_rbac(["viewer"], "tes.submit") assert allowed is False assert "researcher" in reason def test_data_scientist_cannot_access_tes(): + """The data_scientist role alone is denied a tes.* action, with a reason naming researcher.""" allowed, reason = evaluate_rbac(["data_scientist"], "tes.run") assert allowed is False assert "researcher" in reason @@ -45,18 +57,21 @@ def test_data_scientist_cannot_access_tes(): # --------------------------------------------------------------------------- def test_data_scientist_can_access_dataset(): + """The data_scientist role passes RBAC for a dataset action.""" allowed, reason = evaluate_rbac(["data_scientist"], "dataset.read") assert allowed is True assert reason == "rbac passed" def test_missing_data_scientist_denied_dataset_action(): + """Missing the data_scientist role denies a dataset action with a reason naming it.""" allowed, reason = evaluate_rbac(["researcher"], "dataset.write") assert allowed is False assert "data_scientist" in reason def test_viewer_denied_dataset_action(): + """The viewer role alone is denied a dataset action.""" allowed, reason = evaluate_rbac([], "dataset.delete") assert allowed is False @@ -66,11 +81,13 @@ def test_viewer_denied_dataset_action(): # --------------------------------------------------------------------------- def test_unrelated_action_allowed_for_any_role(): + """An action RBAC does not gate is allowed for any role, with reason "rbac passed".""" allowed, reason = evaluate_rbac(["viewer"], "profile.read") assert allowed is True assert reason == "rbac passed" def test_empty_roles_allowed_for_unrelated_action(): + """An action RBAC does not gate is allowed even with no roles at all.""" allowed, reason = evaluate_rbac([], "ping") assert allowed is True diff --git a/tests/test_role_hierarchy_fixtures.py b/tests/test_role_hierarchy_fixtures.py index 4236798..7d0c13f 100644 --- a/tests/test_role_hierarchy_fixtures.py +++ b/tests/test_role_hierarchy_fixtures.py @@ -23,6 +23,8 @@ exception is dataset.read, narrowed in rbac.py by this same PR to be governed by the permission gate alone -- see that change's comment -- which is what makes a genuine read-only Viewer tier possible at all. + +Developer: Manish Kumar """ import pytest @@ -34,6 +36,7 @@ def make_engine(): + """Build a PolicyEngine with its cache backed by a mock Redis client that always misses.""" mock_redis = MagicMock() mock_redis.get.return_value = None with patch("app.services.cache.redis") as mock_redis_module: @@ -51,6 +54,9 @@ def make_engine(): def request_for(tier, action, resource="job_queue", org_id="org-1", context=None): + """Build a PolicyRequest for the given conceptual tier (roles, permissions), action and + resource. + """ roles, perms = tier return PolicyRequest( user_id="u1", @@ -70,6 +76,9 @@ def request_for(tier, action, resource="job_queue", org_id="org-1", context=None @pytest.mark.parametrize("tier", [PLATFORM_OWNER, ORG_ADMIN, SCIENTIST]) def test_execute_workflows_allowed_for_tiers_holding_workflow_execute(tier): + """Platform Owner, Org Admin and Scientist tiers, which all hold workflow.execute, are allowed + to submit a TES job. + """ engine = make_engine() decision = engine._evaluate_core(request_for(tier, "tes.submit")) assert decision.allowed is True @@ -77,6 +86,7 @@ def test_execute_workflows_allowed_for_tiers_holding_workflow_execute(tier): @pytest.mark.parametrize("tier", [PLATFORM_OWNER, ORG_ADMIN, SCIENTIST, VIEWER]) def test_dataset_read_allowed_for_every_tier(tier): + """Every tier, including read-only Viewer, is allowed to read a dataset.""" engine = make_engine() decision = engine._evaluate_core(request_for(tier, "dataset.read")) assert decision.allowed is True @@ -101,12 +111,18 @@ def test_org_admin_workflow_manage_permission_passes_but_immutable_rule_still_bl # --------------------------------------------------------------------------- def test_viewer_denied_execute_workflows(): + """The Viewer tier, holding neither the researcher role nor workflow.execute, is denied + submitting a TES job. + """ engine = make_engine() decision = engine._evaluate_core(request_for(VIEWER, "tes.submit")) assert decision.allowed is False def test_scientist_denied_model_registry_delete(): + """The Scientist tier is denied deleting from the model registry, reporting policy_source + PERMISSION and a reason naming workflow.manage. + """ engine = make_engine() decision = engine._evaluate_core( request_for(SCIENTIST, "delete", resource="model_registry") @@ -121,6 +137,9 @@ def test_scientist_denied_model_registry_delete(): # --------------------------------------------------------------------------- def test_org_admin_denied_cross_org_even_with_manage_org(): + """The Org Admin tier is denied acting on a resource belonging to a different organization, + reporting policy_source TENANCY, even though it holds manage_org. + """ engine = make_engine() decision = engine._evaluate_core( request_for( diff --git a/tests/test_routes_policy.py b/tests/test_routes_policy.py index 8d79c49..35f963b 100644 --- a/tests/test_routes_policy.py +++ b/tests/test_routes_policy.py @@ -1,5 +1,7 @@ """ Route tests — patch evaluate_policy at the routes module level so no Redis is hit. + +Developer: Manish Kumar """ import pytest from unittest.mock import patch, MagicMock @@ -10,11 +12,15 @@ def _make_decision(allowed: bool, reason: str, source: str = "TEST") -> PolicyDecision: + """Build a PolicyDecision with the given allow/reason/source.""" return PolicyDecision(allowed=allowed, reason=reason, policy_source=source) @pytest.fixture def client(): + """Provide a TestClient for the policy router with evaluate_policy mocked, together with that + mock. + """ with patch("app.api.routes_policy.evaluate_policy") as mock_eval: from app.api.routes_policy import router app = FastAPI() @@ -37,6 +43,7 @@ def client(): def test_evaluate_endpoint_returns_allow(client): + """POST /policy/evaluate returns 200 with allow true when evaluate_policy allows.""" tc, mock_eval = client mock_eval.return_value = _make_decision(True, "access granted", "ALL_PASSED") @@ -49,6 +56,7 @@ def test_evaluate_endpoint_returns_allow(client): def test_evaluate_endpoint_returns_deny(client): + """POST /policy/evaluate returns the RBAC denial's reason and policy_source unchanged.""" tc, mock_eval = client mock_eval.return_value = _make_decision(False, "missing role: researcher", "RBAC") @@ -62,6 +70,9 @@ def test_evaluate_endpoint_returns_deny(client): def test_evaluate_endpoint_abac_deny(client): + """POST /policy/evaluate returns policy_source ABAC for a GPU-required request that + evaluate_policy denies. + """ tc, mock_eval = client mock_eval.return_value = _make_decision(False, "GPU access denied", "ABAC") @@ -73,6 +84,9 @@ def test_evaluate_endpoint_abac_deny(client): def test_evaluate_endpoint_rules_deny(client): + """POST /policy/evaluate returns allow false and policy_source RULES for a protected-dataset + delete that evaluate_policy denies. + """ tc, mock_eval = client mock_eval.return_value = _make_decision(False, "protected dataset cannot be deleted", "RULES") @@ -84,6 +98,9 @@ def test_evaluate_endpoint_rules_deny(client): def test_evaluate_endpoint_admin_override(client): + """POST /policy/evaluate returns allow true for an admin request even though the underlying + payload would otherwise be denied. + """ tc, mock_eval = client mock_eval.return_value = _make_decision(True, "admin override", "RBAC") @@ -94,6 +111,9 @@ def test_evaluate_endpoint_admin_override(client): def test_evaluate_passes_full_request_to_service(client): + """The route passes the full request payload, including user_id and action, through to + evaluate_policy. + """ tc, mock_eval = client mock_eval.return_value = _make_decision(True, "ok", "ALL_PASSED") diff --git a/tests/test_security_boundaries.py b/tests/test_security_boundaries.py index cf150ce..43ed91d 100644 --- a/tests/test_security_boundaries.py +++ b/tests/test_security_boundaries.py @@ -3,6 +3,8 @@ These tests deliberately use fakes/mocks only. They cover ordering, validation, cache failure behavior, and boundary values without requiring Redis, IAM, a database, or a network service. + +Developer: Manish Kumar """ import json @@ -17,6 +19,7 @@ def request(**overrides): + """Build a PolicyRequest with sensible defaults, overridable by keyword.""" values = { "user_id": "u1", "roles": ["researcher"], @@ -30,6 +33,7 @@ def request(**overrides): def engine_with_redis(redis): + """Build a PolicyEngine with its cache backed by the given Redis client.""" with patch("app.services.cache.redis") as redis_module: redis_module.from_url.return_value = redis cache = PolicyCache("redis://unused") @@ -38,6 +42,9 @@ def engine_with_redis(redis): def test_evaluation_stops_at_first_denial_in_security_order(): + """When ABAC denies, RBAC, PERMISSION and TENANCY have already run but RULES is never called, + and the decision reports ABAC's denial. + """ redis = MagicMock() redis.get.return_value = None engine = engine_with_redis(redis) @@ -72,6 +79,10 @@ def test_evaluation_stops_at_first_denial_in_security_order(): def test_earlier_denials_prevent_all_later_policy_layers( denying_check, source, reason, later_checks ): + """Whichever of RBAC, PERMISSION or TENANCY denies first, every later stage in the + RBAC/PERMISSION/TENANCY/ABAC/RULES order is skipped and the decision reports that stage's + source and reason. + """ redis = MagicMock() redis.get.return_value = None engine = engine_with_redis(redis) @@ -98,6 +109,9 @@ def test_earlier_denials_prevent_all_later_policy_layers( def test_rules_deny_is_not_overridden_by_admin_or_permissions(): + """Deleting from the model registry is denied at the RULES stage even for an admin holding + workflow.manage. + """ redis = MagicMock() redis.get.return_value = None engine = engine_with_redis(redis) @@ -126,11 +140,17 @@ def test_rules_deny_is_not_overridden_by_admin_or_permissions(): ], ) def test_malformed_policy_input_is_rejected_before_evaluation(payload): + """PolicyRequest raises ValidationError for an empty payload, one missing required fields, or + one with a wrong-typed roles or context field. + """ with pytest.raises(ValidationError): PolicyRequest(**payload) def test_cache_malformed_external_response_is_not_treated_as_an_allow(): + """evaluate raises json.JSONDecodeError, rather than silently allowing, when the cached Redis + value is not valid JSON. + """ redis = MagicMock() redis.get.return_value = "not-json" engine = engine_with_redis(redis) @@ -140,6 +160,9 @@ def test_cache_malformed_external_response_is_not_treated_as_an_allow(): def test_cache_backend_failure_does_not_fall_through_to_allow(): + """evaluate propagates a Redis connection error rather than silently allowing when the cache + backend fails. + """ redis = MagicMock() redis.get.side_effect = ConnectionError("cache unavailable") engine = engine_with_redis(redis) @@ -149,6 +172,9 @@ def test_cache_backend_failure_does_not_fall_through_to_allow(): def test_cache_key_separates_context_values_that_change_abac_decisions(): + """build_key produces different keys for requests that differ only in GPU-required context or in + tenant organization context. + """ redis = MagicMock() cache = engine_with_redis(redis).cache @@ -166,6 +192,9 @@ def test_cache_key_separates_context_values_that_change_abac_decisions(): def test_tenancy_missing_context_fails_closed_when_resource_is_scoped(): + """A request with no caller organization against an organization-scoped resource is denied at + the TENANCY stage with reason "missing organization context". + """ redis = MagicMock() redis.get.return_value = None engine = engine_with_redis(redis) @@ -184,6 +213,9 @@ def test_tenancy_missing_context_fails_closed_when_resource_is_scoped(): reason="Unknown actions currently pass the default RBAC/permission path; fail-closed unknown-action handling is a production defect.", ) def test_unknown_action_should_fail_closed(): + """An unknown action is expected to fail closed but currently passes through the default + RBAC/permission path, tracked here as a known xfail production defect. + """ redis = MagicMock() redis.get.return_value = None engine = engine_with_redis(redis) @@ -200,6 +232,9 @@ def test_unknown_action_should_fail_closed(): reason="Wildcard action/resource semantics are not defined or denied; current implementation treats them as unrelated actions.", ) def test_wildcard_action_should_not_bypass_authorization(): + """A wildcard action and resource are expected to be denied but are currently treated as an + ordinary unrelated action, tracked here as a known xfail production defect. + """ redis = MagicMock() redis.get.return_value = None engine = engine_with_redis(redis) diff --git a/tests/test_tenancy.py b/tests/test_tenancy.py index 1d56ef7..596c275 100644 --- a/tests/test_tenancy.py +++ b/tests/test_tenancy.py @@ -1,31 +1,52 @@ +"""Unit tests for app/core/tenancy.py::evaluate_tenancy: a resource with no +organization context is unscoped and allowed, a matching organization passes, a +mismatched one is denied, a caller with no organization is denied against a +scoped resource, and organization ids are compared as strings so an int and a +string of the same value match. + +Developer: Manish Kumar +""" from app.core.tenancy import evaluate_tenancy def test_no_resource_org_id_is_noop(): + """A resource with no resource_org_id is allowed with reason "no tenancy scoping required".""" allowed, reason = evaluate_tenancy("org-1", {}) assert allowed is True assert reason == "no tenancy scoping required" def test_matching_org_allowed(): + """A resource whose organization matches the caller's is allowed with reason "tenancy check + passed". + """ allowed, reason = evaluate_tenancy("org-1", {"resource_org_id": "org-1"}) assert allowed is True assert reason == "tenancy check passed" def test_mismatched_org_denied(): + """A resource belonging to a different organization is denied with reason "cross-tenant access + denied". + """ allowed, reason = evaluate_tenancy("org-1", {"resource_org_id": "org-2"}) assert allowed is False assert reason == "cross-tenant access denied" def test_missing_requester_org_denied_when_resource_scoped(): + """A caller with no organization is denied against an organization-scoped resource, with reason + "missing organization context". + """ allowed, reason = evaluate_tenancy(None, {"resource_org_id": "org-2"}) assert allowed is False assert reason == "missing organization context" def test_org_id_compared_as_string(): + """An int-typed caller organization id matches a string-typed resource organization id of the + same value. + """ # A gateway/JWT could hand back an int-typed org_id; a resource_org_id # supplied as a string (or vice versa) must not spuriously mismatch. allowed, reason = evaluate_tenancy(1, {"resource_org_id": "1"})