← Files Dataiku HeadlessARCHIVED FILE

tests/test_http_middleware.py

9.37 KB · Nov 11, 2026 · 12:58 UTC

↓ Download file

See the change to this file →

# Copyright 2026 Dataiku SAS
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""The HTTP middleware exchanges a DSS token only for DSS-backed tools."""

import asyncio
from types import SimpleNamespace

import pytest

import dataiku_mcp.server as server
from dataiku_mcp.config import request
from dataiku_mcp.instance_policy import (
    INSTANCE_MANAGEMENT_POLICY,
    PROJECT_READ_POLICY,
    PROJECT_EDIT_POLICY,
)
from fastmcp.exceptions import NotFoundError


def _call_middleware(
    monkeypatch,
    tool_name: str,
    *,
    tool_tags: set[str] | None = None,
    tool_resolves: bool = True,
    include_fastmcp_context: bool = True,
    instance_type: str = "design",
    tool_meta: dict | None = None,
    expected_error: tuple[type[Exception], str] | None = None,
):
    events = []
    access_token = SimpleNamespace(
        token="mcp-token",
        claims={"iss": "https://idp.example", "sub": "alice"},
    )
    middleware = server.RequestContextMiddleware()

    monkeypatch.setattr(server, "get_access_token", lambda: access_token)
    monkeypatch.setattr(
        request,
        "bind_http_identity",
        lambda issuer, subject: (
            events.append(("identity", issuer, subject)) or "identity"
        ),
    )
    monkeypatch.setattr(
        request,
        "pin_current_instance",
        lambda: events.append(("pin",)) or "instance",
    )
    monkeypatch.setattr(
        request,
        "reset_pinned_instance",
        lambda token: events.append(("reset_instance", token)),
    )
    monkeypatch.setattr(
        request,
        "reset_http_identity",
        lambda token: events.append(("reset_identity", token)),
    )
    monkeypatch.setattr(
        server,
        "exchange_http_token",
        lambda token: _exchange(events, token),
    )
    monkeypatch.setattr(
        request,
        "bind_http_dss_token",
        lambda token: events.append(("set_dss", token)) or "dss",
    )
    monkeypatch.setattr(
        request,
        "reset_http_dss_token",
        lambda token: events.append(("reset_dss", token)),
    )

    monkeypatch.setattr(
        request,
        "get_pinned_instance",
        lambda: SimpleNamespace(instance_type=instance_type),
    )

    async def call_next(_context):
        events.append(("tool",))
        return "result"

    class FakeFastMCP:
        async def get_tool(self, name):
            assert name == tool_name
            if not tool_resolves:
                return None
            return SimpleNamespace(
                tags=tool_tags or set(),
                meta=tool_meta
                or (
                    INSTANCE_MANAGEMENT_POLICY
                    if tool_name in {"switch_instance", "get_current_instance"}
                    else PROJECT_READ_POLICY
                ),
            )

    fastmcp_context = None
    if include_fastmcp_context:
        fastmcp_context = SimpleNamespace(fastmcp=FakeFastMCP())
    monkeypatch.setattr(server.mcp, "get_tool", FakeFastMCP().get_tool)
    context = SimpleNamespace(
        message=SimpleNamespace(name=tool_name),
        fastmcp_context=fastmcp_context,
    )
    if expected_error:
        with pytest.raises(expected_error[0], match=expected_error[1]):
            asyncio.run(middleware.on_call_tool(context, call_next))
    else:
        assert asyncio.run(middleware.on_call_tool(context, call_next)) == "result"
    return events


async def _exchange(events, token):
    events.append(("exchange", token))
    return "dss-token"


@pytest.mark.parametrize(
    "claims",
    [
        {"sub": "alice"},
        {"iss": "https://idp.example"},
        {"iss": None, "sub": "alice"},
        {"iss": 42, "sub": "alice"},
        {"iss": [], "sub": "alice"},
        {"iss": "https://idp.example", "sub": None},
        {"iss": "https://idp.example", "sub": 42},
        {"iss": "https://idp.example", "sub": []},
    ],
)
def test_http_invalid_identity_claims_fail_before_request_setup(monkeypatch, claims):
    events = []
    middleware = server.RequestContextMiddleware()
    access_token = SimpleNamespace(token="mcp-token", claims=claims)

    monkeypatch.setattr(server, "get_access_token", lambda: access_token)
    monkeypatch.setattr(
        request, "pin_current_instance", lambda: events.append(("pin",))
    )
    monkeypatch.setattr(
        server, "exchange_http_token", lambda _: events.append(("exchange",))
    )

    async def call_next(_context):
        events.append(("tool",))

    context = SimpleNamespace(message=SimpleNamespace(name="list_projects"))
    with pytest.raises(ValueError, match="non-empty string iss and sub claims"):
        asyncio.run(middleware.on_call_tool(context, call_next))

    assert events == []


def test_dss_independent_tools_are_tagged():
    async def get_tagged_tool_names():
        tools = await server.mcp.list_tools(run_middleware=False)
        return {
            tool.name for tool in tools if server.DSS_INDEPENDENT_TOOL_TAG in tool.tags
        }

    assert asyncio.run(get_tagged_tool_names()) == {
        "list_instances",
        "switch_instance",
        "delete_instance",
        "configure_instance",
        "get_cobuild_turn_status",
        "list_cobuild_conversations",
    }


def test_http_local_only_tool_skips_dss_token_exchange(monkeypatch):
    events = _call_middleware(
        monkeypatch,
        "switch_instance",
        tool_tags={server.DSS_INDEPENDENT_TOOL_TAG},
    )

    assert ("exchange", "mcp-token") not in events
    assert ("set_dss", "dss-token") not in events
    assert events[-2:] == [
        ("reset_instance", "instance"),
        ("reset_identity", "identity"),
    ]


def test_http_dss_tool_exchanges_and_resets_its_dss_token(monkeypatch):
    events = _call_middleware(monkeypatch, "list_projects")

    assert ("exchange", "mcp-token") in events
    assert ("set_dss", "dss-token") in events
    assert events[-3:] == [
        ("reset_dss", "dss"),
        ("reset_instance", "instance"),
        ("reset_identity", "identity"),
    ]


def test_http_current_instance_exchanges_a_dss_token(monkeypatch):
    events = _call_middleware(monkeypatch, "get_current_instance")

    assert ("exchange", "mcp-token") in events
    assert ("set_dss", "dss-token") in events


@pytest.mark.parametrize("include_fastmcp_context", [True, False])
def test_http_unknown_tool_fails_without_token_exchange(
    monkeypatch, include_fastmcp_context
):
    events = _call_middleware(
        monkeypatch,
        "unknown_tool",
        tool_resolves=False,
        include_fastmcp_context=include_fastmcp_context,
        expected_error=(NotFoundError, "Unknown tool"),
    )
    assert ("exchange", "mcp-token") not in events
    assert ("tool",) not in events
    assert events[-2:] == [
        ("reset_instance", "instance"),
        ("reset_identity", "identity"),
    ]


@pytest.mark.parametrize("instance_type", ["govern", "deployer", "agent-management"])
def test_http_unsupported_current_instance_skips_exchange(monkeypatch, instance_type):
    events = _call_middleware(
        monkeypatch, "get_current_instance", instance_type=instance_type
    )
    assert ("exchange", "mcp-token") not in events
    assert ("tool",) in events


@pytest.mark.parametrize(
    "tool_name,tool_tags",
    [
        ("set_project_variables", set()),
        ("list_cobuild_conversations", {server.DSS_INDEPENDENT_TOOL_TAG}),
    ],
)
def test_http_policy_rejection_precedes_exchange_and_handler(
    monkeypatch, tool_name, tool_tags
):
    events = _call_middleware(
        monkeypatch,
        tool_name,
        instance_type="automation",
        tool_tags=tool_tags,
        tool_meta=PROJECT_EDIT_POLICY,
        expected_error=(ValueError, "automation.*Allowed types.*switch_instance"),
    )
    assert ("exchange", "mcp-token") not in events
    assert ("tool",) not in events
    assert events[-2:] == [
        ("reset_instance", "instance"),
        ("reset_identity", "identity"),
    ]


def test_http_identity_is_reset_when_instance_pinning_fails(monkeypatch):
    events = []
    access_token = SimpleNamespace(
        token="mcp-token",
        claims={"iss": "https://idp.example", "sub": "alice"},
    )
    middleware = server.RequestContextMiddleware()

    monkeypatch.setattr(server, "get_access_token", lambda: access_token)
    monkeypatch.setattr(request, "bind_http_identity", lambda *_: "identity")

    def fail_pinning():
        raise ValueError("invalid settings")

    monkeypatch.setattr(
        request,
        "pin_current_instance",
        fail_pinning,
    )
    monkeypatch.setattr(
        request,
        "reset_http_identity",
        lambda token: events.append(("reset_identity", token)),
    )

    async def call_next(_context):
        pytest.fail("The tool must not run after pinning fails.")

    context = SimpleNamespace(message=SimpleNamespace(name="list_projects"))
    with pytest.raises(ValueError, match="invalid settings"):
        asyncio.run(middleware.on_call_tool(context, call_next))

    assert events == [("reset_identity", "identity")]

SHA-256: 2d75ab0ff3aa2b2a9238e84d7c93670c39a37f8ed93f2a5fd4e669334dfe8db1