← Files Dataiku HeadlessARCHIVED FILE

tests/test_http_config.py

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

↓ Download 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.

"""HTTP instance selection is a preference, not an authorization allow-list."""

import asyncio
import json
from pathlib import Path

import pytest

import dataiku_mcp.auth as auth
from dataiku_mcp.config import http, request
from dataiku_mcp.config.models import (
    DSSInstance,
    EntraAuthConfig,
    GenericOIDCAuthConfig,
    GenericOIDCDelegationConfig,
    GenericOIDCInteractiveLoginConfig,
    HTTPConfig,
    HTTPDSSInstanceConfig,
    HTTPServerConfig,
)


@pytest.fixture(autouse=True)
def isolated_http_state(monkeypatch):
    monkeypatch.setattr(http, "_config", None)
    monkeypatch.setattr(http, "_user_selections", None)


@pytest.fixture
def http_config(monkeypatch, tmp_path):
    path = tmp_path / "http-config.json"
    path.write_text(
        json.dumps(
            {
                "server": {
                    "host": "127.0.0.1",
                    "port": 8000,
                    "path": "/mcp",
                    "public_url": "https://mcp.example",
                },
                "auth": {
                    "provider": "generic_oidc",
                    "issuer": "https://idp.example",
                    "jwks_uri": "https://idp.example/jwks",
                    "required_audience": "dataiku-mcp",
                    "required_scope": "mcp.access",
                    "interactive_login": {
                        "client_id": "interactive-client",
                        "client_secret": "interactive-secret",
                    },
                    "delegation": {
                        "token_endpoint": "https://idp.example/token",
                        "client_id": "exchange-client",
                        "client_secret": "exchange-secret",
                    },
                },
                "dss_instances": {
                    "sandbox": {
                        "instance_type": "design",
                        "url": "https://sandbox.example",
                        "delegated_audience": "dss-sandbox",
                        "delegated_scope": "dss.api",
                    },
                    "prod": {
                        "instance_type": "automation",
                        "url": "https://prod.example",
                        "delegated_audience": "dss-prod",
                        "delegated_scope": "dss.api",
                    },
                },
                "user_selections": {},
            }
        )
    )
    http.set_settings_path(path)
    yield path
    http.set_settings_path(None)


def _convert_to_entra(document):
    document["auth"] = {
        "provider": "entra",
        "tenant_id": "tenant-id",
        "client_id": "mcp-client",
        "client_secret": "mcp-secret",
        "required_scope": "mcp.access",
        "interactive_login": True,
    }
    for instance in document["dss_instances"].values():
        instance.pop("delegated_audience")


def test_http_config_uses_canonical_default(monkeypatch):
    default_path = Path.home() / ".dataiku" / "http-config.json"
    monkeypatch.setattr(http, "_settings_path", None)

    assert http.DEFAULT_SETTINGS_PATH == default_path
    assert http.get_settings_path() == default_path


def test_http_getters_require_initialization():
    with pytest.raises(RuntimeError, match="has not been initialized"):
        http.get_server_settings()


def test_setting_http_path_invalidates_cached_state(http_config):
    http.initialize_config()

    http.set_settings_path(http_config)

    with pytest.raises(RuntimeError, match="has not been initialized"):
        http.get_server_settings()


def test_http_config_requires_existing_settings_file(tmp_path, monkeypatch):
    path = tmp_path / "missing.json"
    monkeypatch.setattr(http, "_settings_path", path)

    with pytest.raises(
        ValueError,
        match="HTTP instance configuration was not found",
    ):
        http.initialize_config()


def test_http_config_rejects_non_object(tmp_path, monkeypatch):
    path = tmp_path / "http-config.json"
    path.write_text("[]", encoding="utf-8")
    monkeypatch.setattr(http, "_settings_path", path)

    with pytest.raises(
        ValueError,
        match="HTTP instance configuration must be a JSON object",
    ):
        http.initialize_config()


@pytest.mark.parametrize(
    "location",
    [(), ("server",), ("dss_instances", "sandbox")],
)
def test_http_config_rejects_unknown_fields(http_config, location):
    document = json.loads(http_config.read_text())
    target = document
    for key in location:
        target = target[key]
    target["unexpected"] = True
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="Extra inputs are not permitted"):
        http.initialize_config()


def test_http_config_rejects_api_keys(http_config):
    document = json.loads(http_config.read_text())
    document["dss_instances"]["prod"]["api_key"] = "not-allowed"
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="Extra inputs are not permitted"):
        http.initialize_config()


def test_http_instance_config_converts_to_runtime_instance():
    config = HTTPDSSInstanceConfig(
        instance_type="automation",
        url="https://prod.example.com",
        delegated_audience="dss-prod",
        delegated_scope="dss.api",
        no_check_certificate=True,
        description="Production",
    )

    assert config.to_instance("prod") == DSSInstance(
        name="prod",
        url="https://prod.example.com",
        no_check_certificate=True,
        source="http",
        instance_type="automation",
        description="Production",
        delegated_audience="dss-prod",
        delegated_scope="dss.api",
    )


@pytest.mark.parametrize(
    "instance_type", ["design", "automation", "deployer", "govern", "agent-management"]
)
def test_http_instance_type_survives_selection_save(
    http_config, monkeypatch, instance_type
):
    document = json.loads(http_config.read_text())
    document["dss_instances"]["prod"]["instance_type"] = instance_type
    http_config.write_text(json.dumps(document))

    def unexpected_client(*args, **kwargs):
        pytest.fail("Instance configuration must not construct a DSS client")

    monkeypatch.setattr("dataikuapi.DSSClient", unexpected_client)
    http.initialize_config()
    identity = request.bind_http_identity("https://idp.example", "alice")
    try:
        result = request.set_current_instance("prod")
        pinned = request.pin_current_instance()
        try:
            assert request.get_pinned_instance().instance_type == instance_type
        finally:
            request.reset_pinned_instance(pinned)
    finally:
        request.reset_http_identity(identity)

    saved = json.loads(http_config.read_text())
    assert result["instance_type"] == instance_type
    assert saved["dss_instances"]["prod"]["instance_type"] == instance_type
    assert http._load_config().dss_instances["prod"].instance_type == instance_type


@pytest.mark.parametrize(
    "instance_type", ["", "unsupported", "DESIGN", " design", None, 1]
)
def test_http_config_rejects_invalid_instance_types(http_config, instance_type):
    document = json.loads(http_config.read_text())
    document["dss_instances"]["prod"]["instance_type"] = instance_type
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="instance_type") as error:
        http.initialize_config()
    assert "exchange-secret" not in str(error.value)


def test_http_requires_explicit_type_without_migrating_catalog(http_config):
    document = json.loads(http_config.read_text())
    document["dss_instances"]["prod"].pop("instance_type")
    http_config.write_text(json.dumps(document))
    original = http_config.read_bytes()

    with pytest.raises(ValueError, match="instance_type"):
        http.initialize_config()
    assert http_config.read_bytes() == original


def test_http_config_rejects_obsolete_user_defaults_key(http_config):
    document = json.loads(http_config.read_text())
    document["user_defaults"] = document.pop("user_selections")
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="user_defaults"):
        http.initialize_config()


def test_http_config_validates_the_complete_document(http_config):
    document = json.loads(http_config.read_text())
    document.pop("auth")
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="Field required"):
        http.initialize_config()


def test_http_config_rejects_previous_authentication_sections(http_config):
    document = json.loads(http_config.read_text())
    document["oidc"] = document.pop("auth")
    document["delegation"] = document["oidc"].pop("delegation")
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="auth"):
        http.initialize_config()


@pytest.mark.parametrize(
    "public_url",
    ["https://mcp.example/", "mcp.example", "/mcp", "ftp://mcp.example"],
)
def test_direct_bearer_config_preserves_public_url(http_config, public_url):
    document = json.loads(http_config.read_text())
    document["server"]["public_url"] = public_url
    document["auth"].pop("interactive_login")
    http_config.write_text(json.dumps(document))
    http.initialize_config()

    assert http.get_server_settings().public_url == public_url


def test_http_config_allows_direct_bearer_only(http_config):
    document = json.loads(http_config.read_text())
    document["server"].pop("public_url")
    document["auth"].pop("interactive_login")
    http_config.write_text(json.dumps(document))
    http.initialize_config()

    assert http.get_server_settings().public_url == ""
    assert http.get_auth_settings().interactive_login is None


def test_http_config_requires_public_url_for_interactive_login(http_config):
    document = json.loads(http_config.read_text())
    document["server"].pop("public_url")
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="server.public_url is required"):
        http.initialize_config()


def test_http_config_rejects_entra_tenant_for_generic_oidc(http_config):
    document = json.loads(http_config.read_text())
    document["auth"]["interactive_login"]["tenant_id"] = "tenant-id"
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="tenant_id"):
        http.initialize_config()


def test_http_config_validates_entra_specific_settings(http_config):
    document = json.loads(http_config.read_text())
    _convert_to_entra(document)
    http_config.write_text(json.dumps(document))
    http.initialize_config()

    settings = http.get_auth_settings()

    assert settings.provider == "entra"
    assert settings.interactive_login is True
    assert settings.issuer == "https://login.microsoftonline.com/tenant-id/v2.0"
    assert settings.jwks_uri == (
        "https://login.microsoftonline.com/tenant-id/discovery/v2.0/keys"
    )
    assert settings.required_audience == "mcp-client"
    assert settings.token_endpoint == (
        "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token"
    )


def test_http_config_requires_entra_tenant(http_config):
    document = json.loads(http_config.read_text())
    _convert_to_entra(document)
    document["auth"].pop("tenant_id")
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="tenant_id"):
        http.initialize_config()


def test_http_config_rejects_entra_audience(http_config):
    document = json.loads(http_config.read_text())
    _convert_to_entra(document)
    document["auth"]["required_audience"] = "dataiku-mcp"
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="required_audience"):
        http.initialize_config()


def test_http_config_rejects_entra_dss_audience(http_config):
    document = json.loads(http_config.read_text())
    _convert_to_entra(document)
    document["auth"]["interactive_login"] = False
    document["dss_instances"]["prod"]["delegated_audience"] = "unused"
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="delegated_audience must be omitted"):
        http.initialize_config()


def test_http_config_requires_oidc_audience(http_config):
    document = json.loads(http_config.read_text())
    document["auth"].pop("required_audience")
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="required_audience"):
        http.initialize_config()


def test_http_config_requires_generic_oidc_dss_audience(http_config):
    document = json.loads(http_config.read_text())
    document["dss_instances"]["prod"].pop("delegated_audience")
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="delegated_audience is required"):
        http.initialize_config()


def test_http_config_requires_boolean_entra_interactive_login(http_config):
    document = json.loads(http_config.read_text())
    _convert_to_entra(document)
    document["auth"]["interactive_login"] = {"enabled": True}
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="valid boolean"):
        http.initialize_config()


def test_http_config_rejects_empty_generic_oidc_interactive_login(http_config):
    document = json.loads(http_config.read_text())
    document["auth"]["interactive_login"] = {}
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="Field required"):
        http.initialize_config()


def test_http_config_allows_entra_direct_bearer_mode(http_config):
    document = json.loads(http_config.read_text())
    _convert_to_entra(document)
    document["auth"].pop("interactive_login")
    http_config.write_text(json.dumps(document))
    http.initialize_config()

    settings = http.get_auth_settings()

    assert settings.interactive_login is False


@pytest.mark.parametrize("missing", ["client_id", "client_secret"])
def test_http_config_rejects_partial_oidc_interactive_client(http_config, missing):
    document = json.loads(http_config.read_text())
    document["auth"]["interactive_login"].pop(missing)
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="Field required"):
        http.initialize_config()


def test_http_config_rejects_invalid_provider(http_config):
    document = json.loads(http_config.read_text())
    document["auth"]["provider"] = "unknown"
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError):
        http.initialize_config()


@pytest.mark.parametrize(
    ("path", "value"),
    [
        (("server", "port"), "8000"),
        (("dss_instances", "prod", "no_check_certificate"), "false"),
    ],
)
def test_http_config_does_not_coerce_types(http_config, path, value):
    document = json.loads(http_config.read_text())
    target = document
    for key in path[:-1]:
        target = target[key]
    target[path[-1]] = value
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError):
        http.initialize_config()


def test_http_config_validation_errors_do_not_expose_secrets(http_config):
    document = json.loads(http_config.read_text())
    secret = "do-not-print-this-secret"
    document["auth"]["delegation"]["client_secret"] = [secret]
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError) as exc_info:
        http.initialize_config()

    assert secret not in str(exc_info.value)


def test_generic_oidc_http_config_example_is_valid(monkeypatch):
    example_path = (
        Path(__file__).parents[1] / ".dataiku" / "http-config.json.generic_oidc-example"
    )
    monkeypatch.setattr(http, "_settings_path", example_path)
    http.initialize_config()

    assert http.get_server_settings() == HTTPServerConfig(
        host="127.0.0.1",
        port=8000,
        path="/mcp",
        public_url="https://mcp.example",
    )
    assert http.get_auth_settings() == GenericOIDCAuthConfig(
        provider="generic_oidc",
        issuer="https://idp.example/oauth2/mcp",
        jwks_uri="https://idp.example/oauth2/mcp/keys",
        required_audience="dataiku-mcp",
        required_scope="mcp.access",
        interactive_login=GenericOIDCInteractiveLoginConfig(
            client_id="dataiku-mcp",
            client_secret="replace-with-secret",
        ),
        delegation=GenericOIDCDelegationConfig(
            token_endpoint="https://idp.example/oauth2/token",
            client_id="dataiku-mcp-exchange",
            client_secret="replace-with-exchange-secret",
        ),
    )
    instances, selections = http.get_instances_and_selections()
    assert isinstance(http._load_config(), HTTPConfig)
    assert set(instances) == {"prod"}
    assert instances["prod"].url == "https://dataiku.example"
    assert instances["prod"].instance_type == "automation"
    assert instances["prod"].delegated_audience == "dataiku-prod"
    assert instances["prod"].delegated_scope == "dataiku.api"
    assert selections == {}


def test_entra_http_config_example_is_valid(monkeypatch):
    example_path = (
        Path(__file__).parents[1] / ".dataiku" / "http-config.json.entra-example"
    )
    monkeypatch.setattr(http, "_settings_path", example_path)
    http.initialize_config()

    settings = http.get_auth_settings()
    assert settings == EntraAuthConfig(
        provider="entra",
        tenant_id="replace-with-tenant-id",
        client_id="replace-with-mcp-app-client-id",
        client_secret="replace-with-secret",
        required_scope="mcp.access",
        interactive_login=True,
    )
    assert settings.issuer == (
        "https://login.microsoftonline.com/replace-with-tenant-id/v2.0"
    )
    instances, selections = http.get_instances_and_selections()
    assert instances["prod"].delegated_audience == ""
    assert instances["prod"].instance_type == "automation"
    assert instances["prod"].delegated_scope == (
        "api://replace-with-dataiku-app-client-id/dataiku.access"
    )
    assert selections == {}


def test_http_user_can_select_any_catalog_instance(http_config):
    http.initialize_config()
    identity = request.bind_http_identity("https://idp.example", "alice")
    try:
        assert set(request.get_instances()) == {"sandbox", "prod"}
        pinned = request.pin_current_instance()
        try:
            with pytest.raises(ValueError, match="No active Dataiku instance"):
                request.get_pinned_instance()
        finally:
            request.reset_pinned_instance(pinned)

        request.set_current_instance("prod")
        pinned = request.pin_current_instance()
        try:
            assert request.get_pinned_instance().name == "prod"
        finally:
            request.reset_pinned_instance(pinned)
    finally:
        request.reset_http_identity(identity)

    document = json.loads(http_config.read_text())
    assert document["user_selections"] == {"https://idp.example": {"alice": "prod"}}


@pytest.mark.parametrize(
    ("issuer", "subject", "field_name"),
    [
        ("", "alice", "issuer"),
        (None, "alice", "issuer"),
        ("https://idp.example", "", "subject"),
        ("https://idp.example", None, "subject"),
    ],
)
def test_http_user_selection_rejects_invalid_identity_without_saving(
    http_config,
    issuer,
    subject,
    field_name,
):
    original = http_config.read_text()
    http.initialize_config()

    with pytest.raises(ValueError, match=f"non-empty {field_name}"):
        http.set_user_selection(issuer, subject, "prod")

    assert http_config.read_text() == original


def test_direct_bearer_config_remains_minimal_when_selection_is_saved(http_config):
    document = json.loads(http_config.read_text())
    document["server"].pop("public_url")
    document["auth"].pop("interactive_login")
    http_config.write_text(json.dumps(document))
    http.initialize_config()

    identity = request.bind_http_identity("https://idp.example", "alice")
    try:
        request.set_current_instance("prod")
    finally:
        request.reset_http_identity(identity)

    saved = json.loads(http_config.read_text())
    assert "public_url" not in saved["server"]
    assert "interactive_login" not in saved["auth"]


def test_entra_config_remains_minimal_when_selection_is_saved(http_config):
    document = json.loads(http_config.read_text())
    _convert_to_entra(document)
    http_config.write_text(json.dumps(document))
    http.initialize_config()

    identity = request.bind_http_identity("https://idp.example", "alice")
    try:
        request.set_current_instance("prod")
    finally:
        request.reset_http_identity(identity)

    saved = json.loads(http_config.read_text())
    assert "required_audience" not in saved["auth"]
    assert saved["auth"]["interactive_login"] is True
    assert "issuer" not in saved["auth"]
    assert "jwks_uri" not in saved["auth"]
    assert "token_endpoint" not in saved["auth"]
    assert "delegated_audience" not in saved["dss_instances"]["prod"]


def test_stale_http_selection_can_be_replaced(http_config):
    document = json.loads(http_config.read_text())
    document["dss_instances"].pop("prod")
    document["user_selections"] = {
        "https://idp.example": {"alice": "prod", "bob": "sandbox"}
    }
    http_config.write_text(json.dumps(document))
    http.initialize_config()

    identity = request.bind_http_identity("https://idp.example", "alice")
    try:
        assert set(request.get_instances()) == {"sandbox"}
        pinned = request.pin_current_instance()
        try:
            with pytest.raises(ValueError, match="No active Dataiku instance"):
                request.get_pinned_instance()
        finally:
            request.reset_pinned_instance(pinned)

        request.set_current_instance("sandbox")
    finally:
        request.reset_http_identity(identity)

    document = json.loads(http_config.read_text())
    assert document["user_selections"] == {
        "https://idp.example": {"alice": "sandbox", "bob": "sandbox"}
    }


def test_http_settings_are_read_from_the_settings_file(http_config):
    http.initialize_config()
    assert http.get_server_settings() == HTTPServerConfig(
        host="127.0.0.1",
        port=8000,
        path="/mcp",
        public_url="https://mcp.example",
    )
    assert http.get_auth_settings().issuer == "https://idp.example"


def test_http_getters_use_the_startup_snapshot(http_config, monkeypatch):
    http.initialize_config()
    document = json.loads(http_config.read_text())
    document["server"]["host"] = "changed.example"
    document["auth"]["issuer"] = "https://changed-idp.example"
    document["dss_instances"]["prod"]["url"] = "https://changed-prod.example"
    http_config.write_text(json.dumps(document))

    monkeypatch.setattr(
        http,
        "_load_config",
        lambda: pytest.fail("cached getters must not reload the settings file"),
    )

    assert http.get_server_settings().host == "127.0.0.1"
    assert http.get_auth_settings().issuer == "https://idp.example"
    instances, selections = http.get_instances_and_selections()
    assert instances["prod"].url == "https://prod.example"
    assert selections == {}

    identity = request.bind_http_identity("https://idp.example", "alice")
    try:
        pinned = request.pin_current_instance()
        request.reset_pinned_instance(pinned)
    finally:
        request.reset_http_identity(identity)


def test_http_selection_write_preserves_but_does_not_activate_operator_edits(
    http_config,
):
    http.initialize_config()
    document = json.loads(http_config.read_text())
    document["server"]["host"] = "changed.example"
    document["auth"]["issuer"] = "https://changed-idp.example"
    document["dss_instances"]["prod"]["url"] = "https://changed-prod.example"
    document["user_selections"] = {"https://idp.example": {"bob": "sandbox"}}
    http_config.write_text(json.dumps(document))

    http.set_user_selection("https://idp.example", "alice", "prod")

    saved = json.loads(http_config.read_text())
    assert saved["server"]["host"] == "changed.example"
    assert saved["auth"]["issuer"] == "https://changed-idp.example"
    assert saved["dss_instances"]["prod"]["url"] == "https://changed-prod.example"
    assert saved["user_selections"] == {
        "https://idp.example": {"alice": "prod", "bob": "sandbox"}
    }

    assert http.get_server_settings().host == "127.0.0.1"
    assert http.get_auth_settings().issuer == "https://idp.example"
    instances, selections = http.get_instances_and_selections()
    assert instances["prod"].url == "https://prod.example"
    assert selections == saved["user_selections"]


def test_http_selection_write_failure_leaves_cached_state_unchanged(
    http_config, monkeypatch
):
    http.initialize_config()

    def fail_save(_config):
        raise OSError("write failed")

    monkeypatch.setattr(http, "_save_config", fail_save)

    with pytest.raises(OSError, match="write failed"):
        http.set_user_selection("https://idp.example", "alice", "prod")

    _, selections = http.get_instances_and_selections()
    assert selections == {}


def test_http_selection_rejects_instance_removed_after_startup(http_config):
    http.initialize_config()
    document = json.loads(http_config.read_text())
    document["dss_instances"].pop("prod")
    http_config.write_text(json.dumps(document))

    with pytest.raises(ValueError, match="Restart the server"):
        http.set_user_selection("https://idp.example", "alice", "prod")

    _, selections = http.get_instances_and_selections()
    assert selections == {}


@pytest.mark.parametrize(
    ("provider", "expected_data"),
    [
        (
            "generic_oidc",
            {
                "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
                "subject_token": "mcp-token",
                "subject_token_type": ("urn:ietf:params:oauth:token-type:access_token"),
                "audience": "dss-prod",
                "scope": "dss.api",
            },
        ),
        (
            "entra",
            {
                "client_id": "client",
                "client_secret": "secret",
                "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
                "assertion": "mcp-token",
                "scope": "dss.api",
                "requested_token_use": "on_behalf_of",
            },
        ),
    ],
)
def test_token_exchange_uses_provider_protocol(monkeypatch, provider, expected_data):
    captured = {}

    class Response:
        def raise_for_status(self):
            return None

        def json(self):
            return {"access_token": "dss-token"}

    monkeypatch.setattr(
        http,
        "get_auth_settings",
        lambda: (
            EntraAuthConfig(
                provider="entra",
                tenant_id="tenant-id",
                client_id="client",
                client_secret="secret",
                required_scope="mcp.access",
            )
            if provider == "entra"
            else GenericOIDCAuthConfig(
                provider="generic_oidc",
                issuer="https://idp.example",
                jwks_uri="https://idp.example/jwks",
                required_audience="dataiku-mcp",
                required_scope="mcp.access",
                delegation=GenericOIDCDelegationConfig(
                    token_endpoint="https://idp.example/token",
                    client_id="client",
                    client_secret="secret",
                ),
            )
        ),
    )
    monkeypatch.setattr(
        request,
        "get_pinned_instance",
        lambda: DSSInstance(
            name="prod",
            url="https://prod.example",
            no_check_certificate=False,
            source="http",
            delegated_audience="dss-prod",
            instance_type="automation",
            delegated_scope="dss.api",
        ),
    )
    monkeypatch.setattr(
        auth.requests,
        "post",
        lambda url, **kwargs: captured.update(url=url, **kwargs) or Response(),
    )

    assert asyncio.run(auth.exchange_http_token("mcp-token")) == "dss-token"
    assert captured == {
        "url": (
            "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token"
            if provider == "entra"
            else "https://idp.example/token"
        ),
        "data": expected_data,
        "auth": ("client", "secret") if provider == "generic_oidc" else None,
        "timeout": 10,
    }


@pytest.mark.parametrize(
    ("failure", "message"),
    [
        ("request", "DSS token exchange failed"),
        ("network", "DSS token exchange failed"),
        ("json", "DSS token exchange returned an invalid response"),
        ("non_object", "DSS token exchange returned an invalid response"),
        ("missing", "DSS token exchange returned no access token"),
    ],
)
def test_token_exchange_reports_sanitized_failures(
    monkeypatch, caplog, failure, message
):
    class Response:
        status_code = 400 if failure == "request" else 200
        headers = {"content-type": "text/html"}

        def raise_for_status(self):
            if failure == "request":
                raise auth.requests.HTTPError("sensitive response", response=self)

        def json(self):
            if failure == "json":
                raise ValueError("sensitive response")
            if failure == "request":
                return {
                    "error": "invalid_grant",
                    "error_description": (
                        "AADSTS65001 mcp-subject-token exchange-client-secret"
                    ),
                    "correlation_id": "correlation-id",
                    "trace_id": "trace-id",
                }
            if failure == "non_object":
                return []
            return {}

    monkeypatch.setattr(
        http,
        "get_auth_settings",
        lambda: GenericOIDCAuthConfig(
            provider="generic_oidc",
            issuer="https://idp.example",
            jwks_uri="https://idp.example/jwks",
            required_audience="dataiku-mcp",
            required_scope="mcp.access",
            delegation=GenericOIDCDelegationConfig(
                token_endpoint="https://idp.example/token",
                client_id="client",
                client_secret="exchange-client-secret",
            ),
        ),
    )
    monkeypatch.setattr(
        request,
        "get_pinned_instance",
        lambda: DSSInstance(
            name="prod",
            url="https://prod.example",
            no_check_certificate=False,
            source="http",
            delegated_audience="dss-prod",
            instance_type="automation",
            delegated_scope="dss.api",
        ),
    )

    def post(*args, **kwargs):
        if failure == "network":
            raise auth.requests.RequestException("sensitive response")
        return Response()

    monkeypatch.setattr(auth.requests, "post", post)
    caplog.set_level("WARNING", logger="dataiku-mcp")

    with pytest.raises(PermissionError, match=message) as exc_info:
        asyncio.run(auth.exchange_http_token("mcp-subject-token"))

    assert "sensitive response" not in str(exc_info.value)
    assert "mcp-subject-token" not in caplog.text
    assert "exchange-client-secret" not in caplog.text
    assert "sensitive response" not in caplog.text

    if failure == "request":
        assert "status=400 exception=HTTPError error=invalid_grant" in caplog.text
        assert "description=AADSTS65001 <redacted> <redacted>" in caplog.text
        assert "correlation_id=correlation-id trace_id=trace-id" in caplog.text
    elif failure == "network":
        assert "status=None exception=RequestException error=None" in caplog.text
    elif failure == "json":
        assert "returned invalid JSON" in caplog.text
    elif failure == "non_object":
        assert "returned a non-object response" in caplog.text
    else:
        assert "contained no access token" in caplog.text

SHA-256: 1be66dc0e1efb84c15adecfc40e3a2724997d80198057415ccb571bc95f85330