mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
Co-authored-by: Felix Shi <204130665+shi-felix@users.noreply.github.com>
392 lines
16 KiB
Python
392 lines
16 KiB
Python
"""Wire-contract tests for the canonical /openapi/v1 error body."""
|
|
|
|
import uuid
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from sqlalchemy.orm import Session
|
|
from werkzeug.exceptions import (
|
|
BadGateway,
|
|
BadRequest,
|
|
Conflict,
|
|
Forbidden,
|
|
InternalServerError,
|
|
NotFound,
|
|
TooManyRequests,
|
|
Unauthorized,
|
|
UnprocessableEntity,
|
|
)
|
|
|
|
from constants.oauth_bearer import TokenType
|
|
from controllers.common.errors import (
|
|
BlockedFileExtensionError,
|
|
FileTooLargeError,
|
|
NoFileUploadedError,
|
|
TooManyFilesError,
|
|
UnsupportedFileTypeError,
|
|
)
|
|
from controllers.openapi._catalog import CATALOG_HEADER, catalog_for
|
|
from controllers.openapi._errors import (
|
|
CatalogStale,
|
|
DslInvalid,
|
|
ErrorBody,
|
|
ErrorDetail,
|
|
FilenameNotExists,
|
|
HumanInputFormNotFound,
|
|
KnowledgeFsAccessDeniedError,
|
|
KnowledgeFsConflictError,
|
|
KnowledgeFsInvalidRequestError,
|
|
KnowledgeFsRequestRejectedError,
|
|
KnowledgeFsRequestTooLargeError,
|
|
KnowledgeFsResourceNotFoundError,
|
|
KnowledgeFsUnavailableError,
|
|
MemberLicenseExceeded,
|
|
MemberLimitExceeded,
|
|
OpenApiError,
|
|
OpenApiErrorCode,
|
|
OpenApiErrorFormatter,
|
|
RecipientSurfaceMismatch,
|
|
)
|
|
from controllers.openapi._models import Hint
|
|
from controllers.service_api.app.error import (
|
|
AgentNotPublishedError,
|
|
AppUnavailableError,
|
|
CompletionRequestError,
|
|
ConversationCompletedError,
|
|
ProviderModelCurrentlyNotSupportError,
|
|
ProviderNotInitializeError,
|
|
ProviderQuotaExceededError,
|
|
)
|
|
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
|
from libs.oauth_bearer import AuthContext
|
|
from models.account import Account, AccountStatus
|
|
from services.oauth_device_contracts import ExpiredTokenError
|
|
|
|
|
|
@pytest.fixture
|
|
def fmt() -> OpenApiErrorFormatter:
|
|
return OpenApiErrorFormatter()
|
|
|
|
|
|
class TestErrorBodyModel:
|
|
def test_minimal_body_serializes_without_optional_fields(self) -> None:
|
|
body = ErrorBody(code=OpenApiErrorCode.NOT_FOUND, message="app not found", status=404)
|
|
|
|
wire = body.model_dump(mode="json", exclude_none=True)
|
|
|
|
assert wire == {"code": "not_found", "message": "app not found", "status": 404}
|
|
|
|
def test_full_body_round_trips(self) -> None:
|
|
body = ErrorBody(
|
|
code=OpenApiErrorCode.INVALID_PARAM,
|
|
message="Request validation failed",
|
|
status=422,
|
|
hint="check the request payload",
|
|
details=[ErrorDetail(type="int_parsing", loc=["page"], msg="must be >= 1")],
|
|
)
|
|
|
|
wire = body.model_dump(mode="json", exclude_none=True)
|
|
|
|
assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}]
|
|
assert ErrorBody.model_validate(wire) == body
|
|
|
|
def test_code_field_is_open_string_for_forward_compat(self) -> None:
|
|
# Old CLIs must not hard-fail when a future server adds a code, so the
|
|
# schema type is str; enum membership is enforced by the formatter tests.
|
|
body = ErrorBody.model_validate({"code": "some_future_code", "message": "x", "status": 400})
|
|
|
|
assert body.code == "some_future_code"
|
|
|
|
|
|
class _DataCarryingUnprocessableEntity(UnprocessableEntity):
|
|
"""flask_restx attaches ``abort(422, ...)`` kwargs as ``e.data`` at runtime.
|
|
|
|
Declaring the attribute keeps the type checker honest without an ignore, and
|
|
the instance is still the real ``UnprocessableEntity`` the framework raises.
|
|
"""
|
|
|
|
data: dict[str, object]
|
|
|
|
|
|
class TestOpenApiErrorFormatter:
|
|
def test_flask_restx_abort_data_path_yields_canonical_body(self, fmt: OpenApiErrorFormatter) -> None:
|
|
# Simulates _contract.py's abort(422, message=..., errors=...): flask_restx
|
|
# attaches kwargs to e.data, which handle_error would otherwise put on the
|
|
# wire verbatim (no code/status).
|
|
e = _DataCarryingUnprocessableEntity()
|
|
e.data = {
|
|
"message": "Request validation failed",
|
|
"errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1", "extra": "drop me"}],
|
|
}
|
|
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
|
|
|
|
wire = fmt.finalize(e, data, 422)
|
|
|
|
assert wire["code"] == "invalid_param"
|
|
assert wire["message"] == "Request validation failed"
|
|
assert wire["status"] == 422
|
|
assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}]
|
|
# the override channel now carries the canonical body
|
|
assert e.data == wire
|
|
|
|
def test_finalize_is_idempotent(self, fmt: OpenApiErrorFormatter) -> None:
|
|
e = _DataCarryingUnprocessableEntity()
|
|
e.data = {
|
|
"message": "Request validation failed",
|
|
"errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}],
|
|
}
|
|
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
|
|
|
|
first = fmt.finalize(e, data, 422)
|
|
second = fmt.finalize(e, data, 422)
|
|
|
|
assert second == first
|
|
|
|
def test_malformed_canonical_details_falls_back_instead_of_raising(self, fmt: OpenApiErrorFormatter) -> None:
|
|
# finalize runs inside the framework error handler; a ValidationError
|
|
# escaping it would replace the response with an unformatted 500
|
|
e = _DataCarryingUnprocessableEntity()
|
|
e.data = {"message": "broken", "details": [{"bad": "shape"}]}
|
|
data = {"code": "unprocessable_entity", "message": "broken", "status": 422}
|
|
|
|
wire = fmt.finalize(e, data, 422)
|
|
|
|
assert wire == {"code": "invalid_param", "message": "Unprocessable Entity", "status": 422}
|
|
|
|
def test_params_shape_becomes_details(self, fmt: OpenApiErrorFormatter) -> None:
|
|
e = ValueError("is required")
|
|
data = {"code": "invalid_param", "message": "is required", "params": "email", "status": 400}
|
|
|
|
wire = fmt.finalize(e, data, 400)
|
|
|
|
assert "params" not in wire
|
|
assert wire["details"] == [{"type": "invalid", "loc": ["email"], "msg": "is required"}]
|
|
|
|
def test_catch_all_exception_never_leaks_str_e(self, fmt: OpenApiErrorFormatter) -> None:
|
|
e = RuntimeError("postgres password=hunter2 connection refused")
|
|
data = {"message": str(e), "code": "unknown", "status": 500}
|
|
|
|
wire = fmt.finalize(e, data, 500)
|
|
|
|
assert wire["code"] == "internal_server_error"
|
|
assert "hunter2" not in wire["message"]
|
|
|
|
def test_unmapped_status_falls_back_to_unknown(self, fmt: OpenApiErrorFormatter) -> None:
|
|
from werkzeug.exceptions import Gone
|
|
|
|
e = Gone()
|
|
data = {"code": "gone", "message": e.description, "status": 410}
|
|
|
|
wire = fmt.finalize(e, data, 410)
|
|
|
|
assert wire["code"] == "unknown"
|
|
|
|
@pytest.mark.parametrize("message", [None, "custom reason"], ids=["class_description", "call_site_override"])
|
|
def test_openapi_error_subclass_is_throw_and_done(self, fmt: OpenApiErrorFormatter, message: str) -> None:
|
|
# The dedicated throwable: subclass declares status + code + message once,
|
|
# call sites just `raise`, or pass a message to override the description;
|
|
# the formatter emits everything verbatim.
|
|
class TeapotError(OpenApiError):
|
|
code = 418
|
|
error_code = OpenApiErrorCode.INVALID_PARAM
|
|
description = "kettle says no"
|
|
|
|
e = TeapotError(message, details=[ErrorDetail(type="invalid", loc=["kettle"], msg="too hot")])
|
|
data = {"code": "im_a_teapot", "message": e.description, "status": 418}
|
|
|
|
wire = fmt.finalize(e, data, 418)
|
|
|
|
assert wire["code"] == OpenApiErrorCode.INVALID_PARAM
|
|
assert wire["message"] == (message or TeapotError.description)
|
|
assert wire["details"] == [{"type": "invalid", "loc": ["kettle"], "msg": "too hot"}]
|
|
|
|
|
|
class TestQuotaExceptions:
|
|
@pytest.mark.parametrize("exc_class", [MemberLimitExceeded, MemberLicenseExceeded])
|
|
def test_quota_exception_carries_declared_code_and_message(
|
|
self, fmt: OpenApiErrorFormatter, exc_class: type[OpenApiError]
|
|
) -> None:
|
|
# Single source: assertions read the class attributes, no re-typed strings.
|
|
e = exc_class()
|
|
data = {"code": "forbidden", "message": e.description, "status": 403}
|
|
|
|
wire = fmt.finalize(e, data, 403)
|
|
|
|
assert wire["code"] == exc_class.error_code
|
|
assert wire["message"] == exc_class.description
|
|
assert wire["hint"] == exc_class.hint
|
|
assert wire["status"] == 403
|
|
|
|
|
|
class TestWireContract:
|
|
"""End-to-end: request in, canonical JSON out, through the real openapi blueprint."""
|
|
|
|
def test_accepts_422_carries_code_status_details(
|
|
self, openapi_app: Flask, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""`@endpoint` runs auth outermost, so reaching `@accepts` means passing
|
|
it: one account row and a stubbed authenticator are the whole world
|
|
`GET /account/sessions` needs.
|
|
"""
|
|
account_id = uuid.uuid4()
|
|
account = Account(name="caller", email="caller@example.com", status=AccountStatus.ACTIVE)
|
|
account.id = str(account_id)
|
|
sqlite_session.add(account)
|
|
sqlite_session.commit()
|
|
|
|
token = AuthContext(
|
|
subject_email="caller@example.com",
|
|
subject_issuer="dify:account",
|
|
account_id=account_id,
|
|
client_id="difyctl",
|
|
token_id=uuid.uuid4(),
|
|
token_type=TokenType.OAUTH_ACCOUNT,
|
|
expires_at=None,
|
|
)
|
|
monkeypatch.setattr(
|
|
"controllers.openapi.auth.router.get_authenticator",
|
|
lambda: SimpleNamespace(authenticate=lambda _token: token),
|
|
)
|
|
monkeypatch.setattr("controllers.openapi.auth.pipelines._mount_flask_login", lambda _user: None)
|
|
|
|
resp = openapi_app.test_client().get(
|
|
"/openapi/v1/account/sessions?page=0",
|
|
headers={"Authorization": "Bearer dfoa_wire", CATALOG_HEADER: catalog_for(openapi_app)[1]},
|
|
)
|
|
|
|
assert resp.status_code == 422, resp.get_json()
|
|
wire = resp.get_json()
|
|
ErrorBody.model_validate(wire)
|
|
assert wire["code"] == "invalid_param"
|
|
assert wire["status"] == 422
|
|
assert wire["details"]
|
|
|
|
def test_unknown_route_404_is_canonical_without_route_suggestions(self, openapi_app: Flask) -> None:
|
|
client = openapi_app.test_client()
|
|
|
|
resp = client.get("/openapi/v1/definitely-not-a-route")
|
|
|
|
assert resp.status_code == 404
|
|
wire = resp.get_json()
|
|
ErrorBody.model_validate(wire)
|
|
assert wire["code"] == "not_found"
|
|
assert "did you mean" not in wire["message"].lower()
|
|
|
|
def test_404_outside_blueprint_prefix_is_not_claimed(self, openapi_app: Flask) -> None:
|
|
# catch_all_404s wraps the app-level exception handler; the prefix
|
|
# guard must keep non-/openapi/v1 paths on the app's own 404 handling
|
|
client = openapi_app.test_client()
|
|
|
|
resp = client.get("/console/definitely-not-a-route")
|
|
|
|
assert resp.status_code == 404
|
|
# not intercepted → Flask's default HTML 404, not the canonical JSON body
|
|
assert "application/json" not in (resp.content_type or "")
|
|
|
|
@patch("controllers.openapi.oauth_device.application_services")
|
|
def test_oauth_device_token_keeps_rfc8628_shape(
|
|
self, mock_application_services: MagicMock, openapi_app: Flask
|
|
) -> None:
|
|
mock_application_services.return_value.oauth_device.poll.side_effect = ExpiredTokenError
|
|
|
|
client = openapi_app.test_client()
|
|
|
|
resp = client.post(
|
|
"/openapi/v1/oauth/device/token",
|
|
json={"client_id": "difyctl", "device_code": "nope"},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
wire = resp.get_json()
|
|
assert wire == {"error": "expired_token"}
|
|
|
|
|
|
ERROR_MATRIX = [
|
|
(BadRequest("x"), 400, "bad_request"),
|
|
(Unauthorized("x"), 401, "unauthorized"),
|
|
(Forbidden("x"), 403, "forbidden"),
|
|
(NotFound("x"), 404, "not_found"),
|
|
(Conflict("x"), 409, "conflict"),
|
|
(UnprocessableEntity("x"), 422, "invalid_param"),
|
|
(InternalServerError(), 500, "internal_server_error"),
|
|
(BadGateway("x"), 502, "bad_gateway"),
|
|
(AppUnavailableError(), 400, "app_unavailable"),
|
|
(AgentNotPublishedError(), 400, "agent_not_published"),
|
|
(ConversationCompletedError(), 400, "conversation_completed"),
|
|
(ProviderNotInitializeError(), 400, "provider_not_initialize"),
|
|
(ProviderQuotaExceededError(), 400, "provider_quota_exceeded"),
|
|
(ProviderModelCurrentlyNotSupportError(), 400, "model_currently_not_support"),
|
|
(CompletionRequestError(), 400, "completion_request_error"),
|
|
(InvokeRateLimitHttpError(), 429, "rate_limit_error"),
|
|
(TooManyRequests("x"), 429, "too_many_requests"), # difyctl's classifyRateLimit keys retryability on this code
|
|
(FileTooLargeError(), 413, "file_too_large"),
|
|
(UnsupportedFileTypeError(), 415, "unsupported_file_type"),
|
|
(NoFileUploadedError(), 400, "no_file_uploaded"),
|
|
(TooManyFilesError(), 400, "too_many_files"),
|
|
(FilenameNotExists(), 400, "filename_not_exists"),
|
|
(BlockedFileExtensionError(), 400, "file_extension_blocked"),
|
|
(MemberLimitExceeded(), 403, "member_limit_exceeded"),
|
|
(MemberLicenseExceeded(), 403, "member_license_exceeded"),
|
|
(HumanInputFormNotFound(), 404, "form_not_found"),
|
|
(RecipientSurfaceMismatch(), 403, "recipient_surface_mismatch"),
|
|
(KnowledgeFsInvalidRequestError(), 400, "knowledge_fs_invalid_request"),
|
|
(KnowledgeFsAccessDeniedError(), 403, "knowledge_fs_access_denied"),
|
|
(KnowledgeFsResourceNotFoundError(), 404, "knowledge_fs_resource_not_found"),
|
|
(KnowledgeFsConflictError(), 409, "knowledge_fs_conflict"),
|
|
(KnowledgeFsRequestTooLargeError(), 413, "knowledge_fs_request_too_large"),
|
|
(KnowledgeFsRequestRejectedError(), 422, "knowledge_fs_request_rejected"),
|
|
(KnowledgeFsUnavailableError(), 503, "knowledge_fs_unavailable"),
|
|
(CatalogStale(), 412, "catalog_stale"),
|
|
]
|
|
|
|
|
|
class TestErrorMatrix:
|
|
@pytest.mark.parametrize(
|
|
("exc", "status", "expected_code"),
|
|
ERROR_MATRIX,
|
|
ids=lambda v: type(v).__name__ if isinstance(v, Exception) else str(v),
|
|
)
|
|
def test_every_known_error_path_yields_canonical_code(
|
|
self, fmt: OpenApiErrorFormatter, exc: Exception, status: int, expected_code: str
|
|
) -> None:
|
|
data = dict(getattr(exc, "data", None) or {"message": str(exc), "status": status})
|
|
|
|
wire = fmt.finalize(exc, data, status)
|
|
|
|
assert wire["code"] == expected_code
|
|
assert wire["status"] == status
|
|
assert wire["code"] in {c.value for c in OpenApiErrorCode}
|
|
ErrorBody.model_validate(wire)
|
|
|
|
|
|
class TestErrorCodeEnumRegistration:
|
|
def test_enum_registered_with_all_values(self) -> None:
|
|
from controllers.openapi import openapi_ns
|
|
from controllers.openapi._errors import OpenApiErrorCode
|
|
|
|
model = openapi_ns.models.get("OpenApiErrorCode")
|
|
assert model is not None
|
|
schema = model.__schema__
|
|
assert schema["type"] == "string"
|
|
assert set(schema["enum"]) == {member.value for member in OpenApiErrorCode}
|
|
|
|
|
|
def test_error_body_carries_hints() -> None:
|
|
hint = Hint(summary="Check the DSL", op="check.console_app.dsl", input={})
|
|
error = DslInvalid(hints=[hint])
|
|
assert error.hints == [hint]
|
|
body = ErrorBody(code="dsl_invalid", message="m", status=422, hints=[hint])
|
|
assert body.model_dump(exclude_none=True)["hints"][0]["op"] == "check.console_app.dsl"
|
|
|
|
|
|
def test_formatter_emits_hints_only_from_openapi_errors() -> None:
|
|
hint = Hint(summary="Check the DSL", op="check.console_app.dsl", input={"yaml_content": None})
|
|
wire = OpenApiErrorFormatter().finalize(DslInvalid(hints=[hint]), {}, 422)
|
|
assert (wire["code"], wire["hints"]) == ("dsl_invalid", [hint.model_dump(mode="json", exclude_none=True)])
|
|
|
|
foreign = BadRequest()
|
|
foreign.hints = [hint] # type: ignore[attr-defined]
|
|
assert "hints" not in OpenApiErrorFormatter().finalize(foreign, {}, 400)
|