Files
coder/coderd/ai_providers_test.go
T
Danny Kopping a85462bd49 feat: support adding GitHub Copilot AI provider via UI (#25888)
Copilot is the only AI provider type that could not be added through the `/ai/settings` UI. The aibridge runtime and the env-var seeding path already supported it, but the runtime CRUD API rejected `type=copilot` and the UI omitted it entirely. The root cause is that Copilot's auth model (a per-request GitHub OAuth token, with no pre-shared key) does not fit the credential-centric add-provider flow that every other provider uses.

## Backend

Allow `type=copilot` in `CreateAIProviderRequest.Validate()`, and reject `api_keys` for Copilot on both create (validation) and update (handler sentinel), mirroring the existing Bedrock guards. Copilot carries no stored credential.

## Frontend

Add Copilot to the provider type picker (with the `github-copilot.svg` icon) and give the form a credential-free branch: name, display name, and a free-text endpoint defaulting to `https://api.business.githubcopilot.com`, with copy explaining that authentication happens via the user's GitHub token at request time. Copilot maps to the distinct `copilot` wire type rather than collapsing to `openai`, and the edit flow recovers it correctly.

The endpoint stays required with a business-tier default; users on the individual or enterprise endpoints edit the field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-01 15:26:37 +02:00

1470 lines
54 KiB
Go

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("CopilotCreateWithoutKeys", 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.AIProviderTypeCopilot,
Name: "keys-copilot",
Enabled: true,
BaseURL: "https://api.business.githubcopilot.com",
})
require.NoError(t, err)
require.Equal(t, codersdk.AIProviderTypeCopilot, provider.Type)
require.Empty(t, provider.APIKeys)
})
t.Run("CopilotRejectsCreateWithKeys", 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.AIProviderTypeCopilot,
Name: "keys-copilot-create",
Enabled: true,
BaseURL: "https://api.business.githubcopilot.com",
APIKeys: []string{"sk-should-be-rejected"}, //nolint:gosec // test fixture, not a real credential
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "api_keys", sdkErr.Validations[0].Field)
require.Contains(t, sdkErr.Validations[0].Detail, "type=copilot does not accept api_keys")
})
t.Run("CopilotRejectsUpdateWithKeys", 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.AIProviderTypeCopilot,
Name: "keys-copilot-update",
Enabled: true,
BaseURL: "https://api.business.githubcopilot.com",
})
require.NoError(t, err)
rejected := []codersdk.AIProviderKeyMutation{
{APIKey: ptr.Ref("sk-copilot-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, "Copilot 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)
})
}