742 lines
25 KiB
Go
742 lines
25 KiB
Go
package providerapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"proxy-pool/internal/config"
|
|
controllerProvider "proxy-pool/internal/controller/provider"
|
|
proxyDomain "proxy-pool/internal/domain/proxy"
|
|
"proxy-pool/internal/platform/credentials"
|
|
)
|
|
|
|
var _ controllerProvider.Parser = (*TemplateParser)(nil)
|
|
|
|
func newTemplateParser(
|
|
upstreamID string,
|
|
upstream config.Upstream,
|
|
stores ...credentials.Store,
|
|
) (*TemplateParser, error) {
|
|
var store credentials.Store
|
|
if len(stores) > 0 {
|
|
store = stores[0]
|
|
}
|
|
return NewTemplateParser(upstreamID, upstream, store)
|
|
}
|
|
|
|
func TestTemplateParserFormattingDoesNotExposeTemplateCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `http://response-user:response-secret@192.0.2.10:8080`},
|
|
ProxyAuth: config.ProxyAuth{Type: "static", Username: "static-user", Password: "static-secret"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
formatted := fmt.Sprintf("%v %+v %#v", parser, parser, parser)
|
|
for _, secret := range []string{"response-secret", "static-secret", "response-user"} {
|
|
if strings.Contains(formatted, secret) {
|
|
t.Fatalf("formatted parser contains %q: %s", secret, formatted)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserExecutesWhitelistedRegexAndBuildsProxy(t *testing.T) {
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `{{$value := regexFind "[0-9.]+:[0-9]+" .}}{{if $value}}{{printf "http://%s\n" $value}}{{end}}`},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
Capacity: config.Capacity{MaxConcurrencyPerProxy: 7},
|
|
Fetch: config.Fetch{
|
|
MaxResponseBytes: 1024,
|
|
TemplateTimeout: config.Duration(time.Second),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
proxies, err := parser.Parse(context.Background(), []byte("address=192.0.2.10:8080"))
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
if len(proxies) != 1 {
|
|
t.Fatalf("proxy count = %d, want 1", len(proxies))
|
|
}
|
|
got := proxies[0]
|
|
if got.Scheme != proxyDomain.SchemeHTTP || got.Host != "192.0.2.10" || got.Port != 8080 {
|
|
t.Fatalf("proxy endpoint = %s://%s:%d, want http://192.0.2.10:8080", got.Scheme, got.Host, got.Port)
|
|
}
|
|
if got.SourceUpstream != "provider-a" || got.MaxConcurrency != 7 || got.State != proxyDomain.StateFetched {
|
|
t.Fatalf("proxy metadata = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserRejectsOversizedInput(t *testing.T) {
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `{{.}}`},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
Fetch: config.Fetch{
|
|
MaxResponseBytes: 4,
|
|
TemplateTimeout: config.Duration(time.Second),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
_, err = parser.Parse(context.Background(), []byte("12345"))
|
|
if !errors.Is(err, ErrTemplateInputTooLarge) {
|
|
t.Fatalf("Parse() error = %v, want ErrTemplateInputTooLarge", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserStopsOversizedOutputDuringExecution(t *testing.T) {
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `0123456789`},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
Fetch: config.Fetch{
|
|
MaxResponseBytes: 8,
|
|
TemplateTimeout: config.Duration(time.Second),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
_, err = parser.Parse(context.Background(), []byte("x"))
|
|
if !errors.Is(err, ErrTemplateOutputTooLarge) {
|
|
t.Fatalf("Parse() error = %v, want ErrTemplateOutputTooLarge", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserRejectsTooManyCandidates(t *testing.T) {
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: "http://192.0.2.1:8001\nhttp://192.0.2.2:8002\n"},
|
|
Pool: config.Pool{MaxSize: 1},
|
|
Fetch: config.Fetch{
|
|
MaxResponseBytes: 1024,
|
|
TemplateTimeout: config.Duration(time.Second),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
_, err = parser.Parse(context.Background(), nil)
|
|
if !errors.Is(err, ErrTooManyCandidates) {
|
|
t.Fatalf("Parse() error = %v, want ErrTooManyCandidates", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserRegexFindAllCountSurvivesMaxCandidateBoundary(t *testing.T) {
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `{{range regexFindAll "a" . 1}}http://192.0.2.10:8080
|
|
{{end}}`},
|
|
Pool: config.Pool{MaxSize: int(^uint(0) >> 1)},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
proxies, err := parser.Parse(context.Background(), []byte("aaa"))
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
if len(proxies) != 1 {
|
|
t.Fatalf("proxy count = %d, want regex count limit 1", len(proxies))
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserEnforcesExecutionTimeout(t *testing.T) {
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `{{$matches := regexFindAll "a" . -1}}{{range $matches}}http://192.0.2.10:8080
|
|
{{end}}`},
|
|
Pool: config.Pool{MaxSize: 20_000},
|
|
Fetch: config.Fetch{
|
|
MaxResponseBytes: 1 << 20,
|
|
TemplateTimeout: config.Duration(time.Nanosecond),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
_, err = parser.Parse(context.Background(), []byte(strings.Repeat("a", 10_000)))
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("Parse() error = %v, want context.DeadlineExceeded", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserTimedOutExecutionsRemainBounded(t *testing.T) {
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `{{$matches := regexFindAll "a" . -1}}{{range $matches}}{{printf "%s" .}}{{end}}`},
|
|
Pool: config.Pool{MaxSize: 20_000},
|
|
Fetch: config.Fetch{
|
|
MaxResponseBytes: 1 << 20,
|
|
TemplateTimeout: config.Duration(time.Nanosecond),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
baseline := runtime.NumGoroutine()
|
|
for range 20 {
|
|
_, err := parser.Parse(context.Background(), []byte(strings.Repeat("a", 10_000)))
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("Parse() error = %v, want context.DeadlineExceeded", err)
|
|
}
|
|
}
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for runtime.NumGoroutine() > baseline+4 && time.Now().Before(deadline) {
|
|
runtime.Gosched()
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if got := runtime.NumGoroutine(); got > baseline+4 {
|
|
t.Fatalf("goroutines after timed-out executions = %d, baseline = %d", got, baseline)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserRejectsRecursiveTemplates(t *testing.T) {
|
|
_, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `
|
|
{{define "first"}}{{template "second"}}{{end}}
|
|
{{define "second"}}{{template "first"}}{{end}}
|
|
{{template "first"}}`},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
})
|
|
if !errors.Is(err, ErrRecursiveTemplate) {
|
|
t.Fatalf("newTemplateParser() error = %v, want ErrRecursiveTemplate", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserRejectsNonEmptyAllInvalidOutput(t *testing.T) {
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `not-a-proxy`},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
Fetch: config.Fetch{
|
|
MaxResponseBytes: 1024,
|
|
TemplateTimeout: config.Duration(time.Second),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
_, err = parser.Parse(context.Background(), nil)
|
|
if !errors.Is(err, ErrInvalidProxyOutput) {
|
|
t.Fatalf("Parse() error = %v, want ErrInvalidProxyOutput", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserAcceptsIPv6AndFiltersUnsafeEndpoints(t *testing.T) {
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: strings.Join([]string{
|
|
"http://[2001:db8::1]:8080",
|
|
"http://192.0.2.1:8080/path",
|
|
"http://192.0.2.2:8080?token=secret",
|
|
"http://192.0.2.3:0",
|
|
"http://192.0.2.4:65536",
|
|
"socks5://192.0.2.5:1080",
|
|
}, "\n")},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
proxies, err := parser.Parse(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
if len(proxies) != 1 || proxies[0].Host != "2001:db8::1" || proxies[0].Port != 8080 {
|
|
t.Fatalf("proxies = %+v, want only IPv6 endpoint", proxies)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserDoesNotOverrideStaticProxyAuthFromResponse(t *testing.T) {
|
|
store, err := credentials.NewMemoryStore(10)
|
|
if err != nil {
|
|
t.Fatalf("NewMemoryStore(): %v", err)
|
|
}
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `http://response-user:response-pass@192.0.2.10:8080`},
|
|
ProxyAuth: config.ProxyAuth{Type: "static", Username: "configured-user", Password: "configured-pass"},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
}, store)
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
proxies, err := parser.Parse(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
if len(proxies) != 1 || proxies[0].Username != "configured-user" {
|
|
t.Fatalf("proxies = %+v, want configured static username", proxies)
|
|
}
|
|
if strings.Contains(proxies[0].SecretRef, "configured-pass") || strings.Contains(proxies[0].SecretRef, "response-pass") {
|
|
t.Fatalf("SecretRef contains plaintext password: %q", proxies[0].SecretRef)
|
|
}
|
|
if proxies[0].SecretRef == "" || proxies[0].CredentialVersion == "" {
|
|
t.Fatalf("proxy credential reference is incomplete: %+v", proxies[0])
|
|
}
|
|
value, err := store.Resolve(context.Background(), credentials.Reference{
|
|
SecretRef: proxies[0].SecretRef,
|
|
CredentialVersion: proxies[0].CredentialVersion,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve(): %v", err)
|
|
}
|
|
if value.Username != "configured-user" || value.Password != "configured-pass" {
|
|
t.Fatalf("resolved static credentials = %v, want configured credentials", value)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserRetainsDistinctEndpointsSharingStaticCredentials(t *testing.T) {
|
|
store, err := credentials.NewMemoryStore(2)
|
|
if err != nil {
|
|
t.Fatalf("NewMemoryStore(): %v", err)
|
|
}
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: strings.Join([]string{
|
|
"http://192.0.2.10:8080",
|
|
"http://192.0.2.11:8080",
|
|
}, "\n")},
|
|
ProxyAuth: config.ProxyAuth{Type: "static", Username: "configured-user", Password: "configured-pass"},
|
|
Pool: config.Pool{MaxSize: 2},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
}, store)
|
|
if err != nil {
|
|
t.Fatalf("NewTemplateParser(): %v", err)
|
|
}
|
|
|
|
proxies, err := parser.Parse(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
if len(proxies) != 2 {
|
|
t.Fatalf("proxy count = %d, want both static-auth endpoints", len(proxies))
|
|
}
|
|
if proxies[0].SecretRef == "" || proxies[1].SecretRef == "" || proxies[0].SecretRef == proxies[1].SecretRef {
|
|
t.Fatalf("static credential references = %q and %q, want independent leases", proxies[0].SecretRef, proxies[1].SecretRef)
|
|
}
|
|
if proxies[0].CredentialVersion != proxies[1].CredentialVersion {
|
|
t.Fatalf("static credential versions = %q and %q, want same value version", proxies[0].CredentialVersion, proxies[1].CredentialVersion)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserStoresResponseCredentialsByOpaqueReference(t *testing.T) {
|
|
store, err := credentials.NewMemoryStore(10)
|
|
if err != nil {
|
|
t.Fatalf("NewMemoryStore(): %v", err)
|
|
}
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `http://response-user:response-secret@192.0.2.10:8080`},
|
|
ProxyAuth: config.ProxyAuth{Type: "response"},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
}, store)
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
proxies, err := parser.Parse(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
if len(proxies) != 1 {
|
|
t.Fatalf("proxy count = %d, want 1", len(proxies))
|
|
}
|
|
got := proxies[0]
|
|
if got.Username != "response-user" || got.SecretRef == "" || got.CredentialVersion == "" {
|
|
t.Fatalf("proxy credentials metadata = %+v", got)
|
|
}
|
|
formatted := fmt.Sprintf("%v %+v %#v", got, got, got)
|
|
if strings.Contains(formatted, "response-secret") || strings.Contains(got.SecretRef, "response-secret") {
|
|
t.Fatalf("proxy formatting or reference contains password: %s", formatted)
|
|
}
|
|
value, err := store.Resolve(context.Background(), credentials.Reference{
|
|
SecretRef: got.SecretRef,
|
|
CredentialVersion: got.CredentialVersion,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve(): %v", err)
|
|
}
|
|
if value.Username != "response-user" || value.Password != "response-secret" {
|
|
t.Fatalf("resolved response credentials = %v, want response credentials", value)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserReleasesPartialCredentialsWhenParseFails(t *testing.T) {
|
|
store, err := credentials.NewMemoryStore(1)
|
|
if err != nil {
|
|
t.Fatalf("NewMemoryStore(): %v", err)
|
|
}
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: strings.Join([]string{
|
|
"http://alice:first-password@192.0.2.10:8080",
|
|
"http://bob:second-password@192.0.2.11:8080",
|
|
}, "\n")},
|
|
ProxyAuth: config.ProxyAuth{Type: "response"},
|
|
Pool: config.Pool{MaxSize: 2},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
}, store)
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
if _, err := parser.Parse(context.Background(), nil); !errors.Is(err, credentials.ErrCapacityExceeded) {
|
|
t.Fatalf("Parse() error = %v, want ErrCapacityExceeded", err)
|
|
}
|
|
if _, err := store.Put(context.Background(), "replacement", credentials.Value{Password: "replacement"}); err != nil {
|
|
t.Fatalf("Put(after failed parse): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserKeepsDistinctAccountsForSameEndpointResolvable(t *testing.T) {
|
|
store, err := credentials.NewMemoryStore(2)
|
|
if err != nil {
|
|
t.Fatalf("NewMemoryStore(): %v", err)
|
|
}
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: strings.Join([]string{
|
|
"http://alice:alice-secret@192.0.2.10:8080",
|
|
"http://bob:bob-secret@192.0.2.10:8080",
|
|
}, "\n")},
|
|
ProxyAuth: config.ProxyAuth{Type: "response"},
|
|
Pool: config.Pool{MaxSize: 2},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
}, store)
|
|
if err != nil {
|
|
t.Fatalf("NewTemplateParser(): %v", err)
|
|
}
|
|
|
|
proxies, err := parser.Parse(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
if len(proxies) != 2 {
|
|
t.Fatalf("proxy count = %d, want 2", len(proxies))
|
|
}
|
|
for _, candidate := range proxies {
|
|
value, err := store.Resolve(context.Background(), credentials.Reference{
|
|
SecretRef: candidate.SecretRef,
|
|
CredentialVersion: candidate.CredentialVersion,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve(%s): %v", candidate.Username, err)
|
|
}
|
|
if value.Username != candidate.Username {
|
|
t.Fatalf("resolved username = %q, want %q", value.Username, candidate.Username)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserKeepsLatestCredentialVersionWithinOneResponse(t *testing.T) {
|
|
store, err := credentials.NewMemoryStore(2)
|
|
if err != nil {
|
|
t.Fatalf("NewMemoryStore(): %v", err)
|
|
}
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: strings.Join([]string{
|
|
"http://alice:old-secret@192.0.2.10:8080",
|
|
"http://alice:new-secret@192.0.2.10:8080",
|
|
}, "\n")},
|
|
ProxyAuth: config.ProxyAuth{Type: "response"},
|
|
Pool: config.Pool{MaxSize: 2},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
}, store)
|
|
if err != nil {
|
|
t.Fatalf("NewTemplateParser(): %v", err)
|
|
}
|
|
|
|
proxies, err := parser.Parse(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
if len(proxies) != 1 {
|
|
t.Fatalf("proxy count = %d, want latest credential only", len(proxies))
|
|
}
|
|
value, err := store.Resolve(context.Background(), credentials.Reference{
|
|
SecretRef: proxies[0].SecretRef,
|
|
CredentialVersion: proxies[0].CredentialVersion,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve(): %v", err)
|
|
}
|
|
if value.Password != "new-secret" {
|
|
t.Fatalf("resolved latest password mismatch")
|
|
}
|
|
parser.ReleaseCandidates(proxies)
|
|
if _, err := store.Put(context.Background(), "replacement", credentials.Value{Password: "replacement"}); err != nil {
|
|
t.Fatalf("Put(after releasing latest candidates): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserReleaseCandidatesReturnsCredentialCapacity(t *testing.T) {
|
|
store, err := credentials.NewMemoryStore(1)
|
|
if err != nil {
|
|
t.Fatalf("NewMemoryStore(): %v", err)
|
|
}
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: "http://alice:secret@192.0.2.10:8080"},
|
|
ProxyAuth: config.ProxyAuth{Type: "response"},
|
|
Pool: config.Pool{MaxSize: 1},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
}, store)
|
|
if err != nil {
|
|
t.Fatalf("NewTemplateParser(): %v", err)
|
|
}
|
|
|
|
proxies, err := parser.Parse(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Parse(): %v", err)
|
|
}
|
|
parser.ReleaseCandidates(proxies)
|
|
if _, err := store.Put(context.Background(), "replacement", credentials.Value{Password: "replacement"}); err != nil {
|
|
t.Fatalf("Put(after ReleaseCandidates): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserRedactsCredentialStoreErrors(t *testing.T) {
|
|
const password = "provider-password"
|
|
storeErr := errors.New("store failed for " + password)
|
|
store := &failingCredentialStore{err: storeErr}
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `http://user:` + password + `@192.0.2.10:8080`},
|
|
ProxyAuth: config.ProxyAuth{Type: "response"},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
}, store)
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
_, err = parser.Parse(context.Background(), nil)
|
|
if !errors.Is(err, storeErr) {
|
|
t.Fatalf("Parse() error = %v, want wrapped store error", err)
|
|
}
|
|
if strings.Contains(err.Error(), password) {
|
|
t.Fatalf("Parse() error leaked password: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserRejectsMissingStoreAndEmptyCredentialReference(t *testing.T) {
|
|
upstream := config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `http://user:password@192.0.2.10:8080`},
|
|
ProxyAuth: config.ProxyAuth{Type: "response"},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
}
|
|
parser, err := newTemplateParser("provider-a", upstream)
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
if _, err := parser.Parse(context.Background(), nil); !errors.Is(err, ErrCredentialStoreRequired) {
|
|
t.Fatalf("Parse() error = %v, want ErrCredentialStoreRequired", err)
|
|
}
|
|
|
|
parser, err = newTemplateParser("provider-a", upstream, emptyReferenceCredentialStore{})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(with store): %v", err)
|
|
}
|
|
if _, err := parser.Parse(context.Background(), nil); !errors.Is(err, credentials.ErrInvalidReference) {
|
|
t.Fatalf("Parse() error = %v, want credentials.ErrInvalidReference", err)
|
|
}
|
|
}
|
|
|
|
type failingCredentialStore struct{ err error }
|
|
|
|
func (s *failingCredentialStore) Put(context.Context, string, credentials.Value) (credentials.Reference, error) {
|
|
return credentials.Reference{}, s.err
|
|
}
|
|
|
|
func (s *failingCredentialStore) Resolve(context.Context, credentials.Reference) (credentials.Value, error) {
|
|
return credentials.Value{}, s.err
|
|
}
|
|
|
|
type emptyReferenceCredentialStore struct{}
|
|
|
|
func (emptyReferenceCredentialStore) Put(context.Context, string, credentials.Value) (credentials.Reference, error) {
|
|
return credentials.Reference{}, nil
|
|
}
|
|
|
|
func (emptyReferenceCredentialStore) Resolve(context.Context, credentials.Reference) (credentials.Value, error) {
|
|
return credentials.Value{}, credentials.ErrCredentialMissing
|
|
}
|
|
|
|
func TestTemplateParserRejectsFunctionsOutsideWhitelist(t *testing.T) {
|
|
tests := []string{
|
|
`{{env "SECRET_TOKEN"}}`,
|
|
`{{readFile "credentials.txt"}}`,
|
|
`{{httpGet "https://example.invalid"}}`,
|
|
`{{exec "command"}}`,
|
|
}
|
|
for _, source := range tests {
|
|
if _, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: source},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
}); err == nil {
|
|
t.Fatalf("newTemplateParser(%q) error = nil, want unknown function error", source)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserRedactsInvalidRegexPattern(t *testing.T) {
|
|
const secretPattern = "(?P<secret-token>"
|
|
parser, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: `{{regexFind . "value"}}`},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
Fetch: config.Fetch{MaxResponseBytes: 1024, TemplateTimeout: config.Duration(time.Second)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newTemplateParser(): %v", err)
|
|
}
|
|
|
|
_, err = parser.Parse(context.Background(), []byte(secretPattern))
|
|
if err == nil {
|
|
t.Fatal("Parse() error = nil, want invalid regex error")
|
|
}
|
|
if strings.Contains(err.Error(), secretPattern) || strings.Contains(err.Error(), "secret-token") {
|
|
t.Fatalf("Parse() error leaked regex pattern: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTemplateParserRejectsExcessiveASTNesting(t *testing.T) {
|
|
const nesting = 40
|
|
source := strings.Repeat("{{range .}}", nesting) + strings.Repeat("{{end}}", nesting)
|
|
_, err := newTemplateParser("provider-a", config.Upstream{
|
|
Provider: config.Provider{Protocols: []string{"http"}},
|
|
API: config.ProviderAPI{Template: source},
|
|
Pool: config.Pool{MaxSize: 10},
|
|
})
|
|
if !errors.Is(err, ErrTemplateTooComplex) {
|
|
t.Fatalf("newTemplateParser() error = %v, want ErrTemplateTooComplex", err)
|
|
}
|
|
}
|
|
|
|
func TestExecutionLimiterRetainsSlotUntilTimedOutExecutionExits(t *testing.T) {
|
|
limiter := newExecutionLimiter(1)
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
firstDone := make(chan error, 1)
|
|
firstCtx, cancelFirst := context.WithCancel(context.Background())
|
|
go func() {
|
|
firstDone <- limiter.Run(firstCtx, func() error {
|
|
close(started)
|
|
<-release
|
|
return nil
|
|
})
|
|
}()
|
|
<-started
|
|
cancelFirst()
|
|
if err := <-firstDone; !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("first Run() error = %v, want context.Canceled", err)
|
|
}
|
|
|
|
secondStarted := make(chan struct{})
|
|
secondCtx, cancelSecond := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
|
defer cancelSecond()
|
|
err := limiter.Run(secondCtx, func() error {
|
|
close(secondStarted)
|
|
return nil
|
|
})
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("second Run() error = %v, want context.DeadlineExceeded", err)
|
|
}
|
|
select {
|
|
case <-secondStarted:
|
|
t.Fatal("second execution started while timed-out execution retained the only slot")
|
|
default:
|
|
}
|
|
|
|
close(release)
|
|
deadline := time.Now().Add(time.Second)
|
|
for limiter.InFlight() != 0 && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if got := limiter.InFlight(); got != 0 {
|
|
t.Fatalf("in-flight executions = %d, want 0 after execution exit", got)
|
|
}
|
|
}
|
|
|
|
func TestExecutionLimiterIsSafeForConcurrentInspection(t *testing.T) {
|
|
limiter := newExecutionLimiter(2)
|
|
var wait sync.WaitGroup
|
|
for range 10 {
|
|
wait.Add(1)
|
|
go func() {
|
|
defer wait.Done()
|
|
_ = limiter.InFlight()
|
|
}()
|
|
}
|
|
wait.Wait()
|
|
}
|
|
|
|
func TestExecutionLimiterDoesNotStartWithCanceledContext(t *testing.T) {
|
|
limiter := newExecutionLimiter(1)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
for range 20 {
|
|
started := make(chan struct{}, 1)
|
|
release := make(chan struct{})
|
|
err := limiter.Run(ctx, func() error {
|
|
started <- struct{}{}
|
|
<-release
|
|
return nil
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want context.Canceled", err)
|
|
}
|
|
select {
|
|
case <-started:
|
|
close(release)
|
|
t.Fatal("execution started with an already canceled context")
|
|
case <-time.After(5 * time.Millisecond):
|
|
close(release)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecutionLimiterCapsConfiguredConcurrency(t *testing.T) {
|
|
limiter := newExecutionLimiter(maxTemplateExecutions + 1)
|
|
if got := cap(limiter.slots); got != maxTemplateExecutions {
|
|
t.Fatalf("execution slot capacity = %d, want hard cap %d", got, maxTemplateExecutions)
|
|
}
|
|
}
|