Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
rustatian marked this conversation as resolved.
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
Expand Down
134 changes: 134 additions & 0 deletions init_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
77 changes: 77 additions & 0 deletions tests/cors_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
Loading
Loading