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