mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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.
420 lines
16 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|