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:
Yunlu Wen 2026-06-11 16:26:02 +08:00 committed by GitHub
parent c2f7841266
commit 2bf66813ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 952 additions and 19 deletions

View File

@ -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,
)

View File

@ -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"]

View File

@ -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)

View File

@ -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"

View File

@ -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()))

View File

@ -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 == []

View File

@ -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"

View File

@ -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())

View File

@ -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)

View File

@ -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))