diff --git a/internal/prober/interpolation/interpolation.go b/internal/prober/interpolation/interpolation.go index ef58efd2e..dc243ae95 100644 --- a/internal/prober/interpolation/interpolation.go +++ b/internal/prober/interpolation/interpolation.go @@ -14,7 +14,7 @@ import ( var VariableRegex = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_-]*)\}`) // SecretRegex matches ${secrets.secret_name} patterns -var SecretRegex = regexp.MustCompile(`\$\{secrets\.([^}]*)\}`) +var SecretRegex = regexp.MustCompile(`\$\{secrets\.([a-zA-Z0-9_][a-zA-Z0-9_\.\-]*)\}`) // VariableProvider defines the interface for resolving variables type VariableProvider interface { @@ -219,6 +219,121 @@ func ToJavaScript(value string) string { return s.String() } +// ToJavaScriptWithSecrets converts a string with both variable and secret interpolation to JavaScript code +// This is used by multihttp to generate JavaScript that references both variables and secrets +func ToJavaScriptWithSecrets(value string) string { + if len(value) == 0 { + return `''` + } + + var s strings.Builder + buf := []byte(value) + + // First handle secret variables + p := handleSecretVariables(&s, buf, 0) + + // Then handle regular variables in the remaining text + handleRegularVariables(&s, buf[p:]) + + return s.String() +} + +// handleSecretVariables processes secret variables in the buffer and returns the position after the last secret +func handleSecretVariables(s *strings.Builder, buf []byte, startPos int) int { + locs := SecretRegex.FindAllSubmatchIndex(buf, -1) + p := startPos + + for _, loc := range locs { + if len(loc) < 4 { + panic("unexpected result while building JavaScript") + } + + writePlusIfNeeded(s) + writeTextBeforeMatch(s, buf, p, loc[0]) + + // Generate async secret lookup + s.WriteString(`await secrets.get('`) + s.Write(buf[loc[2]:loc[3]]) + s.WriteString(`')`) + + p = loc[1] + } + + // Write any remaining text after secrets + if len(buf[p:]) > 0 { + writePlusIfNeeded(s) + writeQuotedText(s, buf[p:]) + } + + return p +} + +// handleRegularVariables processes regular variables in the remaining text +func handleRegularVariables(s *strings.Builder, remainingText []byte) { + if len(remainingText) == 0 { + return + } + + regularLocs := VariableRegex.FindAllSubmatchIndex(remainingText, -1) + + if len(regularLocs) > 0 { + processRegularVariableMatches(s, remainingText, regularLocs) + } else { + // No regular variables, just append the remaining text + writePlusIfNeeded(s) + writeQuotedText(s, remainingText) + } +} + +// processRegularVariableMatches processes the matches found by VariableRegex +func processRegularVariableMatches(s *strings.Builder, remainingText []byte, regularLocs [][]int) { + writePlusIfNeeded(s) + + p2 := 0 + for _, loc := range regularLocs { + if len(loc) < 4 { + panic("unexpected result while building JavaScript") + } + + writePlusIfNeeded(s) + writeTextBeforeMatch(s, remainingText, p2, loc[0]) + + s.WriteString(`vars['`) + s.Write(remainingText[loc[2]:loc[3]]) + s.WriteString(`']`) + + p2 = loc[1] + } + + // Write any remaining text after the last variable + if len(remainingText[p2:]) > 0 { + writePlusIfNeeded(s) + writeQuotedText(s, remainingText[p2:]) + } +} + +// writePlusIfNeeded writes a plus sign if the builder already has content +func writePlusIfNeeded(s *strings.Builder) { + if s.Len() > 0 { + s.WriteRune('+') + } +} + +// writeTextBeforeMatch writes the text before a regex match, quoted and escaped +func writeTextBeforeMatch(s *strings.Builder, buf []byte, start, matchStart int) { + if pre := buf[start:matchStart]; len(pre) > 0 { + writeQuotedText(s, pre) + s.WriteRune('+') + } +} + +// writeQuotedText writes text as a quoted JavaScript string with proper escaping +func writeQuotedText(s *strings.Builder, text []byte) { + s.WriteRune('\'') + escapeJavaScript(s, text) + s.WriteRune('\'') +} + // escapeJavaScript escapes a byte slice for use in JavaScript strings func escapeJavaScript(s *strings.Builder, buf []byte) { for _, b := range buf { @@ -235,10 +350,18 @@ func escapeJavaScript(s *strings.Builder, buf []byte) { s.WriteString(`\r`) case '\t': s.WriteString(`\t`) + case '=': + s.WriteString(`\u003D`) + case '>': + s.WriteString(`\u003E`) + case '<': + s.WriteString(`\u003C`) + case '&': + s.WriteString(`\u0026`) default: - if b < 32 || b > 126 { - // Escape non-printable characters - fmt.Fprintf(s, `\x%02x`, b) + if b < 32 { + // Escape control characters using Unicode escape + fmt.Fprintf(s, `\u%04X`, b) } else { s.WriteByte(b) } diff --git a/internal/prober/interpolation/interpolation_test.go b/internal/prober/interpolation/interpolation_test.go index d42e436a3..b1b58c9cf 100644 --- a/internal/prober/interpolation/interpolation_test.go +++ b/internal/prober/interpolation/interpolation_test.go @@ -116,8 +116,8 @@ func TestResolver_Resolve(t *testing.T) { "empty secret name": { input: "${secrets.}", secretEnabled: true, - expectedOutput: "", - expectError: true, + expectedOutput: "${secrets.}", + expectError: false, }, "invalid secret name": { input: "${secrets.invalid-name}", diff --git a/internal/prober/multihttp/script.go b/internal/prober/multihttp/script.go index cac1ae92d..c24831c37 100644 --- a/internal/prober/multihttp/script.go +++ b/internal/prober/multihttp/script.go @@ -9,6 +9,7 @@ import ( "strings" "text/template" + "github.com/grafana/synthetic-monitoring-agent/internal/prober/interpolation" sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring" ) @@ -20,52 +21,7 @@ var templateFS embed.FS var userVariables = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}`) func performVariableExpansion(in string) string { - if len(in) == 0 { - return `''` - } - - var s strings.Builder - buf := []byte(in) - locs := userVariables.FindAllSubmatchIndex(buf, -1) - - p := 0 - for _, loc := range locs { - if len(loc) < 4 { // put the bounds checker at ease - panic("unexpected result while building URL") - } - - if s.Len() > 0 { - s.WriteRune('+') - } - - if pre := buf[p:loc[0]]; len(pre) > 0 { - s.WriteRune('\'') - template.JSEscape(&s, pre) - s.WriteRune('\'') - s.WriteRune('+') - } - - s.WriteString(`vars['`) - // Because of the capture in the regular expression, the result - // has two indices that represent the matched substring, and - // two more indices that represent the capture group. - s.Write(buf[loc[2]:loc[3]]) - s.WriteString(`']`) - - p = loc[1] - } - - if len(buf[p:]) > 0 { - if s.Len() > 0 { - s.WriteRune('+') - } - - s.WriteRune('\'') - template.JSEscape(&s, buf[p:]) - s.WriteRune('\'') - } - - return s.String() + return interpolation.ToJavaScriptWithSecrets(in) } // Query params must be appended to a URL that has already been created. @@ -113,13 +69,19 @@ func interpolateBodyVariables(bodyVarName string, body *sm.HttpRequestBody) []st default: var buf strings.Builder - matches := userVariables.FindAllString(string(body.Payload), -1) + // Find both regular and secret variables using submatch indices + regularMatches := userVariables.FindAllSubmatchIndex(body.Payload, -1) + secretMatches := interpolation.SecretRegex.FindAllSubmatchIndex(body.Payload, -1) + parsedMatches := make(map[string]struct{}) - out := make([]string, 0, len(matches)) + out := make([]string, 0, len(regularMatches)+len(secretMatches)) - // For every instance of ${variable} in the body, - // this block returns {bodyVarName} = {bodyVarName}.replaceAll('${variable}', vars['variable']) - for _, m := range matches { + // Handle regular variables + for _, match := range regularMatches { + if len(match) < 4 { + continue + } + m := string(body.Payload[match[0]:match[1]]) if _, found := parsedMatches[m]; found { continue } @@ -132,15 +94,38 @@ func interpolateBodyVariables(bodyVarName string, body *sm.HttpRequestBody) []st buf.WriteString(m) buf.WriteString("', vars['") // writing the variable name from between ${ and } - for i := 2; i < len(m)-1; i++ { - buf.WriteByte(m[i]) - } + buf.Write(body.Payload[match[2]:match[3]]) buf.WriteString("'])") out = append(out, buf.String()) parsedMatches[m] = struct{}{} } + // Handle secret variables + for _, match := range secretMatches { + if len(match) < 4 { + continue + } + m := string(body.Payload[match[0]:match[1]]) + if _, found := parsedMatches[m]; found { + continue + } + + buf.Reset() + buf.WriteString(bodyVarName) + buf.WriteString("=") + buf.WriteString(bodyVarName) + buf.WriteString(".replaceAll('") + buf.WriteString(m) + buf.WriteString("', await secrets.get('") + // writing the secret name from the capture group + buf.Write(body.Payload[match[2]:match[3]]) + buf.WriteString("'))") + out = append(out, buf.String()) + + parsedMatches[m] = struct{}{} + } + return out } } @@ -422,6 +407,35 @@ func buildVars(variable *sm.MultiHttpEntryVariable) string { return b.String() } +func hasSecretVariables(settings *sm.MultiHttpSettings) bool { + for _, entry := range settings.Entries { + // Check URL + if interpolation.SecretRegex.MatchString(entry.Request.Url) { + return true + } + + // Check headers + for _, header := range entry.Request.Headers { + if interpolation.SecretRegex.MatchString(header.Value) { + return true + } + } + + // Check query fields + for _, field := range entry.Request.QueryFields { + if interpolation.SecretRegex.MatchString(field.Name) || interpolation.SecretRegex.MatchString(field.Value) { + return true + } + } + + // Check body + if entry.Request.Body != nil && interpolation.SecretRegex.MatchString(string(entry.Request.Body.Payload)) { + return true + } + } + return false +} + func settingsToScript(settings *sm.MultiHttpSettings) ([]byte, error) { // Convert settings to script using a Go template tmpl, err := template. @@ -442,9 +456,18 @@ func settingsToScript(settings *sm.MultiHttpSettings) ([]byte, error) { var buf bytes.Buffer + // Create template data with secret variable detection + templateData := struct { + *sm.MultiHttpSettings + HasSecretVariables bool + }{ + MultiHttpSettings: settings, + HasSecretVariables: hasSecretVariables(settings), + } + // TODO(mem): figure out if we need to transform the data in some way // before executing the template - if err := tmpl.ExecuteTemplate(&buf, "script.tmpl", settings); err != nil { + if err := tmpl.ExecuteTemplate(&buf, "script.tmpl", templateData); err != nil { return nil, fmt.Errorf("executing script template: %w", err) } diff --git a/internal/prober/multihttp/script.tmpl b/internal/prober/multihttp/script.tmpl index 72818171e..86c62b380 100644 --- a/internal/prober/multihttp/script.tmpl +++ b/internal/prober/multihttp/script.tmpl @@ -7,6 +7,7 @@ import { test } from 'k6/execution'; import encoding from 'k6/encoding'; import jsonpath from 'https://jslib.k6.io/jsonpath/1.0.2/index.js'; import { URL } from 'https://jslib.k6.io/url/1.0.0/index.js'; +{{ if .HasSecretVariables }}import secrets from 'k6/secrets';{{ end }} export const options = { scenarios: { @@ -65,7 +66,7 @@ function assertHeader(headers, name, matcher) { return false; } -export default function() { +export default {{ if .HasSecretVariables }}async {{ end }}function() { let response; let body; let url; diff --git a/internal/prober/multihttp/script_test.go b/internal/prober/multihttp/script_test.go index d70c9f55e..fe1f484e6 100644 --- a/internal/prober/multihttp/script_test.go +++ b/internal/prober/multihttp/script_test.go @@ -12,6 +12,7 @@ import ( "github.com/go-kit/log/level" "github.com/grafana/synthetic-monitoring-agent/internal/k6runner" "github.com/grafana/synthetic-monitoring-agent/internal/model" + "github.com/grafana/synthetic-monitoring-agent/internal/prober/interpolation" "github.com/grafana/synthetic-monitoring-agent/internal/testhelper" sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring" "github.com/mccutchen/go-httpbin/v2/httpbin" @@ -92,6 +93,75 @@ func TestBuildQueryParams(t *testing.T) { } } +func TestBuildQueryParamsWithSecretVariables(t *testing.T) { + testcases := map[string]struct { + request sm.MultiHttpEntryRequest + expected []string + }{ + "secret variable in query name": { + request: sm.MultiHttpEntryRequest{ + QueryFields: []*sm.QueryField{ + { + Name: "${secrets.api_key}", + Value: "value", + }, + }, + }, + expected: []string{`url.searchParams.append(await secrets.get('api_key'), 'value')`}, + }, + "secret variable in query value": { + request: sm.MultiHttpEntryRequest{ + QueryFields: []*sm.QueryField{ + { + Name: "key", + Value: "${secrets.api_secret}", + }, + }, + }, + expected: []string{`url.searchParams.append('key', await secrets.get('api_secret'))`}, + }, + "secret variable in both query name and value": { + request: sm.MultiHttpEntryRequest{ + QueryFields: []*sm.QueryField{ + { + Name: "${secrets.param_name}", + Value: "${secrets.param_value}", + }, + }, + }, + expected: []string{`url.searchParams.append(await secrets.get('param_name'), await secrets.get('param_value'))`}, + }, + "mixed regular and secret variables": { + request: sm.MultiHttpEntryRequest{ + QueryFields: []*sm.QueryField{ + { + Name: "${variable}", + Value: "${secrets.api_key}", + }, + }, + }, + expected: []string{`url.searchParams.append(vars['variable'], await secrets.get('api_key'))`}, + }, + "multiple secret variables in query value": { + request: sm.MultiHttpEntryRequest{ + QueryFields: []*sm.QueryField{ + { + Name: "q", + Value: "${secrets.prefix}and${secrets.suffix}", + }, + }, + }, + expected: []string{`url.searchParams.append('q', await secrets.get('prefix')+'and'+await secrets.get('suffix'))`}, + }, + } + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual := buildQueryParams("url", &tc.request) + require.Equal(t, tc.expected, actual) + }) + } +} + func TestBuildUrl(t *testing.T) { testcases := map[string]struct { request sm.MultiHttpEntryRequest @@ -132,6 +202,51 @@ func TestBuildUrl(t *testing.T) { } } +func TestBuildUrlWithSecretVariables(t *testing.T) { + testcases := map[string]struct { + request sm.MultiHttpEntryRequest + expected string + }{ + "secret variable in url": { + request: sm.MultiHttpEntryRequest{ + Url: "${secrets.api_endpoint}", + }, + expected: `await secrets.get('api_endpoint')`, + }, + "multiple secret variables in url": { + request: sm.MultiHttpEntryRequest{ + Url: "https://www.${secrets.domain}.com/${secrets.path}", + }, + expected: `'https://www.'+await secrets.get('domain')+'.com/'+await secrets.get('path')`, + }, + "mixed regular and secret variables in url": { + request: sm.MultiHttpEntryRequest{ + Url: "https://www.${variable}.com/${secrets.api_path}", + }, + expected: `'https://www.${variable}.com/'+await secrets.get('api_path')`, + }, + "secret variable with query params": { + request: sm.MultiHttpEntryRequest{ + Url: "${secrets.base_url}", + QueryFields: []*sm.QueryField{ + { + Name: "q", + Value: "hello", + }, + }, + }, + expected: `await secrets.get('base_url')`, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual := performVariableExpansion(tc.request.Url) + require.Equal(t, tc.expected, actual) + }) + } +} + func TestBuildHeaders(t *testing.T) { type input struct { headers []*sm.HttpHeader @@ -310,6 +425,65 @@ func TestBuildHeaders(t *testing.T) { } } +func TestBuildHeadersWithSecretVariables(t *testing.T) { + testcases := map[string]struct { + headers []*sm.HttpHeader + body *sm.HttpRequestBody + expected string + }{ + "secret variable in header value": { + headers: []*sm.HttpHeader{ + { + Name: "Authorization", + Value: "Bearer ${secrets.auth_token}", + }, + }, + expected: `{"Authorization":'Bearer '+await secrets.get('auth_token')}`, + }, + "secret variable in header name": { + headers: []*sm.HttpHeader{ + { + Name: "${secrets.header_name}", + Value: "value", + }, + }, + expected: `{"${secrets.header_name}":'value'}`, + }, + "mixed regular and secret variables in headers": { + headers: []*sm.HttpHeader{ + { + Name: "X-API-Key", + Value: "${secrets.api_key}", + }, + { + Name: "X-User-ID", + Value: "${user_id}", + }, + }, + expected: `{"X-API-Key":await secrets.get('api_key'),"X-User-ID":vars['user_id']}`, + }, + "secret variable with content type": { + headers: []*sm.HttpHeader{ + { + Name: "Authorization", + Value: "${secrets.token}", + }, + }, + body: &sm.HttpRequestBody{ + ContentType: "application/json", + }, + expected: `{'Content-Type':"application/json","Authorization":await secrets.get('token')}`, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual := buildHeaders(tc.headers, tc.body) + require.Equal(t, tc.expected, actual) + }) + } +} + func TestBuildBody(t *testing.T) { type input struct { body *sm.HttpRequestBody @@ -380,6 +554,56 @@ func TestInterpolateBodyVariables(t *testing.T) { } } +func TestInterpolateBodyVariablesWithSecrets(t *testing.T) { + testcases := map[string]struct { + bodyVarName string + body *sm.HttpRequestBody + expected []string + }{ + "secret variable in body": { + bodyVarName: "body", + body: &sm.HttpRequestBody{ + Payload: []byte(`{"password": "${secrets.user_password}"}`), + }, + expected: []string{`body=body.replaceAll('${secrets.user_password}', await secrets.get('user_password'))`}, + }, + "multiple secret variables in body": { + bodyVarName: "body", + body: &sm.HttpRequestBody{ + Payload: []byte(`{"username": "${secrets.username}", "password": "${secrets.password}"}`), + }, + expected: []string{ + `body=body.replaceAll('${secrets.username}', await secrets.get('username'))`, + `body=body.replaceAll('${secrets.password}', await secrets.get('password'))`, + }, + }, + "mixed regular and secret variables in body": { + bodyVarName: "body", + body: &sm.HttpRequestBody{ + Payload: []byte(`{"user": "${user_id}", "token": "${secrets.auth_token}"}`), + }, + expected: []string{ + `body=body.replaceAll('${user_id}', vars['user_id'])`, + `body=body.replaceAll('${secrets.auth_token}', await secrets.get('auth_token'))`, + }, + }, + "duplicate secret variables in body": { + bodyVarName: "body", + body: &sm.HttpRequestBody{ + Payload: []byte(`{"token1": "${secrets.token}", "token2": "${secrets.token}"}`), + }, + expected: []string{`body=body.replaceAll('${secrets.token}', await secrets.get('token'))`}, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual := interpolateBodyVariables(tc.bodyVarName, tc.body) + require.Equal(t, tc.expected, actual) + }) + } +} + func TestAssertionConditionName(t *testing.T) { testcases := map[string]struct { condition sm.MultiHttpEntryAssertionConditionVariant @@ -900,3 +1124,632 @@ func TestReplaceVariablesInString(t *testing.T) { require.Equal(t, testcase.expected, actual, name) } } + +func TestHasSecretVariables(t *testing.T) { + testcases := map[string]struct { + settings *sm.MultiHttpSettings + expected bool + }{ + "no secret variables": { + settings: &sm.MultiHttpSettings{ + Entries: []*sm.MultiHttpEntry{ + { + Request: &sm.MultiHttpEntryRequest{ + Url: "https://example.com", + Headers: []*sm.HttpHeader{ + {Name: "Content-Type", Value: "application/json"}, + }, + }, + }, + }, + }, + expected: false, + }, + "secret variable in URL": { + settings: &sm.MultiHttpSettings{ + Entries: []*sm.MultiHttpEntry{ + { + Request: &sm.MultiHttpEntryRequest{ + Url: "${secrets.api_endpoint}", + }, + }, + }, + }, + expected: true, + }, + "secret variable in header": { + settings: &sm.MultiHttpSettings{ + Entries: []*sm.MultiHttpEntry{ + { + Request: &sm.MultiHttpEntryRequest{ + Url: "https://example.com", + Headers: []*sm.HttpHeader{ + {Name: "Authorization", Value: "Bearer ${secrets.token}"}, + }, + }, + }, + }, + }, + expected: true, + }, + "secret variable in query field": { + settings: &sm.MultiHttpSettings{ + Entries: []*sm.MultiHttpEntry{ + { + Request: &sm.MultiHttpEntryRequest{ + Url: "https://example.com", + QueryFields: []*sm.QueryField{ + {Name: "key", Value: "${secrets.api_key}"}, + }, + }, + }, + }, + }, + expected: true, + }, + "secret variable in body": { + settings: &sm.MultiHttpSettings{ + Entries: []*sm.MultiHttpEntry{ + { + Request: &sm.MultiHttpEntryRequest{ + Url: "https://example.com", + Body: &sm.HttpRequestBody{ + Payload: []byte(`{"password": "${secrets.user_password}"}`), + }, + }, + }, + }, + }, + expected: true, + }, + "secret variable in second entry": { + settings: &sm.MultiHttpSettings{ + Entries: []*sm.MultiHttpEntry{ + { + Request: &sm.MultiHttpEntryRequest{ + Url: "https://example.com", + }, + }, + { + Request: &sm.MultiHttpEntryRequest{ + Url: "${secrets.api_url}", + }, + }, + }, + }, + expected: true, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual := hasSecretVariables(tc.settings) + require.Equal(t, tc.expected, actual) + }) + } +} + +func TestSecretVariableEdgeCases(t *testing.T) { + testcases := map[string]struct { + input string + expected string + }{ + "empty string": { + input: "", + expected: `''`, + }, + "only secret variable": { + input: "${secrets.key}", + expected: `await secrets.get('key')`, + }, + "secret variable with special characters": { + input: "${secrets.api-key_123}", + expected: `await secrets.get('api-key_123')`, + }, + "secret variable with periods": { + input: "${secrets.api.v1.key}", + expected: `await secrets.get('api.v1.key')`, + }, + "mixed content with secret variable": { + input: "prefix${secrets.key}suffix", + expected: `'prefix'+await secrets.get('key')+'suffix'`, + }, + "multiple secret variables": { + input: "${secrets.key1}${secrets.key2}", + expected: `await secrets.get('key1')+await secrets.get('key2')`, + }, + "secret variable with escaped content": { + input: "https://example.com/${secrets.path}?q=test", + expected: `'https://example.com/'+await secrets.get('path')+'?q\u003Dtest'`, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual := performVariableExpansion(tc.input) + require.Equal(t, tc.expected, actual) + }) + } +} + +func TestSecretNameFormatValidation(t *testing.T) { + testcases := map[string]struct { + input string + expected string + shouldMatch bool + }{ + // Valid formats - should match and generate correct output + "simple lowercase": { + input: "${secrets.key}", + expected: `await secrets.get('key')`, + shouldMatch: true, + }, + "with uppercase": { + input: "${secrets.API_KEY}", + expected: `await secrets.get('API_KEY')`, + shouldMatch: true, + }, + "with numbers": { + input: "${secrets.key123}", + expected: `await secrets.get('key123')`, + shouldMatch: true, + }, + "with dashes": { + input: "${secrets.api-key}", + expected: `await secrets.get('api-key')`, + shouldMatch: true, + }, + "with periods": { + input: "${secrets.api.v1.key}", + expected: `await secrets.get('api.v1.key')`, + shouldMatch: true, + }, + "with underscores": { + input: "${secrets.api_key}", + expected: `await secrets.get('api_key')`, + shouldMatch: true, + }, + "mixed characters": { + input: "${secrets.api-v1.key_123}", + expected: `await secrets.get('api-v1.key_123')`, + shouldMatch: true, + }, + "starts with number": { + input: "${secrets.123key}", + expected: `await secrets.get('123key')`, + shouldMatch: true, + }, + "single character": { + input: "${secrets.a}", + expected: `await secrets.get('a')`, + shouldMatch: true, + }, + "single number": { + input: "${secrets.1}", + expected: `await secrets.get('1')`, + shouldMatch: true, + }, + "multiple periods": { + input: "${secrets.a.b.c.d}", + expected: `await secrets.get('a.b.c.d')`, + shouldMatch: true, + }, + "consecutive dashes": { + input: "${secrets.api--key}", + expected: `await secrets.get('api--key')`, + shouldMatch: true, + }, + "consecutive periods": { + input: "${secrets.api..key}", + expected: `await secrets.get('api..key')`, + shouldMatch: true, + }, + "consecutive underscores": { + input: "${secrets.api__key}", + expected: `await secrets.get('api__key')`, + shouldMatch: true, + }, + "starts with dash": { + input: "${secrets.-key}", + expected: `'${secrets.-key}'`, + shouldMatch: false, + }, + "starts with period": { + input: "${secrets..key}", + expected: `'${secrets..key}'`, + shouldMatch: false, + }, + "starts with underscore": { + input: "${secrets._key}", + expected: `await secrets.get('_key')`, + shouldMatch: true, + }, + "ends with dash": { + input: "${secrets.key-}", + expected: `await secrets.get('key-')`, + shouldMatch: true, + }, + "ends with period": { + input: "${secrets.key.}", + expected: `await secrets.get('key.')`, + shouldMatch: true, + }, + "ends with underscore": { + input: "${secrets.key_}", + expected: `await secrets.get('key_')`, + shouldMatch: true, + }, + "all dashes": { + input: "${secrets.---}", + expected: `'${secrets.---}'`, + shouldMatch: false, + }, + "all periods": { + input: "${secrets...}", + expected: `'${secrets...}'`, + shouldMatch: false, + }, + "all underscores": { + input: "${secrets.___}", + expected: `await secrets.get('___')`, + shouldMatch: true, + }, + "very long name": { + input: "${secrets.this-is-a-very-long-secret-name-with-many-characters-and-numbers-123456789}", + expected: `await secrets.get('this-is-a-very-long-secret-name-with-many-characters-and-numbers-123456789')`, + shouldMatch: true, + }, + "complex nested structure": { + input: "${secrets.prod.api.v1.user.auth.token}", + expected: `await secrets.get('prod.api.v1.user.auth.token')`, + shouldMatch: true, + }, + + // Invalid formats - should not match and return literal string + "invalid character space": { + input: "${secrets.key name}", + expected: `'${secrets.key name}'`, + shouldMatch: false, + }, + "invalid character at": { + input: "${secrets.key@name}", + expected: `'${secrets.key@name}'`, + shouldMatch: false, + }, + "invalid character hash": { + input: "${secrets.key#name}", + expected: `'${secrets.key#name}'`, + shouldMatch: false, + }, + "invalid character dollar": { + input: "${secrets.key$name}", + expected: `'${secrets.key$name}'`, + shouldMatch: false, + }, + "invalid character percent": { + input: "${secrets.key%name}", + expected: `'${secrets.key%name}'`, + shouldMatch: false, + }, + "invalid character caret": { + input: "${secrets.key^name}", + expected: `'${secrets.key^name}'`, + shouldMatch: false, + }, + "invalid character ampersand": { + input: "${secrets.key&name}", + expected: `'${secrets.key\u0026name}'`, + shouldMatch: false, + }, + "invalid character asterisk": { + input: "${secrets.key*name}", + expected: `'${secrets.key*name}'`, + shouldMatch: false, + }, + "invalid character parentheses": { + input: "${secrets.key(name)}", + expected: `'${secrets.key(name)}'`, + shouldMatch: false, + }, + "invalid character brackets": { + input: "${secrets.key[name]}", + expected: `'${secrets.key[name]}'`, + shouldMatch: false, + }, + "invalid character braces": { + input: "${secrets.key{name}}", + expected: `'${secrets.key{name}}'`, + shouldMatch: false, + }, + "invalid character pipe": { + input: "${secrets.key|name}", + expected: `'${secrets.key|name}'`, + shouldMatch: false, + }, + "invalid character backslash": { + input: "${secrets.key\\name}", + expected: `'${secrets.key\\name}'`, + shouldMatch: false, + }, + "invalid character forward slash": { + input: "${secrets.key/name}", + expected: `'${secrets.key/name}'`, + shouldMatch: false, + }, + "invalid character semicolon": { + input: "${secrets.key;name}", + expected: `'${secrets.key;name}'`, + shouldMatch: false, + }, + "invalid character colon": { + input: "${secrets.key:name}", + expected: `'${secrets.key:name}'`, + shouldMatch: false, + }, + "invalid character quote": { + input: "${secrets.key\"name}", + expected: `'${secrets.key\"name}'`, + shouldMatch: false, + }, + "invalid character single quote": { + input: "${secrets.key'name}", + expected: `'${secrets.key\'name}'`, + shouldMatch: false, + }, + "invalid character comma": { + input: "${secrets.key,name}", + expected: `'${secrets.key,name}'`, + shouldMatch: false, + }, + "invalid character question mark": { + input: "${secrets.key?name}", + expected: `'${secrets.key?name}'`, + shouldMatch: false, + }, + "invalid character exclamation": { + input: "${secrets.key!name}", + expected: `'${secrets.key!name}'`, + shouldMatch: false, + }, + "invalid character tilde": { + input: "${secrets.key~name}", + expected: `'${secrets.key~name}'`, + shouldMatch: false, + }, + "invalid character backtick": { + input: "${secrets.key`name}", + expected: `'${secrets.key` + "`" + `name}'`, + shouldMatch: false, + }, + "invalid character plus": { + input: "${secrets.key+name}", + expected: `'${secrets.key+name}'`, + shouldMatch: false, + }, + "invalid character equals": { + input: "${secrets.key=name}", + expected: `'${secrets.key\u003Dname}'`, + shouldMatch: false, + }, + "invalid character less than": { + input: "${secrets.keyname}", + expected: `'${secrets.key\u003Ename}'`, + shouldMatch: false, + }, + "empty secret name": { + input: "${secrets.}", + expected: `'${secrets.}'`, + shouldMatch: false, + }, + "missing secret prefix": { + input: "${key}", + expected: `vars['key']`, + shouldMatch: false, + }, + "wrong prefix": { + input: "${env.key}", + expected: `'${env.key}'`, + shouldMatch: false, + }, + "malformed syntax": { + input: "${secretkey}", + expected: `vars['secretkey']`, + shouldMatch: false, + }, + "malformed syntax with space": { + input: "${secrets key}", + expected: `'${secrets key}'`, + shouldMatch: false, + }, + "malformed syntax with colon": { + input: "${secrets:key}", + expected: `'${secrets:key}'`, + shouldMatch: false, + }, + "malformed syntax with slash": { + input: "${secrets/key}", + expected: `'${secrets/key}'`, + shouldMatch: false, + }, + "unicode characters": { + input: "${secrets.kéy}", + expected: `'${secrets.kéy}'`, + shouldMatch: false, + }, + "emoji characters": { + input: "${secrets.key🔑}", + expected: `'${secrets.key🔑}'`, + shouldMatch: false, + }, + "control characters": { + input: "${secrets.key\x00name}", + expected: `'${secrets.key\u0000name}'`, + shouldMatch: false, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual := performVariableExpansion(tc.input) + require.Equal(t, tc.expected, actual) + + // Additional validation: check if the regex actually matches + matches := interpolation.SecretRegex.MatchString(tc.input) + require.Equal(t, tc.shouldMatch, matches, + "Regex match result doesn't match expected. Input: %s, Expected match: %v, Actual match: %v", + tc.input, tc.shouldMatch, matches) + }) + } +} + +func TestSecretNameFormatInContexts(t *testing.T) { + testcases := map[string]struct { + context string + secretName string + expected string + }{ + "in URL": { + context: "url", + secretName: "api.v1.endpoint", + expected: `await secrets.get('api.v1.endpoint')`, + }, + "in header value": { + context: "header", + secretName: "auth-token-123", + expected: `{"Authorization":await secrets.get('auth-token-123')}`, + }, + "in query parameter": { + context: "query", + secretName: "api_key.prod", + expected: `url.searchParams.append('key', await secrets.get('api_key.prod'))`, + }, + "in request body": { + context: "body", + secretName: "user.password.encrypted", + expected: `body=body.replaceAll('${secrets.user.password.encrypted}', await secrets.get('user.password.encrypted'))`, + }, + "mixed with regular variables": { + context: "mixed", + secretName: "prod.api.v1.key", + expected: `'https://api.${env}.com/'+await secrets.get('prod.api.v1.key')`, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + var actual string + switch tc.context { + case "url": + actual = performVariableExpansion("${secrets." + tc.secretName + "}") + case "header": + headers := []*sm.HttpHeader{ + {Name: "Authorization", Value: "${secrets." + tc.secretName + "}"}, + } + actual = buildHeaders(headers, nil) + case "query": + req := sm.MultiHttpEntryRequest{ + QueryFields: []*sm.QueryField{ + {Name: "key", Value: "${secrets." + tc.secretName + "}"}, + }, + } + result := buildQueryParams("url", &req) + actual = result[0] + case "body": + body := &sm.HttpRequestBody{ + Payload: []byte(`{"password": "${secrets.` + tc.secretName + `}"}`), + } + result := interpolateBodyVariables("body", body) + actual = result[0] + case "mixed": + actual = performVariableExpansion("https://api.${env}.com/${secrets." + tc.secretName + "}") + } + require.Equal(t, tc.expected, actual) + }) + } +} + +func TestSecretNameFormatEdgeCases(t *testing.T) { + testcases := map[string]struct { + input string + expected string + description string + }{ + "zero length name": { + input: "${secrets.}", + expected: `'${secrets.}'`, + description: "Empty secret name should not match", + }, + "single dot": { + input: "${secrets..}", + expected: `'${secrets..}'`, + description: "Single dot should not match as it doesn't start with a valid character", + }, + "single dash": { + input: "${secrets.-}", + expected: `'${secrets.-}'`, + description: "Single dash should not match as it doesn't start with a valid character", + }, + "single underscore": { + input: "${secrets._}", + expected: `await secrets.get('_')`, + description: "Single underscore should match as it starts with a valid character", + }, + "leading and trailing dots": { + input: "${secrets..key..}", + expected: `'${secrets..key..}'`, + description: "Leading and trailing dots should not match as it doesn't start with a valid character", + }, + "leading and trailing dashes": { + input: "${secrets.--key--}", + expected: `'${secrets.--key--}'`, + description: "Leading and trailing dashes should not match as it doesn't start with a valid character", + }, + "leading and trailing underscores": { + input: "${secrets.__key__}", + expected: `await secrets.get('__key__')`, + description: "Leading and trailing underscores should be preserved", + }, + "alternating special characters": { + input: "${secrets.a-b_c.d-e_f}", + expected: `await secrets.get('a-b_c.d-e_f')`, + description: "Alternating special characters should be preserved", + }, + "consecutive special characters": { + input: "${secrets.a--b__c..d}", + expected: `await secrets.get('a--b__c..d')`, + description: "Consecutive special characters should be preserved", + }, + "numbers only": { + input: "${secrets.123456789}", + expected: `await secrets.get('123456789')`, + description: "Numbers only should be valid", + }, + "mixed case with numbers": { + input: "${secrets.ApiKey123}", + expected: `await secrets.get('ApiKey123')`, + description: "Mixed case with numbers should be valid", + }, + "complex nested structure": { + input: "${secrets.prod.api.v1.user.auth.token.encrypted}", + expected: `await secrets.get('prod.api.v1.user.auth.token.encrypted')`, + description: "Complex nested structure should be valid", + }, + "very long name with all valid characters": { + input: "${secrets.this-is-a-very-long-secret-name-with-many-characters-and-numbers-123456789-and-underscores_and_periods.and.more}", + expected: `await secrets.get('this-is-a-very-long-secret-name-with-many-characters-and-numbers-123456789-and-underscores_and_periods.and.more')`, + description: "Very long name with all valid characters should be valid", + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual := performVariableExpansion(tc.input) + require.Equal(t, tc.expected, actual, tc.description) + }) + } +}