diff --git a/backend/api_v2/tests/test_deployment_auth.py b/backend/api_v2/tests/test_deployment_auth.py new file mode 100644 index 0000000000..ccbf307fa7 --- /dev/null +++ b/backend/api_v2/tests/test_deployment_auth.py @@ -0,0 +1,109 @@ +"""Critical path ``api-deployment-auth``: the public deployment endpoint +rejects unauthenticated callers before any work is dispatched. + +``POST /deployment/api///`` is the only unauthenticated surface +in the product — it is guarded by the ``@DeploymentHelper.validate_api_key`` +decorator rather than DRF permission classes, so a regression here silently +opens the endpoint. Every rejection must land before ``execute_workflow``, i.e. +before a ``WorkflowExecution`` row is written or a Celery task is queued; +the mock below asserts exactly that. Needs a live DB (integration tier). +""" + +from __future__ import annotations + +import uuid +from unittest.mock import patch + +import pytest +from account_v2.models import Organization +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework.test import APIRequestFactory +from utils.user_context import UserContext +from workflow_manager.workflow_v2.models.workflow import Workflow + +from api_v2.api_deployment_views import DeploymentExecution +from api_v2.models import APIDeployment, APIKey + +ORG_ID = "org-auth" + + +class APIDeploymentAuthTest(TestCase): + def setUp(self) -> None: + self.org = Organization.objects.create( + name=ORG_ID, display_name="Org Auth", organization_id=ORG_ID + ) + UserContext.set_organization_identifier(ORG_ID) + workflow = Workflow.objects.create(workflow_name="wf-auth", is_active=True) + + self.api = APIDeployment.objects.create(api_name="live-api", workflow=workflow) + self.key = APIKey.objects.create(api=self.api) + self.inactive_key = APIKey.objects.create(api=self.api, is_active=False) + + self.inactive_api = APIDeployment.objects.create( + api_name="dead-api", workflow=workflow, is_active=False + ) + self.other_api = APIDeployment.objects.create( + api_name="other-api", workflow=workflow + ) + self.other_key = APIKey.objects.create(api=self.other_api) + + self.view = DeploymentExecution.as_view() + self.factory = APIRequestFactory() + + def _post(self, api_name: str, auth: str | None, org: str = ORG_ID): + headers = {"HTTP_AUTHORIZATION": auth} if auth is not None else {} + payload = {"files": SimpleUploadedFile("doc.txt", b"hello")} + request = self.factory.post( + f"/deployment/api/{org}/{api_name}/", payload, format="multipart", **headers + ) + return self.view(request, org_name=org, api_name=api_name) + + @pytest.mark.critical_path("api-deployment-auth") + @patch("api_v2.api_deployment_views.DeploymentHelper.execute_workflow") + def test_bad_credentials_rejected_before_dispatch(self, execute_workflow) -> None: + cases = [ + ("missing header", "live-api", None, ORG_ID, 403), + ("no bearer prefix", "live-api", f"Token {self.key.api_key}", ORG_ID, 403), + ("empty bearer", "live-api", "Bearer ", ORG_ID, 403), + ("unknown key", "live-api", f"Bearer {uuid.uuid4()}", ORG_ID, 401), + ("not a uuid", "live-api", "Bearer not-a-uuid", ORG_ID, 401), + ( + "inactive key", + "live-api", + f"Bearer {self.inactive_key.api_key}", + ORG_ID, + 401, + ), + ( + "key of another api", + "live-api", + f"Bearer {self.other_key.api_key}", + ORG_ID, + 401, + ), + ("unknown api", "ghost-api", f"Bearer {self.key.api_key}", ORG_ID, 404), + ("inactive api", "dead-api", f"Bearer {self.key.api_key}", ORG_ID, 404), + ("wrong org", "live-api", f"Bearer {self.key.api_key}", "no-such-org", 404), + ] + for label, api_name, auth, org, expected in cases: + with self.subTest(label): + response = self._post(api_name, auth, org) + assert response.status_code == expected, response.data + + execute_workflow.assert_not_called() + + @pytest.mark.critical_path("api-deployment-auth") + @patch("api_v2.api_deployment_views.APIDeploymentRateLimiter.check_and_acquire") + @patch("api_v2.api_deployment_views.DeploymentHelper.execute_workflow") + def test_valid_key_reaches_execution(self, execute_workflow, rate_limit) -> None: + """Guard the inverse of the rejection cases: a guard that rejected + everything would pass them all. + """ + rate_limit.return_value = (True, {}) + execute_workflow.return_value = {"execution_status": "COMPLETED"} + + response = self._post("live-api", f"Bearer {self.key.api_key}") + + assert response.status_code == 200, response.data + execute_workflow.assert_called_once() diff --git a/backend/api_v2/tests/test_deployment_provision.py b/backend/api_v2/tests/test_deployment_provision.py new file mode 100644 index 0000000000..5f59630194 --- /dev/null +++ b/backend/api_v2/tests/test_deployment_provision.py @@ -0,0 +1,88 @@ +"""Critical path ``api-deployment-provision``: deploying a workflow as an API +mints a usable key and a resolvable endpoint. + +Provisioning is the only place an API key is created for a deployment, and the +endpoint string it derives is what callers POST to. Both are returned exactly +once — at create time — so a regression here is unrecoverable for the caller. +Fully synchronous. Needs a live DB (integration tier). +""" + +from __future__ import annotations + +import secrets + +import pytest +from account_v2.models import Organization, User +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIRequestFactory, force_authenticate +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext +from workflow_manager.endpoint_v2.models import WorkflowEndpoint +from workflow_manager.workflow_v2.models.workflow import Workflow + +from api_v2.api_deployment_views import APIDeploymentViewSet +from api_v2.api_key_views import APIKeyViewSet +from api_v2.models import APIDeployment, APIKey + +ORG_ID = "org-provision" + + +class APIDeploymentProvisionTest(TestCase): + def setUp(self) -> None: + self.org = Organization.objects.create( + name=ORG_ID, display_name="Org Provision", organization_id=ORG_ID + ) + UserContext.set_organization_identifier(ORG_ID) + self.user = User.objects.create_user( + username="deployer@example.com", + email="deployer@example.com", + password=secrets.token_urlsafe(), + ) + OrganizationMember.objects.create( + organization=self.org, user=self.user, role="user" + ) + self.workflow = Workflow.objects.create(workflow_name="wf-deploy", is_active=True) + for endpoint_type in WorkflowEndpoint.EndpointType: + WorkflowEndpoint.objects.create( + workflow=self.workflow, + endpoint_type=endpoint_type, + connection_type=WorkflowEndpoint.ConnectionType.API, + ) + self.factory = APIRequestFactory() + + @pytest.mark.critical_path("api-deployment-provision") + def test_deploy_mints_key_and_endpoint(self) -> None: + create = APIDeploymentViewSet.as_view({"post": "create"}) + request = self.factory.post( + "/api/v1/api/deployment/", + { + "workflow": str(self.workflow.id), + "display_name": "my api", + "api_name": "my-api", + "description": "provisioned in a test", + }, + format="json", + ) + force_authenticate(request, user=self.user) + response = create(request) + + assert response.status_code == status.HTTP_201_CREATED, response.data + assert response.data["api_endpoint"] == f"deployment/api/{ORG_ID}/my-api/" + + api = APIDeployment.objects.get(api_name="my-api") + assert api.is_active + assert api.organization_id == self.org.id + + # The key is returned once, at create time, and never again. + key = APIKey.objects.get(api=api) + assert str(key.api_key) == response.data["api_key"] + assert key.is_active + + list_keys = APIKeyViewSet.as_view({"get": "api_keys"}) + list_request = self.factory.get(f"/api/v1/api/keys/api/{api.id}/") + force_authenticate(list_request, user=self.user) + listed = list_keys(list_request, api_id=str(api.id)) + + assert listed.status_code == status.HTTP_200_OK, listed.data + assert [k["id"] for k in listed.data] == [str(key.id)] diff --git a/backend/connector_v2/tests/__init__.py b/backend/connector_v2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/connector_v2/tests/test_connector_register.py b/backend/connector_v2/tests/test_connector_register.py new file mode 100644 index 0000000000..889a961eca --- /dev/null +++ b/backend/connector_v2/tests/test_connector_register.py @@ -0,0 +1,123 @@ +"""Critical path ``connector-register-test``: credentials are validated against +the live external system, and a registered connector persists them encrypted. + +``POST /test_connectors/`` is the only place the platform proves it can actually +reach a customer's storage before a workflow depends on it; a version that +always answers "valid" would surface as a run-time failure deep inside an +execution. Runs against the rig's MinIO. Needs a live DB (integration tier). +""" + +from __future__ import annotations + +import os +import secrets + +import pytest +from account_v2.models import Organization, User +from django.db import connection +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIRequestFactory, force_authenticate +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext + +from connector_v2.models import ConnectorInstance + +MINIO_CONNECTOR_ID = "minio|c799f6e3-2b57-434e-aaac-b5daa415da19" + +pytestmark = pytest.mark.skipif( + not all( + os.environ.get(var) + for var in ( + "MINIO_ACCESS_KEY_ID", + "MINIO_SECRET_ACCESS_KEY", + "MINIO_ENDPOINT_URL", + ) + ), + reason="needs a live MinIO (provisioned by the rig for this group)", +) + + +def _credentials(secret: str | None = None) -> dict: + return { + "connectorName": "rig-minio", + "key": os.environ["MINIO_ACCESS_KEY_ID"], + "secret": secret or os.environ["MINIO_SECRET_ACCESS_KEY"], + "endpoint_url": os.environ["MINIO_ENDPOINT_URL"], + "region_name": "", + "path": "/", + } + + +class ConnectorRegisterTest(TestCase): + def setUp(self) -> None: + self.org = Organization.objects.create( + name="org-conn", display_name="Org Conn", organization_id="org-conn" + ) + UserContext.set_organization_identifier(self.org.organization_id) + self.user = User.objects.create_user( + username="connector@example.com", + email="connector@example.com", + password=secrets.token_urlsafe(), + ) + OrganizationMember.objects.create( + organization=self.org, user=self.user, role="user" + ) + self.factory = APIRequestFactory() + + def _test_connector(self, credentials: dict): + from connector_processor.views import ConnectorViewSet + + view = ConnectorViewSet.as_view({"post": "test"}) + request = self.factory.post( + "/api/v1/test_connectors/", + {"connector_id": MINIO_CONNECTOR_ID, "connector_metadata": credentials}, + format="json", + ) + force_authenticate(request, user=self.user) + return view(request) + + @pytest.mark.critical_path("connector-register-test") + def test_good_credentials_validate_bad_ones_do_not(self) -> None: + valid = self._test_connector(_credentials()) + assert valid.status_code == status.HTTP_200_OK, valid.data + assert valid.data["is_valid"] is True + + invalid = self._test_connector(_credentials(secret="wrong-secret")) + assert invalid.status_code >= status.HTTP_400_BAD_REQUEST, invalid.data + assert "is_valid" not in invalid.data + + @pytest.mark.critical_path("connector-register-test") + def test_register_persists_credentials_encrypted(self) -> None: + from connector_v2.views import ConnectorInstanceViewSet + + view = ConnectorInstanceViewSet.as_view({"post": "create"}) + request = self.factory.post( + "/api/v1/connector/", + { + "connector_id": MINIO_CONNECTOR_ID, + "connector_name": "rig-minio", + "connector_metadata": _credentials(), + }, + format="json", + ) + force_authenticate(request, user=self.user) + response = view(request) + + assert response.status_code == status.HTTP_201_CREATED, response.data + instance = ConnectorInstance.objects.get(id=response.data["id"]) + assert instance.organization_id == self.org.id + # Decrypts back to the input on read... + assert instance.connector_metadata["key"] == os.environ["MINIO_ACCESS_KEY_ID"] + + # ...but the raw column must be ciphertext. Read it past the field's + # decrypting descriptor, else a regression to plaintext still passes. + with connection.cursor() as cursor: + cursor.execute( + "SELECT connector_metadata FROM connector_instance WHERE id = %s", + [str(instance.id)], + ) + raw = bytes(cursor.fetchone()[0]) + secret = os.environ["MINIO_SECRET_ACCESS_KEY"].encode() + assert secret not in raw + assert os.environ["MINIO_ACCESS_KEY_ID"].encode() not in raw diff --git a/backend/prompt_studio/prompt_studio_core_v2/tests/test_prompt_studio_author.py b/backend/prompt_studio/prompt_studio_core_v2/tests/test_prompt_studio_author.py new file mode 100644 index 0000000000..1b5946ab9c --- /dev/null +++ b/backend/prompt_studio/prompt_studio_core_v2/tests/test_prompt_studio_author.py @@ -0,0 +1,85 @@ +"""Critical path ``prompt-studio-author``: create a Prompt Studio project and +add a prompt to it. + +Authoring is the synchronous half of prompt-studio-fetch-response — running a +prompt needs a real LLM, but composing one does not. A project must be creatable +before any adapter is configured (a fresh org has none), and prompts must bind +to their project. Needs a live DB (integration tier). +""" + +from __future__ import annotations + +import secrets + +import pytest +from account_v2.models import Organization, User +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIRequestFactory, force_authenticate +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext + +from prompt_studio.prompt_studio_core_v2.models import CustomTool +from prompt_studio.prompt_studio_core_v2.views import PromptStudioCoreView +from prompt_studio.prompt_studio_v2.models import ToolStudioPrompt + + +class PromptStudioAuthorAPITest(TestCase): + def setUp(self) -> None: + self.org = Organization.objects.create( + name="org-ps", display_name="Org PS", organization_id="org-ps" + ) + UserContext.set_organization_identifier(self.org.organization_id) + self.user = User.objects.create_user( + username="prompter@example.com", + email="prompter@example.com", + password=secrets.token_urlsafe(), + ) + OrganizationMember.objects.create( + organization=self.org, user=self.user, role="user" + ) + self.factory = APIRequestFactory() + + def _call(self, view, method: str, path: str, payload: dict, **kwargs): + request = getattr(self.factory, method)(path, payload, format="json") + force_authenticate(request, user=self.user) + return view(request, **kwargs) + + @pytest.mark.critical_path("prompt-studio-author") + def test_create_project_then_add_prompt(self) -> None: + create_project = PromptStudioCoreView.as_view({"post": "create"}) + project = self._call( + create_project, + "post", + "/api/v1/prompt-studio/", + { + "tool_name": "invoice-parser", + "description": "extracts invoice fields", + "author": "test", + }, + ) + assert project.status_code == status.HTTP_201_CREATED, project.data + + tool = CustomTool.objects.get(tool_id=project.data["tool_id"]) + assert tool.organization_id == self.org.id + assert tool.created_by == self.user + + create_prompt = PromptStudioCoreView.as_view({"post": "create_prompt"}) + prompt = self._call( + create_prompt, + "post", + f"/api/v1/prompt-studio/prompt-studio-prompt/{tool.tool_id}/", + { + "tool_id": str(tool.tool_id), + "prompt_key": "invoice_number", + "prompt": "What is the invoice number?", + "prompt_type": ToolStudioPrompt.PromptType.PROMPT, + "sequence_number": 1, + }, + pk=str(tool.tool_id), + ) + assert prompt.status_code == status.HTTP_201_CREATED, prompt.data + + persisted = ToolStudioPrompt.objects.get(prompt_id=prompt.data["prompt_id"]) + assert persisted.tool_id_id == tool.tool_id + assert persisted.active diff --git a/backend/usage_v2/tests/test_usage_aggregate.py b/backend/usage_v2/tests/test_usage_aggregate.py new file mode 100644 index 0000000000..6efd45dd5c --- /dev/null +++ b/backend/usage_v2/tests/test_usage_aggregate.py @@ -0,0 +1,91 @@ +"""Critical path ``usage-aggregate-read``: per-run token usage aggregates +correctly and never crosses organization boundaries. + +Usage rows are written by workers, but the aggregation that bills against them +is a synchronous read. Under-counting loses revenue; leaking another org's rows +into the sum is both a billing and a disclosure bug — the org scoping lives in a +default manager, so a stray ``objects`` -> ``_base_manager`` change would break +it silently. Needs a live DB (integration tier). +""" + +from __future__ import annotations + +import secrets +import uuid + +import pytest +from account_v2.models import Organization, User +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIRequestFactory, force_authenticate +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext + +from usage_v2.models import Usage, UsageType + + +def _usage(organization: Organization, run_id: uuid.UUID, tokens: int) -> Usage: + return Usage.objects.create( + organization=organization, + run_id=run_id, + adapter_instance_id=str(uuid.uuid4()), + usage_type=UsageType.LLM, + model_name="gpt-4o-mini", + embedding_tokens=0, + prompt_tokens=tokens, + completion_tokens=tokens, + total_tokens=tokens * 2, + cost_in_dollars=0.5, + ) + + +class UsageAggregateReadTest(TestCase): + def setUp(self) -> None: + self.org = Organization.objects.create( + name="org-usage", display_name="Org Usage", organization_id="org-usage" + ) + self.other_org = Organization.objects.create( + name="org-other", display_name="Org Other", organization_id="org-other" + ) + UserContext.set_organization_identifier(self.org.organization_id) + self.user = User.objects.create_user( + username="biller@example.com", + email="biller@example.com", + password=secrets.token_urlsafe(), + ) + OrganizationMember.objects.create( + organization=self.org, user=self.user, role="user" + ) + self.run_id = uuid.uuid4() + self.factory = APIRequestFactory() + + def tearDown(self) -> None: + # UserContext is thread-local, not rolled back with the test DB; clear it + # so the org identifier can't leak into a later manager-filtered query. + UserContext.set_organization_identifier(None) + super().tearDown() + + @pytest.mark.critical_path("usage-aggregate-read") + def test_token_usage_sums_own_org_only(self) -> None: + _usage(self.org, self.run_id, tokens=100) + _usage(self.org, self.run_id, tokens=50) + _usage(self.org, uuid.uuid4(), tokens=999) # different run + _usage(self.other_org, self.run_id, tokens=777) # same run, other org + + # Deferred: UsageView's filterset resolves a queryset at class-body + # evaluation, so a module-scope import queries the DB during pytest + # collection — before any test has DB access. + from usage_v2.views import UsageView + + view = UsageView.as_view({"get": "get_token_usage"}) + request = self.factory.get( + "/api/v1/usage/get_token_usage/", {"run_id": self.run_id} + ) + force_authenticate(request, user=self.user) + response = view(request) + + assert response.status_code == status.HTTP_200_OK, response.data + assert response.data["prompt_tokens"] == 150 + assert response.data["completion_tokens"] == 150 + assert response.data["total_tokens"] == 300 + assert response.data["cost_in_dollars"] == pytest.approx(1.0) diff --git a/backend/workflow_manager/workflow_v2/tests/__init__.py b/backend/workflow_manager/workflow_v2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/workflow_manager/workflow_v2/tests/test_workflow_author.py b/backend/workflow_manager/workflow_v2/tests/test_workflow_author.py new file mode 100644 index 0000000000..bc6b5eac26 --- /dev/null +++ b/backend/workflow_manager/workflow_v2/tests/test_workflow_author.py @@ -0,0 +1,95 @@ +"""Critical path ``workflow-author``: create a workflow and configure its +source + destination endpoints. + +This is the authoring contract that every execution path builds on. Creating a +workflow implicitly materialises its two endpoints — a workflow whose endpoints +are missing is unconfigurable and unexecutable, and nothing else in the product +recreates them. Fully synchronous: no workers, no execution. Needs a live DB +(integration tier). +""" + +from __future__ import annotations + +import secrets + +import pytest +from account_v2.models import Organization, User +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIRequestFactory, force_authenticate +from tenant_account_v2.models import OrganizationMember +from utils.user_context import UserContext + +from workflow_manager.endpoint_v2.endpoint_utils import WorkflowEndpointUtils +from workflow_manager.endpoint_v2.models import WorkflowEndpoint +from workflow_manager.workflow_v2.models.workflow import Workflow +from workflow_manager.workflow_v2.views import WorkflowViewSet + + +class WorkflowAuthorAPITest(TestCase): + def setUp(self) -> None: + self.org = Organization.objects.create( + name="org-wf", display_name="Org WF", organization_id="org-wf" + ) + UserContext.set_organization_identifier(self.org.organization_id) + self.user = User.objects.create_user( + username="author@example.com", + email="author@example.com", + password=secrets.token_urlsafe(), + ) + OrganizationMember.objects.create( + organization=self.org, user=self.user, role="user" + ) + self.factory = APIRequestFactory() + + def _create_workflow(self, name: str): + view = WorkflowViewSet.as_view({"post": "create"}) + request = self.factory.post( + "/api/v1/workflow/", {"workflow_name": name}, format="json" + ) + force_authenticate(request, user=self.user) + return view(request) + + def _patch_endpoint(self, endpoint: WorkflowEndpoint, payload: dict): + # Deferred: WorkflowEndpointSerializer resolves a queryset at class-body + # evaluation, so importing its view at module scope queries the DB during + # pytest collection — before any test has DB access. + from workflow_manager.endpoint_v2.views import WorkflowEndpointViewSet + + view = WorkflowEndpointViewSet.as_view({"patch": "partial_update"}) + request = self.factory.patch( + f"/api/v1/workflow/endpoint/{endpoint.id}/", payload, format="json" + ) + force_authenticate(request, user=self.user) + return view(request, pk=str(endpoint.id)) + + @pytest.mark.critical_path("workflow-author") + def test_create_workflow_materialises_configurable_endpoints(self) -> None: + response = self._create_workflow("wf-author") + assert response.status_code == status.HTTP_201_CREATED, response.data + + workflow = Workflow.objects.get(id=response.data["id"]) + assert workflow.organization_id == self.org.id + assert workflow.is_active + + endpoints = {e.endpoint_type: e for e in workflow.workflowendpoint_set.all()} + assert set(endpoints) == { + WorkflowEndpoint.EndpointType.SOURCE, + WorkflowEndpoint.EndpointType.DESTINATION, + } + # Endpoints land unconfigured — authoring, not execution, fills them in. + assert all(not e.connection_type for e in endpoints.values()) + + source = self._patch_endpoint( + endpoints[WorkflowEndpoint.EndpointType.SOURCE], + {"connection_type": WorkflowEndpoint.ConnectionType.API}, + ) + assert source.status_code == status.HTTP_200_OK, source.data + + destination = self._patch_endpoint( + endpoints[WorkflowEndpoint.EndpointType.DESTINATION], + {"connection_type": WorkflowEndpoint.ConnectionType.API}, + ) + assert destination.status_code == status.HTTP_200_OK, destination.data + + assert WorkflowEndpointUtils.is_api_workflow(workflow) diff --git a/tests/critical_paths.yaml b/tests/critical_paths.yaml index 2c7b045da6..2a2ff8c06b 100644 --- a/tests/critical_paths.yaml +++ b/tests/critical_paths.yaml @@ -27,47 +27,68 @@ version: 1 paths: - id: auth-login description: "User can log in and obtain a session cookie." - entry: "POST /api/v1/login" covered_by: [e2e-login] proof: marker - id: adapter-register-llm description: "Register and validate an LLM adapter." - entry: "POST /api/v1/adapter/" + covered_by: [integration-backend] + proof: marker + + - id: workflow-author + description: "Create a workflow; its source+destination endpoints materialise and are configurable." covered_by: [integration-backend] proof: marker - id: workflow-create-execute description: "Create a workflow, configure source+destination, execute, poll, fetch result." - entry: "POST /api/v1/workflow/{id}/execute/" covered_by: [e2e-workflow] + - id: api-deployment-provision + description: "Deploying a workflow as an API mints a usable key and a resolvable endpoint." + covered_by: [integration-backend] + proof: marker + + - id: api-deployment-auth + description: "Unauthenticated or mis-scoped API-deployment calls are rejected before dispatch." + covered_by: [integration-backend] + proof: marker + - id: api-deployment-run description: "Deploy a workflow as an API, POST a document, receive structured JSON." - entry: "POST /deployment/api/{org}/{name}/" covered_by: [e2e-api-deployment] + - id: prompt-studio-author + description: "Create a Prompt Studio project and add a prompt to it." + covered_by: [integration-backend] + proof: marker + - id: prompt-studio-fetch-response description: "Prompt Studio: create project, add prompt, run single-pass, get response." - entry: "POST /api/v1/prompt-studio/prompt-studio-tool/{id}/fetch_response/" covered_by: [e2e-prompt-studio] + - id: connector-register-test + description: "Connector credentials are validated against the live system and stored encrypted." + covered_by: [integration-backend] + proof: marker + - id: pipeline-etl-execute description: "Run an ETL pipeline from source connector to destination." - entry: "POST /api/v1/pipeline/{id}/execute/" covered_by: [] # gap + - id: usage-aggregate-read + description: "Per-run token usage aggregates correctly and stays scoped to its organization." + covered_by: [integration-backend] + proof: marker + - id: usage-token-tracking description: "Per-execution token usage is recorded and retrievable." - entry: "GET /api/v1/usage/get_token_usage/" covered_by: [] # gap - id: workflow-execution-fan-out description: "Multi-file workflow execution fans out to file-processing workers and rejoins." - entry: "internal: backend → rabbitmq → workers/file_processing" covered_by: [] # gap - id: callback-result-delivery description: "Async results are posted back via the callback worker." - entry: "internal: workers/callback → backend /internal endpoints" covered_by: [] # gap diff --git a/tests/groups.yaml b/tests/groups.yaml index 47b77ce169..ee91fddd44 100644 --- a/tests/groups.yaml +++ b/tests/groups.yaml @@ -117,7 +117,7 @@ groups: markers: "integration" uv_sync_group: test env: *backend_test_env - requires_services: [postgres, redis] + requires_services: [postgres, redis, minio] coverage_source: . integration-connectors: diff --git a/tests/rig/critical_paths.py b/tests/rig/critical_paths.py index f0b8fb9147..e9cb3ccbbb 100644 --- a/tests/rig/critical_paths.py +++ b/tests/rig/critical_paths.py @@ -34,7 +34,6 @@ class BaselineCorruptError(RuntimeError): class CriticalPath: id: str description: str - entry: str covered_by: tuple[str, ...] # "group": covered when any covered_by group runs green (coarse — survives # the covering test being deleted). "marker": additionally requires ≥1 @@ -103,7 +102,6 @@ def load_critical_paths(path: Path | None = None) -> CriticalPathRegistry: CriticalPath( id=str(p["id"]), description=str(p.get("description", "")), - entry=str(p.get("entry", "")), covered_by=tuple(p.get("covered_by") or ()), proof=proof, ) diff --git a/tests/rig/reporting.py b/tests/rig/reporting.py index 51b5bea52c..21ef3f6fa9 100644 --- a/tests/rig/reporting.py +++ b/tests/rig/reporting.py @@ -270,9 +270,7 @@ def _render_markdown( lines.append("### ❌ Regressions (must be zero)") lines.append("") for s in regressions: - lines.append( - f"- **{s.path.id}** — {s.path.description} (entry: `{s.path.entry}`)" - ) + lines.append(f"- **{s.path.id}** — {s.path.description}") lines.append("") if gaps: lines.append("### ⚠️ Critical paths not yet covered") @@ -281,7 +279,7 @@ def _render_markdown( covers = ", ".join(s.path.covered_by) or "_no groups declared_" lines.append( f"- **{s.path.id}** — {s.path.description} " - f"(entry: `{s.path.entry}`; declared coverage: {covers})" + f"(declared coverage: {covers})" ) lines.append("") if covered: diff --git a/tests/rig/runtime.py b/tests/rig/runtime.py index 06a6b82fd5..2643d9da99 100644 --- a/tests/rig/runtime.py +++ b/tests/rig/runtime.py @@ -263,16 +263,17 @@ def _run(cmd: list[str], *, check: bool = True) -> None: def health_targets(endpoints: PlatformEndpoints) -> list[tuple[str, str]]: - """(service name, health URL) for every HTTP service in the platform. + """(service name, health URL) for every HTTP service the e2e tier depends on. Single source of truth for the readiness probe and the e2e smoke test. - Paths are service-specific: runner and x2text mount health under blueprint - prefixes, and there is no standalone prompt-service (folded into workers). + Paths are service-specific: x2text mounts health under a blueprint prefix, + and there is no standalone prompt-service (folded into workers). The runner + is intentionally absent — container-based execution is being retired in + favour of in-worker execution, so e2e must not depend on it being up. """ return [ ("backend", endpoints.backend_url.rstrip("/") + "/health"), ("platform-service", endpoints.platform_service_url.rstrip("/") + "/health"), - ("runner", endpoints.runner_url.rstrip("/") + "/v1/api/health"), ("x2text-service", endpoints.x2text_url.rstrip("/") + "/api/v1/x2text/health"), ] diff --git a/tests/rig/tests/test_cli.py b/tests/rig/tests/test_cli.py index 3e3ac4dfd7..4669488fcf 100644 --- a/tests/rig/tests/test_cli.py +++ b/tests/rig/tests/test_cli.py @@ -241,7 +241,6 @@ def _run_gap_scenario( "paths:\n" " - id: p1\n" " description: ''\n" - " entry: ''\n" f" covered_by: {covered_by}\n" ) (tmp_path / "groups.yaml").write_text(manifest_yaml) diff --git a/tests/rig/tests/test_critical_paths.py b/tests/rig/tests/test_critical_paths.py index 8ee4251798..214c958d62 100644 --- a/tests/rig/tests/test_critical_paths.py +++ b/tests/rig/tests/test_critical_paths.py @@ -19,7 +19,7 @@ def _registry(*ids_and_covers: tuple[str, tuple[str, ...]]) -> CriticalPathRegistry: return CriticalPathRegistry( paths=tuple( - CriticalPath(id=i, description="", entry="", covered_by=c) + CriticalPath(id=i, description="", covered_by=c) for i, c in ids_and_covers ) ) @@ -97,7 +97,7 @@ def test_critical_path_status_rejects_contradictions() -> None: """Make the contradictory states unrepresentable.""" from tests.rig.critical_paths import CriticalPath, CriticalPathStatus - path = CriticalPath(id="p", description="", entry="", covered_by=("g",)) + path = CriticalPath(id="p", description="", covered_by=("g",)) with pytest.raises(ValueError, match="covered.*non-empty"): CriticalPathStatus(path=path, state="covered", covering_groups_run=()) with pytest.raises(ValueError, match="empty covering_groups_run"): @@ -211,7 +211,7 @@ def test_scope_none_preserves_legacy_behavior() -> None: def _marker_path(path_id: str, covers: tuple[str, ...]) -> CriticalPath: return CriticalPath( - id=path_id, description="", entry="", covered_by=covers, proof="marker" + id=path_id, description="", covered_by=covers, proof="marker" )