From 2bf66813ae4ed3971209600e4d44cd3e1b043976 Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Thu, 11 Jun 2026 16:26:02 +0800 Subject: [PATCH] chore: add integration tests for openapi group (#37314) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/services/oauth_device_flow.py | 36 ++- .../.ruff.toml | 2 + .../controllers/openapi/__init__.py | 0 .../controllers/openapi/conftest.py | 119 +++++++++ .../controllers/openapi/test_account.py | 50 ++++ .../openapi/test_account_sessions.py | 137 ++++++++++ .../controllers/openapi/test_app_dsl.py | 238 ++++++++++++++++++ .../controllers/openapi/test_app_run.py | 49 ++++ .../controllers/openapi/test_apps.py | 156 ++++++++++++ .../controllers/openapi/test_files.py | 63 +++++ .../controllers/openapi/test_workspaces.py | 121 +++++++++ 11 files changed, 952 insertions(+), 19 deletions(-) create mode 100644 api/tests/test_containers_integration_tests/controllers/openapi/__init__.py create mode 100644 api/tests/test_containers_integration_tests/controllers/openapi/conftest.py create mode 100644 api/tests/test_containers_integration_tests/controllers/openapi/test_account.py create mode 100644 api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py create mode 100644 api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py create mode 100644 api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py create mode 100644 api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py create mode 100644 api/tests/test_containers_integration_tests/controllers/openapi/test_files.py create mode 100644 api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py diff --git a/api/services/oauth_device_flow.py b/api/services/oauth_device_flow.py index d11c5292ad2..9ec5711890b 100644 --- a/api/services/oauth_device_flow.py +++ b/api/services/oauth_device_flow.py @@ -13,7 +13,6 @@ from enum import StrEnum from typing import Any, NotRequired, TypedDict from sqlalchemy import and_, func, select, update -from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session, scoped_session from libs.oauth_bearer import TOKEN_CACHE_KEY_FMT, AuthContext, SubjectType @@ -405,6 +404,9 @@ def _upsert( # Snapshot prior live row's hash for Redis invalidation post-rotate. # subject_issuer is always non-null here (account flow uses sentinel, # external-SSO is validated upstream), so equality matches the index. + # FOR UPDATE locks the row so that concurrent logins for the same + # (subject, client, device) serialize here rather than both reading + # the same prior and producing two active tokens (TOCTOU race). prior = session.execute( select(OAuthAccessToken.id, OAuthAccessToken.token_hash) .where( @@ -415,10 +417,18 @@ def _upsert( OAuthAccessToken.revoked_at.is_(None), ) .limit(1) + .with_for_update() ).first() old_hash = prior.token_hash if prior else None - insert_stmt = pg_insert(OAuthAccessToken).values( + # Revoke any existing active token for this (subject, client, device) combination. + # PostgreSQL's ON CONFLICT doesn't support partial unique indexes (those with WHERE clauses), + # so we use a manual revoke-then-insert pattern instead. + if prior: + session.execute(update(OAuthAccessToken).where(OAuthAccessToken.id == prior.id).values(revoked_at=func.now())) + + # Insert the new token. + new_token = OAuthAccessToken( subject_email=subject_email, subject_issuer=subject_issuer, account_id=account_id, @@ -428,26 +438,14 @@ def _upsert( token_hash=new_hash, expires_at=expires_at, ) - upsert_stmt = insert_stmt.on_conflict_do_update( - index_elements=["subject_email", "subject_issuer", "client_id", "device_label"], - index_where=OAuthAccessToken.revoked_at.is_(None), - set_={ - "token_hash": insert_stmt.excluded.token_hash, - "prefix": insert_stmt.excluded.prefix, - "account_id": insert_stmt.excluded.account_id, - "expires_at": insert_stmt.excluded.expires_at, - "created_at": func.now(), - "last_used_at": None, - }, - ).returning(OAuthAccessToken.id) - row = session.execute(upsert_stmt).first() + session.add(new_token) + session.flush() + + token_id = new_token.id session.commit() - if row is None: - raise RuntimeError("oauth_token upsert returned no row") - token_id = uuid.UUID(str(row.id)) return UpsertOutcome( - token_id=token_id, + token_id=uuid.UUID(str(token_id)), rotated=prior is not None, old_hash=old_hash, ) diff --git a/api/tests/test_containers_integration_tests/.ruff.toml b/api/tests/test_containers_integration_tests/.ruff.toml index 390eb14851c..11088d48679 100644 --- a/api/tests/test_containers_integration_tests/.ruff.toml +++ b/api/tests/test_containers_integration_tests/.ruff.toml @@ -21,6 +21,8 @@ extend-select = ["ANN401", "ARG", "TID251"] "controllers/console/test_apikey.py" = ["ARG"] "controllers/console/workspace/test_tool_provider.py" = ["ARG"] "controllers/mcp/test_mcp.py" = ["ARG"] +"controllers/openapi/test_app_dsl.py" = ["ARG"] +"controllers/openapi/test_workspaces.py" = ["ARG"] "controllers/service_api/dataset/test_dataset.py" = ["ARG"] "controllers/web/test_conversation.py" = ["ARG"] "controllers/web/test_human_input_form.py" = ["ARG"] diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/__init__.py b/api/tests/test_containers_integration_tests/controllers/openapi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py new file mode 100644 index 00000000000..d961479f55b --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import uuid +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from typing import Literal +from unittest.mock import patch + +import pytest +from faker import Faker +from flask import Flask +from sqlalchemy.orm import Session + +from controllers.openapi.auth.data import AuthData +from libs.oauth_bearer import AuthContext, Scope, SubjectType, TokenType, reset_auth_ctx, set_auth_ctx +from models import Account, Tenant +from services.account_service import AccountService, TenantService +from tests.test_containers_integration_tests.helpers import generate_valid_password + + +@pytest.fixture +def app(flask_app_with_containers: Flask) -> Flask: + return flask_app_with_containers + + +@pytest.fixture +def make_account(db_session_with_containers: Session) -> Callable[..., Account]: + """Factory that registers a real Account and gives it an owner workspace. + + System feature gates are stubbed (registration / workspace creation + allowed) exactly like the AppDslService integration tests, so this stays a + pure account+tenant setup helper. + """ + + # Depend on db_session_with_containers so the app context / DB session is + # active for the real AccountService/TenantService calls below. + assert db_session_with_containers is not None + + def _make(*, with_owner_tenant: bool = True) -> Account: + fake = Faker() + with patch("services.account_service.FeatureService") as mock_feature_service: + mock_feature_service.get_system_features.return_value.is_allow_register = True + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + if with_owner_tenant: + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + return account + + return _make + + +def add_tenant_for_account(account: Account, *, role: str = "normal", name: str = "Second WS") -> Tenant: + """Create an additional tenant and join ``account`` to it (real service calls).""" + with patch("services.account_service.FeatureService") as mock_feature_service: + mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True + tenant = TenantService.create_tenant(name=name) + TenantService.create_tenant_member(tenant, account, role=role) + return tenant + + +def auth_for( + account: Account, + *, + app_model: object | None = None, + token_id: uuid.UUID | None = None, + caller_kind: Literal["account", "end_user"] | None = None, +) -> AuthData: + """Build an AuthData for ``account`` (and optionally an app context). + + ``token_id`` is needed by the self-revoke endpoint, and ``caller_kind`` by + any handler calling ``require_app_context`` (e.g. file upload / task stop). + """ + return AuthData( + token_type=TokenType.OAUTH_ACCOUNT, + account_id=uuid.UUID(str(account.id)), + token_hash="integration-test", + token_id=token_id, + scopes=frozenset({Scope.FULL}), + caller=account, + caller_kind=caller_kind, + app=app_model, # type: ignore[arg-type] + ) + + +@contextmanager +def account_auth_context( + account: Account, + *, + token_id: uuid.UUID, + client_id: str = "integration-cli", +) -> Iterator[AuthContext]: + """Publish an account ``AuthContext`` for handlers that read ``get_auth_ctx()``. + + The auth pipeline normally sets this ContextVar; the integration suite + bypasses the pipeline via ``inspect.unwrap``, so endpoints that resolve the + caller through ``get_auth_ctx()`` (the ``/account/sessions*`` family) need it + set explicitly. Resets on exit so the worker thread can't leak identity. + """ + ctx = AuthContext( + subject_type=SubjectType.ACCOUNT, + subject_email=account.email, + subject_issuer=None, + account_id=uuid.UUID(str(account.id)), + client_id=client_id, + scopes=frozenset({Scope.FULL}), + token_id=token_id, + token_type=TokenType.OAUTH_ACCOUNT, + expires_at=None, + token_hash="integration-test", + ) + reset_token = set_auth_ctx(ctx) + try: + yield ctx + finally: + reset_auth_ctx(reset_token) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_account.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_account.py new file mode 100644 index 00000000000..77c812c0b34 --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_account.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap + +from flask import Flask + +from controllers.openapi.account import AccountApi +from models import Account +from models.account import TenantAccountRole +from tests.test_containers_integration_tests.controllers.openapi.conftest import add_tenant_for_account, auth_for + + +class TestAccountInfo: + def test_returns_account_and_owner_workspace(self, app: Flask, make_account: Callable[..., Account]) -> None: + account = make_account() + owner_tenant = account.current_tenant + assert owner_tenant is not None + + api = AccountApi() + with app.test_request_context("/openapi/v1/account"): + result = unwrap(api.get)(api, auth_data=auth_for(account)) + + assert result.subject_type == "account" + assert result.subject_email == account.email + assert result.account is not None + assert result.account.id == account.id + assert result.account.email == account.email + + workspaces = {w.id: w for w in result.workspaces} + assert set(workspaces) == {owner_tenant.id} + assert workspaces[owner_tenant.id].role == TenantAccountRole.OWNER.value + # No membership is flagged `current` yet, so the default falls back to + # the only workspace the account belongs to. + assert result.default_workspace_id == owner_tenant.id + + def test_lists_all_joined_workspaces(self, app: Flask, make_account: Callable[..., Account]) -> None: + account = make_account() + owner_tenant = account.current_tenant + assert owner_tenant is not None + second = add_tenant_for_account(account, role="normal", name="Second WS") + + api = AccountApi() + with app.test_request_context("/openapi/v1/account"): + result = unwrap(api.get)(api, auth_data=auth_for(account)) + + assert {w.id for w in result.workspaces} == {owner_tenant.id, second.id} + roles = {w.id: w.role for w in result.workspaces} + assert roles[owner_tenant.id] == TenantAccountRole.OWNER.value + assert roles[second.id] == "normal" diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py new file mode 100644 index 00000000000..4cdbec3e30e --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap +from uuid import uuid4 + +import pytest +from flask import Flask +from sqlalchemy.orm import Session +from werkzeug.exceptions import NotFound + +from controllers.openapi._models import SessionListQuery +from controllers.openapi.account import ( + AccountSessionByIdApi, + AccountSessionsApi, + AccountSessionsSelfApi, +) +from extensions.ext_redis import redis_client +from models import Account +from services.oauth_device_flow import PREFIX_OAUTH_ACCOUNT, MintResult, mint_oauth_token +from tests.test_containers_integration_tests.controllers.openapi.conftest import account_auth_context, auth_for + + +def _mint_account_token( + db_session: Session, + account: Account, + *, + client_id: str = "integration-cli", + device_label: str = "Test Device", +) -> MintResult: + """Mint a real, persisted ``dfoa_`` access token for ``account``.""" + return mint_oauth_token( + db_session, + redis_client, + subject_email=account.email, + subject_issuer=None, + account_id=str(account.id), + client_id=client_id, + device_label=device_label, + prefix=PREFIX_OAUTH_ACCOUNT, + ttl_days=14, + ) + + +class TestSessionList: + def test_lists_active_session( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + mint = _mint_account_token(db_session_with_containers, account, device_label="Laptop") + + api = AccountSessionsApi() + with app.test_request_context("/openapi/v1/account/sessions"): + with account_auth_context(account, token_id=mint.token_id): + result = unwrap(api.get)( + api, auth_data=auth_for(account, token_id=mint.token_id), query=SessionListQuery() + ) + + assert result.total == 1 + row = result.data[0] + assert row.id == str(mint.token_id) + assert row.prefix == PREFIX_OAUTH_ACCOUNT + assert row.device_label == "Laptop" + + def test_excludes_other_accounts_sessions( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """Sessions are subject-scoped: another account's token must not appear.""" + account = make_account() + other = make_account() + mine = _mint_account_token(db_session_with_containers, account) + _mint_account_token(db_session_with_containers, other) + + api = AccountSessionsApi() + with app.test_request_context("/openapi/v1/account/sessions"): + with account_auth_context(account, token_id=mine.token_id): + result = unwrap(api.get)( + api, auth_data=auth_for(account, token_id=mine.token_id), query=SessionListQuery() + ) + + assert {row.id for row in result.data} == {str(mine.token_id)} + + +class TestSessionRevoke: + def test_revoke_self_removes_from_active_list( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + mint = _mint_account_token(db_session_with_containers, account) + + revoke_api = AccountSessionsSelfApi() + with app.test_request_context("/openapi/v1/account/sessions/self", method="DELETE"): + with account_auth_context(account, token_id=mint.token_id): + result = unwrap(revoke_api.delete)(revoke_api, auth_data=auth_for(account, token_id=mint.token_id)) + + assert result.status == "revoked" + + # Revocation persisted: the real list path no longer returns it. + list_api = AccountSessionsApi() + with app.test_request_context("/openapi/v1/account/sessions"): + with account_auth_context(account, token_id=mint.token_id): + listing = unwrap(list_api.get)( + list_api, auth_data=auth_for(account, token_id=mint.token_id), query=SessionListQuery() + ) + assert listing.total == 0 + + def test_revoke_by_id_for_own_session( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + mint = _mint_account_token(db_session_with_containers, account) + session_id = str(mint.token_id) + + api = AccountSessionByIdApi() + with app.test_request_context(f"/openapi/v1/account/sessions/{session_id}", method="DELETE"): + with account_auth_context(account, token_id=mint.token_id): + result = unwrap(api.delete)( + api, session_id=session_id, auth_data=auth_for(account, token_id=mint.token_id) + ) + + assert result.status == "revoked" + + def test_revoke_foreign_session_is_404( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """A token id owned by another subject must be indistinguishable from a + missing one (404), so token ids can't be probed across subjects.""" + owner = make_account() + outsider = make_account() + foreign = _mint_account_token(db_session_with_containers, owner) + + api = AccountSessionByIdApi() + session_id = str(foreign.token_id) + with app.test_request_context(f"/openapi/v1/account/sessions/{session_id}", method="DELETE"): + with account_auth_context(outsider, token_id=uuid4()): + with pytest.raises(NotFound): + unwrap(api.delete)(api, session_id=session_id, auth_data=auth_for(outsider, token_id=uuid4())) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py new file mode 100644 index 00000000000..12018c3c67c --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import json +from collections.abc import Callable, Generator +from inspect import unwrap +from unittest.mock import patch +from uuid import uuid4 + +import pytest +import yaml +from faker import Faker +from flask import Flask +from sqlalchemy.orm import Session + +from controllers.openapi._models import AppDslExportQuery, AppDslImportPayload +from controllers.openapi.app_dsl import ( + AppDslCheckDependenciesApi, + AppDslExportApi, + AppDslImportApi, + AppDslImportConfirmApi, +) +from models import Account, App +from models.model import AppModelConfig +from services.account_service import AccountService, TenantService +from services.app_dsl_service import CURRENT_DSL_VERSION +from services.app_service import AppService, CreateAppParams +from services.entities.dsl_entities import ImportStatus +from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for +from tests.test_containers_integration_tests.helpers import generate_valid_password + + +def _workflow_yaml(*, version: str = CURRENT_DSL_VERSION, name: str = "My App") -> str: + return yaml.safe_dump( + { + "version": version, + "kind": "app", + "app": {"name": name, "mode": "workflow"}, + "workflow": {"graph": {"nodes": []}, "features": {}}, + }, + allow_unicode=True, + ) + + +@pytest.fixture +def external_deps() -> Generator[dict[str, object], None, None]: + """Stub the heavy collaborators an import/export touches (model runtime, + workflow sync, dependency analysis, enterprise hooks) while leaving the DSL + service and DB writes real.""" + with ( + patch("services.app_dsl_service.WorkflowService") as mock_workflow_service, + patch("services.app_dsl_service.DependenciesAnalysisService") as mock_dependencies_service, + patch("services.app_dsl_service.app_was_created") as mock_app_was_created, + patch("services.app_service.ModelManager.for_tenant") as mock_model_manager, + patch("services.app_service.FeatureService") as mock_feature_service, + patch("services.app_service.EnterpriseService") as mock_enterprise_service, + ): + mock_workflow_service.return_value.get_draft_workflow.return_value = None + mock_workflow_service.return_value.sync_draft_workflow.return_value = None + mock_dependencies_service.generate_latest_dependencies.return_value = [] # type: ignore[assignment] + mock_dependencies_service.get_leaked_dependencies.return_value = [] # type: ignore[assignment] + mock_dependencies_service.generate_dependencies.return_value = [] # type: ignore[assignment] + mock_app_was_created.send.return_value = None + + mock_model_instance = mock_model_manager.return_value + mock_model_instance.get_default_model_instance.return_value = None + mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo") + + mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False + mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None + mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None + + yield {"workflow_service": mock_workflow_service} + + +def _app_and_account(db_session: Session, *, mode: str = "chat") -> tuple[App, Account]: + fake = Faker() + with patch("services.account_service.FeatureService") as mock_account_feature_service: + mock_account_feature_service.get_system_features.return_value.is_allow_register = True + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + assert tenant is not None + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode=mode, # type: ignore[arg-type] + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) + app_model = AppService().create_app(tenant.id, app_args, account) + return app_model, account + + +class TestDslImport: + def test_invalid_dsl_maps_to_400_and_persists_nothing( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + + api = AppDslImportApi() + body = AppDslImportPayload(mode="yaml-content", yaml_content="[]") # not a mapping + with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}/apps/imports", method="POST"): + result, code = unwrap(api.post)(api, workspace_id=tenant.id, auth_data=auth_for(account), body=body) + + assert code == 400 + assert result.status == ImportStatus.FAILED + # A failed import must not leave an app behind. + assert db_session_with_containers.query(App).filter(App.tenant_id == tenant.id).count() == 0 + + def test_major_version_mismatch_maps_to_202_pending( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + + api = AppDslImportApi() + body = AppDslImportPayload(mode="yaml-content", yaml_content=_workflow_yaml(version="99.0.0")) + with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}/apps/imports", method="POST"): + result, code = unwrap(api.post)(api, workspace_id=tenant.id, auth_data=auth_for(account), body=body) + + assert code == 202 + assert result.status == ImportStatus.PENDING + assert result.id # a pending import id the caller can confirm with + + def test_valid_dsl_maps_to_200_completed( + self, + app: Flask, + db_session_with_containers: Session, + make_account: Callable[..., Account], + external_deps: dict[str, object], + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + + api = AppDslImportApi() + body = AppDslImportPayload(mode="yaml-content", yaml_content=_workflow_yaml(name="Imported")) + with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}/apps/imports", method="POST"): + result, code = unwrap(api.post)(api, workspace_id=tenant.id, auth_data=auth_for(account), body=body) + + assert code == 200 + assert result.status in (ImportStatus.COMPLETED, ImportStatus.COMPLETED_WITH_WARNINGS) + assert result.app_id is not None + + +class TestDslImportConfirm: + def test_unknown_pending_import_maps_to_400( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """An expired/unknown import id has no Redis pending data → FAILED → 400.""" + account = make_account() + tenant = account.current_tenant + assert tenant is not None + import_id = str(uuid4()) + + api = AppDslImportConfirmApi() + with app.test_request_context( + f"/openapi/v1/workspaces/{tenant.id}/apps/imports/{import_id}/confirm", method="POST" + ): + result, code = unwrap(api.post)( + api, workspace_id=tenant.id, import_id=import_id, auth_data=auth_for(account) + ) + + assert code == 400 + assert result.status == ImportStatus.FAILED + + +class TestDslExport: + def test_export_returns_dsl_yaml( + self, app: Flask, db_session_with_containers: Session, external_deps: dict[str, object] + ) -> None: + app_model, account = _app_and_account(db_session_with_containers, mode="chat") + model_config = AppModelConfig( + app_id=app_model.id, + provider="openai", + model_id="gpt-3.5-turbo", + model=json.dumps({"provider": "openai", "name": "gpt-3.5-turbo", "mode": "chat", "completion_params": {}}), + pre_prompt="You are a helpful assistant.", + prompt_type="simple", # type: ignore[arg-type] + created_by=account.id, + updated_by=account.id, + ) + model_config.id = str(uuid4()) + app_model.app_model_config_id = model_config.id + db_session_with_containers.add(model_config) + db_session_with_containers.commit() + + api = AppDslExportApi() + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/export"): + response, code = unwrap(api.get)( + api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery() + ) + + assert code == 200 + parsed = yaml.safe_load(response.data) + assert parsed["kind"] == "app" + assert parsed["app"]["name"] == app_model.name + + def test_export_workflow_app_without_draft_maps_to_404( + self, app: Flask, db_session_with_containers: Session, external_deps: dict[str, object] + ) -> None: + """A workflow app with no draft workflow can't be exported → the service + raises WorkflowNotFoundError, which the controller maps to 404.""" + app_model, account = _app_and_account(db_session_with_containers, mode="workflow") + + api = AppDslExportApi() + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/export"): + result, code = unwrap(api.get)( + api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery() + ) + + assert code == 404 + assert isinstance(result, str) + + +class TestDslCheckDependencies: + def test_check_dependencies_returns_result( + self, app: Flask, db_session_with_containers: Session, external_deps: dict[str, object] + ) -> None: + app_model, account = _app_and_account(db_session_with_containers, mode="chat") + + api = AppDslCheckDependenciesApi() + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/check-dependencies"): + result, code = unwrap(api.get)(api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model)) + + assert code == 200 + assert result.leaked_dependencies == [] diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py new file mode 100644 index 00000000000..c6fde623677 --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap +from uuid import uuid4 + +from flask import Flask +from sqlalchemy.orm import Session + +from controllers.openapi.app_run import AppRunTaskStopApi +from models import Account, App +from services.app_service import AppService, CreateAppParams +from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for + + +def _create_app(db_session: Session, account: Account, *, name: str = "Runner") -> App: + tenant = account.current_tenant + assert tenant is not None + params = CreateAppParams( + name=name, + description="", + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + ) + app_model = AppService().create_app(tenant.id, params, account) + db_session.commit() + return app_model + + +class TestAppRunTaskStop: + def test_task_stop_returns_success( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + app_model = _create_app(db_session_with_containers, account) + task_id = str(uuid4()) + + api = AppRunTaskStopApi() + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/tasks/{task_id}/stop", method="POST"): + result = unwrap(api.post)( + api, + app_id=app_model.id, + task_id=task_id, + auth_data=auth_for(account, app_model=app_model, caller_kind="account"), + ) + + assert result.result == "success" diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py new file mode 100644 index 00000000000..22f812e125b --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap +from uuid import uuid4 + +import pytest +from flask import Flask +from sqlalchemy.orm import Session +from werkzeug.exceptions import NotFound + +from controllers.openapi._models import AppDescribeQuery, AppListQuery +from controllers.openapi.apps import AppDescribeApi, AppListApi +from models import Account, App +from services.app_service import AppService, CreateAppParams +from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for + + +def _create_app( + db_session: Session, + account: Account, + *, + name: str, + enable_api: bool = True, +) -> App: + """Create a workflow app in the account's owner tenant. + + Workflow mode is used because its template seeds no ``model_config``, so + ``AppService.create_app`` never reaches ``ModelManager`` — keeping the + fixture free of model-runtime patching. + """ + tenant = account.current_tenant + assert tenant is not None + params = CreateAppParams( + name=name, + description="", + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + ) + app_model = AppService().create_app(tenant.id, params, account) + # The openapi surface gate keys off ``enable_api``; flip it explicitly so + # the test states the visibility precondition rather than relying on the + # template default. + app_model.enable_api = enable_api + db_session.add(app_model) + db_session.commit() + return app_model + + +class TestAppList: + def test_lists_only_api_enabled_apps_in_workspace( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + visible = _create_app(db_session_with_containers, account, name="Visible", enable_api=True) + _create_app(db_session_with_containers, account, name="Hidden", enable_api=False) + + api = AppListApi() + with app.test_request_context(f"/openapi/v1/apps?workspace_id={tenant.id}"): + result = unwrap(api.get)(api, auth_data=auth_for(account), query=AppListQuery(workspace_id=str(tenant.id))) + + # The api-disabled app is gated out, so it counts neither in `data` + # nor in `total` (the gate is pushed into the query for stable paging). + assert result.total == 1 + assert [row.id for row in result.data] == [visible.id] + assert result.has_more is False + + def test_uuid_name_filter_returns_matching_app( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + target = _create_app(db_session_with_containers, account, name="Target", enable_api=True) + + api = AppListApi() + with app.test_request_context(f"/openapi/v1/apps?workspace_id={tenant.id}&name={target.id}"): + result = unwrap(api.get)( + api, + auth_data=auth_for(account), + query=AppListQuery(workspace_id=str(tenant.id), name=str(target.id)), + ) + + assert result.total == 1 + assert result.data[0].id == target.id + assert result.data[0].workspace_id == str(tenant.id) + + def test_uuid_name_filter_for_foreign_app_returns_empty( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """A UUID that resolves to an app in another workspace must not leak + across tenants — the list returns empty rather than the foreign app.""" + owner = make_account() + outsider = make_account() + foreign_app = _create_app(db_session_with_containers, owner, name="Foreign", enable_api=True) + outsider_tenant = outsider.current_tenant + assert outsider_tenant is not None + + api = AppListApi() + with app.test_request_context(f"/openapi/v1/apps?workspace_id={outsider_tenant.id}&name={foreign_app.id}"): + result = unwrap(api.get)( + api, + auth_data=auth_for(outsider), + query=AppListQuery(workspace_id=str(outsider_tenant.id), name=str(foreign_app.id)), + ) + + assert result.total == 0 + assert result.data == [] + + +class TestAppDescribe: + def test_describe_info_returns_metadata( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + app_model = _create_app(db_session_with_containers, account, name="Describe Me", enable_api=True) + + api = AppDescribeApi() + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/describe?fields=info"): + result = unwrap(api.get)( + api, app_id=app_model.id, auth_data=auth_for(account), query=AppDescribeQuery(fields="info") + ) + + assert result.info is not None + assert result.info.id == app_model.id + assert result.info.name == "Describe Me" + assert result.info.service_api_enabled is True + # Only the requested block is materialized. + assert result.parameters is None + assert result.input_schema is None + + def test_describe_unknown_app_is_404(self, app: Flask, make_account: Callable[..., Account]) -> None: + account = make_account() + missing_id = str(uuid4()) + + api = AppDescribeApi() + with app.test_request_context(f"/openapi/v1/apps/{missing_id}/describe"): + with pytest.raises(NotFound): + unwrap(api.get)(api, app_id=missing_id, auth_data=auth_for(account), query=AppDescribeQuery()) + + def test_describe_api_disabled_app_is_404( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """An api-disabled app fails the openapi visibility gate, so describe + must behave as if it doesn't exist (404), not expose it.""" + account = make_account() + hidden = _create_app(db_session_with_containers, account, name="Hidden", enable_api=False) + + api = AppDescribeApi() + with app.test_request_context(f"/openapi/v1/apps/{hidden.id}/describe"): + with pytest.raises(NotFound): + unwrap(api.get)(api, app_id=hidden.id, auth_data=auth_for(account), query=AppDescribeQuery()) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py new file mode 100644 index 00000000000..b90d5ab907c --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap +from io import BytesIO + +from flask import Flask +from sqlalchemy.orm import Session + +from controllers.openapi.files import AppFileUploadApi +from models import Account, App +from services.app_service import AppService, CreateAppParams +from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for + + +def _create_app(db_session: Session, account: Account, *, name: str = "Uploader") -> App: + """Create a workflow app (no model_config → no ModelManager) for the upload context.""" + tenant = account.current_tenant + assert tenant is not None + params = CreateAppParams( + name=name, + description="", + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + ) + app_model = AppService().create_app(tenant.id, params, account) + db_session.commit() + return app_model + + +class TestAppFileUpload: + def test_upload_persists_and_returns_metadata( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + app_model = _create_app(db_session_with_containers, account) + content = b"hello integration world" + + api = AppFileUploadApi() + data = {"file": (BytesIO(content), "note.txt", "text/plain")} + with app.test_request_context( + f"/openapi/v1/apps/{app_model.id}/files/upload", + method="POST", + data=data, + content_type="multipart/form-data", + ): + result = unwrap(api.post)( + api, + app_id=app_model.id, + auth_data=auth_for(account, app_model=app_model, caller_kind="account"), + ) + + assert result.id + assert result.name == "note.txt" + assert result.size == len(content) + assert result.extension == "txt" + assert result.mime_type == "text/plain" + # Persisted under the caller's tenant. + assert result.tenant_id == str(tenant.id) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py new file mode 100644 index 00000000000..aed8c415454 --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from collections.abc import Callable +from inspect import unwrap + +import pytest +from flask import Flask +from sqlalchemy.orm import Session +from werkzeug.exceptions import NotFound + +from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi, WorkspaceSwitchApi +from models import Account +from models.account import TenantAccountRole +from tests.test_containers_integration_tests.controllers.openapi.conftest import add_tenant_for_account, auth_for + + +class TestWorkspacesList: + def test_lists_only_members_workspaces_with_role( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + owner_tenant = account.current_tenant + assert owner_tenant is not None + + api = WorkspacesApi() + with app.test_request_context("/openapi/v1/workspaces"): + result = unwrap(api.get)(api, auth_data=auth_for(account)) + + ids = {w.id for w in result.workspaces} + assert ids == {owner_tenant.id} + only = result.workspaces[0] + assert only.role == TenantAccountRole.OWNER.value + assert only.status == "normal" + # Newly-created owner membership is not yet "current"; switching flips it + # (see TestWorkspaceSwitch). + assert only.current is False + + def test_lists_all_joined_workspaces( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + owner_tenant = account.current_tenant + assert owner_tenant is not None + second = add_tenant_for_account(account, role="normal", name="Second WS") + + api = WorkspacesApi() + with app.test_request_context("/openapi/v1/workspaces"): + result = unwrap(api.get)(api, auth_data=auth_for(account)) + + assert {w.id for w in result.workspaces} == {owner_tenant.id, second.id} + + +class TestWorkspaceDetail: + def test_member_can_read_detail( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + tenant = account.current_tenant + assert tenant is not None + + api = WorkspaceByIdApi() + with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}"): + detail = unwrap(api.get)(api, workspace_id=tenant.id, auth_data=auth_for(account)) + + assert detail.id == tenant.id + assert detail.role == TenantAccountRole.OWNER.value + assert detail.current is False + assert detail.created_at is not None + + def test_non_member_detail_is_404_not_403( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + """A workspace the caller doesn't belong to must be indistinguishable + from a missing one (404), so IDs can't be probed across tenants.""" + owner = make_account() + outsider = make_account() + someone_elses_ws = owner.current_tenant + assert someone_elses_ws is not None + + api = WorkspaceByIdApi() + with app.test_request_context(f"/openapi/v1/workspaces/{someone_elses_ws.id}"): + with pytest.raises(NotFound): + unwrap(api.get)(api, workspace_id=someone_elses_ws.id, auth_data=auth_for(outsider)) + + +class TestWorkspaceSwitch: + def test_switch_sets_current_and_persists( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + owner_tenant = account.current_tenant + assert owner_tenant is not None + target = add_tenant_for_account(account, role="normal", name="Switch Target") + + api = WorkspaceSwitchApi() + with app.test_request_context(f"/openapi/v1/workspaces/{target.id}/switch", method="POST"): + detail = unwrap(api.post)(api, workspace_id=target.id, auth_data=auth_for(account)) + + # Response reflects the post-switch state. + assert detail.id == target.id + assert detail.current is True + + # And the switch persisted: the previously-current owner workspace is no + # longer current (verified through the real read path). + with app.test_request_context("/openapi/v1/workspaces"): + listing = unwrap(WorkspacesApi().get)(WorkspacesApi(), auth_data=auth_for(account)) + by_id = {w.id: w for w in listing.workspaces} + assert by_id[target.id].current is True + assert by_id[owner_tenant.id].current is False + + def test_switch_to_non_member_workspace_is_404( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: + account = make_account() + outsider_ws = make_account().current_tenant + assert outsider_ws is not None + + api = WorkspaceSwitchApi() + with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}/switch", method="POST"): + with pytest.raises(NotFound): + unwrap(api.post)(api, workspace_id=outsider_ws.id, auth_data=auth_for(account))