mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
85984ff142
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.
919 lines
30 KiB
Go
919 lines
30 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestPostUserSecret(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
t.Run("Success", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "github-token",
|
|
Value: "ghp_xxxxxxxxxxxx",
|
|
Description: "Personal GitHub PAT",
|
|
EnvName: "GITHUB_TOKEN",
|
|
FilePath: "~/.github-token",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "github-token", secret.Name)
|
|
assert.Equal(t, "Personal GitHub PAT", secret.Description)
|
|
assert.Equal(t, "GITHUB_TOKEN", secret.EnvName)
|
|
assert.Equal(t, "~/.github-token", secret.FilePath)
|
|
assert.NotZero(t, secret.ID)
|
|
assert.NotZero(t, secret.CreatedAt)
|
|
})
|
|
|
|
t.Run("MissingName", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Value: "some-value",
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "name", "required")
|
|
})
|
|
|
|
t.Run("MissingValue", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "missing-value-secret",
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "required")
|
|
})
|
|
|
|
t.Run("InvalidName", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "foo/bar",
|
|
Value: "some-value",
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "name", "must not contain")
|
|
})
|
|
|
|
t.Run("WhitespaceName", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: " github",
|
|
Value: "some-value",
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "name", "whitespace")
|
|
})
|
|
|
|
t.Run("DuplicateName", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "dup-secret",
|
|
Value: "value1",
|
|
EnvName: "DUP_SECRET_ENV_1",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "dup-secret",
|
|
Value: "value2",
|
|
EnvName: "DUP_SECRET_ENV_2",
|
|
})
|
|
requireSecretValidationEqualsError(t, err, http.StatusConflict, "name", "name already in use")
|
|
})
|
|
|
|
t.Run("DuplicateEnvName", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "env-dup-1",
|
|
Value: "value1",
|
|
EnvName: "DUPLICATE_ENV",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "env-dup-2",
|
|
Value: "value2",
|
|
EnvName: "DUPLICATE_ENV",
|
|
})
|
|
requireSecretValidationEqualsError(t, err, http.StatusConflict, "env_name", "environment variable already in use")
|
|
})
|
|
|
|
t.Run("DuplicateFilePath", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "fp-dup-1",
|
|
Value: "value1",
|
|
FilePath: "/tmp/dup-file",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "fp-dup-2",
|
|
Value: "value2",
|
|
FilePath: "/tmp/dup-file",
|
|
})
|
|
requireSecretValidationEqualsError(t, err, http.StatusConflict, "file_path", "file path already in use")
|
|
})
|
|
|
|
t.Run("InvalidEnvName", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "invalid-env-secret",
|
|
Value: "value",
|
|
EnvName: "1INVALID",
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "must start")
|
|
})
|
|
|
|
t.Run("ReservedEnvName", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "reserved-env-secret",
|
|
Value: "value",
|
|
EnvName: "PATH",
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "reserved")
|
|
})
|
|
|
|
t.Run("CoderPrefixEnvName", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "coder-prefix-secret",
|
|
Value: "value",
|
|
EnvName: "CODER_AGENT_TOKEN",
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "CODER_")
|
|
})
|
|
|
|
t.Run("InvalidFilePath", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "bad-path-secret",
|
|
Value: "value",
|
|
FilePath: "relative/path",
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "file_path", "must start")
|
|
})
|
|
|
|
t.Run("NullByteInValue", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "null-byte-secret",
|
|
Value: "before\x00after",
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "null bytes")
|
|
})
|
|
|
|
t.Run("OversizedValue", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "oversized-secret",
|
|
Value: strings.Repeat("a", codersdk.MaxUserSecretValueBytes+1),
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "must not exceed")
|
|
})
|
|
|
|
t.Run("MissingInjectionTarget", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "missing-target-secret",
|
|
Value: "value",
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path")
|
|
})
|
|
|
|
t.Run("DisabledByDefault", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
disabled := false
|
|
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "create-disabled",
|
|
Value: "value",
|
|
EnvName: "CREATE_DISABLED",
|
|
Enabled: &disabled,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, secret.Enabled)
|
|
})
|
|
|
|
t.Run("DisabledWithoutTarget", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
// A disabled secret may omit both injection targets. Bulk
|
|
// imports rely on this for keys that cannot be env-injected.
|
|
disabled := false
|
|
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "create-disabled-no-target",
|
|
Value: "value",
|
|
Enabled: &disabled,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, secret.Enabled)
|
|
assert.Empty(t, secret.EnvName)
|
|
assert.Empty(t, secret.FilePath)
|
|
})
|
|
|
|
t.Run("EnabledByDefault", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "create-default-enabled",
|
|
Value: "value",
|
|
EnvName: "CREATE_DEFAULT_ENABLED",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, secret.Enabled)
|
|
})
|
|
}
|
|
|
|
func TestPostUserSecretForbiddenForAnotherUser(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.CreateUserSecret(ctx, owner.UserID.String(), codersdk.CreateUserSecretRequest{
|
|
Name: "forbidden",
|
|
Value: "value",
|
|
EnvName: "FORBIDDEN",
|
|
})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
|
}
|
|
|
|
func TestGetUserSecrets(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
// Verify no secrets exist on a fresh user.
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
secrets, err := client.UserSecrets(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, secrets)
|
|
|
|
t.Run("WithSecrets", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "list-secret-a",
|
|
Value: "value-a",
|
|
EnvName: "LIST_SECRET_A",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "list-secret-b",
|
|
Value: "value-b",
|
|
EnvName: "LIST_SECRET_B",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
secrets, err := client.UserSecrets(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Len(t, secrets, 2)
|
|
// Sorted by name.
|
|
assert.Equal(t, "list-secret-a", secrets[0].Name)
|
|
assert.Equal(t, "list-secret-b", secrets[1].Name)
|
|
})
|
|
}
|
|
|
|
func TestGetUserSecret(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
t.Run("Found", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
created, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "get-found-secret",
|
|
Value: "my-value",
|
|
EnvName: "GET_FOUND_SECRET",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
got, err := client.UserSecretByName(ctx, codersdk.Me, "get-found-secret")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, got.ID)
|
|
assert.Equal(t, "get-found-secret", got.Name)
|
|
assert.Equal(t, "GET_FOUND_SECRET", got.EnvName)
|
|
})
|
|
|
|
t.Run("NotFound", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.UserSecretByName(ctx, codersdk.Me, "nonexistent")
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
})
|
|
}
|
|
|
|
func TestPatchUserSecret(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
t.Run("UpdateDescription", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "patch-desc-secret",
|
|
Value: "my-value",
|
|
Description: "original",
|
|
EnvName: "PATCH_DESC_ENV",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
newDesc := "updated"
|
|
updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "patch-desc-secret", codersdk.UpdateUserSecretRequest{
|
|
Description: &newDesc,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "updated", updated.Description)
|
|
// Other fields unchanged.
|
|
assert.Equal(t, "PATCH_DESC_ENV", updated.EnvName)
|
|
})
|
|
|
|
t.Run("NoFields", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "patch-nofields-secret",
|
|
Value: "my-value",
|
|
EnvName: "PATCH_NOFIELDS_ENV",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "patch-nofields-secret", codersdk.UpdateUserSecretRequest{})
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
})
|
|
|
|
t.Run("NotFound", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
newVal := "new-value"
|
|
_, err := client.UpdateUserSecret(ctx, codersdk.Me, "nonexistent", codersdk.UpdateUserSecretRequest{
|
|
Value: &newVal,
|
|
})
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
})
|
|
|
|
t.Run("ConflictEnvName", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "conflict-env-1",
|
|
Value: "value1",
|
|
EnvName: "CONFLICT_TAKEN_ENV",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "conflict-env-2",
|
|
Value: "value2",
|
|
FilePath: "/tmp/conflict-env-2",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
taken := "CONFLICT_TAKEN_ENV"
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "conflict-env-2", codersdk.UpdateUserSecretRequest{
|
|
EnvName: &taken,
|
|
})
|
|
requireSecretValidationEqualsError(t, err, http.StatusConflict, "env_name", "environment variable already in use")
|
|
})
|
|
|
|
t.Run("ConflictFilePath", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "conflict-fp-1",
|
|
Value: "value1",
|
|
FilePath: "/tmp/conflict-taken",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "conflict-fp-2",
|
|
Value: "value2",
|
|
EnvName: "CONFLICT_FP_2",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
taken := "/tmp/conflict-taken"
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "conflict-fp-2", codersdk.UpdateUserSecretRequest{
|
|
FilePath: &taken,
|
|
})
|
|
requireSecretValidationEqualsError(t, err, http.StatusConflict, "file_path", "file path already in use")
|
|
})
|
|
|
|
t.Run("InvalidEnvName", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "patch-invalid-env",
|
|
Value: "good-value",
|
|
FilePath: "/tmp/patch-invalid-env",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
badEnvName := "1INVALID"
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "patch-invalid-env", codersdk.UpdateUserSecretRequest{
|
|
EnvName: &badEnvName,
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "must start")
|
|
})
|
|
|
|
t.Run("InvalidFilePath", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "patch-invalid-file-path",
|
|
Value: "good-value",
|
|
EnvName: "PATCH_INVALID_FILE_PATH",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
badFilePath := "relative/path"
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "patch-invalid-file-path", codersdk.UpdateUserSecretRequest{
|
|
FilePath: &badFilePath,
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "file_path", "must start")
|
|
})
|
|
|
|
t.Run("InvalidValue", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "patch-invalid-val",
|
|
Value: "good-value",
|
|
EnvName: "PATCH_INVALID_VAL",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
badVal := "before\x00after"
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "patch-invalid-val", codersdk.UpdateUserSecretRequest{
|
|
Value: &badVal,
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "null bytes")
|
|
})
|
|
|
|
t.Run("ToggleEnabled", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "toggle-enabled",
|
|
Value: "value",
|
|
EnvName: "TOGGLE_ENABLED",
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, secret.Enabled)
|
|
|
|
disable := false
|
|
updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "toggle-enabled", codersdk.UpdateUserSecretRequest{
|
|
Enabled: &disable,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, updated.Enabled)
|
|
// Other fields should be unchanged.
|
|
assert.Equal(t, "TOGGLE_ENABLED", updated.EnvName)
|
|
|
|
enable := true
|
|
updated, err = client.UpdateUserSecret(ctx, codersdk.Me, "toggle-enabled", codersdk.UpdateUserSecretRequest{
|
|
Enabled: &enable,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, updated.Enabled)
|
|
})
|
|
|
|
t.Run("ClearingBothTargetsRejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "clear-both",
|
|
Value: "value",
|
|
EnvName: "CLEAR_BOTH_ENV",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// PATCH that clears env_name while file_path is also empty
|
|
// should be rejected: the row stays enabled but would have no
|
|
// injection target.
|
|
empty := ""
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-both", codersdk.UpdateUserSecretRequest{
|
|
EnvName: &empty,
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path")
|
|
})
|
|
|
|
t.Run("ClearingTargetsWhileDisablingAllowed", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "clear-and-disable",
|
|
Value: "value",
|
|
EnvName: "CLEAR_AND_DISABLE",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Clearing the last target is allowed when the same PATCH also
|
|
// disables the secret: only enabled secrets need a target.
|
|
empty := ""
|
|
disabled := false
|
|
updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{
|
|
EnvName: &empty,
|
|
Enabled: &disabled,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, updated.Enabled)
|
|
assert.Empty(t, updated.EnvName)
|
|
|
|
// Re-enabling without restoring a target is rejected.
|
|
enable := true
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{
|
|
Enabled: &enable,
|
|
})
|
|
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path")
|
|
|
|
// Re-enabling and restoring a target in one PATCH succeeds.
|
|
envName := "CLEAR_AND_DISABLE"
|
|
updated, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{
|
|
EnvName: &envName,
|
|
Enabled: &enable,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, updated.Enabled)
|
|
assert.Equal(t, "CLEAR_AND_DISABLE", updated.EnvName)
|
|
})
|
|
|
|
t.Run("AtomicEnvFileSwap", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "atomic-swap",
|
|
Value: "value",
|
|
EnvName: "ATOMIC_SWAP_ENV",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Clearing env_name and setting file_path in the same PATCH must
|
|
// succeed: the post-update row still has an injection target.
|
|
empty := ""
|
|
newPath := "/tmp/atomic-swap"
|
|
updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "atomic-swap", codersdk.UpdateUserSecretRequest{
|
|
EnvName: &empty,
|
|
FilePath: &newPath,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "", updated.EnvName)
|
|
assert.Equal(t, "/tmp/atomic-swap", updated.FilePath)
|
|
})
|
|
}
|
|
|
|
func requireSecretValidationContainsError(t *testing.T, err error, status int, field string, detailContains string) {
|
|
t.Helper()
|
|
validation := requireSecretValidation(t, err, status, field)
|
|
assert.Contains(t, validation.Detail, detailContains)
|
|
}
|
|
|
|
func requireSecretValidationEqualsError(t *testing.T, err error, status int, field string, detail string) {
|
|
t.Helper()
|
|
validation := requireSecretValidation(t, err, status, field)
|
|
assert.Equal(t, detail, validation.Detail)
|
|
}
|
|
|
|
func requireSecretValidation(t *testing.T, err error, status int, field string) codersdk.ValidationError {
|
|
t.Helper()
|
|
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
assert.Equal(t, status, sdkErr.StatusCode())
|
|
for _, validation := range sdkErr.Validations {
|
|
if validation.Field == field {
|
|
return validation
|
|
}
|
|
}
|
|
require.Failf(t, "missing validation", "field %q not found in %#v", field, sdkErr.Validations)
|
|
return codersdk.ValidationError{}
|
|
}
|
|
|
|
// TestUserSecretLimits exercises the per-user count and byte caps
|
|
// enforced by enforce_user_secrets_per_user_limits across both POST
|
|
// (creating a new secret) and PATCH (updating an existing one).
|
|
// Each subtest spins up its own server so it can burn the budget
|
|
// without affecting other tests.
|
|
//
|
|
// Each subtest checks three things per cap:
|
|
//
|
|
// - POST past the cap is rejected with a 400.
|
|
// - PATCH of an existing row at the cap is accepted; the trigger
|
|
// uses FILTER (WHERE id IS DISTINCT FROM NEW.id) so an UPDATE
|
|
// does not double-count its own row.
|
|
// - A different user's budget is independent; the trigger groups
|
|
// by user_id.
|
|
func TestUserSecretLimits(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("CountLimit", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
client := coderdtest.New(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, client)
|
|
otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
|
|
|
// Fill the count budget exactly to the cap.
|
|
var firstSecret codersdk.UserSecret
|
|
for i := 0; i < codersdk.MaxUserSecretsPerUserCount; i++ {
|
|
s, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: fmt.Sprintf("count-limit-%03d", i),
|
|
Value: "x",
|
|
FilePath: fmt.Sprintf("/tmp/count-limit-%03d", i),
|
|
})
|
|
require.NoError(t, err)
|
|
if i == 0 {
|
|
firstSecret = s
|
|
}
|
|
}
|
|
|
|
// POST: the 51st secret is rejected.
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "one-too-many",
|
|
Value: "x",
|
|
FilePath: "/tmp/one-too-many",
|
|
})
|
|
requireSecretAPIError(t, err, http.StatusBadRequest, "at most")
|
|
|
|
// PATCH at the cap: changing the description must succeed.
|
|
// Without the FILTER clause the trigger would re-count
|
|
// firstSecret and reject this UPDATE.
|
|
newDescription := "renamed"
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, firstSecret.Name, codersdk.UpdateUserSecretRequest{
|
|
Description: &newDescription,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Other-user isolation: the second user's budget is independent.
|
|
_, err = otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "other-user-secret",
|
|
Value: "x",
|
|
FilePath: "/tmp/other-user-secret",
|
|
})
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("TotalBytesLimit", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
client := coderdtest.New(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, client)
|
|
otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
|
|
|
// Pre-fill the total-bytes budget exactly to the cap using
|
|
// max-sized file-only secrets (which don't count against env
|
|
// bytes).
|
|
big := strings.Repeat("a", codersdk.MaxUserSecretValueBytes)
|
|
numBig := codersdk.MaxUserSecretsTotalValueBytes / codersdk.MaxUserSecretValueBytes
|
|
remainder := codersdk.MaxUserSecretsTotalValueBytes % codersdk.MaxUserSecretValueBytes
|
|
var firstSecret codersdk.UserSecret
|
|
for i := 0; i < numBig; i++ {
|
|
s, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: fmt.Sprintf("big-%03d", i),
|
|
Value: big,
|
|
FilePath: fmt.Sprintf("/tmp/big-%03d", i),
|
|
})
|
|
require.NoError(t, err)
|
|
if i == 0 {
|
|
firstSecret = s
|
|
}
|
|
}
|
|
if remainder > 0 {
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "big-pad",
|
|
Value: strings.Repeat("a", remainder),
|
|
FilePath: "/tmp/big-pad",
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// POST: one more byte pushes past the total budget.
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "overflow",
|
|
Value: "x",
|
|
FilePath: "/tmp/overflow",
|
|
})
|
|
requireSecretAPIError(t, err, http.StatusBadRequest, "per-user budget")
|
|
|
|
// PATCH at the cap: rewriting the existing row with a value
|
|
// of the same size must succeed. The FILTER clause excludes
|
|
// firstSecret's old bytes from the aggregate so the trigger
|
|
// computes (cap - old) + new = cap, not cap + new.
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, firstSecret.Name, codersdk.UpdateUserSecretRequest{
|
|
Value: &big,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Other-user isolation: a fresh user can fill their own
|
|
// total-bytes budget without interference.
|
|
for i := 0; i < numBig; i++ {
|
|
_, err := otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: fmt.Sprintf("other-big-%03d", i),
|
|
Value: big,
|
|
FilePath: fmt.Sprintf("/tmp/other-big-%03d", i),
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
if remainder > 0 {
|
|
_, err := otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "other-big-pad",
|
|
Value: strings.Repeat("a", remainder),
|
|
FilePath: "/tmp/other-big-pad",
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
|
|
t.Run("EnvBytesLimit", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
client := coderdtest.New(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, client)
|
|
otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
|
|
|
// One env-injected secret consumes nearly the whole env budget.
|
|
envBig, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "env-big",
|
|
Value: strings.Repeat("a", codersdk.MaxUserSecretValueBytes-16),
|
|
EnvName: "ENV_BIG",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// POST: another env-injected secret pushes us over the env budget.
|
|
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "env-overflow",
|
|
Value: strings.Repeat("a", 1024),
|
|
EnvName: "ENV_OVERFLOW",
|
|
})
|
|
requireSecretAPIError(t, err, http.StatusBadRequest, "env_name")
|
|
|
|
// A same-sized value used purely as a file is fine because
|
|
// file_path secrets do not count against the env budget.
|
|
fileOK, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "file-ok",
|
|
Value: strings.Repeat("a", 1024),
|
|
FilePath: "/tmp/file-ok",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// PATCH at the cap: updating envBig's description must
|
|
// succeed. Without FILTER, the trigger would re-add envBig's
|
|
// 24 KiB to itself and reject the UPDATE.
|
|
newDescription := "renamed"
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, envBig.Name, codersdk.UpdateUserSecretRequest{
|
|
Description: &newDescription,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// PATCH a file_path secret to env mode: moves its 1 KiB into
|
|
// the env budget, which already holds envBig's 24 KiB - 16.
|
|
// new_env_bytes = 24560 + 1024 = 25584 > 24576, rejected.
|
|
envName := "ENV_LATE"
|
|
_, err = client.UpdateUserSecret(ctx, codersdk.Me, fileOK.Name, codersdk.UpdateUserSecretRequest{
|
|
EnvName: &envName,
|
|
})
|
|
requireSecretAPIError(t, err, http.StatusBadRequest, "env_name")
|
|
|
|
// Other-user isolation: a fresh user can create their own
|
|
// near-cap env secret.
|
|
_, err = otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "other-env-big",
|
|
Value: strings.Repeat("a", codersdk.MaxUserSecretValueBytes-16),
|
|
EnvName: "OTHER_ENV_BIG",
|
|
})
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
// requireSecretAPIError asserts a non-validation user-facing error.
|
|
// Used for trigger-driven failures (per-user limits) whose responses
|
|
// are plain codersdk.Response without ValidationError entries.
|
|
func requireSecretAPIError(t *testing.T, err error, status int, detailContains string) {
|
|
t.Helper()
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
assert.Equal(t, status, sdkErr.StatusCode())
|
|
combined := sdkErr.Message + " " + sdkErr.Response.Detail
|
|
assert.Containsf(t, combined, detailContains,
|
|
"expected response to contain %q; got Message=%q Detail=%q",
|
|
detailContains, sdkErr.Message, sdkErr.Response.Detail)
|
|
}
|
|
|
|
func TestDeleteUserSecret(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
t.Run("Success", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "delete-me-secret",
|
|
Value: "my-value",
|
|
EnvName: "DELETE_ME_SECRET",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = client.DeleteUserSecret(ctx, codersdk.Me, "delete-me-secret")
|
|
require.NoError(t, err)
|
|
|
|
// Verify it's gone.
|
|
_, err = client.UserSecretByName(ctx, codersdk.Me, "delete-me-secret")
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
})
|
|
|
|
t.Run("NotFound", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
err := client.DeleteUserSecret(ctx, codersdk.Me, "nonexistent")
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
})
|
|
}
|