diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 06e2f2c..1897bbb 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -59,18 +59,21 @@ jobs: - name: Install Go dependencies run: go mod download + - name: Run root module unit tests with coverage + run: | + mkdir ./tests/coverage-ci + go test -timeout 5m -v -race -cover -tags=debug -coverpkg=./... -coverprofile=./tests/coverage-ci/headers_unit.out -covermode=atomic ./... + - name: Run golang tests with coverage run: | cd tests - mkdir ./coverage-ci - go test -timeout 20m -v -race -cover -tags=debug -coverpkg=github.com/roadrunner-server/headers/v6/... -coverprofile=./coverage-ci/headers.out -covermode=atomic ./... - name: Archive code coverage results uses: actions/upload-artifact@v7 with: name: coverage-headers - path: ./tests/coverage-ci/headers.out + path: ./tests/coverage-ci codecov: name: Upload codecov diff --git a/init_test.go b/init_test.go new file mode 100644 index 0000000..21c0550 --- /dev/null +++ b/init_test.go @@ -0,0 +1,134 @@ +package headers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/roadrunner-server/errors" + "github.com/stretchr/testify/require" +) + +// stubConfigurer hands Init a pre-built Config instead of decoding YAML, so the +// tests exercise the plugin's own option mapping rather than the config decoder. +type stubConfigurer struct { + sections map[string]bool + cfg *Config +} + +func (s *stubConfigurer) Has(name string) bool { return s.sections[name] } + +func (s *stubConfigurer) UnmarshalKey(_ string, out any) error { + p, ok := out.(**Config) + if !ok { + return errors.Str("unexpected target type") + } + *p = s.cfg + return nil +} + +func bothSections() map[string]bool { + return map[string]bool{RootPluginName: true, configKey: true} +} + +func TestInitDisabledWithoutHTTPSection(t *testing.T) { + err := (&Plugin{}).Init(&stubConfigurer{sections: map[string]bool{}}) + + require.Error(t, err) + require.True(t, errors.Is(errors.Disabled, err)) +} + +func TestInitDisabledWithoutHeadersSection(t *testing.T) { + err := (&Plugin{}).Init(&stubConfigurer{sections: map[string]bool{RootPluginName: true}}) + + require.Error(t, err) + require.True(t, errors.Is(errors.Disabled, err)) +} + +func TestInitWithoutCORSLeavesHandlerUnset(t *testing.T) { + p := &Plugin{} + require.NoError(t, p.Init(&stubConfigurer{sections: bothSections(), cfg: &Config{}})) + + require.Nil(t, p.cors) + require.Nil(t, p.allowedOriginRegex) + require.NotNil(t, p.prop) +} + +func TestInitCompilesAllowedOriginRegex(t *testing.T) { + p := &Plugin{} + cfg := &Config{CORS: &CORSConfig{AllowedOriginRegex: `^https?://example\.com$`}} + require.NoError(t, p.Init(&stubConfigurer{sections: bothSections(), cfg: cfg})) + + require.NotNil(t, p.allowedOriginRegex) + require.True(t, p.allowedOriginRegex.MatchString("https://example.com")) + require.False(t, p.allowedOriginRegex.MatchString("https://evil.com")) +} + +func TestInitRejectsBadAllowedOriginRegex(t *testing.T) { + cfg := &Config{CORS: &CORSConfig{AllowedOriginRegex: "("}} + + err := (&Plugin{}).Init(&stubConfigurer{sections: bothSections(), cfg: cfg}) + + require.Error(t, err) +} + +// TestInitDefaultsOptionsSuccessStatus covers the compatibility default: an +// unset options_success_status must still answer preflights with 200. +func TestInitDefaultsOptionsSuccessStatus(t *testing.T) { + p := &Plugin{} + cfg := &Config{CORS: &CORSConfig{AllowedOrigin: "*", AllowedMethods: "GET"}} + require.NoError(t, p.Init(&stubConfigurer{sections: bothSections(), cfg: cfg})) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodOptions, "/", nil) + req.Header.Set("Origin", "http://example.com") + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + + p.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Result().StatusCode) +} + +func TestInitHonoursCustomOptionsSuccessStatus(t *testing.T) { + p := &Plugin{} + cfg := &Config{CORS: &CORSConfig{ + AllowedOrigin: "*", + AllowedMethods: "GET", + OptionsSuccessStatus: http.StatusNoContent, + }} + require.NoError(t, p.Init(&stubConfigurer{sections: bothSections(), cfg: cfg})) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodOptions, "/", nil) + req.Header.Set("Origin", "http://example.com") + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + + p.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(rec, req) + + require.Equal(t, http.StatusNoContent, rec.Result().StatusCode) +} + +// TestMiddlewareAppliesConfiguredHeaders checks both directions in one pass: the +// request header must be visible to the downstream handler, the response header +// must reach the recorder. +func TestMiddlewareAppliesConfiguredHeaders(t *testing.T) { + p := &Plugin{cfg: &Config{ + Request: map[string]string{"Input": "custom-header"}, + Response: map[string]string{"Output": "output-header"}, + }} + + var seen string + h := p.Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = r.Header.Get("Input") + })) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)) + + require.Equal(t, "custom-header", seen) + require.Equal(t, "output-header", rec.Header().Get("Output")) +} + +func TestName(t *testing.T) { + require.Equal(t, PluginName, (&Plugin{}).Name()) +} diff --git a/tests/cors_test.go b/tests/cors_test.go new file mode 100644 index 0000000..7b27a47 --- /dev/null +++ b/tests/cors_test.go @@ -0,0 +1,77 @@ +package headers + +import ( + "net/http" + "testing" + + "tests/helpers" + + "github.com/stretchr/testify/require" +) + +const exposedHeaders = "Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma" + +// preflight sends the OPTIONS request a browser would send before a cross-origin +// call. +func preflight(t *testing.T, url string) response { + t.Helper() + + return do(t, http.MethodOptions, url, map[string]string{ + "Access-Control-Request-Method": http.MethodGet, + "Access-Control-Request-Headers": "origin, x-requested-with", + "Origin": requestOrig, + }) +} + +// assertPreflight covers the headers a preflight response must carry. expOrigin +// differs between the wildcard and the regex config: the wildcard config echoes +// "*", the regex config echoes the caller's origin back. +func assertPreflight(t *testing.T, resp response, expOrigin string) { + t.Helper() + + require.Equal(t, http.StatusOK, resp.status) + require.Equal(t, "true", resp.header.Get("Access-Control-Allow-Credentials")) + require.Equal(t, "origin, x-requested-with", resp.header.Get("Access-Control-Allow-Headers")) + require.Equal(t, http.MethodGet, resp.header.Get("Access-Control-Allow-Methods")) + require.Equal(t, expOrigin, resp.header.Get("Access-Control-Allow-Origin")) + require.Equal(t, "600", resp.header.Get("Access-Control-Max-Age")) +} + +// assertActualRequest covers the headers the response to the real (non-preflight) +// cross-origin request must carry. +func assertActualRequest(t *testing.T, resp response, expOrigin string) { + t.Helper() + + require.Equal(t, http.StatusOK, resp.status) + require.Equal(t, "true", resp.header.Get("Access-Control-Allow-Credentials")) + require.Equal(t, expOrigin, resp.header.Get("Access-Control-Allow-Origin")) + require.Equal(t, exposedHeaders, resp.header.Get("Access-Control-Expose-Headers")) +} + +// TestCORSWildcardOrigin uses allowed_origin "*", so both the preflight and the +// actual request echo "*" back. +func TestCORSWildcardOrigin(t *testing.T) { + helpers.Start(t, "configs/.rr-cors-headers.yaml", headersPlugins(), helpers.WithTCPProbe(corsAddr)) + + assertPreflight(t, preflight(t, "http://"+corsAddr), "*") + assertActualRequest(t, get(t, "http://"+corsAddr, map[string]string{"Origin": requestOrig}), "*") +} + +// TestCORSOriginRegex uses allowed_origin_regex, which matches the caller's +// origin and echoes it back rather than a wildcard. +func TestCORSOriginRegex(t *testing.T) { + helpers.Start(t, "configs/.rr-cors-headers-regex.yaml", headersPlugins(), helpers.WithTCPProbe(corsAddr)) + + assertPreflight(t, preflight(t, "http://"+corsAddr), requestOrig) + assertActualRequest(t, get(t, "http://"+corsAddr, map[string]string{"Origin": requestOrig}), requestOrig) +} + +// TestCORSOriginRegexRejectsNonMatchingOrigin sends an origin the regex does not +// cover, so no allow-origin header comes back and a browser would block it. +func TestCORSOriginRegexRejectsNonMatchingOrigin(t *testing.T) { + helpers.Start(t, "configs/.rr-cors-headers-regex.yaml", headersPlugins(), helpers.WithTCPProbe(corsAddr)) + + resp := get(t, "http://"+corsAddr, map[string]string{"Origin": "http://evil.example.com"}) + + require.Empty(t, resp.header.Get("Access-Control-Allow-Origin")) +} diff --git a/tests/headers_plugin_test.go b/tests/headers_plugin_test.go deleted file mode 100644 index 263084d..0000000 --- a/tests/headers_plugin_test.go +++ /dev/null @@ -1,437 +0,0 @@ -package headers - -import ( - "io" - "log/slog" - "net/http" - "os" - "os/signal" - "sync" - "syscall" - "testing" - "time" - - "github.com/roadrunner-server/config/v6" - "github.com/roadrunner-server/endure/v2" - "github.com/roadrunner-server/headers/v6" - httpPlugin "github.com/roadrunner-server/http/v6" - "github.com/roadrunner-server/logger/v6" - "github.com/roadrunner-server/server/v6" - "github.com/stretchr/testify/assert" -) - -func TestHeadersInit(t *testing.T) { - cont := endure.New(slog.LevelDebug) - - cfg := &config.Plugin{ - Version: "2024.1.0", - Path: "configs/.rr-headers-init.yaml", - } - - err := cont.RegisterAll( - cfg, - &logger.Plugin{}, - &server.Plugin{}, - &httpPlugin.Plugin{}, - &headers.Plugin{}, - ) - assert.NoError(t, err) - - err = cont.Init() - if err != nil { - t.Fatal(err) - } - - ch, err := cont.Serve() - assert.NoError(t, err) - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - wg := &sync.WaitGroup{} - - stopCh := make(chan struct{}, 1) - - wg.Go(func() { - for { - select { - case e := <-ch: - assert.Fail(t, "error", e.Error.Error()) - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - case <-sig: - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - case <-stopCh: - // timeout - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - } - } - }) - - time.Sleep(time.Second) - stopCh <- struct{}{} - wg.Wait() -} - -func TestRequestHeaders(t *testing.T) { - cont := endure.New(slog.LevelDebug) - - cfg := &config.Plugin{ - Version: "2024.1.0", - Path: "configs/.rr-req-headers.yaml", - } - - err := cont.RegisterAll( - cfg, - &logger.Plugin{}, - &server.Plugin{}, - &httpPlugin.Plugin{}, - &headers.Plugin{}, - ) - assert.NoError(t, err) - - err = cont.Init() - if err != nil { - t.Fatal(err) - } - - ch, err := cont.Serve() - assert.NoError(t, err) - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - wg := &sync.WaitGroup{} - - stopCh := make(chan struct{}, 1) - - wg.Go(func() { - for { - select { - case e := <-ch: - assert.Fail(t, "error", e.Error.Error()) - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - case <-sig: - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - case <-stopCh: - // timeout - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - } - } - }) - - time.Sleep(time.Second) - t.Run("RequestHeaders", reqHeaders) - - stopCh <- struct{}{} - wg.Wait() -} - -func reqHeaders(t *testing.T) { - req, err := http.NewRequest("GET", "http://127.0.0.1:22655?hello=value", nil) - assert.NoError(t, err) - - r, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - - b, err := io.ReadAll(r.Body) - assert.NoError(t, err) - - assert.Equal(t, 200, r.StatusCode) - assert.Equal(t, "CUSTOM-HEADER", string(b)) - - err = r.Body.Close() - assert.NoError(t, err) -} - -func TestResponseHeaders(t *testing.T) { - cont := endure.New(slog.LevelDebug) - - cfg := &config.Plugin{ - Version: "2024.1.0", - Path: "configs/.rr-res-headers.yaml", - } - - err := cont.RegisterAll( - cfg, - &logger.Plugin{}, - &server.Plugin{}, - &httpPlugin.Plugin{}, - &headers.Plugin{}, - ) - assert.NoError(t, err) - - err = cont.Init() - if err != nil { - t.Fatal(err) - } - - ch, err := cont.Serve() - assert.NoError(t, err) - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - wg := &sync.WaitGroup{} - - stopCh := make(chan struct{}, 1) - - wg.Go(func() { - for { - select { - case e := <-ch: - assert.Fail(t, "error", e.Error.Error()) - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - case <-sig: - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - case <-stopCh: - // timeout - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - } - } - }) - - time.Sleep(time.Second) - t.Run("ResponseHeaders", resHeaders) - - stopCh <- struct{}{} - wg.Wait() -} - -func resHeaders(t *testing.T) { - req, err := http.NewRequest("GET", "http://127.0.0.1:22455?hello=value", nil) - assert.NoError(t, err) - - r, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - - assert.Equal(t, "output-header", r.Header.Get("output")) - - b, err := io.ReadAll(r.Body) - assert.NoError(t, err) - assert.Equal(t, 200, r.StatusCode) - assert.Equal(t, "CUSTOM-HEADER", string(b)) - - err = r.Body.Close() - assert.NoError(t, err) -} - -func TestCORSHeaders(t *testing.T) { - cont := endure.New(slog.LevelDebug) - - cfg := &config.Plugin{ - Version: "2023.2.0", - Path: "configs/.rr-cors-headers.yaml", - } - - err := cont.RegisterAll( - cfg, - &logger.Plugin{}, - &server.Plugin{}, - &httpPlugin.Plugin{}, - &headers.Plugin{}, - ) - assert.NoError(t, err) - - err = cont.Init() - if err != nil { - t.Fatal(err) - } - - ch, err := cont.Serve() - assert.NoError(t, err) - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - wg := &sync.WaitGroup{} - - stopCh := make(chan struct{}, 1) - - wg.Go(func() { - for { - select { - case e := <-ch: - assert.Fail(t, "error", e.Error.Error()) - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - case <-sig: - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - case <-stopCh: - // timeout - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - } - } - }) - - time.Sleep(time.Second) - t.Run("CORSHeaders", corsHeaders("*")) - t.Run("CORSHeadersPass", corsHeadersPass("*")) - - stopCh <- struct{}{} - wg.Wait() -} - -func TestCORSHeadersRegex(t *testing.T) { - cont := endure.New(slog.LevelDebug) - - cfg := &config.Plugin{ - Version: "2023.2.0", - Path: "configs/.rr-cors-headers-regex.yaml", - } - - err := cont.RegisterAll( - cfg, - &logger.Plugin{}, - &server.Plugin{}, - &httpPlugin.Plugin{}, - &headers.Plugin{}, - ) - assert.NoError(t, err) - - err = cont.Init() - if err != nil { - t.Fatal(err) - } - - ch, err := cont.Serve() - assert.NoError(t, err) - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - wg := &sync.WaitGroup{} - - stopCh := make(chan struct{}, 1) - - wg.Go(func() { - for { - select { - case e := <-ch: - assert.Fail(t, "error", e.Error.Error()) - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - case <-sig: - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - case <-stopCh: - // timeout - err = cont.Stop() - if err != nil { - assert.FailNow(t, "error", err.Error()) - } - return - } - } - }) - - time.Sleep(time.Second) - t.Run("CORSHeaders", corsHeaders("http://127.0.0.1:10")) - t.Run("CORSHeadersPass", corsHeadersPass("http://127.0.0.1:10")) - - stopCh <- struct{}{} - wg.Wait() -} - -func corsHeadersPass(expOrigin string) func(t *testing.T) { - return func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:22855", nil) - req.Header.Add("Origin", "http://127.0.0.1:10") - assert.NoError(t, err) - - r, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - - assert.Equal(t, "true", r.Header.Get("Access-Control-Allow-Credentials")) - assert.Equal(t, expOrigin, r.Header.Get("Access-Control-Allow-Origin")) - assert.Equal(t, "Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma", r.Header.Get("Access-Control-Expose-Headers")) - - _, err = io.ReadAll(r.Body) - assert.NoError(t, err) - assert.Equal(t, 200, r.StatusCode) - - err = r.Body.Close() - assert.NoError(t, err) - } -} - -func corsHeaders(expOrigin string) func(t *testing.T) { - return func(t *testing.T) { - // PREFLIGHT - req, err := http.NewRequest(http.MethodOptions, "http://127.0.0.1:22855", nil) - req.Header.Add("Access-Control-Request-Method", "GET") - req.Header.Add("Access-Control-Request-Headers", "origin, x-requested-with") - req.Header.Add("Origin", "http://127.0.0.1:10") - assert.NoError(t, err) - - r, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - - /* - Access-Control-Allow-Origin: https://foo.bar.org - Access-Control-Allow-Methods: POST, GET, OPTIONS, DELETE - Access-Control-Max-Age: 86400 - */ - - assert.Equal(t, "true", r.Header.Get("Access-Control-Allow-Credentials")) - assert.Equal(t, "origin, x-requested-with", r.Header.Get("Access-Control-Allow-Headers")) - assert.Equal(t, "GET", r.Header.Get("Access-Control-Allow-Methods")) - assert.Equal(t, expOrigin, r.Header.Get("Access-Control-Allow-Origin")) - assert.Equal(t, "600", r.Header.Get("Access-Control-Max-Age")) - assert.Equal(t, "true", r.Header.Get("Access-Control-Allow-Credentials")) - - _, err = io.ReadAll(r.Body) - assert.NoError(t, err) - assert.Equal(t, 200, r.StatusCode) - - err = r.Body.Close() - assert.NoError(t, err) - } -} diff --git a/tests/headers_test.go b/tests/headers_test.go new file mode 100644 index 0000000..67f7a2f --- /dev/null +++ b/tests/headers_test.go @@ -0,0 +1,90 @@ +package headers + +import ( + "io" + "net/http" + "testing" + + "tests/helpers" + + headersPlugin "github.com/roadrunner-server/headers/v6" + httpPlugin "github.com/roadrunner-server/http/v6" + "github.com/roadrunner-server/server/v6" + "github.com/stretchr/testify/require" +) + +const ( + initAddr = "127.0.0.1:33453" + reqAddr = "127.0.0.1:22655" + respAddr = "127.0.0.1:22455" + corsAddr = "127.0.0.1:22855" + requestOrig = "http://127.0.0.1:10" +) + +func headersPlugins() []any { + return []any{&server.Plugin{}, &httpPlugin.Plugin{}, &headersPlugin.Plugin{}} +} + +// response is the part of an http.Response the tests assert on, captured after +// the body has been read and closed. +type response struct { + status int + header http.Header + body string +} + +// do issues the request and drains the response so no body is left open. +func do(t *testing.T, method, url string, hdr map[string]string) response { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, url, nil) + require.NoError(t, err) + for k, v := range hdr { + req.Header.Add(k, v) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer func() { require.NoError(t, resp.Body.Close()) }() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return response{status: resp.StatusCode, header: resp.Header.Clone(), body: string(body)} +} + +func get(t *testing.T, url string, hdr map[string]string) response { + t.Helper() + return do(t, http.MethodGet, url, hdr) +} + +// TestBootsWithHeadersSection proves the plugin initializes and serves when the +// http.headers section is present. +func TestBootsWithHeadersSection(t *testing.T) { + helpers.Start(t, "configs/.rr-headers-init.yaml", headersPlugins(), helpers.WithTCPProbe(initAddr)) +} + +// TestRequestHeaderReachesWorker relies on header.php echoing the "input" +// request header back uppercased, so the body proves the middleware injected it +// before the request reached PHP. +func TestRequestHeaderReachesWorker(t *testing.T) { + helpers.Start(t, "configs/.rr-req-headers.yaml", headersPlugins(), helpers.WithTCPProbe(reqAddr)) + + resp := get(t, "http://"+reqAddr+"?hello=value", nil) + + require.Equal(t, http.StatusOK, resp.status) + require.Equal(t, "CUSTOM-HEADER", resp.body) +} + +// TestResponseHeaderIsAdded checks the configured response header lands on the +// way out while the request header still reaches the worker. +func TestResponseHeaderIsAdded(t *testing.T) { + helpers.Start(t, "configs/.rr-res-headers.yaml", headersPlugins(), helpers.WithTCPProbe(respAddr)) + + resp := get(t, "http://"+respAddr+"?hello=value", nil) + + require.Equal(t, http.StatusOK, resp.status) + require.Equal(t, "output-header", resp.header.Get("output")) + require.Equal(t, "CUSTOM-HEADER", resp.body) +} diff --git a/tests/helpers/rr.go b/tests/helpers/rr.go new file mode 100644 index 0000000..fa26d26 --- /dev/null +++ b/tests/helpers/rr.go @@ -0,0 +1,161 @@ +package helpers + +import ( + "context" + "log/slog" + "net" + "net/http" + "sync" + "testing" + "time" + + "github.com/roadrunner-server/config/v6" + "github.com/roadrunner-server/endure/v2" + "github.com/roadrunner-server/logger/v6" + "github.com/stretchr/testify/require" +) + +const ( + // defaultConfigVersion is the config schema version used by the test configs. + defaultConfigVersion = "2024.1.0" + // probeTimeout caps how long Start waits for the server to answer the probe. + probeTimeout = time.Second * 15 + probeTick = time.Millisecond * 20 + probeDial = time.Second +) + +// bootCfg holds the options applied to a container before it is started. +type bootCfg struct { + version string + logLevel slog.Level + probe func(ctx context.Context) bool +} + +// Option customizes the container built by Start. +type Option func(*bootCfg) + +// WithConfigVersion overrides the config schema version. +func WithConfigVersion(v string) Option { + return func(b *bootCfg) { b.version = v } +} + +// WithLogLevel sets the endure container log level (debug by default). +func WithLogLevel(l slog.Level) Option { + return func(b *bootCfg) { b.logLevel = l } +} + +// WithTCPProbe makes Start return only once addr accepts a connection. The +// listener binds after the worker pool is allocated, so this proves readiness +// without sending a request through the pool. +func WithTCPProbe(addr string) Option { + return func(b *bootCfg) { + b.probe = func(ctx context.Context) bool { + d := net.Dialer{Timeout: probeDial} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return false + } + + _ = conn.Close() + return true + } + } +} + +// WithProbe makes Start return only once a GET to url gets a response. This +// reaches the worker pool, so tests asserting exact log counts want +// WithTCPProbe instead. +func WithProbe(url string) Option { + return func(b *bootCfg) { + b.probe = func(ctx context.Context) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + + _ = resp.Body.Close() + return true + } + } +} + +// Start registers the plugins, boots the container and waits for the probe, if +// any, to answer. Errors arriving on the container channel are reported through +// t.Errorf and stop the container, but they do not abort the test. +// +// The returned stop is idempotent and also registered with t.Cleanup, so tests +// asserting on shutdown behavior can stop the container mid-test. +func Start(t *testing.T, cfgPath string, plugins []any, opts ...Option) func() { + t.Helper() + + cont, bc := newContainer(t, cfgPath, plugins, opts) + require.NoError(t, cont.Init()) + + ch, err := cont.Serve() + require.NoError(t, err) + + stopCont := sync.OnceValue(cont.Stop) + done := make(chan struct{}) + wg := &sync.WaitGroup{} + + wg.Go(func() { + for { + select { + case res := <-ch: + if res == nil { + return + } + t.Errorf("plugin %s reported an error: %v", res.VertexID, res.Error) + if errS := stopCont(); errS != nil { + t.Errorf("container stop: %v", errS) + } + case <-done: + if errS := stopCont(); errS != nil { + t.Errorf("container stop: %v", errS) + } + return + } + } + }) + + // The drain goroutine calls t.Errorf, so it has to be joined while the test + // is still running. + stop := sync.OnceFunc(func() { + close(done) + wg.Wait() + }) + t.Cleanup(stop) + + if bc.probe != nil { + require.Eventually(t, func() bool { return bc.probe(t.Context()) }, probeTimeout, probeTick, "server did not become ready") + } + + return stop +} + +// newContainer builds the container and registers the config, the logger and +// the caller's plugins. The container is not initialized yet. +func newContainer(t *testing.T, cfgPath string, plugins []any, opts []Option) (*endure.Endure, *bootCfg) { + t.Helper() + + bc := &bootCfg{version: defaultConfigVersion, logLevel: slog.LevelDebug} + for _, o := range opts { + o(bc) + } + + all := make([]any, 0, 2+len(plugins)) + all = append(all, + &config.Plugin{Version: bc.version, Path: cfgPath}, + &logger.Plugin{}, + ) + + cont := endure.New(bc.logLevel) + require.NoError(t, cont.RegisterAll(append(all, plugins...)...)) + + return cont, bc +}