feat: add user secrets client utilities (#25370)

Add frontend API methods, mocks, and form helpers for user secrets CRUD. The new client methods cover list, get, create, update, and delete requests, including URL encoding for secret names used in route paths.

Add user secret form utilities for create and update payload construction, required create field checks, and structured API validation error mapping back to form fields. User secret name validation now lives in codersdk with tests, and coderd returns field-level validation errors for create, update, and uniqueness conflicts so the frontend can show backend-owned validation results consistently.
This commit is contained in:
dylanhuff-at-coder
2026-05-19 09:30:31 -07:00
committed by GitHub
parent 01492e0e7b
commit 441854daa8
10 changed files with 816 additions and 132 deletions
+98 -61
View File
@@ -45,11 +45,7 @@ func TestPostUserSecret(t *testing.T) {
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Value: "some-value",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
assert.Contains(t, sdkErr.Message, "Name is required")
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "name", "required")
})
t.Run("MissingValue", func(t *testing.T) {
@@ -59,11 +55,29 @@ func TestPostUserSecret(t *testing.T) {
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "missing-value-secret",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
assert.Contains(t, sdkErr.Message, "Value is required")
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) {
@@ -80,10 +94,7 @@ func TestPostUserSecret(t *testing.T) {
Name: "dup-secret",
Value: "value2",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusConflict, sdkErr.StatusCode())
requireSecretValidationEqualsError(t, err, http.StatusConflict, "name", "name already in use")
})
t.Run("DuplicateEnvName", func(t *testing.T) {
@@ -102,10 +113,7 @@ func TestPostUserSecret(t *testing.T) {
Value: "value2",
EnvName: "DUPLICATE_ENV",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusConflict, sdkErr.StatusCode())
requireSecretValidationEqualsError(t, err, http.StatusConflict, "env_name", "environment variable already in use")
})
t.Run("DuplicateFilePath", func(t *testing.T) {
@@ -124,10 +132,7 @@ func TestPostUserSecret(t *testing.T) {
Value: "value2",
FilePath: "/tmp/dup-file",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusConflict, sdkErr.StatusCode())
requireSecretValidationEqualsError(t, err, http.StatusConflict, "file_path", "file path already in use")
})
t.Run("InvalidEnvName", func(t *testing.T) {
@@ -139,10 +144,7 @@ func TestPostUserSecret(t *testing.T) {
Value: "value",
EnvName: "1INVALID",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "must start")
})
t.Run("ReservedEnvName", func(t *testing.T) {
@@ -154,10 +156,7 @@ func TestPostUserSecret(t *testing.T) {
Value: "value",
EnvName: "PATH",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "reserved")
})
t.Run("CoderPrefixEnvName", func(t *testing.T) {
@@ -169,10 +168,7 @@ func TestPostUserSecret(t *testing.T) {
Value: "value",
EnvName: "CODER_AGENT_TOKEN",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "CODER_")
})
t.Run("InvalidFilePath", func(t *testing.T) {
@@ -184,10 +180,7 @@ func TestPostUserSecret(t *testing.T) {
Value: "value",
FilePath: "relative/path",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "file_path", "must start")
})
t.Run("NullByteInValue", func(t *testing.T) {
@@ -198,11 +191,7 @@ func TestPostUserSecret(t *testing.T) {
Name: "null-byte-secret",
Value: "before\x00after",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
assert.Contains(t, sdkErr.Message, "Invalid secret value")
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "null bytes")
})
t.Run("OversizedValue", func(t *testing.T) {
@@ -213,11 +202,7 @@ func TestPostUserSecret(t *testing.T) {
Name: "oversized-secret",
Value: strings.Repeat("a", codersdk.MaxSecretValueSize+1),
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
assert.Contains(t, sdkErr.Message, "Invalid secret value")
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "must not exceed")
})
}
@@ -371,10 +356,7 @@ func TestPatchUserSecret(t *testing.T) {
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "conflict-env-2", codersdk.UpdateUserSecretRequest{
EnvName: &taken,
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusConflict, sdkErr.StatusCode())
requireSecretValidationEqualsError(t, err, http.StatusConflict, "env_name", "environment variable already in use")
})
t.Run("ConflictFilePath", func(t *testing.T) {
@@ -398,10 +380,41 @@ func TestPatchUserSecret(t *testing.T) {
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "conflict-fp-2", codersdk.UpdateUserSecretRequest{
FilePath: &taken,
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusConflict, sdkErr.StatusCode())
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",
})
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",
})
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) {
@@ -418,14 +431,38 @@ func TestPatchUserSecret(t *testing.T) {
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "patch-invalid-val", codersdk.UpdateUserSecretRequest{
Value: &badVal,
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
assert.Contains(t, sdkErr.Message, "Invalid secret value")
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "null bytes")
})
}
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{}
}
func TestDeleteUserSecret(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)