mirror of
https://github.com/langgenius/dify.git
synced 2026-06-12 11:32:08 +08:00
chore: add integration tests for openapi group (#37314)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
c2f7841266
commit
2bf66813ae
@ -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,
|
||||
)
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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)
|
||||
@ -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"
|
||||
@ -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()))
|
||||
@ -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 == []
|
||||
@ -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"
|
||||
@ -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())
|
||||
@ -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)
|
||||
@ -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))
|
||||
Loading…
Reference in New Issue
Block a user