Ctrl K
ComunidadGuías…

coderd/mcp_transport_internal_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package coderd

import (
	"context"
	"database/sql"
	"io"
	"net/http"
	"net/url"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbgen"
	"github.com/coder/coder/v2/coderd/database/dbtestutil"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/httpmw"
	"github.com/coder/coder/v2/coderd/mcp"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/testutil"
	"github.com/coder/websocket"
)

func TestMCPTransportAuthentication(t *testing.T) {
	t.Parallel()
	db, _ := dbtestutil.NewDB(t)
	user := dbgen.User(t, db, database.User{})
	app := dbgen.OAuth2ProviderApp(t, db, database.OAuth2ProviderApp{})
	origin, err := url.Parse("https://coder.example.com")
	require.NoError(t, err)
	handler := httpmw.PrecheckAPIKey(httpmw.ValidateAPIKeyConfig{DB: db})(
		httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{DB: db, AccessURL: origin, Logger: testutil.Logger(t)})(
			http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				assert.Equal(t, user.ID.String(), httpmw.UserAuthorization(r.Context()).ID)
				w.WriteHeader(http.StatusNoContent)
			})))

	for _, tc := range []struct {
		name       string
		audience   string
		status     int
		expired    bool
		deleted    bool
		wrongKeyID bool
	}{
		{name: "MCPAudience", audience: origin.String() + mcp.MCPEndpoint, status: http.StatusNoContent},
		{name: "TrailingSlash", audience: origin.String() + mcp.MCPEndpoint + "/", status: http.StatusNoContent},
		{name: "UnboundLegacyToken", status: http.StatusNoContent},
		{name: "RootAudience", audience: origin.String(), status: http.StatusForbidden},
		{name: "OtherDeployment", audience: "https://other.example.com" + mcp.MCPEndpoint, status: http.StatusForbidden},
		{name: "WrongKey", audience: origin.String() + mcp.MCPEndpoint, wrongKeyID: true, status: http.StatusUnauthorized},
		{name: "Expired", audience: origin.String() + mcp.MCPEndpoint, expired: true, status: http.StatusUnauthorized},
		{name: "Deleted", audience: origin.String() + mcp.MCPEndpoint, deleted: true, status: http.StatusUnauthorized},
	} {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			ctx := testutil.Context(t, testutil.WaitLong)
			key, token := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID, LoginType: database.LoginTypeOAuth2ProviderApp})
			dbgen.OAuth2ProviderAppToken(t, db, database.OAuth2ProviderAppToken{
				AppID: app.ID, UserID: user.ID, APIKeyID: key.ID, HashPrefix: []byte(key.ID),
				Audience: sql.NullString{String: tc.audience, Valid: tc.audience != ""},
			})
			delegatedID := key.ID
			if tc.wrongKeyID {
				delegatedID = "other-key"
			}
			transport, closeTransport := newMCPDelegatedTransport(testutil.Logger(t), handler, origin, delegatedID)
			t.Cleanup(closeTransport)
			client := codersdk.New(origin, codersdk.WithSessionToken(token), codersdk.WithHTTPClient(&http.Client{Transport: transport}))

			if tc.expired || tc.deleted {
				// Validate once before mutation, then reuse the same private
				// transport. Internal calls must not cache authentication.
				resp, err := client.Request(ctx, http.MethodGet, "/api/v2/users/me", nil)
				require.NoError(t, err)
				require.NoError(t, resp.Body.Close())
				require.Equal(t, http.StatusNoContent, resp.StatusCode)
				if tc.deleted {
					require.NoError(t, db.DeleteAPIKeyByID(ctx, key.ID))
				} else {
					require.NoError(t, db.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{
						ID: key.ID, LastUsed: key.LastUsed, ExpiresAt: dbtime.Now().Add(-time.Hour), IPAddress: key.IPAddress,
					}))
				}
			}
			resp, err := client.Request(ctx, http.MethodGet, "/api/v2/users/me", nil)
			require.NoError(t, err)
			defer resp.Body.Close()
			require.Equal(t, tc.status, resp.StatusCode)
		})
	}
}

func TestMCPTransportIsolation(t *testing.T) {
	t.Parallel()
	ctx := testutil.Context(t, testutil.WaitLong)
	origin, err := url.Parse("https://coder.example.com")
	require.NoError(t, err)
	type callerContextKey struct{}
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		assert.Nil(t, r.Context().Value(callerContextKey{}), "caller auth and routing state must not cross the connection")
		if r.URL.Path == "/api/v2/redirect" {
			http.Redirect(w, r, "https://other.example.com/api/v2/users/me", http.StatusFound)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	})
	transport, closeTransport := newMCPDelegatedTransport(testutil.Logger(t), handler, origin, "test-key")
	t.Cleanup(closeTransport)
	client := &http.Client{Transport: transport}

	for _, target := range []string{
		"https://other.example.com/api/v2/users/me",
		"http://coder.example.com/api/v2/users/me",
		"https://coder.example.com/oauth2/tokens",
		"https://coder.example.com/api/experimental/mcp/http",
		"https://coder.example.com/api/experimental//mcp/http",
		"https://coder.example.com/api/v2/../../oauth2/tokens",
		"https://coder.example.com/api/v2/redirect",
	} {
		body := &mcpRequestBody{Reader: strings.NewReader("test")}
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, body)
		require.NoError(t, err)
		resp, err := client.Do(req)
		if resp != nil {
			_ = resp.Body.Close()
		}
		require.Error(t, err, "untrusted destination must not be reachable")
		require.True(t, body.closed.Load(), "rejected request body must be closed")
	}
	req, err := http.NewRequestWithContext(context.WithValue(ctx, callerContextKey{}, "outer-auth"), http.MethodGet, origin.String()+"/api/v2/users/me", nil)
	require.NoError(t, err)
	resp, err := client.Do(req)
	require.NoError(t, err)
	require.NoError(t, resp.Body.Close())
	require.Equal(t, http.StatusNoContent, resp.StatusCode)

	closeTransport()
	resp, err = client.Do(req)
	if resp != nil {
		_ = resp.Body.Close()
	}
	require.Error(t, err, "transport must not outlive its MCP request")
}

type mcpRequestBody struct {
	io.Reader
	closed atomic.Bool
}

func (b *mcpRequestBody) Close() error {
	b.closed.Store(true)
	return nil
}

func TestMCPTransportStreaming(t *testing.T) {
	t.Parallel()
	ctx := testutil.Context(t, testutil.WaitLong)
	origin, err := url.Parse("https://coder.example.com")
	require.NoError(t, err)
	handlerDone := make(chan struct{})
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer close(handlerDone)
		_, _ = io.WriteString(w, "first\n")
		w.(http.Flusher).Flush()
		<-r.Context().Done()
	})
	transport, closeTransport := newMCPDelegatedTransport(testutil.Logger(t), handler, origin, "test-key")
	t.Cleanup(closeTransport)
	client := &http.Client{Transport: transport}
	requestCtx, cancel := context.WithCancel(ctx)
	defer cancel()
	req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, origin.String()+"/api/v2/logs", nil)
	require.NoError(t, err)
	resp, err := client.Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()
	first := make([]byte, len("first\n"))
	_, err = io.ReadFull(resp.Body, first)
	require.NoError(t, err)
	require.Equal(t, "first\n", string(first))
	cancel()
	_, err = resp.Body.Read(first)
	require.Error(t, err)
	testutil.TryReceive(ctx, t, handlerDone)
}

func TestMCPTransportWebSocket(t *testing.T) {
	t.Parallel()
	ctx := testutil.Context(t, testutil.WaitLong)
	origin, err := url.Parse("https://coder.example.com")
	require.NoError(t, err)
	handlerDone := make(chan struct{})
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer close(handlerDone)
		conn, err := websocket.Accept(w, r, nil)
		if !assert.NoError(t, err) {
			return
		}
		defer conn.CloseNow()
		kind, data, err := conn.Read(ctx)
		if !assert.NoError(t, err) {
			return
		}
		assert.NoError(t, conn.Write(ctx, kind, data))
		_, _, err = conn.Read(ctx)
		assert.Error(t, err, "transport cleanup must close hijacked connections")
	})
	transport, closeTransport := newMCPDelegatedTransport(testutil.Logger(t), handler, origin, "test-key")
	t.Cleanup(closeTransport)
	conn, resp, err := websocket.Dial(ctx, "wss://coder.example.com/api/v2/chats/watch", &websocket.DialOptions{HTTPClient: &http.Client{Transport: transport}})
	if resp != nil && resp.Body != nil {
		defer resp.Body.Close()
	}
	require.NoError(t, err)
	defer conn.CloseNow()
	require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte("ping")))
	_, data, err := conn.Read(ctx)
	require.NoError(t, err)
	require.Equal(t, "ping", string(data))
	closeTransport()
	testutil.TryReceive(ctx, t, handlerDone)
}