mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
581 lines
23 KiB
Python
581 lines
23 KiB
Python
"""Final Flask response contracts; token/execution boundaries are isolated, MCP identities are SQLite rows."""
|
|
|
|
import io
|
|
import json
|
|
import logging
|
|
from collections.abc import Buffer, Generator, Iterator
|
|
from contextlib import contextmanager
|
|
from typing import Literal, Never, override
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from flask import Blueprint, Flask, Response, jsonify, request
|
|
from flask.testing import FlaskClient
|
|
from flask_restx import Namespace, Resource
|
|
from pytest_mock import MockerFixture
|
|
from sqlalchemy import event
|
|
from sqlalchemy.engine import Engine
|
|
from sqlalchemy.orm import Session, object_session, sessionmaker
|
|
from werkzeug.exceptions import Unauthorized
|
|
|
|
import controllers.service_api.wraps as token_wraps
|
|
import services.api_token_service as token_service
|
|
from controllers.common.access_response import (
|
|
MCP_ERROR_BODY_LIMIT,
|
|
MISSING_BEARER_MESSAGE,
|
|
mcp_server_not_found_response,
|
|
register_auth_error_response,
|
|
)
|
|
from controllers.mcp import mcp
|
|
from extensions import ext_request_logging
|
|
from extensions.ext_redis import RedisClientWrapper
|
|
from libs.external_api import ExternalApi
|
|
from models.engine import db
|
|
from models.model import ApiToken, App, AppMCPServer, AppMode
|
|
from services.app.mcp_server_service import AppMCPServerStatus
|
|
from tests.helpers.unit.config_override import apply_config_overrides
|
|
|
|
type TokenClient = tuple[FlaskClient, MagicMock, MagicMock, MagicMock]
|
|
|
|
|
|
def _mcp_server(app_id: str, server_code: str) -> AppMCPServer:
|
|
server = AppMCPServer(
|
|
app_id=app_id,
|
|
tenant_id="tenant",
|
|
status=AppMCPServerStatus.ACTIVE,
|
|
name="Fixture",
|
|
description="",
|
|
server_code=server_code,
|
|
parameters="{}",
|
|
)
|
|
server.id = "server"
|
|
return server
|
|
|
|
|
|
def _app(app_id: str, tenant_id: str) -> App:
|
|
return App(id=app_id, tenant_id=tenant_id, name="Fixture", mode=AppMode.CHAT, enable_site=False, enable_api=True)
|
|
|
|
|
|
def _persist(session: Session, *rows: App | AppMCPServer) -> None:
|
|
session.add_all(rows)
|
|
session.commit()
|
|
|
|
|
|
@contextmanager
|
|
def _sqlite_writes(engine: Engine) -> Generator[list[str]]:
|
|
"""Collect the INSERT/UPDATE/DELETE statements issued while the block runs."""
|
|
writes: list[str] = []
|
|
|
|
def record_write(
|
|
_connection: object,
|
|
_cursor: object,
|
|
statement: str,
|
|
_parameters: object,
|
|
_context: object,
|
|
_executemany: bool,
|
|
) -> None:
|
|
if statement.lstrip().upper().startswith(("INSERT", "UPDATE", "DELETE")):
|
|
writes.append(statement)
|
|
|
|
event.listen(engine, "before_cursor_execute", record_write)
|
|
try:
|
|
yield writes
|
|
finally:
|
|
event.remove(engine, "before_cursor_execute", record_write)
|
|
|
|
|
|
type MCPClient = tuple[FlaskClient, MagicMock, MagicMock]
|
|
|
|
|
|
@pytest.fixture
|
|
def http_app(sqlite_engine: Engine) -> Flask:
|
|
app = Flask(__name__)
|
|
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=sqlite_engine.url)
|
|
db.init_app(app)
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def token_client(http_app: Flask, mocker: MockerFixture) -> TokenClient:
|
|
bp = Blueprint("service_test", __name__, url_prefix="/v1")
|
|
register_auth_error_response(bp, www_authenticate='Bearer realm="api"')
|
|
api = ExternalApi(bp)
|
|
|
|
class TokenResource(Resource):
|
|
def get(self) -> dict[str, bool]:
|
|
token_wraps.validate_and_get_api_token("app")
|
|
return {"ok": True}
|
|
|
|
api.add_resource(TokenResource, "/info")
|
|
http_app.register_blueprint(bp)
|
|
other = Blueprint("other_test", __name__, url_prefix="/other")
|
|
other_api = ExternalApi(other)
|
|
|
|
class OtherResource(Resource):
|
|
def get(self) -> Never:
|
|
raise Unauthorized("Other API authentication")
|
|
|
|
other_api.add_resource(OtherResource, "/info")
|
|
http_app.register_blueprint(other)
|
|
cache_get = mocker.patch.object(token_wraps.ApiTokenCache, "get", return_value=None)
|
|
fetch = mocker.patch.object(
|
|
token_wraps,
|
|
"fetch_token_with_single_flight",
|
|
side_effect=token_service.ApiTokenInvalidError("Access token is invalid"),
|
|
)
|
|
usage = mocker.patch.object(token_wraps, "record_token_usage")
|
|
return http_app.test_client(), cache_get, fetch, usage
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"header",
|
|
[
|
|
None,
|
|
"",
|
|
"Bearer",
|
|
"Bearer ",
|
|
"Bearer\t",
|
|
" ",
|
|
"Basic abc",
|
|
"Bearer a b",
|
|
"Bearer\xa0valid-token",
|
|
"Bearer\x1cvalid-token",
|
|
"Bearer\vvalid-token",
|
|
"Bearer\u2003valid-token",
|
|
],
|
|
)
|
|
def test_bad_bearer_final_response_never_looks_up_or_records_token(
|
|
token_client: TokenClient, header: str | None
|
|
) -> None:
|
|
client, cache_get, fetch, usage = token_client
|
|
response = client.get("/v1/info", headers={} if header is None else {"Authorization": header})
|
|
assert response.status_code == 401
|
|
expected = {"code": "unauthorized", "message": MISSING_BEARER_MESSAGE, "status": 401}
|
|
assert response.data == json.dumps(expected, separators=(",", ":"), sort_keys=True).encode()
|
|
assert response.headers["Content-Type"] == "application/json"
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
|
|
cache_get.assert_not_called()
|
|
fetch.assert_not_called()
|
|
usage.assert_not_called()
|
|
|
|
|
|
def test_invalid_token_final_response_and_no_usage(token_client: TokenClient) -> None:
|
|
client, _cache_get, fetch, usage = token_client
|
|
response = client.get("/v1/info", headers={"Authorization": "Bearer missing-token"})
|
|
assert response.status_code == 401
|
|
assert response.data == b'{"code":"unauthorized","message":"Access token is invalid","status":401}'
|
|
assert response.headers["Content-Type"] == "application/json"
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
|
|
fetch.assert_called_once_with("missing-token", "app")
|
|
usage.assert_not_called()
|
|
|
|
|
|
def test_invalid_token_database_path_does_not_record_usage(http_app: Flask, mocker: MockerFixture) -> None:
|
|
usage = mocker.patch.object(token_service, "record_token_usage")
|
|
mocker.patch.object(token_service.ApiTokenCache, "set")
|
|
with http_app.app_context():
|
|
with pytest.raises(token_service.ApiTokenInvalidError, match="Access token is invalid"):
|
|
token_service.query_token_from_db("missing-token", "app")
|
|
usage.assert_not_called()
|
|
|
|
|
|
@pytest.fixture
|
|
def uncached_token_client(
|
|
http_app: Flask, redis_transport: tuple[RedisClientWrapper, MagicMock]
|
|
) -> Iterator[tuple[FlaskClient, MagicMock]]:
|
|
"""Run real token lookup and error translation, replacing only Redis network dispatch."""
|
|
redis, commands = redis_transport
|
|
|
|
def execute(command: str, *_args: object, **_kwargs: object) -> bool | None:
|
|
return True if command in {"SET", "EVALSHA"} else None
|
|
|
|
commands.side_effect = execute
|
|
previous_redis = token_service.redis_client
|
|
token_service.redis_client = redis
|
|
bp = Blueprint("uncached_service_test", __name__, url_prefix="/v1")
|
|
register_auth_error_response(bp, www_authenticate='Bearer realm="api"')
|
|
api = ExternalApi(bp)
|
|
|
|
class AppTokenResource(Resource):
|
|
@token_wraps.validate_app_token
|
|
def get(self, app_model: App) -> Never:
|
|
pytest.fail(f"Invalid credentials reached the handler for app {app_model.id}")
|
|
|
|
class DatasetTokenResource(Resource):
|
|
@token_wraps.validate_dataset_token
|
|
def get(self, tenant_id: str) -> Never:
|
|
pytest.fail(f"Invalid credentials reached the dataset handler for tenant {tenant_id}")
|
|
|
|
api.add_resource(AppTokenResource, "/app")
|
|
api.add_resource(DatasetTokenResource, "/dataset")
|
|
http_app.register_blueprint(bp)
|
|
try:
|
|
yield http_app.test_client(), commands
|
|
finally:
|
|
token_service.redis_client = previous_redis
|
|
with http_app.app_context():
|
|
db.session.remove()
|
|
db.engine.dispose()
|
|
|
|
|
|
@pytest.mark.parametrize("scope", ["app", "dataset"])
|
|
@pytest.mark.parametrize("authorization", [None, "Bearer missing-token"])
|
|
def test_real_token_errors_preserve_http_response_contract(
|
|
uncached_token_client: tuple[FlaskClient, MagicMock], scope: str, authorization: str | None
|
|
) -> None:
|
|
client, commands = uncached_token_client
|
|
response = client.get(f"/v1/{scope}", headers={} if authorization is None else {"Authorization": authorization})
|
|
|
|
message = MISSING_BEARER_MESSAGE if authorization is None else "Access token is invalid"
|
|
expected = {"code": "unauthorized", "message": message, "status": 401}
|
|
assert response.status_code == 401
|
|
assert response.data == json.dumps(expected, separators=(",", ":"), sort_keys=True).encode()
|
|
assert response.headers["Content-Type"] == "application/json"
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
|
|
|
|
if authorization is None:
|
|
commands.assert_not_called()
|
|
else:
|
|
calls = [call.args for call in commands.call_args_list]
|
|
assert calls.count(("GET", f"api_token:{scope}:missing-token")) == 2
|
|
assert ("SETEX", f"api_token:{scope}:missing-token", 60, b"null") in calls
|
|
assert not any(str(call[1]).startswith(token_service.ACTIVE_TOKEN_KEY_PREFIX) for call in calls)
|
|
|
|
|
|
def test_success_token_and_other_surface_are_not_rewritten(token_client: TokenClient) -> None:
|
|
client, cache_get, _fetch, usage = token_client
|
|
cache_get.return_value = ApiToken(id="existing")
|
|
response = client.get("/v1/info", headers={"Authorization": "bEaReR\tvalid-token"})
|
|
assert response.status_code == 200
|
|
assert response.get_json() == {"ok": True}
|
|
assert "Cache-Control" not in response.headers
|
|
usage.assert_called_once_with("valid-token", "app")
|
|
other = client.get("/other/info")
|
|
assert other.status_code == 401
|
|
assert other.get_json()["message"] == "Other API authentication"
|
|
assert "Cache-Control" not in other.headers
|
|
|
|
|
|
@pytest.fixture
|
|
def mcp_client(http_app: Flask, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> MCPClient:
|
|
bp = Blueprint("mcp_test", __name__, url_prefix="/mcp")
|
|
api = ExternalApi(bp)
|
|
namespace = Namespace("mcp_test", path="/")
|
|
namespace.models.update(mcp.mcp_ns.models)
|
|
namespace.add_resource(mcp.MCPAppApi, "/server/<string:server_code>/mcp")
|
|
api.add_namespace(namespace)
|
|
http_app.register_blueprint(bp)
|
|
monkeypatch.setattr(mcp, "mcp_ns", namespace)
|
|
# Identity lookups run through the autouse per-test SQLite session factory.
|
|
execute = mocker.patch.object(mcp, "handle_mcp_request")
|
|
end_user = mocker.patch.object(mcp.MCPAppApi, "_retrieve_end_user")
|
|
return http_app.test_client(), execute, end_user
|
|
|
|
|
|
@pytest.mark.parametrize("cached", [False, True])
|
|
@pytest.mark.parametrize("identity", ["missing-server", "missing-app", "foreign-tenant-app", "inactive"])
|
|
@pytest.mark.parametrize(
|
|
("body", "expected_id"),
|
|
[
|
|
(b'{"id":17}', b"17"),
|
|
(b'{"id":-0}', b"-0"),
|
|
(b'{"id":1.2500}', b"1.2500"),
|
|
(b'{"id":1e+90}', b"1e+90"),
|
|
(b'{"id":900719925474099312345678901234567890}', b"900719925474099312345678901234567890"),
|
|
(b'{"id":"request-1"}', b'"request-1"'),
|
|
('{"id":"<>&雪\u2028\u2029"}'.encode(), '"<>&雪\\u2028\\u2029"'.encode()),
|
|
(b'{"id":true}', b"null"),
|
|
(b'{"id":[]}', b"null"),
|
|
(b'{"id":{}}', b"null"),
|
|
(b'{"id":null}', b"null"),
|
|
(b"{}", b"null"),
|
|
(b'[{"id":1}]', b"null"),
|
|
(b'{"id":NaN}', b"null"),
|
|
(b'{"id":Infinity}', b"null"),
|
|
(b'{"id":', b"null"),
|
|
(b'{"id":1}{}', b"null"),
|
|
(b'\xef\xbb\xbf{"id":1}', b"null"),
|
|
('{"id":1}'.encode("utf-16"), b"null"),
|
|
(b'{"id":"\xff"}', b"null"),
|
|
(b'{"id":"\\ud800"}', b"null"),
|
|
(b'{"id":"\\udfff"}', b"null"),
|
|
(b'{"id":"\\ud83d\\ude00"}', '"😀"'.encode()),
|
|
(b'{"id":"\\\\ud800"}', b'"\\\\ud800"'),
|
|
(b'{"id":1,"params":' + b"[" * 63 + b"0" + b"]" * 63 + b"}", b"1"),
|
|
(b'{"id":1,"params":' + b"[" * 64 + b"0" + b"]" * 64 + b"}", b"null"),
|
|
(b'{"id":1,"params":"' + b"[" * 100 + b'\\"}"}', b"1"),
|
|
(b'{"id":1}' + b" " * (MCP_ERROR_BODY_LIMIT - len(b'{"id":1}')), b"1"),
|
|
(b'{"id":1}' + b" " * MCP_ERROR_BODY_LIMIT, b"null"),
|
|
],
|
|
)
|
|
def test_mcp_missing_identity_final_response_is_bounded_and_opaque(
|
|
mcp_client: MCPClient,
|
|
sqlite_engine: Engine,
|
|
sqlite_session: Session,
|
|
identity: Literal["missing-server", "missing-app", "foreign-tenant-app", "inactive"],
|
|
cached: bool,
|
|
body: bytes,
|
|
expected_id: bytes,
|
|
) -> None:
|
|
client, execute, end_user = mcp_client
|
|
server = _mcp_server("private-app", "missing-fixture")
|
|
if cached:
|
|
|
|
@client.application.before_request
|
|
def cache_body() -> None:
|
|
request.get_data()
|
|
|
|
if identity == "missing-app":
|
|
_persist(sqlite_session, server)
|
|
elif identity == "foreign-tenant-app":
|
|
# The App exists, but outside the server's tenant.
|
|
_persist(sqlite_session, server, _app("private-app", "other-tenant"))
|
|
elif identity == "inactive":
|
|
server.status = "inactive"
|
|
_persist(sqlite_session, server, _app("private-app", "tenant"))
|
|
with _sqlite_writes(sqlite_engine) as writes:
|
|
response = client.post("/mcp/server/missing-fixture/mcp", data=body, content_type="application/json")
|
|
assert response.status_code == 404
|
|
assert (
|
|
response.data
|
|
== b'{"error":{"code":-32600,"message":"Server Not Found"},"id":' + expected_id + b',"jsonrpc":"2.0"}'
|
|
)
|
|
assert response.headers["Content-Type"] == "application/json"
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
execute.assert_not_called()
|
|
end_user.assert_not_called()
|
|
assert writes == []
|
|
|
|
|
|
@pytest.mark.parametrize("cached", [False, True])
|
|
@pytest.mark.parametrize("oversized", [False, True])
|
|
def test_mcp_unknown_length_stream_read_is_capped(http_app: Flask, cached: bool, oversized: bool) -> None:
|
|
class RecordingStream(io.BytesIO):
|
|
requested: list[int | None] = []
|
|
|
|
@override
|
|
def read(self, size: int | None = -1) -> bytes:
|
|
self.requested.append(size)
|
|
return super().read(size)
|
|
|
|
stream = RecordingStream(b'{"id":1}' + (b" " * MCP_ERROR_BODY_LIMIT if oversized else b""))
|
|
with http_app.test_request_context(
|
|
"/", method="POST", environ_overrides={"wsgi.input": stream, "wsgi.input_terminated": True}
|
|
):
|
|
if cached:
|
|
request.get_data()
|
|
stream.requested.clear()
|
|
response = mcp_server_not_found_response()
|
|
assert stream.requested == ([] if cached else [MCP_ERROR_BODY_LIMIT + 1])
|
|
assert response.get_json()["id"] == (None if oversized else 1)
|
|
|
|
|
|
def test_mcp_debug_request_logging_preserves_raw_error_id(
|
|
mcp_client: MCPClient, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
client, execute, end_user = mcp_client
|
|
caplog.set_level(logging.DEBUG, logger=ext_request_logging.__name__)
|
|
# Exercise the real existing request-started logger, including request.data caching.
|
|
apply_config_overrides(monkeypatch, ENABLE_REQUEST_LOGGING=True)
|
|
ext_request_logging.init_app(client.application)
|
|
response = client.post("/mcp/server/missing-fixture/mcp", data=b'{"id":1.2500}', content_type="application/json")
|
|
assert response.status_code == 404
|
|
assert b'"id":1.2500,' in response.data
|
|
assert "Received Request" in caplog.text
|
|
execute.assert_not_called()
|
|
end_user.assert_not_called()
|
|
|
|
|
|
def test_existing_mcp_keeps_normal_large_payload_and_response(
|
|
mcp_client: MCPClient, sqlite_session: Session, mocker: MockerFixture
|
|
) -> None:
|
|
client, _execute, _end_user = mcp_client
|
|
_persist(sqlite_session, _mcp_server("existing-app", "existing-fixture"), _app("existing-app", "tenant"))
|
|
mocker.patch.object(mcp.MCPAppApi, "_get_user_input_form", return_value=[])
|
|
expected_body = b'{"id":2,"jsonrpc":"2.0","result":{}}'
|
|
process = mocker.patch.object(
|
|
mcp.MCPAppApi,
|
|
"_process_mcp_message",
|
|
return_value=Response(expected_body, status=200, content_type="application/json"),
|
|
)
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"method": "initialize",
|
|
"id": 2,
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {"experimental": {"fixture": {"padding": "x" * (MCP_ERROR_BODY_LIMIT + 1)}}},
|
|
"clientInfo": {"name": "fixture", "version": "1"},
|
|
},
|
|
}
|
|
response = client.post("/mcp/server/existing-fixture/mcp", json=payload)
|
|
assert response.status_code == 200
|
|
assert response.data == expected_body
|
|
assert "Cache-Control" not in response.headers
|
|
process.assert_called_once()
|
|
|
|
|
|
def test_existing_mcp_still_validates_malformed_payload(mcp_client: MCPClient, sqlite_session: Session) -> None:
|
|
client, execute, end_user = mcp_client
|
|
_persist(sqlite_session, _mcp_server("existing-app", "existing-fixture"), _app("existing-app", "tenant"))
|
|
response = client.post("/mcp/server/existing-fixture/mcp", data=b"{", content_type="application/json")
|
|
assert response.status_code == 400
|
|
execute.assert_not_called()
|
|
end_user.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"change",
|
|
[
|
|
"initial-missing",
|
|
"missing-server",
|
|
"missing-app",
|
|
"inactive",
|
|
"server-replaced",
|
|
"app-rebound",
|
|
"tenant-changed",
|
|
"unchanged",
|
|
],
|
|
)
|
|
def test_mcp_reads_body_outside_sessions_and_rechecks_identity_before_execution(
|
|
mcp_client: MCPClient,
|
|
sqlite_engine: Engine,
|
|
sqlite_session: Session,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
mocker: MockerFixture,
|
|
change: str,
|
|
) -> None:
|
|
client, execute, end_user = mcp_client
|
|
active_sessions: list[Session] = []
|
|
opened: list[Session] = []
|
|
read_sessions: list[bool] = []
|
|
server = _mcp_server("app", "fixture")
|
|
app = _app("app", "tenant")
|
|
if change != "initial-missing":
|
|
_persist(sqlite_session, server, app)
|
|
|
|
def change_identity() -> None:
|
|
"""Commit the change the execution session must observe after the body read."""
|
|
if change == "missing-server":
|
|
sqlite_session.delete(server)
|
|
elif change == "missing-app":
|
|
sqlite_session.delete(app)
|
|
elif change == "inactive":
|
|
server.status = "inactive"
|
|
elif change == "server-replaced":
|
|
server.id = "other-server"
|
|
elif change == "app-rebound":
|
|
sqlite_session.add(_app("other-app", "tenant"))
|
|
server.app_id = "other-app"
|
|
elif change == "tenant-changed":
|
|
server.tenant_id = app.tenant_id = "other-tenant"
|
|
sqlite_session.commit()
|
|
|
|
def create_session(**_kwargs: object) -> Session:
|
|
if opened:
|
|
# The identity change is the test's own write, not the request's.
|
|
request_writes = len(writes)
|
|
change_identity()
|
|
del writes[request_writes:]
|
|
session = sqlite_session_factory()
|
|
opened.append(session)
|
|
active_sessions.append(session)
|
|
close = session.close
|
|
|
|
def close_session() -> None:
|
|
try:
|
|
close()
|
|
finally:
|
|
active_sessions.pop()
|
|
|
|
mocker.patch.object(session, "close", side_effect=close_session)
|
|
return session
|
|
|
|
maker = sessionmaker()
|
|
mocker.patch.object(maker, "class_", side_effect=create_session)
|
|
monkeypatch.setattr(mcp.session_factory, "get_session_maker", lambda: maker)
|
|
form = mocker.patch.object(mcp.MCPAppApi, "_get_user_input_form", return_value=[])
|
|
attached_to: list[Session | None] = []
|
|
|
|
def process_message(*args: object) -> Response:
|
|
attached_to.extend(object_session(record) for record in args[2:4])
|
|
return Response("ok", status=200)
|
|
|
|
process = mocker.patch.object(mcp.MCPAppApi, "_process_mcp_message", side_effect=process_message)
|
|
original_error = mcp.mcp_server_not_found_response
|
|
|
|
def error_response() -> Response:
|
|
assert not active_sessions, "404 response must be constructed after releasing sessions"
|
|
return original_error()
|
|
|
|
monkeypatch.setattr(mcp, "mcp_server_not_found_response", error_response)
|
|
|
|
class RecordingStream(io.BytesIO):
|
|
@override
|
|
def read(self, size: int | None = -1) -> bytes:
|
|
read_sessions.append(bool(active_sessions))
|
|
return super().read(size)
|
|
|
|
@override
|
|
def readinto(self, buffer: Buffer) -> int:
|
|
read_sessions.append(bool(active_sessions))
|
|
return super().readinto(buffer)
|
|
|
|
payload = b'{"jsonrpc":"2.0","method":"ping","id":900719925474099312345678901234567890}'
|
|
with _sqlite_writes(sqlite_engine) as writes:
|
|
response = client.post(
|
|
"/mcp/server/fixture/mcp", input_stream=RecordingStream(payload), content_type="application/json"
|
|
)
|
|
assert read_sessions
|
|
assert not any(read_sessions)
|
|
assert not active_sessions
|
|
assert len(opened) == (1 if change == "initial-missing" else 2)
|
|
if change == "unchanged":
|
|
assert response.status_code == 200
|
|
execution = opened[1]
|
|
fresh_app = process.call_args.args[2]
|
|
form.assert_called_once_with(fresh_app, session=execution)
|
|
assert (fresh_app.id, process.call_args.args[3].id) == ("app", "server")
|
|
assert process.call_args.args[5] is execution
|
|
assert attached_to == [execution, execution]
|
|
else:
|
|
assert response.status_code == 404
|
|
assert b'"id":900719925474099312345678901234567890,' in response.data
|
|
form.assert_not_called()
|
|
process.assert_not_called()
|
|
execute.assert_not_called()
|
|
end_user.assert_not_called()
|
|
assert writes == []
|
|
|
|
|
|
@pytest.mark.parametrize("status", [401, 403, 429])
|
|
def test_openapi_local_hook_preserves_native_details_and_oauth_challenge(http_app: Flask, status: int) -> None:
|
|
payload = {
|
|
"code": "unauthorized",
|
|
"message": "invalid_token",
|
|
"status": status,
|
|
"hint": "native hint",
|
|
"details": [{"type": "native", "loc": ["header"], "msg": "detail"}],
|
|
}
|
|
bp = Blueprint("openapi_test", __name__, url_prefix="/openapi/v1")
|
|
register_auth_error_response(bp)
|
|
|
|
@bp.route("/fixture")
|
|
def fixture() -> tuple[Response, int, dict[str, str]]:
|
|
return jsonify(payload), status, {"WWW-Authenticate": 'Bearer realm="native-openapi", error="invalid_token"'}
|
|
|
|
http_app.register_blueprint(bp)
|
|
response = http_app.test_client().get("/openapi/v1/fixture")
|
|
assert response.status_code == status
|
|
assert response.get_json() == payload
|
|
assert response.headers["WWW-Authenticate"] == 'Bearer realm="native-openapi", error="invalid_token"'
|
|
if status == 401:
|
|
assert response.data == json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
else:
|
|
assert "Cache-Control" not in response.headers
|