mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
355 lines
14 KiB
Python
355 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import uuid
|
|
from dataclasses import replace
|
|
from io import BytesIO
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from pydantic import BaseModel, ValidationError
|
|
from sqlalchemy import event
|
|
from sqlalchemy.orm import Session
|
|
from werkzeug.datastructures import FileStorage
|
|
from werkzeug.exceptions import BadRequest, NotFound, TooManyRequests
|
|
|
|
from constants.oauth_bearer import TokenType
|
|
from controllers.openapi import app_run
|
|
from controllers.openapi._errors import AppModeMismatch
|
|
from controllers.openapi._models import (
|
|
AdvancedChatRunPayload,
|
|
ChatRunPayload,
|
|
CompletionRunPayload,
|
|
TaskStopResponse,
|
|
WorkflowRunPayload,
|
|
)
|
|
from controllers.openapi.app_run import (
|
|
AdvancedChatRunApi,
|
|
AppTaskStopApi,
|
|
ChatRunApi,
|
|
CompletionRunApi,
|
|
_translate_service_errors,
|
|
)
|
|
from controllers.openapi.auth.subjects import Subject, subject_from_auth
|
|
from controllers.service_api.app.error import TriggerWorkflowServiceModeUnavailableError
|
|
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
|
from core.app.entities.app_invoke_entities import InvokeFrom
|
|
from core.errors.error import AppInvokeQuotaExceededError
|
|
from extensions.ext_application_services import ApplicationServices
|
|
from graphon.file import FileType
|
|
from libs.oauth_bearer import AuthContext
|
|
from models import Account
|
|
from models.enums import CreatorUserRole
|
|
from models.model import App, AppMode, EndUser, UploadFile
|
|
from services.app_task_service import AppTaskControlService
|
|
from services.errors.app import (
|
|
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
|
)
|
|
from services.errors.llm import InvokeRateLimitError
|
|
from tests.helpers.unit.model_factories import make_upload_file
|
|
from tests.unit_tests.services.test_app_task_service import _AppTaskServiceStopredis as _StopRedis
|
|
|
|
"""The openapi run boundary maps internal rate-limit exceptions to canonical 429s.
|
|
|
|
Both render through the ErrorBody formatter: TooManyRequests -> code "too_many_requests"
|
|
(retryable throttle), InvokeRateLimitError -> code "rate_limit_error" (quota).
|
|
"""
|
|
|
|
|
|
def test_translate_maps_app_concurrency_to_too_many_requests():
|
|
# Regression guard: this used to fall through to a 500 (it was not caught here).
|
|
with pytest.raises(TooManyRequests) as exc:
|
|
with _translate_service_errors():
|
|
raise AppInvokeQuotaExceededError("internal: client_id=abc max=10")
|
|
assert exc.value.code == 429
|
|
|
|
|
|
def test_translate_maps_workflow_quota_to_rate_limit_error():
|
|
with pytest.raises(InvokeRateLimitHttpError) as exc:
|
|
with _translate_service_errors():
|
|
raise InvokeRateLimitError("workflow quota exhausted")
|
|
assert exc.value.error_code == "rate_limit_error"
|
|
assert exc.value.code == 429
|
|
|
|
|
|
def test_translate_maps_trigger_workflow_to_stable_unavailable_error():
|
|
with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc:
|
|
with _translate_service_errors():
|
|
raise TriggerWorkflowServiceModeUnavailableServiceError()
|
|
assert exc.value.error_code == "trigger_workflow_service_mode_unavailable"
|
|
assert exc.value.code == 403
|
|
|
|
|
|
def test_translate_maps_value_error_to_a_fixed_bad_request():
|
|
# Regression guard: an unpublished workflow used to surface as a detail-less 500.
|
|
# The service message names internals, so the client gets a fixed text.
|
|
with pytest.raises(BadRequest) as exc:
|
|
with _translate_service_errors():
|
|
raise ValueError("variable 'secret_key' of node 42 is missing")
|
|
assert exc.value.code == 400
|
|
assert exc.value.description == "invalid run input"
|
|
|
|
|
|
"""Tests for the /openapi/v1 run routes: per-mode bodies and handlers, task stop."""
|
|
|
|
|
|
_TEST_APP_ID = str(uuid.uuid4())
|
|
_TEST_TENANT_ID = str(uuid.uuid4())
|
|
_TEST_ACCOUNT_ID = str(uuid.uuid4())
|
|
|
|
|
|
def _make_app() -> App:
|
|
app = App()
|
|
app.id = _TEST_APP_ID
|
|
app.tenant_id = _TEST_TENANT_ID
|
|
app.name = "Streaming app"
|
|
app.mode = AppMode.CHAT
|
|
app.enable_site = False
|
|
app.enable_api = True
|
|
return app
|
|
|
|
|
|
def _make_account() -> Account:
|
|
account = Account(name="OpenAPI caller", email="caller@example.com")
|
|
account.id = _TEST_ACCOUNT_ID
|
|
return account
|
|
|
|
|
|
def _subject(token_type: TokenType = TokenType.OAUTH_ACCOUNT) -> Subject:
|
|
return subject_from_auth(
|
|
AuthContext(
|
|
token_type=token_type,
|
|
subject_email="sso@example.com" if token_type == TokenType.OAUTH_EXTERNAL_SSO else None,
|
|
subject_issuer=None,
|
|
account_id=uuid.UUID(_TEST_ACCOUNT_ID) if token_type == TokenType.OAUTH_ACCOUNT else None,
|
|
client_id=None,
|
|
token_id=uuid.uuid4(),
|
|
expires_at=None,
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model", "payload"),
|
|
[
|
|
pytest.param(ChatRunPayload, {"inputs": {}, "query": " "}, id="chat.blank_query"),
|
|
pytest.param(WorkflowRunPayload, {"inputs": {}, "query": "x"}, id="workflow.query_is_foreign"),
|
|
pytest.param(CompletionRunPayload, {"inputs": {}, "conversation_id": "x"}, id="completion.conversation_id"),
|
|
pytest.param(ChatRunPayload, {"inputs": {}, "query": "hi", "response_mode": "blocking"}, id="chat.foreign"),
|
|
pytest.param(ChatRunPayload, {"inputs": {}, "query": "hi", "conversation_id": "not-a-uuid"}, id="chat.bad_cid"),
|
|
],
|
|
)
|
|
def test_per_mode_payloads_reject_what_the_mode_does_not_take(model: type[BaseModel], payload: dict):
|
|
with pytest.raises(ValidationError):
|
|
model.model_validate(payload)
|
|
|
|
|
|
def test_chat_payload_normalizes_conversation_id():
|
|
assert ChatRunPayload(inputs={}, query="hi", conversation_id=" ").conversation_id is None
|
|
cid = str(uuid.uuid4())
|
|
assert ChatRunPayload(inputs={}, query="hi", conversation_id=cid).conversation_id == cid
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"token_type", [TokenType.OAUTH_ACCOUNT, TokenType.OAUTH_EXTERNAL_SSO, TokenType.RESOURCE_ACCESS]
|
|
)
|
|
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION])
|
|
@pytest.mark.parametrize("owns_task", [False, True])
|
|
def test_stop_task_enforces_resolved_caller_ownership(
|
|
app: Flask,
|
|
account_application_services: ApplicationServices,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
sqlite_session: Session,
|
|
token_type: TokenType,
|
|
mode: AppMode,
|
|
owns_task: bool,
|
|
):
|
|
import services.app_task_service as task_module
|
|
|
|
subject = _subject(token_type)
|
|
caller = _make_account() if subject.caller_role == CreatorUserRole.ACCOUNT else EndUser(id="user-1")
|
|
owner_id = caller.id if owns_task else "another-user"
|
|
task_id = str(uuid.uuid4())
|
|
redis = _StopRedis(values={f"generate_task_belong:{task_id}": f"end-user-{owner_id}".encode()})
|
|
monkeypatch.setattr(type(task_module.db), "engine", property(lambda _: sqlite_session.get_bind()))
|
|
monkeypatch.setattr(
|
|
sys.modules["controllers.openapi.app_run"],
|
|
"application_services",
|
|
lambda: replace(account_application_services, app_tasks=AppTaskControlService(redis_client=redis)),
|
|
)
|
|
ctx = _SealableContext(
|
|
subject=subject,
|
|
caller=caller,
|
|
app=App(id="app-1", tenant_id="tenant-1", mode=mode),
|
|
)
|
|
api = AppTaskStopApi()
|
|
allowed = owns_task or subject.caller_role == CreatorUserRole.ACCOUNT
|
|
with app.test_request_context(f"/openapi/v1/apps/app-1/tasks/{task_id}:stop", method="POST"):
|
|
if allowed:
|
|
assert api.post.__handler__(api, ctx, app_id="app-1", task_id=task_id) == TaskStopResponse(result="success")
|
|
else:
|
|
with pytest.raises(NotFound, match="Task not found"):
|
|
api.post.__handler__(api, ctx, app_id="app-1", task_id=task_id)
|
|
if allowed:
|
|
assert redis.values[f"generate_task_stopped:{task_id}"] == b"1"
|
|
else:
|
|
assert redis.operations == []
|
|
assert redis.commands == {}
|
|
|
|
|
|
class _SealableContext:
|
|
"""A `Context` stand-in that refuses reads once `seal()` is called.
|
|
|
|
The router's session closes when the handler returns, so anything the SSE
|
|
body still needs off `ctx` would be read through a closed session.
|
|
"""
|
|
|
|
def __init__(self, **values: object) -> None:
|
|
self._values = values
|
|
self._sealed = False
|
|
|
|
def seal(self) -> None:
|
|
self._sealed = True
|
|
|
|
def __getattr__(self, name: str) -> object:
|
|
if self._sealed:
|
|
raise AssertionError(f"ctx.{name} was read after the handler returned")
|
|
return self._values[name]
|
|
|
|
|
|
def _generate_stub(monkeypatch: pytest.MonkeyPatch, chunks: list[str]) -> Mock:
|
|
generate_mock = Mock(return_value=iter(chunks))
|
|
|
|
class GenerateService:
|
|
generate = generate_mock
|
|
|
|
monkeypatch.setattr(sys.modules["controllers.openapi.app_run"], "AppGenerateService", GenerateService)
|
|
return generate_mock
|
|
|
|
|
|
def _ctx(mode: AppMode, session: Session | None = None) -> _SealableContext:
|
|
app_model = _make_app()
|
|
app_model.mode = mode
|
|
return _SealableContext(
|
|
app=app_model,
|
|
caller=_make_account(),
|
|
session=session or Session(),
|
|
subject=_subject(),
|
|
)
|
|
|
|
|
|
def test_run_reads_everything_off_the_context_before_streaming(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
|
) -> None:
|
|
generate_mock = _generate_stub(monkeypatch, ["event: a\n\n", "event: b\n\n"])
|
|
commits: list[Session] = []
|
|
event.listen(unbound_session, "after_commit", commits.append)
|
|
ctx = _ctx(AppMode.ADVANCED_CHAT, unbound_session)
|
|
api = AdvancedChatRunApi()
|
|
body = AdvancedChatRunPayload(inputs={}, query="hi")
|
|
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/advanced-chat:run", method="POST"):
|
|
response = api.post.__handler__(api, ctx, app_id=_TEST_APP_ID, body=body)
|
|
ctx.seal()
|
|
body = "".join(response.response)
|
|
|
|
assert body == "event: a\n\nevent: b\n\n"
|
|
assert generate_mock.call_args.kwargs["streaming"] is True
|
|
assert commits == []
|
|
|
|
|
|
def test_per_mode_route_refuses_an_app_of_another_mode(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
|
generate_mock = _generate_stub(monkeypatch, [])
|
|
api = ChatRunApi()
|
|
body = ChatRunPayload(inputs={}, query="hi")
|
|
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/chat:run", method="POST"):
|
|
with pytest.raises(AppModeMismatch):
|
|
api.post.__handler__(api, _ctx(AppMode.WORKFLOW), app_id=_TEST_APP_ID, body=body)
|
|
generate_mock.assert_not_called()
|
|
|
|
|
|
def test_run_hands_the_generator_file_mappings_for_inputs_and_attachments(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
|
):
|
|
generate_mock = _generate_stub(monkeypatch, [])
|
|
upload_service = Mock()
|
|
operations: list[str] = []
|
|
sqlite_session.begin()
|
|
event.listen(sqlite_session, "after_commit", lambda _session: operations.append("commit"))
|
|
|
|
def upload_file(*, filename: str, content: bytes, mimetype: str, user: Account) -> UploadFile:
|
|
assert not sqlite_session.in_transaction()
|
|
operations.append("upload")
|
|
return make_upload_file(
|
|
file_id=f"uf-{filename}",
|
|
extension=filename.rsplit(".", 1)[-1],
|
|
mime_type=mimetype,
|
|
)
|
|
|
|
upload_service.upload_file.side_effect = upload_file
|
|
monkeypatch.setattr(
|
|
sys.modules["controllers.openapi._files"], "application_services", lambda: SimpleNamespace(files=upload_service)
|
|
)
|
|
body = CompletionRunPayload(
|
|
inputs={},
|
|
files={"doc": FileStorage(stream=BytesIO(b"pdf"), filename="r.pdf", content_type="application/pdf")},
|
|
attachments=[FileStorage(stream=BytesIO(b"jpg"), filename="p.jpg", content_type="image/jpeg")],
|
|
)
|
|
api = CompletionRunApi()
|
|
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/completion:run", method="POST"):
|
|
api.post.__handler__(api, _ctx(AppMode.COMPLETION, sqlite_session), app_id=_TEST_APP_ID, body=body)
|
|
|
|
# The read transaction ends before the first object-storage write (api/AGENTS.md).
|
|
assert operations == ["commit", "upload", "upload"]
|
|
args = generate_mock.call_args.kwargs["args"]
|
|
assert args["inputs"]["doc"] == {
|
|
"transfer_method": "local_file",
|
|
"upload_file_id": "uf-r.pdf",
|
|
"type": FileType.DOCUMENT,
|
|
}
|
|
assert args["files"] == [{"transfer_method": "local_file", "upload_file_id": "uf-p.jpg", "type": FileType.IMAGE}]
|
|
assert args["query"] == ""
|
|
assert {"attachments", "auto_generate_name"}.isdisjoint(args)
|
|
|
|
|
|
def test_chat_route_hints_the_reply_on_message_end(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
|
message = 'data: {"event": "message", "answer": "hi"}\n\n'
|
|
end = {"event": "message_end", "conversation_id": "c1", "message_id": "m1", "created_at": 1, "id": "m1"}
|
|
end["task_id"] = "t1"
|
|
_generate_stub(monkeypatch, [message, f"data: {json.dumps(end)}\n\n"])
|
|
api = ChatRunApi()
|
|
body = ChatRunPayload(inputs={}, query="hi")
|
|
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/chat:run", method="POST"):
|
|
response = api.post.__handler__(api, _ctx(AppMode.CHAT), app_id=_TEST_APP_ID, body=body)
|
|
chunks = list(response.response)
|
|
assert chunks[0] == message
|
|
assert json.loads(chunks[1][len("data: ") :])["hints"] == [
|
|
{
|
|
"summary": "Reply in this conversation",
|
|
"op": "run.console_app.chat",
|
|
"input": {"app_id": _TEST_APP_ID, "conversation_id": "c1", "query": None, "inputs": {}},
|
|
}
|
|
]
|
|
|
|
|
|
def test_chat_route_passes_a_message_end_without_conversation_through(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
|
end = 'data: {"event": "message_end", "message_id": "m1", "created_at": 1, "id": "m1", "task_id": "t1"}\n\n'
|
|
_generate_stub(monkeypatch, [end])
|
|
api = ChatRunApi()
|
|
body = ChatRunPayload(inputs={}, query="hi")
|
|
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/chat:run", method="POST"):
|
|
response = api.post.__handler__(api, _ctx(AppMode.CHAT), app_id=_TEST_APP_ID, body=body)
|
|
assert list(response.response) == [end]
|
|
|
|
|
|
def test_draft_runs_do_not_hint_the_form_submit_op() -> None:
|
|
"""A draft run's human-input form is a debugger form; the submit op only takes web-app
|
|
forms, so hinting it would send the agent straight into a 403."""
|
|
draft_routes = [route for route in app_run._RUN_ROUTES if route.invoke_from == InvokeFrom.DEBUGGER]
|
|
assert draft_routes
|
|
for route in draft_routes:
|
|
assert app_run._form_layer not in route.hints
|
|
assert "cannot be resumed over openapi" in route.summary
|