package coderd_test import ( "encoding/json" "io" "net/http" "strings" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) // keyIDs extracts the IDs from a slice of AIProviderKey responses, in // order, to make assertions on key-set membership easier to read. func keyIDs(keys []codersdk.AIProviderKey) []uuid.UUID { out := make([]uuid.UUID, len(keys)) for i, k := range keys { out[i] = k.ID } return out } func TestAIProvidersCRUD(t *testing.T) { t.Parallel() t.Run("EmptyList", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. got, err := client.AIProviders(ctx) require.NoError(t, err) require.Empty(t, got) }) t.Run("CreateGetUpdateDelete", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // Create. req := codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeAnthropic, Name: "primary-anthropic", DisplayName: "Primary Anthropic", Enabled: true, BaseURL: "https://api.anthropic.com/", Settings: codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-east-1", }, }, } //nolint:gocritic // Owner role is the audience for this endpoint. created, err := client.CreateAIProvider(ctx, req) require.NoError(t, err) require.NotEqual(t, [16]byte{}, created.ID) require.Equal(t, req.Type, created.Type) require.Equal(t, req.Name, created.Name) require.Equal(t, req.DisplayName, created.DisplayName) require.Equal(t, req.Enabled, created.Enabled) require.Equal(t, req.BaseURL, created.BaseURL) require.NotNil(t, created.Settings.Bedrock) require.Equal(t, req.Settings.Bedrock.Region, created.Settings.Bedrock.Region) // Get by ID. gotByID, err := client.AIProvider(ctx, created.ID.String()) require.NoError(t, err) require.Equal(t, created.ID, gotByID.ID) // Get by name. gotByName, err := client.AIProvider(ctx, created.Name) require.NoError(t, err) require.Equal(t, created.ID, gotByName.ID) // List. list, err := client.AIProviders(ctx) require.NoError(t, err) require.Len(t, list, 1) require.Equal(t, created.ID, list[0].ID) // Update. newDisplay := "Updated Display" newURL := "https://api.anthropic.com/v1" disabled := false updated, err := client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ DisplayName: &newDisplay, BaseURL: &newURL, Enabled: &disabled, Settings: &codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-west-2", Model: "anthropic.claude-3-5-sonnet", }, }, }) require.NoError(t, err) require.Equal(t, newDisplay, updated.DisplayName) require.Equal(t, newURL, updated.BaseURL) require.False(t, updated.Enabled) require.NotNil(t, updated.Settings.Bedrock) require.Equal(t, "us-west-2", updated.Settings.Bedrock.Region) require.Equal(t, "anthropic.claude-3-5-sonnet", updated.Settings.Bedrock.Model) // Delete. err = client.DeleteAIProvider(ctx, created.ID.String()) require.NoError(t, err) // Subsequent get returns 404. _, err = client.AIProvider(ctx, created.ID.String()) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Resource not found") // List excludes the deleted provider. list, err = client.AIProviders(ctx) require.NoError(t, err) require.Empty(t, list) // Soft-deleted rows do not block name reuse: the unique index // is partial on deleted = FALSE, so re-creating the same name // succeeds and produces a new row with a different id. recreated, err := client.CreateAIProvider(ctx, req) require.NoError(t, err) require.NotEqual(t, created.ID, recreated.ID) require.Equal(t, req.Name, recreated.Name) }) t.Run("DefaultDisplayName", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "no-display", Enabled: true, BaseURL: "https://api.openai.com/v1", }) require.NoError(t, err) // Server falls back to Name when DisplayName is empty. require.Equal(t, "no-display", created.DisplayName) }) t.Run("RequiredBaseURL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "missing-base-url", Enabled: true, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid AI provider request.", sdkErr.Message) require.Contains(t, sdkErr.Validations, codersdk.ValidationError{Field: "base_url", Detail: "base_url is required"}) created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "required-base-url", Enabled: true, BaseURL: "https://api.openai.com/v1", }) require.NoError(t, err) baseURL := "https://proxy.example.com/v1" updated, err := client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ BaseURL: &baseURL, }) require.NoError(t, err) require.Equal(t, baseURL, updated.BaseURL) baseURL = "" _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ BaseURL: &baseURL, }) sdkErr = requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid AI provider request.", sdkErr.Message) require.Contains(t, sdkErr.Validations, codersdk.ValidationError{Field: "base_url", Detail: "base_url is required"}) }) t.Run("DuplicateNameConflict", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) req := codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "duplicate", Enabled: true, BaseURL: "https://api.openai.com/v1", } //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, req) require.NoError(t, err) _, err = client.CreateAIProvider(ctx, req) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusConflict, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, `"duplicate"`) require.Contains(t, sdkErr.Message, "already exists") }) t.Run("InvalidName", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // Invalid character in name. //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "Bad_Name", Enabled: true, BaseURL: "https://api.openai.com/v1", }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid AI provider request") require.Len(t, sdkErr.Validations, 1) require.Equal(t, "name", sdkErr.Validations[0].Field) }) t.Run("InvalidType", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: "nope", Name: "nope", Enabled: true, BaseURL: "https://api.example.com", }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid AI provider request") require.Len(t, sdkErr.Validations, 1) require.Equal(t, "type", sdkErr.Validations[0].Field) require.Contains(t, sdkErr.Validations[0].Detail, `"nope"`) }) t.Run("InvalidBaseURL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "bad-url", Enabled: true, BaseURL: "not-a-url", }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid AI provider request") require.Len(t, sdkErr.Validations, 1) require.Equal(t, "base_url", sdkErr.Validations[0].Field) require.Contains(t, sdkErr.Validations[0].Detail, "absolute URL") _, err = client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "bad-scheme", Enabled: true, BaseURL: "ftp://api.example.com", }) require.Error(t, err) require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid AI provider request") require.Len(t, sdkErr.Validations, 1) require.Equal(t, "base_url", sdkErr.Validations[0].Field) require.Contains(t, sdkErr.Validations[0].Detail, "http or https") }) t.Run("UpdateNoFields", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "patchable", Enabled: true, BaseURL: "https://api.openai.com/v1", }) require.NoError(t, err) _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{}) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "At least one field must be provided") }) t.Run("UpdateCannotMutateName", func(t *testing.T) { t.Parallel() // ai_providers.name is the stable key that aibridge_interceptions // snapshots into provider_name. Renames would silently desync // historical interceptions from their live row and break the // future FK backfill, so the PATCH endpoint must ignore any "name" // field in the payload. The SDK type intentionally has no Name // field; this test sends raw JSON to defend against a future // regression where someone adds one without thinking. client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "stable-name", Enabled: true, BaseURL: "https://api.openai.com/v1", }) require.NoError(t, err) res, err := client.Request(ctx, http.MethodPatch, "/api/v2/ai/providers/"+created.Name, json.RawMessage(`{"name":"renamed","display_name":"New Display"}`), ) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) got, err := client.AIProvider(ctx, created.Name) require.NoError(t, err) require.Equal(t, "stable-name", got.Name, "name must not be mutable via PATCH") require.Equal(t, "New Display", got.DisplayName, "display_name should still update") // Confirm the original name still resolves and the attempted new // name does not exist as a separate row. _, err = client.AIProvider(ctx, "renamed") require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) t.Run("UpdateSettingsEmptyObjectRejected", func(t *testing.T) { t.Parallel() // "settings": {} cannot decode because the _type discriminator // is missing. The handler must reject with 400; nothing about // the provider should change. client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "patch-settings-empty", Enabled: true, BaseURL: "https://api.openai.com/v1", }) require.NoError(t, err) res, err := client.Request(ctx, http.MethodPatch, "/api/v2/ai/providers/"+created.Name, json.RawMessage(`{"settings":{}}`), ) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusBadRequest, res.StatusCode) var body codersdk.Response require.NoError(t, json.NewDecoder(res.Body).Decode(&body)) require.Contains(t, body.Message, "valid JSON") require.Contains(t, body.Detail, "_type discriminator") }) t.Run("NotFound", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.AIProvider(ctx, "missing") require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Resource not found") err = client.DeleteAIProvider(ctx, "missing") require.Error(t, err) require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Resource not found") }) t.Run("ListExcludesDeletedProviderKeys", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // A soft-deleted provider's keys must not bleed into the list // response. Create one provider, delete it, then create a // second; the list should only contain the live one with its // own keys. //nolint:gocritic // Owner role is the audience for this endpoint. deleted, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "list-deleted", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{"sk-openai-deleted-qqqqqqqqqqqqqqqqqq"}, //nolint:gosec // test fixture }) require.NoError(t, err) err = client.DeleteAIProvider(ctx, deleted.ID.String()) require.NoError(t, err) live, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "list-live", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{"sk-openai-live-rrrrrrrrrrrrrrrrrr"}, //nolint:gosec // test fixture }) require.NoError(t, err) list, err := client.AIProviders(ctx) require.NoError(t, err) require.Len(t, list, 1) require.Equal(t, live.ID, list[0].ID) require.Len(t, list[0].APIKeys, 1) require.Equal(t, live.APIKeys[0].ID, list[0].APIKeys[0].ID) }) t.Run("LookupInvalidName", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // A string that is neither a UUID nor a syntactically-valid // provider name must surface a 400, not a misleading 404. //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.AIProvider(ctx, "Bad_Name") require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid provider id or name") err = client.DeleteAIProvider(ctx, "Bad_Name") require.Error(t, err) require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid provider id or name") }) t.Run("Unauthenticated", func(t *testing.T) { t.Parallel() ownerClient := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, ownerClient) ctx := testutil.Context(t, testutil.WaitLong) anon := codersdk.New(ownerClient.URL) _, err := anon.AIProviders(ctx) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode()) require.NotEmpty(t, sdkErr.Message) }) t.Run("BedrockSettingsRequireAnthropic", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // Create: OpenAI-typed provider with Bedrock settings is a type // mismatch and must be rejected so the runtime never silently // drops the operator's authentication intent. //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "bedrock-on-openai", Enabled: true, BaseURL: "https://api.openai.com/v1", Settings: codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-east-1", AccessKey: ptr.Ref("AKIA-fixture"), //nolint:gosec // test fixture AccessKeySecret: ptr.Ref("bedrock-fixture"), //nolint:gosec // test fixture }, }, }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid AI provider request") require.NotEmpty(t, sdkErr.Validations) require.Equal(t, "settings", sdkErr.Validations[0].Field) require.Contains(t, sdkErr.Validations[0].Detail, "bedrock settings are only valid for type=anthropic") // Update: existing OpenAI provider patched with Bedrock settings // must also be rejected. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "openai-then-bedrock", Enabled: true, BaseURL: "https://api.openai.com/v1", }) require.NoError(t, err) _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ Settings: &codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1"}, }, }) require.Error(t, err) require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Bedrock settings are only valid for type=anthropic") }) t.Run("BedrockSecretsHidden", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // Bedrock providers carry their AWS access key + secret inside the // encrypted settings blob. The response never echoes those fields // back, so callers cannot recover them after creation. //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeAnthropic, Name: "bedrock-secret-leak", Enabled: true, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", Settings: codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-east-1", Model: "anthropic.claude-3-5-sonnet", AccessKey: ptr.Ref("AKIA-leak"), //nolint:gosec // test fixture, not a real credential AccessKeySecret: ptr.Ref("bedrock-supersecret"), }, }, }) require.NoError(t, err) res, err := client.Request(ctx, http.MethodGet, "/api/v2/ai/providers/bedrock-secret-leak", nil) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) bodyBytes, err := io.ReadAll(res.Body) require.NoError(t, err) body := string(bodyBytes) require.NotContains(t, body, "AKIA-leak") require.NotContains(t, body, "bedrock-supersecret") require.NotContains(t, body, `"access_key"`) require.NotContains(t, body, `"access_key_secret"`) }) } func TestAIProvidersKeyManagement(t *testing.T) { t.Parallel() t.Run("CreateWithKeysReturnsMasked", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) const ( primary = "sk-openai-primary-fixture-aaaaaa" //nolint:gosec // test fixture, not a real credential secondary = "sk-openai-secondary-fixture-bbbbbb" //nolint:gosec // test fixture, not a real credential ) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-openai", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{primary, secondary}, }) require.NoError(t, err) require.Len(t, provider.APIKeys, 2) // Masked form preserves prefix and suffix while hiding the // middle, so it's enough for an operator to recognize the key // without recovering the plaintext. require.True(t, strings.HasPrefix(provider.APIKeys[0].Masked, "sk-o")) require.True(t, strings.HasSuffix(provider.APIKeys[0].Masked, "aaaa")) require.NotContains(t, provider.APIKeys[0].Masked, primary) require.NotContains(t, provider.APIKeys[1].Masked, secondary) require.NotEqual(t, uuid.Nil, provider.APIKeys[0].ID) require.NotEqual(t, uuid.Nil, provider.APIKeys[1].ID) }) t.Run("ResponseHidesPlaintext", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) const plaintext = "sk-openai-extra-secret-cccccccccccc" //nolint:gosec // test fixture, not a real credential //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-secret", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{plaintext}, }) require.NoError(t, err) // Inspect the raw HTTP body of the GET response. The masked // form must replace the plaintext entirely on the wire. res, err := client.Request(ctx, http.MethodGet, "/api/v2/ai/providers/keys-secret", nil) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) bodyBytes, err := io.ReadAll(res.Body) require.NoError(t, err) require.NotContains(t, string(bodyBytes), plaintext) }) t.Run("UpdateReplacesKeys", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-replace", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{"sk-openai-original-ddddddddddddddd"}, //nolint:gosec // test fixture, not a real credential }) require.NoError(t, err) require.Len(t, provider.APIKeys, 1) originalID := provider.APIKeys[0].ID // Omitting the original ID from the mutation list deletes it; // the two APIKey-bearing entries add fresh rows. replacement := []codersdk.AIProviderKeyMutation{ {APIKey: ptr.Ref("sk-openai-rotated-eeeeeeeeeeeeeeeeeee")}, //nolint:gosec // test fixture {APIKey: ptr.Ref("sk-openai-rotated-second-ffffffffffffffff")}, //nolint:gosec // test fixture } updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &replacement, }) require.NoError(t, err) require.Len(t, updated.APIKeys, 2) for _, k := range updated.APIKeys { require.NotEqual(t, originalID, k.ID) } }) t.Run("UpdateKeepsExistingByID", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-keep-by-id", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{ "sk-openai-keep-aaaaaaaaaaaaaaaaaaaaaa", //nolint:gosec // test fixture "sk-openai-evict-bbbbbbbbbbbbbbbbbbbbbb", //nolint:gosec // test fixture }, }) require.NoError(t, err) require.Len(t, provider.APIKeys, 2) keepID := provider.APIKeys[0].ID keepMasked := provider.APIKeys[0].Masked evictID := provider.APIKeys[1].ID // Reference only keepID and add one new plaintext: evictID is // implicitly removed. patch := []codersdk.AIProviderKeyMutation{ {ID: &keepID}, {APIKey: ptr.Ref("sk-openai-added-cccccccccccccccccccccc")}, //nolint:gosec // test fixture } updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &patch, }) require.NoError(t, err) require.Len(t, updated.APIKeys, 2) ids := keyIDs(updated.APIKeys) require.Contains(t, ids, keepID) require.NotContains(t, ids, evictID) // The kept key's masked value is unchanged. for _, k := range updated.APIKeys { if k.ID == keepID { require.Equal(t, keepMasked, k.Masked) } } }) t.Run("UpdateClearsKeys", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-clear", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{"sk-openai-tobedeleted-gggggggggggggg"}, //nolint:gosec // test fixture, not a real credential }) require.NoError(t, err) require.Len(t, provider.APIKeys, 1) empty := []codersdk.AIProviderKeyMutation{} updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &empty, }) require.NoError(t, err) require.Empty(t, updated.APIKeys) }) t.Run("UpdateKeepOnlyIsNoOp", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-keeponly", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{ "sk-openai-stay-1-iiiiiiiiiiiiiiiiiiii", //nolint:gosec // test fixture "sk-openai-stay-2-jjjjjjjjjjjjjjjjjjjj", //nolint:gosec // test fixture }, }) require.NoError(t, err) require.Len(t, provider.APIKeys, 2) originalIDs := keyIDs(provider.APIKeys) mutations := []codersdk.AIProviderKeyMutation{ {ID: &provider.APIKeys[0].ID}, {ID: &provider.APIKeys[1].ID}, } updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &mutations, }) require.NoError(t, err) require.ElementsMatch(t, originalIDs, keyIDs(updated.APIKeys)) }) t.Run("UpdateWithoutKeysPreserves", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-preserve", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{"sk-openai-keepme-hhhhhhhhhhhhhhhh"}, //nolint:gosec // test fixture, not a real credential }) require.NoError(t, err) require.Len(t, provider.APIKeys, 1) original := provider.APIKeys[0] // PATCH with no APIKeys field must leave keys untouched. newDisplay := "Keep Display" updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ DisplayName: &newDisplay, }) require.NoError(t, err) require.Len(t, updated.APIKeys, 1) require.Equal(t, original.ID, updated.APIKeys[0].ID) require.Equal(t, original.Masked, updated.APIKeys[0].Masked) }) t.Run("BedrockRejectsCreateWithKeys", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // Bedrock providers authenticate via the settings blob (AWS // access key + secret), so an api_keys list would be silently // unused. //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeAnthropic, Name: "keys-bedrock-create", Enabled: true, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", APIKeys: []string{"sk-should-be-rejected"}, //nolint:gosec // test fixture, not a real credential Settings: codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-east-1", Model: "anthropic.claude-3-5-sonnet", AccessKey: ptr.Ref("AKIA-test"), //nolint:gosec // test fixture, not a real credential AccessKeySecret: ptr.Ref("bedrock-test-secret"), }, }, }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Bedrock providers do not accept api_keys") }) t.Run("BedrockRejectsUpdateWithKeys", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeAnthropic, Name: "keys-bedrock-update", Enabled: true, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", Settings: codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-east-1", Model: "anthropic.claude-3-5-sonnet", AccessKey: ptr.Ref("AKIA-test"), //nolint:gosec // test fixture, not a real credential AccessKeySecret: ptr.Ref("bedrock-test-secret"), }, }, }) require.NoError(t, err) rejected := []codersdk.AIProviderKeyMutation{ {APIKey: ptr.Ref("sk-bedrock-no")}, //nolint:gosec // test fixture, not a real credential } _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &rejected, }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Bedrock providers do not accept api_keys") }) t.Run("EmptyKeyRejected", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-empty-element", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{""}, }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid AI provider request") require.Len(t, sdkErr.Validations, 1) require.Equal(t, "api_keys[0]", sdkErr.Validations[0].Field) require.Contains(t, sdkErr.Validations[0].Detail, "must not be empty") }) t.Run("WhitespaceKeyRejected", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // Surrounding whitespace would silently break upstream auth, // since the server stores credentials verbatim. Reject up-front // so the operator gets a clear signal instead of a 401 later. //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-whitespace-create", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{" sk-openai-padded-nnnnnnnnnnnnnnnnnnnn "}, //nolint:gosec // test fixture }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-whitespace-update", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{"sk-openai-clean-oooooooooooooooooooo"}, //nolint:gosec // test fixture }) require.NoError(t, err) padded := " sk-openai-padded-pppppppppppppppppppp " muts := []codersdk.AIProviderKeyMutation{{APIKey: &padded}} _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &muts, }) require.Error(t, err) require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) }) t.Run("NonOwnerForbidden", func(t *testing.T) { t.Parallel() ownerClient := coderdtest.New(t, nil) firstUser := coderdtest.CreateFirstUser(t, ownerClient) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := ownerClient.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-owner-only", Enabled: true, BaseURL: "https://api.openai.com/v1", }) require.NoError(t, err) memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, firstUser.OrganizationID) patch := []codersdk.AIProviderKeyMutation{ {APIKey: ptr.Ref("sk-not-allowed")}, //nolint:gosec // test fixture, not a real credential } _, err = memberClient.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &patch, }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) }) t.Run("MutationBothFieldsRejected", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-mut-both", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{"sk-openai-existing-kkkkkkkkkkkkkkkk"}, //nolint:gosec // test fixture }) require.NoError(t, err) existingID := provider.APIKeys[0].ID muts := []codersdk.AIProviderKeyMutation{ {ID: &existingID, APIKey: ptr.Ref("sk-conflict")}, //nolint:gosec // test fixture } _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &muts, }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid AI provider request") require.Len(t, sdkErr.Validations, 1) require.Equal(t, "api_keys[0]", sdkErr.Validations[0].Field) require.Contains(t, sdkErr.Validations[0].Detail, "exactly one of id or api_key must be set") }) t.Run("MutationNeitherFieldRejected", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-mut-empty", Enabled: true, BaseURL: "https://api.openai.com/v1", }) require.NoError(t, err) muts := []codersdk.AIProviderKeyMutation{{}} _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &muts, }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid AI provider request") require.Len(t, sdkErr.Validations, 1) require.Equal(t, "api_keys[0]", sdkErr.Validations[0].Field) require.Contains(t, sdkErr.Validations[0].Detail, "exactly one of id or api_key must be set") }) t.Run("MutationDuplicateIDRejected", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-mut-dup", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{"sk-openai-dup-llllllllllllllllllll"}, //nolint:gosec // test fixture }) require.NoError(t, err) id := provider.APIKeys[0].ID muts := []codersdk.AIProviderKeyMutation{ {ID: &id}, {ID: &id}, } _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &muts, }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "Invalid AI provider request") require.Len(t, sdkErr.Validations, 1) require.Equal(t, "api_keys[1].id", sdkErr.Validations[0].Field) require.Contains(t, sdkErr.Validations[0].Detail, "already referenced") }) t.Run("PATCHPropertiesAudited", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-props-audit", Enabled: true, BaseURL: "https://api.openai.com/v1", }) require.NoError(t, err) // Reset before the update so we look only at audits produced by // the PATCH (the create path emits its own AIProvider audit). auditor.ResetLogs() newDisplay := "Renamed" newURL := "https://api.openai.com/v2" disabled := false _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ DisplayName: &newDisplay, BaseURL: &newURL, Enabled: &disabled, }) require.NoError(t, err) // The parent AIProvider audit entry fires for property-only // PATCHes; the enterprise auditor populates the diff with the // changed fields (display_name, base_url, enabled). The mock // auditor used here returns an empty diff so we only assert the // entry shape; the actual diff content is exercised by the // enterprise audit unit tests. var sawUpdate bool for _, lg := range auditor.AuditLogs() { if lg.Action == database.AuditActionWrite && lg.ResourceType == database.ResourceTypeAIProvider { require.Equal(t, provider.ID, lg.ResourceID) sawUpdate = true } } require.True(t, sawUpdate, "expected parent AIProvider audit for property-only PATCH") }) t.Run("PATCHKeysSurfacesOpsInAudit", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // Without surfacing per-op detail, a PATCH that only rotates // keys would produce an audit entry whose top-level diff is // empty: invisible key rotation in the log. //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-audit-ops", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{ "sk-openai-audit-1-ssssssssssssssssssss", //nolint:gosec // test fixture "sk-openai-audit-2-tttttttttttttttttttt", //nolint:gosec // test fixture }, }) require.NoError(t, err) keepID := provider.APIKeys[0].ID // Keep one, drop one, add one. mutations := []codersdk.AIProviderKeyMutation{ {ID: &keepID}, {APIKey: ptr.Ref("sk-openai-audit-3-uuuuuuuuuuuuuuuuuuuu")}, //nolint:gosec // test fixture } updatedProvider, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &mutations, }) require.NoError(t, err) // The newly-inserted row's ID and masked rendering are dynamic; // pull them from the PATCH response so we can build the expected // audit payload without re-declaring the audit struct shape. var added codersdk.AIProviderKey for _, k := range updatedProvider.APIKeys { if k.ID != keepID { added = k break } } require.NotEqual(t, uuid.Nil, added.ID) require.NotEmpty(t, added.Masked) require.NotContains(t, added.Masked, "sk-openai-audit-3-uuuuuuuuuuuuuuuuuuuu") removed := provider.APIKeys[1] logs := auditor.AuditLogs() var updated *database.AuditLog for i := range logs { if logs[i].Action == database.AuditActionWrite && logs[i].ResourceType == database.ResourceTypeAIProvider { updated = &logs[i] } } require.NotNil(t, updated, "expected audit log for AI provider update") expected, err := json.Marshal(map[string]any{ "added": []map[string]any{{"id": added.ID, "masked": added.Masked}}, "removed": []map[string]any{{"id": removed.ID, "masked": removed.Masked}}, "kept": 1, }) require.NoError(t, err) require.JSONEq(t, string(expected), string(updated.AdditionalFields)) // Per-key audit entries surface the added/removed keys as their // own log lines, so a key-only PATCH is visible even without // frontend changes. The Create handler also emits per-key // audits for the initial two keys, so match by ResourceID. var sawCreate, sawDelete bool for _, lg := range logs { if lg.ResourceType != database.ResourceTypeAIProviderKey { continue } switch { case lg.Action == database.AuditActionCreate && lg.ResourceID == added.ID: sawCreate = true case lg.Action == database.AuditActionDelete && lg.ResourceID == removed.ID: sawDelete = true } } require.True(t, sawCreate, "expected create audit for added key") require.True(t, sawDelete, "expected delete audit for removed key") }) t.Run("MutationUnknownIDRejected", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, Name: "keys-mut-unknown", Enabled: true, BaseURL: "https://api.openai.com/v1", APIKeys: []string{"sk-openai-real-mmmmmmmmmmmmmmmmmmmm"}, //nolint:gosec // test fixture }) require.NoError(t, err) bogus := uuid.New() muts := []codersdk.AIProviderKeyMutation{{ID: &bogus}} _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ APIKeys: &muts, }) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Message, "api_keys references an unknown id for this provider") // Provider's real key is left untouched. reread, err := client.AIProvider(ctx, provider.Name) require.NoError(t, err) require.Len(t, reread.APIKeys, 1) require.Equal(t, provider.APIKeys[0].ID, reread.APIKeys[0].ID) }) } // TestAIProviderSettingsMerge exercises the PATCH merge semantics for // the write-only Bedrock secrets through a real HTTP client. Because // the API never echoes AccessKey or AccessKeySecret back, each // subtest reads the provider row directly from the database to // confirm what the merge actually persisted. func TestAIProviderSettingsMerge(t *testing.T) { t.Parallel() t.Run("OmittedSecretsPreserveExisting", func(t *testing.T) { t.Parallel() // A PATCH that only rotates non-secret fields must keep the // existing AccessKey and AccessKeySecret intact so the provider // keeps authenticating after the update. client, db := coderdtest.NewWithDatabase(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeAnthropic, Name: "merge-omit", Enabled: true, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", Settings: codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-east-1", Model: "anthropic.claude-3-5-sonnet", AccessKey: ptr.Ref("AKIA-old"), //nolint:gosec // test fixture, not a real credential AccessKeySecret: ptr.Ref("secret-old"), }, }, }) require.NoError(t, err) _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ Settings: &codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-west-2", Model: "anthropic.claude-3-5-haiku", }, }, }) require.NoError(t, err) //nolint:gocritic // Test reads the row to verify write-only fields. row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), created.ID) require.NoError(t, err) persisted, err := db2sdk.AIProviderSettings(row.Settings) require.NoError(t, err) require.NotNil(t, persisted.Bedrock) require.Equal(t, "us-west-2", persisted.Bedrock.Region) require.Equal(t, "anthropic.claude-3-5-haiku", persisted.Bedrock.Model) require.NotNil(t, persisted.Bedrock.AccessKey) require.Equal(t, "AKIA-old", *persisted.Bedrock.AccessKey) require.NotNil(t, persisted.Bedrock.AccessKeySecret) require.Equal(t, "secret-old", *persisted.Bedrock.AccessKeySecret) }) t.Run("ExplicitEmptyClearsSecrets", func(t *testing.T) { t.Parallel() // An admin migrating from static AWS credentials to IAM // role-based auth needs to clear AccessKey and AccessKeySecret // in a single PATCH. Sending the field with an empty string is // the explicit clear signal; the *string field distinguishes // "omitted" (nil) from "set to empty" (pointer to ""). client, db := coderdtest.NewWithDatabase(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeAnthropic, Name: "merge-clear", Enabled: true, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", Settings: codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-east-1", AccessKey: ptr.Ref("AKIA-old"), //nolint:gosec // test fixture, not a real credential AccessKeySecret: ptr.Ref("secret-old"), }, }, }) require.NoError(t, err) _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ Settings: &codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-east-1", AccessKey: ptr.Ref(""), AccessKeySecret: ptr.Ref(""), }, }, }) require.NoError(t, err) //nolint:gocritic // Test reads the row to verify write-only fields. row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), created.ID) require.NoError(t, err) persisted, err := db2sdk.AIProviderSettings(row.Settings) require.NoError(t, err) require.NotNil(t, persisted.Bedrock) require.NotNil(t, persisted.Bedrock.AccessKey) require.Equal(t, "", *persisted.Bedrock.AccessKey) require.NotNil(t, persisted.Bedrock.AccessKeySecret) require.Equal(t, "", *persisted.Bedrock.AccessKeySecret) }) t.Run("ExplicitRotatesSecrets", func(t *testing.T) { t.Parallel() client, db := coderdtest.NewWithDatabase(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // Owner role is the audience for this endpoint. created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeAnthropic, Name: "merge-rotate", Enabled: true, BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", Settings: codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-east-1", AccessKey: ptr.Ref("AKIA-old"), //nolint:gosec // test fixture, not a real credential AccessKeySecret: ptr.Ref("secret-old"), }, }, }) require.NoError(t, err) _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ Settings: &codersdk.AIProviderSettings{ Bedrock: &codersdk.AIProviderBedrockSettings{ Region: "us-east-1", AccessKey: ptr.Ref("AKIA-new"), //nolint:gosec // test fixture, not a real credential AccessKeySecret: ptr.Ref("secret-new"), }, }, }) require.NoError(t, err) //nolint:gocritic // Test reads the row to verify write-only fields. row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), created.ID) require.NoError(t, err) persisted, err := db2sdk.AIProviderSettings(row.Settings) require.NoError(t, err) require.NotNil(t, persisted.Bedrock) require.NotNil(t, persisted.Bedrock.AccessKey) require.Equal(t, "AKIA-new", *persisted.Bedrock.AccessKey) require.NotNil(t, persisted.Bedrock.AccessKeySecret) require.Equal(t, "secret-new", *persisted.Bedrock.AccessKeySecret) }) }