Files
coder/codersdk/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

420 lines
16 KiB
Go

package codersdk_test
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
func TestParseSecretsFileEnv(t *testing.T) {
t.Parallel()
content := strings.Join([]string{
"# full-line comment",
" # indented full-line comment",
"",
" ",
"export EXPORTED=exported-value",
"PLAIN=plain-value",
"WITH_SPACES= trimmed ",
`DQUOTED="double quoted"`,
`DQ_ESCAPES="a\nb\tc\\d\"e"`,
`DQ_ESCAPED_QUOTE="a\"b"`,
`DQ_EVEN_BACKSLASH_CLOSE="two backslashes\\"`,
`DQ_CARRIAGE="a\rb"`,
`DQ_UNKNOWN="x\zy"`,
`SQUOTED='literal \n no escape'`,
"SQ_TRAILING_WS='abc' \t",
"SQ_EMPTY=''",
`SQ_DOUBLE_INSIDE='a"b'`,
`SQ_HASH='#not a comment'`,
"SQ_UNICODE='héllo 世界'",
"EQ_IN_VALUE=a=b=c",
"HASH=value # kept literal",
"UNICODE=héllo 世界 café",
"exportFOO=literal-key",
"export=literal-export-key",
"EQ_ONLY_VALUE==",
"EMPTY_VAL=",
"TABBED=\t tab trimmed \t",
"export\tTAB_EXPORT=via-tab",
}, "\n")
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content)
require.NoError(t, err)
want := []codersdk.CreateUserSecretRequest{
{Name: "EXPORTED", EnvName: "EXPORTED", Value: "exported-value"},
{Name: "PLAIN", EnvName: "PLAIN", Value: "plain-value"},
{Name: "WITH_SPACES", EnvName: "WITH_SPACES", Value: "trimmed"},
{Name: "DQUOTED", EnvName: "DQUOTED", Value: "double quoted"},
{Name: "DQ_ESCAPES", EnvName: "DQ_ESCAPES", Value: "a\nb\tc\\d\"e"},
{Name: "DQ_ESCAPED_QUOTE", EnvName: "DQ_ESCAPED_QUOTE", Value: `a"b`},
{Name: "DQ_EVEN_BACKSLASH_CLOSE", EnvName: "DQ_EVEN_BACKSLASH_CLOSE", Value: `two backslashes\`},
{Name: "DQ_CARRIAGE", EnvName: "DQ_CARRIAGE", Value: "a\rb"},
{Name: "DQ_UNKNOWN", EnvName: "DQ_UNKNOWN", Value: `x\zy`},
{Name: "SQUOTED", EnvName: "SQUOTED", Value: `literal \n no escape`},
{Name: "SQ_TRAILING_WS", EnvName: "SQ_TRAILING_WS", Value: "abc"},
{Name: "SQ_EMPTY", EnvName: "SQ_EMPTY", Value: ""},
{Name: "SQ_DOUBLE_INSIDE", EnvName: "SQ_DOUBLE_INSIDE", Value: `a"b`},
{Name: "SQ_HASH", EnvName: "SQ_HASH", Value: "#not a comment"},
{Name: "SQ_UNICODE", EnvName: "SQ_UNICODE", Value: "héllo 世界"},
{Name: "EQ_IN_VALUE", EnvName: "EQ_IN_VALUE", Value: "a=b=c"},
{Name: "HASH", EnvName: "HASH", Value: "value # kept literal"},
{Name: "UNICODE", EnvName: "UNICODE", Value: "héllo 世界 café"},
{Name: "exportFOO", EnvName: "exportFOO", Value: "literal-key"},
{Name: "export", EnvName: "export", Value: "literal-export-key"},
{Name: "EQ_ONLY_VALUE", EnvName: "EQ_ONLY_VALUE", Value: "="},
{Name: "EMPTY_VAL", EnvName: "EMPTY_VAL", Value: ""},
{Name: "TABBED", EnvName: "TABBED", Value: "tab trimmed"},
{Name: "TAB_EXPORT", EnvName: "TAB_EXPORT", Value: "via-tab"},
}
require.Equal(t, want, reqs)
}
func TestParseSecretsFileEnvCRLFAndBOM(t *testing.T) {
t.Parallel()
content := "\ufeffKEY1=val1\r\nKEY2=val2\r\n"
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content)
require.NoError(t, err)
require.Equal(t, []codersdk.CreateUserSecretRequest{
{Name: "KEY1", EnvName: "KEY1", Value: "val1"},
{Name: "KEY2", EnvName: "KEY2", Value: "val2"},
}, reqs)
}
func TestParseSecretsFileEnvErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
errMsgs []string
}{
{name: "NoEquals", content: "OK=value\nNOEQUALS\n", errMsgs: []string{"no '='", "line 2"}},
{name: "MissingKey", content: "=value", errMsgs: []string{"missing key"}},
{name: "UnterminatedDouble", content: `KEY="oops`, errMsgs: []string{"missing closing double quote"}},
{name: "EscapedDoubleQuoteNotClosing", content: `KEY="oops\"`, errMsgs: []string{"missing closing double quote"}},
{name: "DoubleQuoteTrailingData", content: `KEY="ok" # comment`, errMsgs: []string{"unexpected data after closing double quote"}},
{name: "UnterminatedSingle", content: `KEY='oops`, errMsgs: []string{"missing closing single quote", "line 1"}},
{name: "SingleQuoteOnly", content: `KEY='`, errMsgs: []string{"missing closing single quote", "line 1"}},
{name: "SingleQuoteTrailingComment", content: `KEY='abc' # 'note'`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}},
{name: "SingleQuoteEmbeddedQuote", content: `KEY='a'b'`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}},
{name: "SingleQuotePairAfterClose", content: `KEY='a' 'b'`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}},
{name: "SingleQuoteTrailingData", content: `KEY='abc' extra`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}},
{name: "SingleQuoteAdjacentQuoted", content: `KEY='it''s'`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}},
{name: "SingleQuoteTrailingDataNoSpace", content: `KEY=''extra`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}},
{name: "SingleQuoteErrorOnLaterLine", content: "OK=fine\nKEY='abc' extra", errMsgs: []string{"unexpected data after closing single quote", "line 2"}},
{name: "DuplicateKey", content: "DUP=a\nDUP=b", errMsgs: []string{"duplicate key", "line 2"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, tt.content)
require.Error(t, err)
for _, msg := range tt.errMsgs {
assert.Contains(t, err.Error(), msg)
}
})
}
}
func TestParseSecretsFileJSON(t *testing.T) {
t.Parallel()
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatJSON, `{"A":"1","B":"two","C":"a=b#c"}`)
require.NoError(t, err)
require.Equal(t, []codersdk.CreateUserSecretRequest{
{Name: "A", EnvName: "A", Value: "1"},
{Name: "B", EnvName: "B", Value: "two"},
{Name: "C", EnvName: "C", Value: "a=b#c"},
}, reqs)
}
func TestParseSecretsFileJSONErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
errMsg string
}{
{name: "Malformed", content: `{"A":`, errMsg: "invalid JSON"},
{name: "NonObjectArray", content: `["a","b"]`, errMsg: "must be an object"},
{name: "NonObjectScalar", content: `"just a string"`, errMsg: "must be an object"},
{name: "NumberValue", content: `{"A":1}`, errMsg: "must be a string"},
{name: "BoolValue", content: `{"A":true}`, errMsg: "must be a string"},
{name: "NullValue", content: `{"A":null}`, errMsg: "must be a string"},
{name: "NestedObject", content: `{"A":{"x":"y"}}`, errMsg: "nested object or array"},
{name: "NestedArray", content: `{"A":["x"]}`, errMsg: "nested object or array"},
{name: "DuplicateKey", content: `{"DUP":"a","DUP":"b"}`, errMsg: "duplicate key"},
{name: "TrailingData", content: `{"A":"1"} {"B":"2"}`, errMsg: "trailing data"},
{name: "InvalidTrailingJSON", content: `{"A":"1"} {`, errMsg: "invalid JSON"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatJSON, tt.content)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
})
}
}
func TestParseSecretsFileYAML(t *testing.T) {
t.Parallel()
content := strings.Join([]string{
"# a comment",
"A: one",
`B: "two"`,
"C: 'a=b#c'",
}, "\n")
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, content)
require.NoError(t, err)
require.Equal(t, []codersdk.CreateUserSecretRequest{
{Name: "A", EnvName: "A", Value: "one"},
{Name: "B", EnvName: "B", Value: "two"},
{Name: "C", EnvName: "C", Value: "a=b#c"},
}, reqs)
}
func TestParseSecretsFileYAMLErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
errMsg string
}{
{name: "Malformed", content: "A: [unclosed", errMsg: "invalid YAML"},
{name: "NonMappingScalar", content: "just a scalar", errMsg: "must be a mapping"},
{name: "NonMappingSequence", content: "- a\n- b", errMsg: "must be a mapping"},
{name: "NestedMapping", content: "OUTER:\n inner: x", errMsg: "nested mapping or sequence"},
{name: "SequenceValue", content: "LIST:\n - a\n - b", errMsg: "nested mapping or sequence"},
{name: "IntValue", content: "PORT: 8080", errMsg: "must be a string"},
{name: "BoolValue", content: "FLAG: true", errMsg: "must be a string"},
{name: "NullValue", content: "KEY: null", errMsg: "must be a string"},
{name: "BoolKey", content: "true: value", errMsg: "keys must be strings"},
{name: "IntKey", content: "1: value", errMsg: "keys must be strings"},
{name: "SequenceKey", content: "? [a, b]\n: value", errMsg: "keys must be strings"},
{name: "DuplicateKey", content: "DUP: a\nDUP: b", errMsg: "duplicate key"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, tt.content)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
})
}
}
func TestParseSecretsFileYAMLAlias(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "a: &a \"x\"\nb: *a\n")
require.Error(t, err)
assert.Contains(t, err.Error(), "must be a string")
}
func TestParseSecretsFileYAMLMultiDocument(t *testing.T) {
t.Parallel()
t.Run("SecondMappingRejected", func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\nB: \"2\"\n")
require.Error(t, err)
assert.Contains(t, err.Error(), "single document")
})
t.Run("SecondScalarRejected", func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\nplain\n")
require.Error(t, err)
assert.Contains(t, err.Error(), "single document")
})
t.Run("TrailingSeparatorRejected", func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\n")
require.Error(t, err)
assert.Contains(t, err.Error(), "single document")
})
}
// FuzzParseSecretsFile checks two invariants: (1) the parser never panics
// regardless of input (the fuzz engine catches panics automatically); (2) on
// success the result is well-formed: at least one entry, at most
// MaxUserSecretsPerUserCount entries, EnvName is empty or equals Name for every
// entry, and all keys unique. On error the returned slice must be nil/empty.
func FuzzParseSecretsFile(f *testing.F) {
// env - valid
f.Add("env", "KEY=value")
f.Add("env", "export EXPORTED=val\nPLAIN=plain")
f.Add("env", "\ufeffKEY1=val1\r\nKEY2=val2\r\n")
f.Add("env", "EMPTY=\nKEY=val")
f.Add("env", "EQ=a=b=c")
f.Add("env", `DQUOTED="double quoted"`)
f.Add("env", `SQUOTED='single quoted'`)
f.Add("env", "# comment\nKEY=val")
// env - malformed / tricky quoting
f.Add("env", `KEY="unterminated`)
f.Add("env", `KEY='unterminated`)
f.Add("env", `KEY="escaped\"`)
f.Add("env", `KEY="two backslashes\\"`)
f.Add("env", "NOEQUALS")
f.Add("env", "=value")
f.Add("env", `KEY="ok" # trailing`)
f.Add("env", `KEY='ok' extra`)
f.Add("env", "DUP=a\nDUP=b")
// json - valid
f.Add("json", `{"A":"1","B":"two"}`)
// json - malformed
f.Add("json", `{"A":`)
f.Add("json", `["a","b"]`)
f.Add("json", `"just a string"`)
f.Add("json", `{"A":1}`)
f.Add("json", `{"A":true}`)
f.Add("json", `{"A":null}`)
f.Add("json", `{"A":{"x":"y"}}`)
f.Add("json", `{"A":["x"]}`)
f.Add("json", `{"DUP":"a","DUP":"b"}`)
f.Add("json", `{"A":"1"} {"B":"2"}`)
// yaml - valid
f.Add("yaml", "A: one\nB: \"two\"\n")
// yaml - malformed
f.Add("yaml", "A: [unclosed")
f.Add("yaml", "- a\n- b\n")
f.Add("yaml", "OUTER:\n inner: x\n")
f.Add("yaml", "PORT: 8080\n")
f.Add("yaml", "FLAG: true\n")
f.Add("yaml", "a: &a \"x\"\nb: *a\n")
f.Add("yaml", "A: \"1\"\n---\nB: \"2\"\n")
// unknown / empty format
f.Add("", "KEY=value")
f.Add("toml", "KEY=value")
f.Fuzz(func(t *testing.T, format string, content string) {
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormat(format), content)
if err != nil {
require.Empty(t, reqs)
return
}
// On success: at least one entry, within the per-user cap.
require.NotEmpty(t, reqs)
require.LessOrEqual(t, len(reqs), codersdk.MaxUserSecretsPerUserCount)
seen := make(map[string]struct{}, len(reqs))
for _, req := range reqs {
require.True(t, req.EnvName == "" || req.EnvName == req.Name, "EnvName must be empty or equal to Name")
_, dup := seen[req.Name]
require.False(t, dup, "duplicate key %q in result", req.Name)
seen[req.Name] = struct{}{}
}
})
}
func TestParseSecretsFileGeneralErrors(t *testing.T) {
t.Parallel()
t.Run("UnknownFormat", func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile("toml", "A=1")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown secrets file format")
})
t.Run("EmptyFormat", func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile("", "A=1")
require.Error(t, err)
assert.Contains(t, err.Error(), "format is required")
})
t.Run("MaxBytesBoundary", func(t *testing.T) {
t.Parallel()
value := strings.Repeat("a", codersdk.MaxSecretsFileBytes-len("KEY="))
reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, "KEY="+value)
require.NoError(t, err)
require.Equal(t, []codersdk.CreateUserSecretRequest{
{Name: "KEY", EnvName: "KEY", Value: value},
}, reqs)
})
t.Run("Oversized", func(t *testing.T) {
t.Parallel()
content := strings.Repeat("a", codersdk.MaxSecretsFileBytes+1)
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content)
require.Error(t, err)
assert.Contains(t, err.Error(), "maximum allowed size")
})
t.Run("TooManySecrets", func(t *testing.T) {
t.Parallel()
lines := make([]string, 0, codersdk.MaxUserSecretsPerUserCount+1)
for i := 0; i < codersdk.MaxUserSecretsPerUserCount+1; i++ {
lines = append(lines, fmt.Sprintf("KEY_%d=value", i))
}
_, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, strings.Join(lines, "\n"))
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds the maximum")
})
emptyCases := []struct {
name string
format codersdk.SecretsFileFormat
content string
}{
{name: "EnvEmpty", format: codersdk.SecretsFileFormatEnv, content: ""},
{name: "EnvWhitespace", format: codersdk.SecretsFileFormatEnv, content: " \n\t\n"},
{name: "EnvAllComments", format: codersdk.SecretsFileFormatEnv, content: "# one\n# two\n"},
{name: "JSONEmptyObject", format: codersdk.SecretsFileFormatJSON, content: "{}"},
{name: "YAMLEmpty", format: codersdk.SecretsFileFormatYAML, content: ""},
{name: "YAMLCommentsOnly", format: codersdk.SecretsFileFormatYAML, content: "# nothing here\n"},
}
for _, tt := range emptyCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := codersdk.ParseSecretsFile(tt.format, tt.content)
require.Error(t, err)
assert.Contains(t, err.Error(), "no secrets found")
})
}
}
func TestParseSecretsFileBestEffortEnvName(t *testing.T) {
t.Parallel()
cases := []struct {
format codersdk.SecretsFileFormat
content string
}{
{format: codersdk.SecretsFileFormatEnv, content: "PATH=value"},
{format: codersdk.SecretsFileFormatJSON, content: `{"PATH":"value"}`},
{format: codersdk.SecretsFileFormatYAML, content: "PATH: value"},
}
for _, tc := range cases {
t.Run(string(tc.format), func(t *testing.T) {
t.Parallel()
reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content)
require.NoError(t, err)
// Reserved keys cannot be env-injected, so they are imported
// without an injection target and therefore disabled.
disabled := false
require.Equal(t, []codersdk.CreateUserSecretRequest{{
Name: "PATH",
Value: "value",
Enabled: &disabled,
}}, reqs)
})
}
}