Files
coder/coderd/usersecretsimport_test.go
T
Zach 85984ff142 feat: add enable/disable support for user secrets (#27537)
Users can now disable a secret to stop it from being injected into
workspaces without deleting it, and re-enable it later. Disabled secrets
stay visible and editable everywhere they already appear.

An enabled secret must have at least one injection target; a secret with
no target can be stored only while disabled. Existing target-less secrets
are migrated to disabled to preserve current behavior.

Support spans the REST API, SDK, CLI, dashboard, and audit log.
2026-07-28 09:58:33 -06:00

346 lines
12 KiB
Go

package coderd_test
import (
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"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/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestImportUserSecrets(t *testing.T) {
t.Parallel()
t.Run("Success", 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.WaitMedium)
auditor.ResetLogs()
secrets, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
Format: codersdk.SecretsFileFormatEnv,
Content: "ALPHA=a\nBETA=b\nPATH=c\n",
})
require.NoError(t, err)
require.Len(t, secrets, 3)
// Valid keys are env-injected, while reserved names are imported
// without env injection.
assert.Equal(t, "ALPHA", secrets[0].Name)
assert.Equal(t, "ALPHA", secrets[0].EnvName)
assert.Equal(t, "PATH", secrets[2].Name)
assert.Empty(t, secrets[2].EnvName)
listed, err := client.UserSecrets(ctx, codersdk.Me)
require.NoError(t, err)
names := make([]string, 0, len(listed))
for _, s := range listed {
names = append(names, s.Name)
}
assert.ElementsMatch(t, []string{"ALPHA", "BETA", "PATH"}, names)
// Exactly one create audit log per imported secret.
logs := auditor.AuditLogs()
require.Len(t, logs, 3)
resourceIDs := make([]string, 0, len(logs))
resourceTargets := make([]string, 0, len(logs))
for _, l := range logs {
assert.Equal(t, database.AuditActionCreate, l.Action)
assert.EqualValues(t, http.StatusCreated, l.StatusCode)
resourceIDs = append(resourceIDs, l.ResourceID.String())
resourceTargets = append(resourceTargets, l.ResourceTarget)
}
assert.ElementsMatch(t, []string{
secrets[0].ID.String(), secrets[1].ID.String(), secrets[2].ID.String(),
}, resourceIDs)
assert.ElementsMatch(t, []string{"ALPHA", "BETA", "PATH"}, resourceTargets)
})
t.Run("ValuesNotInResponse", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitMedium)
const secretValue = "super-secret-sentinel-value-123"
res, err := client.Request(ctx, http.MethodPost,
fmt.Sprintf("/api/v2/users/%s/secrets/batch", codersdk.Me),
codersdk.ImportUserSecretsRequest{
Format: codersdk.SecretsFileFormatEnv,
Content: "LEAKY=" + secretValue,
})
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusCreated, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.NotContains(t, string(body), secretValue)
})
}
func TestImportUserSecretsForbiddenForAnotherUser(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleAuditor())
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := memberClient.ImportUserSecrets(ctx, owner.UserID.String(), codersdk.ImportUserSecretsRequest{
Format: codersdk.SecretsFileFormatEnv,
Content: "FORBIDDEN=value",
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
}
func TestImportUserSecretsBodyTooLarge(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
Format: codersdk.SecretsFileFormatEnv,
Content: strings.Repeat("a", 8*codersdk.MaxSecretsFileBytes),
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusRequestEntityTooLarge, sdkErr.StatusCode())
}
// TestImportUserSecretsValidationRollback verifies that a single
// invalid entry rejects the whole batch: nothing is created and no
// audit log is written. The valid sibling entry must not leak through.
func TestImportUserSecretsValidationRollback(t *testing.T) {
t.Parallel()
cases := []struct {
name string
badLine string
}{
// Empty values are always invalid; this is the canonical rollback case.
{name: "EmptyValue", badLine: "EMPTY_ONE="},
{name: "OversizedValue", badLine: "BIG=" + strings.Repeat("a", codersdk.MaxUserSecretValueBytes+1)},
// A slash in the name is invalid regardless of env-name handling.
{name: "NameWithSlash", badLine: "bad/name=value"},
}
for _, tc := range cases {
t.Run(tc.name, 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.WaitMedium)
auditor.ResetLogs()
_, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
Format: codersdk.SecretsFileFormatEnv,
Content: "GOOD_ENTRY=fine\n" + tc.badLine,
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
// Errors are attributed to the offending entry (index 1).
require.NotEmpty(t, sdkErr.Validations)
for _, v := range sdkErr.Validations {
assert.Truef(t, strings.HasPrefix(v.Field, "secrets[1]."),
"unexpected field %q", v.Field)
}
listed, err := client.UserSecrets(ctx, codersdk.Me)
require.NoError(t, err)
assert.Empty(t, listed)
assert.Empty(t, auditor.AuditLogs())
})
}
}
// TestImportUserSecretsConflict verifies that a batch containing an
// already-existing secret name aborts entirely: the new entry is not
// created and no audit log is written.
func TestImportUserSecretsConflict(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.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "EXISTING",
Value: "original",
EnvName: "EXISTING",
})
require.NoError(t, err)
auditor.ResetLogs()
_, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
Format: codersdk.SecretsFileFormatEnv,
Content: "BRANDNEW=x\nEXISTING=collision",
})
validation := requireSecretValidation(t, err, http.StatusConflict, "secrets[1].name")
assert.Equal(t, "name already in use", validation.Detail)
// Only the pre-existing secret should remain; BRANDNEW must not be created.
listed, err := client.UserSecrets(ctx, codersdk.Me)
require.NoError(t, err)
require.Len(t, listed, 1)
assert.Equal(t, "EXISTING", listed[0].Name)
assert.Empty(t, auditor.AuditLogs())
}
// TestImportUserSecretsLimits exercises each per-user cap. A cap
// tripped mid-batch must roll back every row in the import and, because
// audit logs are emitted only after the transaction commits, write no
// import audit logs.
func TestImportUserSecretsLimits(t *testing.T) {
t.Parallel()
t.Run("CountLimit", 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)
for i := 0; i < codersdk.MaxUserSecretsPerUserCount-1; i++ {
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: fmt.Sprintf("prefill-%03d", i),
Value: "original",
FilePath: fmt.Sprintf("/tmp/prefill-%03d", i),
})
require.NoError(t, err)
}
before, err := client.UserSecrets(ctx, codersdk.Me)
require.NoError(t, err)
require.Len(t, before, codersdk.MaxUserSecretsPerUserCount-1)
auditor.ResetLogs()
_, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
Format: codersdk.SecretsFileFormatEnv,
Content: "COUNT_FIRST=x\nCOUNT_SECOND=y\n",
})
requireSecretAPIError(t, err, http.StatusBadRequest, "secrets[1]")
after, err := client.UserSecrets(ctx, codersdk.Me)
require.NoError(t, err)
require.Len(t, after, len(before))
beforeNames := make([]string, 0, len(before))
afterNames := make([]string, 0, len(after))
for _, secret := range before {
beforeNames = append(beforeNames, secret.Name)
}
for _, secret := range after {
afterNames = append(afterNames, secret.Name)
}
assert.ElementsMatch(t, beforeNames, afterNames)
assert.Empty(t, auditor.AuditLogs())
})
t.Run("EnvBytesLimit", 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)
// Every imported secret is env-injected, so two values that are
// each within the per-value cap can still exceed the env-bytes
// aggregate together.
content := fmt.Sprintf("ENV_A=%s\nENV_B=%s\n",
strings.Repeat("a", codersdk.MaxUserSecretValueBytes-16),
strings.Repeat("a", 1024))
auditor.ResetLogs()
_, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
Format: codersdk.SecretsFileFormatEnv,
Content: content,
})
requireSecretAPIError(t, err, http.StatusBadRequest, "env_name")
listed, err := client.UserSecrets(ctx, codersdk.Me)
require.NoError(t, err)
assert.Empty(t, listed)
assert.Empty(t, auditor.AuditLogs())
})
t.Run("TotalBytesLimit", 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)
// Pre-fill the total-bytes budget to the cap using file-only
// secrets (no env_name), which do not count against the smaller
// env budget. Creating them via CreateUserSecret directly avoids
// going through the import parser.
big := strings.Repeat("a", codersdk.MaxUserSecretValueBytes)
numBig := codersdk.MaxUserSecretsTotalValueBytes / codersdk.MaxUserSecretValueBytes
remainder := codersdk.MaxUserSecretsTotalValueBytes % codersdk.MaxUserSecretValueBytes
for i := 0; i < numBig; i++ {
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: fmt.Sprintf("prefill-%03d", i),
Value: big,
FilePath: fmt.Sprintf("/tmp/prefill-%03d", i),
})
require.NoError(t, err)
}
if remainder > 0 {
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "prefill-pad",
Value: strings.Repeat("a", remainder),
FilePath: "/tmp/prefill-pad",
})
require.NoError(t, err)
}
before, err := client.UserSecrets(ctx, codersdk.Me)
require.NoError(t, err)
// Reset after the prefill (which legitimately emits create audit
// logs) so the assertion below only sees logs from the rolled-back
// import.
auditor.ResetLogs()
_, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
Format: codersdk.SecretsFileFormatEnv,
Content: "OVERFLOW=x",
})
requireSecretAPIError(t, err, http.StatusBadRequest, "per-user budget")
after, err := client.UserSecrets(ctx, codersdk.Me)
require.NoError(t, err)
assert.Len(t, after, len(before))
assert.Empty(t, auditor.AuditLogs())
})
}
func TestImportUserSecretsParseErrors(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitMedium)
// Parse-error variety is covered by the parser unit tests; this only
// asserts the endpoint maps a parse failure to 400.
_, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
Format: codersdk.SecretsFileFormatJSON,
Content: "{not json",
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
}