Ctrl K
ComunidadGuías…

coderd/mcp_internal_test.go

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

Archivo original ↗Preguntar a KiBot
package coderd

import (
	"context"
	"encoding/json"
	"net/url"
	"sync/atomic"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/stretchr/testify/require"
	"golang.org/x/oauth2"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbauthz"
	"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/rbac"
	"github.com/coder/coder/v2/coderd/util/ptr"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/testutil"
)

// dbauthzTestStore wraps the test database with the same dbauthz layer
// used in production (coderd.go:370). Without it the test would not
// catch RBAC failures from the chatd subject; with it the test fails
// loudly if the elevation in OIDCAccessToken is removed or weakened.
func dbauthzTestStore(t *testing.T, db database.Store) database.Store {
	t.Helper()

	authz := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
	acs := &atomic.Pointer[dbauthz.AccessControlStore]{}
	var tacs dbauthz.AccessControlStore = fakeAccessControlStore{}
	acs.Store(&tacs)
	return dbauthz.New(db, authz, testutil.Logger(t), acs)
}

// fakeAccessControlStore mirrors coderdtest.FakeAccessControlStore but is
// inlined here to avoid an import cycle (coderdtest imports coderd).
type fakeAccessControlStore struct{}

func (fakeAccessControlStore) GetTemplateAccessControl(t database.Template) dbauthz.TemplateAccessControl {
	return dbauthz.TemplateAccessControl{
		RequireActiveVersion: t.RequireActiveVersion,
	}
}

func (fakeAccessControlStore) SetTemplateAccessControl(context.Context, database.Store, uuid.UUID, dbauthz.TemplateAccessControl) error {
	panic("not implemented")
}

func TestShouldRefreshOIDCToken(t *testing.T) {
	t.Parallel()

	now := dbtime.Now()
	cases := []struct {
		name string
		link database.UserLink
		want bool
	}{
		{
			name: "NoRefreshToken",
			link: database.UserLink{OAuthExpiry: now.Add(-time.Hour)},
		},
		{
			name: "ZeroExpiry",
			link: database.UserLink{OAuthRefreshToken: "refresh"},
		},
		{
			name: "Expired",
			link: database.UserLink{
				OAuthRefreshToken: "refresh",
				OAuthExpiry:       now.Add(-time.Hour),
			},
			want: true,
		},
		{
			name: "Fresh",
			link: database.UserLink{
				OAuthRefreshToken: "refresh",
				OAuthExpiry:       now.Add(time.Hour),
			},
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got, _ := shouldRefreshOIDCToken(tc.link)
			require.Equal(t, tc.want, got)
		})
	}
}

func TestOIDCMCPTokenSource(t *testing.T) {
	t.Parallel()

	logger := testutil.Logger(t)

	t.Run("NilConfig", func(t *testing.T) {
		t.Parallel()
		db, _ := dbtestutil.NewDB(t)
		require.Nil(t, newOIDCMCPTokenSource(db, nil, logger))
	})

	t.Run("NoLink", func(t *testing.T) {
		// When the user has no OIDC link the source returns ("", nil)
		// rather than an error so the caller can fall through to
		// "no Authorization header".
		t.Parallel()
		db, _ := dbtestutil.NewDB(t)
		store := dbauthzTestStore(t, db)
		user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})

		src := newOIDCMCPTokenSource(store, &testutil.OAuth2Config{}, logger)
		ctx := dbauthz.AsChatd(context.Background())

		tok, err := src.OIDCAccessToken(ctx, user.ID)
		require.NoError(t, err)
		require.Empty(t, tok)
	})

	t.Run("FreshToken", func(t *testing.T) {
		// A non-expired token is returned as-is; no refresh is performed.
		t.Parallel()
		db, _ := dbtestutil.NewDB(t)
		store := dbauthzTestStore(t, db)
		user := dbgen.User(t, db, database.User{})
		dbgen.UserLink(t, db, database.UserLink{
			UserID:            user.ID,
			LoginType:         database.LoginTypeOIDC,
			OAuthAccessToken:  "fresh",
			OAuthRefreshToken: "refresh",
			OAuthExpiry:       dbtime.Now().Add(time.Hour),
		})

		src := newOIDCMCPTokenSource(store, &testutil.OAuth2Config{
			Token: &oauth2.Token{AccessToken: "should-not-be-used"},
		}, logger)
		ctx := dbauthz.AsChatd(context.Background())

		tok, err := src.OIDCAccessToken(ctx, user.ID)
		require.NoError(t, err)
		require.Equal(t, "fresh", tok)
	})

	t.Run("RefreshExpired", func(t *testing.T) {
		// An expired token triggers a refresh; the new token is
		// persisted via UpdateUserLink. This exercises the dbauthz
		// elevation: chatd lacks ResourceSystem.Read and
		// ResourceUser.UpdatePersonal so a non-elevated context
		// would fail both reads and writes.
		t.Parallel()
		db, _ := dbtestutil.NewDB(t)
		store := dbauthzTestStore(t, db)
		user := dbgen.User(t, db, database.User{})
		dbgen.UserLink(t, db, database.UserLink{
			UserID:            user.ID,
			LoginType:         database.LoginTypeOIDC,
			OAuthAccessToken:  "stale",
			OAuthRefreshToken: "refresh",
			OAuthExpiry:       dbtime.Now().Add(-time.Hour),
		})

		src := newOIDCMCPTokenSource(store, &testutil.OAuth2Config{
			Token: &oauth2.Token{
				AccessToken:  "fresh",
				RefreshToken: "new-refresh",
				Expiry:       dbtime.Now().Add(time.Hour),
			},
		}, logger)
		ctx := dbauthz.AsChatd(context.Background())

		tok, err := src.OIDCAccessToken(ctx, user.ID)
		require.NoError(t, err)
		require.Equal(t, "fresh", tok)

		// Verify the refresh was persisted via UpdateUserLink.
		got, err := db.GetUserLinkByUserIDLoginType(
			dbauthz.AsSystemRestricted(context.Background()),
			database.GetUserLinkByUserIDLoginTypeParams{
				UserID:    user.ID,
				LoginType: database.LoginTypeOIDC,
			},
		)
		require.NoError(t, err)
		require.Equal(t, "fresh", got.OAuthAccessToken)
		require.Equal(t, "new-refresh", got.OAuthRefreshToken)
	})

	t.Run("RefreshFailureReturnsEmpty", func(t *testing.T) {
		// A refresh attempt that fails (e.g. invalid client config)
		// must not surface an error to the caller; per the
		// UserOIDCTokenSource contract this is treated as "no
		// Authorization header".
		t.Parallel()
		db, _ := dbtestutil.NewDB(t)
		store := dbauthzTestStore(t, db)
		user := dbgen.User(t, db, database.User{})
		dbgen.UserLink(t, db, database.UserLink{
			UserID:            user.ID,
			LoginType:         database.LoginTypeOIDC,
			OAuthAccessToken:  "stale",
			OAuthRefreshToken: "refresh",
			OAuthExpiry:       dbtime.Now().Add(-time.Hour),
		})

		// An empty oauth2.Config triggers a refresh failure
		// because it has no token endpoint to call.
		src := newOIDCMCPTokenSource(store, &oauth2.Config{}, logger)
		ctx := dbauthz.AsChatd(context.Background())

		tok, err := src.OIDCAccessToken(ctx, user.ID)
		require.NoError(t, err)
		require.Empty(t, tok)
	})
}

func TestProtectedResourceMetadataResourceUnmarshal(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name    string
		input   string
		want    resourceIdentifiers
		wantErr bool
	}{
		{
			name:  "String",
			input: `{"resource": "https://gitlab.example.com/api/v4/mcp", "authorization_servers": ["https://gitlab.example.com"]}`,
			want:  resourceIdentifiers{"https://gitlab.example.com/api/v4/mcp"},
		},
		{
			// GitLab's official MCP server returns an array of
			// resources despite RFC 9728 defining a string.
			name:  "Array",
			input: `{"resource": ["https://gitlab.example.com/api/v4/mcp", "https://gitlab.example.com/api/v4/orbit/mcp"], "authorization_servers": ["https://gitlab.example.com"]}`,
			want: resourceIdentifiers{
				"https://gitlab.example.com/api/v4/mcp",
				"https://gitlab.example.com/api/v4/orbit/mcp",
			},
		},
		{
			name:  "Absent",
			input: `{"authorization_servers": ["https://gitlab.example.com"]}`,
			want:  nil,
		},
		{
			name:    "InvalidType",
			input:   `{"resource": 42, "authorization_servers": ["https://gitlab.example.com"]}`,
			wantErr: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			var meta protectedResourceMetadata
			err := json.Unmarshal([]byte(tt.input), &meta)
			if tt.wantErr {
				require.Error(t, err)
				return
			}
			require.NoError(t, err)
			require.Equal(t, tt.want, meta.Resource)
		})
	}
}

func TestMCPOAuth2ResourceIndicator(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name       string
		serverURL  string
		advertised []string
		want       string
	}{
		{
			name:       "ExactMatch",
			serverURL:  "https://mcp.agent.dagster.cloud/mcp",
			advertised: []string{"https://mcp.agent.dagster.cloud/mcp"},
			want:       "https://mcp.agent.dagster.cloud/mcp",
		},
		{
			name:       "OriginCoversPath",
			serverURL:  "https://mcp.example.com/v1/mcp",
			advertised: []string{"https://mcp.example.com"},
			want:       "https://mcp.example.com",
		},
		{
			name:       "CaseInsensitiveOriginKeptVerbatim",
			serverURL:  "https://mcp.example.com/mcp",
			advertised: []string{"HTTPS://MCP.Example.com/mcp"},
			want:       "HTTPS://MCP.Example.com/mcp",
		},
		{
			name:       "PathCoversSubpath",
			serverURL:  "https://mcp.example.com/mcp/x",
			advertised: []string{"https://mcp.example.com/mcp"},
			want:       "https://mcp.example.com/mcp",
		},
		{
			name:       "TrailingSlashResourceDoesNotCoverBarePath",
			serverURL:  "https://mcp.example.com/mcp",
			advertised: []string{"https://mcp.example.com/mcp/"},
			want:       "https://mcp.example.com/mcp",
		},
		{
			name:       "EmptyPathEqualsSlash",
			serverURL:  "https://mcp.example.com",
			advertised: []string{"https://mcp.example.com/"},
			want:       "https://mcp.example.com/",
		},
		{
			name:       "PathPrefixIsNotSegment",
			serverURL:  "https://mcp.example.com/mcp2",
			advertised: []string{"https://mcp.example.com/mcp"},
			want:       "https://mcp.example.com/mcp2",
		},
		{
			name:       "OtherOriginFallsBack",
			serverURL:  "https://mcp.example.com/mcp",
			advertised: []string{"https://other.example.com/mcp", "http://mcp.example.com/mcp", "https://mcp.example.com:8443/mcp"},
			want:       "https://mcp.example.com/mcp",
		},
		{
			name:      "MostSpecificWins",
			serverURL: "https://mcp.example.com/a/b/c",
			advertised: []string{
				"https://mcp.example.com/",
				"https://mcp.example.com/a/b",
				"https://mcp.example.com/a",
			},
			want: "https://mcp.example.com/a/b",
		},
		{
			name:      "QueryMustMatch",
			serverURL: "https://mcp.example.com/mcp?tenant=b",
			advertised: []string{
				"https://mcp.example.com/mcp?tenant=a",
				"https://mcp.example.com/mcp?tenant=b",
			},
			want: "https://mcp.example.com/mcp?tenant=b",
		},
		{
			name:       "MatchingQueryBeatsNoQuery",
			serverURL:  "https://mcp.example.com/mcp?tenant=b",
			advertised: []string{"https://mcp.example.com/mcp", "https://mcp.example.com/mcp?tenant=b"},
			want:       "https://mcp.example.com/mcp?tenant=b",
		},
		{
			name:       "NoQueryCoversQuery",
			serverURL:  "https://mcp.example.com/mcp?tenant=a",
			advertised: []string{"https://mcp.example.com/mcp"},
			want:       "https://mcp.example.com/mcp",
		},
		{
			name:       "EmptyQueryIsNotOtherQuery",
			serverURL:  "https://mcp.example.com/mcp?tenant=b",
			advertised: []string{"https://mcp.example.com/mcp?"},
			want:       "https://mcp.example.com/mcp?tenant=b",
		},
		{
			name:       "EmptyQueryIsNotNoQuery",
			serverURL:  "https://mcp.example.com/mcp",
			advertised: []string{"https://mcp.example.com/mcp?"},
			want:       "https://mcp.example.com/mcp",
		},
		{
			name:       "ExplicitDefaultPortOnServer",
			serverURL:  "https://mcp.example.com:443/mcp",
			advertised: []string{"https://mcp.example.com/mcp"},
			want:       "https://mcp.example.com/mcp",
		},
		{
			name:       "ExplicitDefaultPortOnResource",
			serverURL:  "http://mcp.example.com/mcp",
			advertised: []string{"http://mcp.example.com:80/mcp"},
			want:       "http://mcp.example.com:80/mcp",
		},
		{
			name:       "PortLeadingZerosAreIgnored",
			serverURL:  "https://mcp.example.com:0443/mcp",
			advertised: []string{"https://mcp.example.com/mcp"},
			want:       "https://mcp.example.com/mcp",
		},
		{
			name:       "EscapedSlashIsNotSegmentBoundary",
			serverURL:  "https://mcp.example.com/mcp%2Ftenant",
			advertised: []string{"https://mcp.example.com/mcp"},
			want:       "https://mcp.example.com/mcp%2Ftenant",
		},
		{
			name:       "EscapeHexCaseIsEquivalent",
			serverURL:  "https://mcp.example.com/mcp%2ftenant",
			advertised: []string{"https://mcp.example.com/mcp%2Ftenant"},
			want:       "https://mcp.example.com/mcp%2Ftenant",
		},
		{
			name:       "EscapedUnreservedIsEquivalent",
			serverURL:  "https://mcp.example.com/mcp",
			advertised: []string{"https://mcp.example.com/%6Dcp"},
			want:       "https://mcp.example.com/%6Dcp",
		},
		{
			name:       "EscapedQueryIsEquivalent",
			serverURL:  "https://mcp.example.com/mcp?tenant=%61",
			advertised: []string{"https://mcp.example.com/mcp?tenant=a"},
			want:       "https://mcp.example.com/mcp?tenant=a",
		},
		{
			name:       "DotSegmentsAreRemoved",
			serverURL:  "https://mcp.example.com/a/../mcp",
			advertised: []string{"https://mcp.example.com/mcp"},
			want:       "https://mcp.example.com/mcp",
		},
		{
			name:       "EscapedDotSegmentsAreRemoved",
			serverURL:  "https://mcp.example.com/mcp",
			advertised: []string{"https://mcp.example.com/a/%2E%2E/mcp"},
			want:       "https://mcp.example.com/a/%2E%2E/mcp",
		},
		{
			name:      "InvalidFallbackIsEmpty",
			serverURL: "https://mcp.example.com/a[b]",
			want:      "",
		},
		{
			name:       "IPLiteralHost",
			serverURL:  "https://[::1]:8443/mcp",
			advertised: []string{"https://[::1]:8443/"},
			want:       "https://[::1]:8443/",
		},
		{
			name:       "EquivalentIPv6Literal",
			serverURL:  "https://[0:0:0:0:0:0:0:1]/mcp",
			advertised: []string{"https://[::1]/mcp"},
			want:       "https://[::1]/mcp",
		},
		{
			name:      "NoEntriesFallsBack",
			serverURL: "https://mcp.example.com/mcp",
			want:      "https://mcp.example.com/mcp",
		},
		{
			name:      "FallbackIsCanonical",
			serverURL: "HTTPS://user:pass@MCP.Example.COM:8443/Path/MCP?tenant=Acme#frag",
			want:      "https://mcp.example.com:8443/Path/MCP?tenant=Acme",
		},
		{
			name:       "InvalidEntriesIgnored",
			serverURL:  "https://mcp.example.com/mcp",
			advertised: []string{"https://mcp.example.com/#frag", "/mcp", "", "  "},
			want:       "https://mcp.example.com/mcp",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			serverURL, err := url.Parse(tc.serverURL)
			require.NoError(t, err)
			require.Equal(t, tc.want, mcpOAuth2ResourceIndicator(serverURL, tc.advertised))
		})
	}
}

func TestPlanMCPServerConfigWrite(t *testing.T) {
	t.Parallel()

	const (
		serverURL = "https://mcp.example.com/mcp"
		otherURL  = "https://other.example.com/mcp"
		manual    = string(codersdk.MCPServerOAuth2ClientRegistrationManual)
		dynamic   = string(codersdk.MCPServerOAuth2ClientRegistrationDynamic)
	)
	noAuth := database.MCPServerConfig{
		Url:                      serverURL,
		AuthType:                 "none",
		OAuth2ClientRegistration: manual,
		CustomHeaders:            "{}",
	}
	manualOAuth2 := noAuth
	manualOAuth2.AuthType = "oauth2"
	manualOAuth2.OAuth2ClientID = "manual-client"
	manualOAuth2.OAuth2AuthURL = "https://auth.example.com/authorize"
	manualOAuth2.OAuth2TokenURL = "https://auth.example.com/token"
	dynamicOAuth2 := manualOAuth2
	dynamicOAuth2.OAuth2ClientID = "dynamic-client"
	dynamicOAuth2.OAuth2ClientRegistration = dynamic
	// A row that an update saved as oauth2 without a client before
	// updates could register one.
	brokenOAuth2 := noAuth
	brokenOAuth2.AuthType = "oauth2"

	cases := []struct {
		name            string
		prev            database.MCPServerConfig
		req             codersdk.UpdateMCPServerConfigRequest
		wantMode        string
		wantRegisterFor string
		wantClientID    string
		wantErr         string
	}{
		{
			name: "CreateWithClientIsManual",
			req: mcpServerConfigChangeFromCreate(codersdk.CreateMCPServerConfigRequest{
				URL:            serverURL,
				AuthType:       "oauth2",
				OAuth2ClientID: "manual-client",
				OAuth2AuthURL:  "https://auth.example.com/authorize",
				OAuth2TokenURL: "https://auth.example.com/token",
			}),
			wantMode: manual,
		},
		{
			name: "CreateWithoutClientRegisters",
			req: mcpServerConfigChangeFromCreate(codersdk.CreateMCPServerConfigRequest{
				URL:      serverURL,
				AuthType: "oauth2",
			}),
			wantMode:        dynamic,
			wantRegisterFor: serverURL,
		},
		{
			name:            "NoneToOAuth2Registers",
			prev:            noAuth,
			req:             codersdk.UpdateMCPServerConfigRequest{AuthType: ptr.Ref("oauth2"), OAuth2ClientID: ptr.Ref("")},
			wantMode:        dynamic,
			wantRegisterFor: serverURL,
		},
		{
			name:            "BrokenOAuth2Registers",
			prev:            brokenOAuth2,
			req:             codersdk.UpdateMCPServerConfigRequest{URL: ptr.Ref(serverURL)},
			wantMode:        dynamic,
			wantRegisterFor: serverURL,
		},
		{
			name:     "EnabledOnlySkipsAuth",
			prev:     brokenOAuth2,
			req:      codersdk.UpdateMCPServerConfigRequest{Enabled: ptr.Ref(false)},
			wantMode: manual,
		},
		{
			name:            "ManualToDynamicRegisters",
			prev:            manualOAuth2,
			req:             codersdk.UpdateMCPServerConfigRequest{OAuth2ClientRegistration: ptr.Ref(codersdk.MCPServerOAuth2ClientRegistrationDynamic)},
			wantMode:        dynamic,
			wantRegisterFor: serverURL,
		},
		{
			name:            "ManualClearedClientRegisters",
			prev:            manualOAuth2,
			req:             codersdk.UpdateMCPServerConfigRequest{OAuth2ClientID: ptr.Ref(""), OAuth2AuthURL: ptr.Ref(""), OAuth2TokenURL: ptr.Ref("")},
			wantMode:        dynamic,
			wantRegisterFor: serverURL,
		},
		{
			name:     "ManualURLChangeKeepsClient",
			prev:     manualOAuth2,
			req:      codersdk.UpdateMCPServerConfigRequest{URL: ptr.Ref(otherURL)},
			wantMode: manual,
		},
		{
			name:            "DynamicURLChangeRegisters",
			prev:            dynamicOAuth2,
			req:             codersdk.UpdateMCPServerConfigRequest{URL: ptr.Ref(otherURL)},
			wantMode:        dynamic,
			wantRegisterFor: otherURL,
		},
		{
			name: "DynamicRepeatedClientKeepsClient",
			prev: dynamicOAuth2,
			req: codersdk.UpdateMCPServerConfigRequest{
				URL:            ptr.Ref(serverURL),
				OAuth2ClientID: ptr.Ref(dynamicOAuth2.OAuth2ClientID),
				OAuth2AuthURL:  ptr.Ref(dynamicOAuth2.OAuth2AuthURL),
				OAuth2TokenURL: ptr.Ref(dynamicOAuth2.OAuth2TokenURL),
			},
			wantMode: dynamic,
		},
		{
			name:         "DynamicClearedClientKeepsClient",
			prev:         dynamicOAuth2,
			req:          codersdk.UpdateMCPServerConfigRequest{OAuth2ClientID: ptr.Ref("")},
			wantMode:     dynamic,
			wantClientID: dynamicOAuth2.OAuth2ClientID,
		},
		{
			name:     "DynamicToManualKeepsClient",
			prev:     dynamicOAuth2,
			req:      codersdk.UpdateMCPServerConfigRequest{OAuth2ClientRegistration: ptr.Ref(codersdk.MCPServerOAuth2ClientRegistrationManual)},
			wantMode: manual,
		},
		{
			name:    "PartialManualClient",
			prev:    noAuth,
			req:     codersdk.UpdateMCPServerConfigRequest{AuthType: ptr.Ref("oauth2"), OAuth2ClientID: ptr.Ref("manual-client")},
			wantErr: "automatic discovery",
		},
		{
			name:    "DynamicRejectsClientChange",
			prev:    dynamicOAuth2,
			req:     codersdk.UpdateMCPServerConfigRequest{OAuth2ClientID: ptr.Ref("other-client")},
			wantErr: "Set oauth2_client_registration to manual",
		},
		{
			name: "ManualToDynamicRejectsClient",
			prev: manualOAuth2,
			req: codersdk.UpdateMCPServerConfigRequest{
				OAuth2ClientRegistration: ptr.Ref(codersdk.MCPServerOAuth2ClientRegistrationDynamic),
				OAuth2ClientID:           ptr.Ref("other-client"),
			},
			wantErr: "Set oauth2_client_registration to manual",
		},
		{
			name:    "APIKeyWithoutValue",
			prev:    noAuth,
			req:     codersdk.UpdateMCPServerConfigRequest{AuthType: ptr.Ref("api_key"), APIKeyHeader: ptr.Ref("X-Api-Key")},
			wantErr: "requires api_key_header and api_key_value",
		},
		{
			name:    "CustomHeadersWithoutHeader",
			prev:    noAuth,
			req:     codersdk.UpdateMCPServerConfigRequest{AuthType: ptr.Ref("custom_headers")},
			wantErr: "requires at least one custom header",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			w, err := planMCPServerConfigWrite(tc.prev, tc.req, "")
			if tc.wantErr != "" {
				require.ErrorContains(t, err, tc.wantErr)
				return
			}
			require.NoError(t, err)
			require.Equal(t, tc.wantMode, w.next.OAuth2ClientRegistration)
			require.Equal(t, tc.wantRegisterFor, w.registerFor)
			if tc.wantClientID != "" {
				require.Equal(t, tc.wantClientID, w.next.OAuth2ClientID)
			}
		})
	}
}

func TestMCPServerConfigWriteWithRegistration(t *testing.T) {
	t.Parallel()

	const (
		serverURL = "https://mcp.example.com/mcp"
		otherURL  = "https://other.example.com/mcp"
		clientID  = "dynamic-client"
	)
	next := database.MCPServerConfig{
		Url:                      serverURL,
		AuthType:                 "oauth2",
		OAuth2ClientRegistration: string(codersdk.MCPServerOAuth2ClientRegistrationDynamic),
	}
	registrationFor := func(u string) *mcpOAuth2Discovery {
		return &mcpOAuth2Discovery{
			serverURL: u,
			clientID:  clientID,
			authURL:   "https://auth.example.com/authorize",
			tokenURL:  "https://auth.example.com/token",
		}
	}

	cases := []struct {
		name         string
		registerFor  string
		registration *mcpOAuth2Discovery
		wantClientID string
		wantConflict bool
	}{
		{
			name: "NoRegistration",
		},
		{
			name:         "MatchingRegistration",
			registerFor:  serverURL,
			registration: registrationFor(serverURL),
			wantClientID: clientID,
		},
		{
			name:         "MissingRegistration",
			registerFor:  serverURL,
			wantConflict: true,
		},
		{
			name:         "RegistrationForOtherURL",
			registerFor:  serverURL,
			registration: registrationFor(otherURL),
			wantConflict: true,
		},
		{
			name:         "UnneededRegistration",
			registration: registrationFor(serverURL),
			wantConflict: true,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			w := mcpServerConfigWrite{next: next, registerFor: tc.registerFor}
			got, err := w.withRegistration(database.MCPServerConfig{}, codersdk.UpdateMCPServerConfigRequest{}, tc.registration)
			if tc.wantConflict {
				require.ErrorIs(t, err, errMCPConfigChangedDuringRegistration)
				return
			}
			require.NoError(t, err)
			require.Equal(t, serverURL, got.Url)
			require.Equal(t, tc.wantClientID, got.OAuth2ClientID)
		})
	}
}