router_test.go
Rama master · réplica de lectura del repositorio original.
package aastro
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/voidrunner3074/aastro/sdk"
)
func TestRouter_ServeHTTP_SuccessfulFlow(t *testing.T) {
t.Run("returns the aggregated array as the body, unwrapped", func(t *testing.T) {
d := &mockScatter{
results: []upstreamResponse{
{status: http.StatusOK, body: []byte(`"A"`), err: nil},
{status: http.StatusOK, body: []byte(`"B"`), err: nil},
},
}
r := newTestRouter([]flow{{
path: "/test/basic",
method: http.MethodGet,
aggregation: aggregation{
strategy: strategyArray,
bestEffort: false,
},
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/basic", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Header.Get("Content-Type"), "application/json")
assert.Empty(t, res.Header.Values("X-Partial-Errors"))
jsonEqual(t, `["A","B"]`, body)
})
}
func TestRouter_ServeHTTP_PartialResponse(t *testing.T) {
t.Run("returns 206, the successful data unwrapped, and failures on X-Partial-Errors", func(t *testing.T) {
d := &mockScatter{
results: []upstreamResponse{
{status: http.StatusOK, body: []byte(`"A"`), err: nil},
{status: http.StatusInternalServerError, body: nil, err: &upstreamError{
kind: upstreamTimeout,
err: errors.New("upstream timeout"),
}},
},
}
r := newTestRouter([]flow{{
path: "/test/partial",
method: http.MethodGet,
aggregation: aggregation{
strategy: strategyArray,
bestEffort: true,
},
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/partial", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
assert.Equal(t, http.StatusPartialContent, res.StatusCode)
assert.ElementsMatch(t, []string{ClientErrUpstreamUnavailable.String()}, res.Header.Values("X-Partial-Errors"))
jsonEqual(t, `["A"]`, body)
})
}
func TestRouter_ServeHTTP_AllUpstreamsFailing(t *testing.T) {
t.Run("returns 502 as a Problem Details document", func(t *testing.T) {
d := &mockScatter{
results: []upstreamResponse{
{status: http.StatusOK, body: []byte(`"A"`), err: nil},
{status: http.StatusInternalServerError, err: &upstreamError{
kind: upstreamTimeout,
err: errors.New("upstream timeout"),
}},
},
}
r := newTestRouter([]flow{{
path: "/test/error",
method: http.MethodGet,
aggregation: aggregation{
strategy: strategyArray,
bestEffort: false,
},
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/error", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
problem := decodeProblem(t, body)
assert.Equal(t, http.StatusBadGateway, res.StatusCode)
assert.Contains(t, res.Header.Get("Content-Type"), "application/problem+json")
assert.Equal(t, http.StatusBadGateway, problem.Status)
assert.Equal(t, "about:blank", problem.Type)
assert.ElementsMatch(t, []ClientError{ClientErrUpstreamUnavailable}, problem.Errors)
})
}
func TestRouter_ServeHTTP_MultipleDistinctUpstreamErrors(t *testing.T) {
t.Run("lists every distinct error in the Problem Details Errors extension", func(t *testing.T) {
d := &mockScatter{
results: []upstreamResponse{
{err: &upstreamError{kind: "unknown_error_kind", err: errors.New("unknown")}},
{err: &upstreamError{kind: upstreamTimeout, err: errors.New("timeout")}},
},
}
r := newTestRouter([]flow{{
path: "/test/priority",
method: http.MethodGet,
aggregation: aggregation{
strategy: strategyArray,
bestEffort: true,
},
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/priority", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
problem := decodeProblem(t, body)
assert.Equal(t, http.StatusBadGateway, res.StatusCode)
assert.Len(t, problem.Errors, 2)
})
}
func TestRouter_ServeHTTP_SingleUpstream(t *testing.T) {
t.Run("forwards a successful response and its content-type as-is", func(t *testing.T) {
d := &mockScatter{
results: []upstreamResponse{
{status: http.StatusOK, body: []byte("plain text"), headers: http.Header{"Content-Type": {"text/plain"}}},
},
}
r := newTestRouter([]flow{{
path: "/test/single",
method: http.MethodGet,
upstreams: mockUpstreams("u"),
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/single", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Equal(t, "text/plain", res.Header.Get("Content-Type"))
assert.Equal(t, "plain text", string(body))
})
t.Run("strips hop-by-hop headers, including TE, but keeps everything else", func(t *testing.T) {
// Regression test: hopByHopHeaders keyed "TE" (not net/textproto's
// canonical "Te") never matched, since http.Header always stores
// and iterates canonical keys - the header leaked to the client.
d := &mockScatter{
results: []upstreamResponse{
{
status: http.StatusOK,
body: []byte("ok"),
headers: http.Header{
"Connection": {"keep-alive"},
"Te": {"trailers"},
"Trailer": {"X-Trailer"},
"Keep-Alive": {"timeout=5"},
"Proxy-Authenticate": {"Basic"},
"Upgrade": {"h2c"},
"X-Custom": {"kept"},
},
},
},
}
r := newTestRouter([]flow{{
path: "/test/hop-by-hop",
method: http.MethodGet,
upstreams: mockUpstreams("u"),
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/hop-by-hop", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
for _, h := range []string{"Connection", "Te", "Trailer", "Keep-Alive", "Proxy-Authenticate", "Upgrade"} {
assert.Emptyf(t, res.Header.Get(h), "hop-by-hop header %q must not reach the client", h)
}
assert.Equal(t, "kept", res.Header.Get("X-Custom"))
})
t.Run("forwards a non-JSON client-error body verbatim, not wrapped in the envelope", func(t *testing.T) {
d := &mockScatter{
results: []upstreamResponse{
{
status: http.StatusNotFound,
body: []byte("<html>not found</html>"),
headers: http.Header{"Content-Type": {"text/html"}},
err: &upstreamError{kind: upstreamClientError, err: errors.New("upstream returned 404")},
},
},
}
r := newTestRouter([]flow{{
path: "/test/single-404",
method: http.MethodGet,
upstreams: mockUpstreams("u"),
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/single-404", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
assert.Equal(t, http.StatusNotFound, res.StatusCode)
assert.Equal(t, "text/html", res.Header.Get("Content-Type"))
assert.Equal(t, "<html>not found</html>", string(body))
})
t.Run("reports a gateway-side failure as a Problem Details response", func(t *testing.T) {
d := &mockScatter{
results: []upstreamResponse{
{err: &upstreamError{kind: upstreamTimeout, err: errors.New("upstream timeout")}},
},
}
r := newTestRouter([]flow{{
path: "/test/single-timeout",
method: http.MethodGet,
upstreams: mockUpstreams("u"),
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/single-timeout", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
problem := decodeProblem(t, body)
assert.Equal(t, http.StatusBadGateway, res.StatusCode)
assert.Contains(t, res.Header.Get("Content-Type"), "application/problem+json")
assert.Equal(t, http.StatusBadGateway, problem.Status)
assert.Equal(t, "about:blank", problem.Type)
assert.ElementsMatch(t, []ClientError{ClientErrUpstreamUnavailable}, problem.Errors)
})
t.Run("returns 413 when the request body is too large", func(t *testing.T) {
d := &mockScatter{tooLarge: true}
r := newTestRouter([]flow{{
path: "/test/single-413",
method: http.MethodGet,
upstreams: mockUpstreams("u"),
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/single-413", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
})
}
func TestRouter_ServeHTTP_NoFlowMatches(t *testing.T) {
t.Run("returns 404", func(t *testing.T) {
r := newTestRouter(nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/missing", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
}
func TestRouter_ServeHTTP_Plugins(t *testing.T) {
t.Run("runs request and response plugins in order", func(t *testing.T) {
var executed []string
requestPlugin := &mockPlugin{
name: "req",
typ: sdk.PluginTypeRequest,
fn: func(_ sdk.Context) {
executed = append(executed, "req")
},
}
responsePlugin := &mockPlugin{
name: "resp",
typ: sdk.PluginTypeResponse,
fn: func(ctx sdk.Context) {
executed = append(executed, "resp")
ctx.Response().Header.Set("X-Plugin", "done")
},
}
d := &mockScatter{
results: []upstreamResponse{
{status: http.StatusOK, body: []byte(`"OK"`)},
},
}
r := newTestRouter([]flow{{
path: "/test/plugins",
method: http.MethodGet,
plugins: []sdk.Plugin{requestPlugin, responsePlugin},
upstreams: mockUpstreams("u"),
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/plugins", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Equal(t, `"OK"`, string(body))
assert.Equal(t, "done", res.Header.Get("X-Plugin"))
assert.Equal(t, []string{"req", "resp"}, executed)
})
}
func TestRouter_ServeHTTP_Middleware(t *testing.T) {
t.Run("runs middleware before the handler", func(t *testing.T) {
d := &mockScatter{
results: []upstreamResponse{
{status: http.StatusOK, body: []byte(`"OK"`)},
},
}
r := newTestRouter([]flow{{
path: "/test/mw",
method: http.MethodGet,
middlewares: []sdk.Middleware{&mockMiddleware{}},
upstreams: mockUpstreams("u"),
}}, d, &defaultAggregator{})
req := httptest.NewRequest(http.MethodGet, "/test/mw", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Equal(t, "ok", res.Header.Get("X-Middleware"))
})
}
func TestRouter_ComputeFingerprint(t *testing.T) {
t.Run("distinguishes header from query key with same name", func(t *testing.T) {
r1 := httptest.NewRequest(http.MethodGet, "/u/{id}?id=1", nil)
r1.Header.Set("Accept", "*/*")
r2 := httptest.NewRequest(http.MethodGet, "/u/{id}", nil)
r2.Header.Set("Accept", "*/*")
r2.Header.Set("Id", "anything")
assert.NotEqual(t, computeFingerprint(r2, "/u/{id}"), computeFingerprint(r1, "/u/{id}"))
})
}