proxy-pool/internal/adapters/providerapi/template_parser_test.go
youfak 4de3ffb85f
Some checks are pending
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
feat: add ephemeral proxy activity pool
2026-07-29 12:51:18 +08:00

682 lines
23 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(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://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[0].SecretRef != proxies[1].SecretRef {
t.Fatalf("static credential references = %q and %q, want same opaque reference", proxies[0].SecretRef, proxies[1].SecretRef)
}
}
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 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(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: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")
}
}
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)
}
}