mirror of
https://github.com/willnorris/imageproxy.git
synced 2026-04-26 05:16:25 +02:00
to address the segfault we're seeing in production. The necessary conditions for the issue are: 1. The 303 must return a location URI that changes every time, so that the real location of the image is never cached. As an example, if the remove service redirects to S3, the presence of the `X-Amz-Security-Token` accomplishes this. 2. The image responses must match by Etag, so that imageProxy.should304() returns true causing TranformingTransport.RoundTrip() to return a bare 304 response. If those conditions are met, then the bare 304 Response returned will be used and read by one of the callers. Specifically, the lack of a Body causes a segfault. So let's make it more like a real Response and use http.NoBody so when it's used it doesn't cause things to explode.
808 lines
22 KiB
Go
808 lines
22 KiB
Go
// Copyright 2013 The imageproxy authors.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package imageproxy
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/png"
|
|
"log"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/die-net/lrucache"
|
|
"github.com/google/uuid"
|
|
"github.com/gregjones/httpcache"
|
|
)
|
|
|
|
func TestPeekContentType(t *testing.T) {
|
|
// 1 pixel png image, base64 encoded
|
|
b, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAEUlEQVR4nGJiYGBgAAQAAP//AA8AA/6P688AAAAASUVORK5CYII=")
|
|
got := peekContentType(bufio.NewReader(bytes.NewReader(b)))
|
|
if want := "image/png"; got != want {
|
|
t.Errorf("peekContentType returned %v, want %v", got, want)
|
|
}
|
|
|
|
// single zero byte
|
|
got = peekContentType(bufio.NewReader(bytes.NewReader([]byte{0x0})))
|
|
if want := "application/octet-stream"; got != want {
|
|
t.Errorf("peekContentType returned %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCopyHeader(t *testing.T) {
|
|
tests := []struct {
|
|
dst, src http.Header
|
|
keys []string
|
|
want http.Header
|
|
}{
|
|
// empty
|
|
{http.Header{}, http.Header{}, nil, http.Header{}},
|
|
{http.Header{}, http.Header{}, []string{}, http.Header{}},
|
|
{http.Header{}, http.Header{}, []string{"A"}, http.Header{}},
|
|
|
|
// nothing to copy
|
|
{
|
|
dst: http.Header{"A": []string{"a1"}},
|
|
src: http.Header{},
|
|
keys: nil,
|
|
want: http.Header{"A": []string{"a1"}},
|
|
},
|
|
{
|
|
dst: http.Header{},
|
|
src: http.Header{"A": []string{"a"}},
|
|
keys: []string{"B"},
|
|
want: http.Header{},
|
|
},
|
|
|
|
// copy headers
|
|
{
|
|
dst: http.Header{"A": []string{"a"}},
|
|
src: http.Header{"B": []string{"b"}, "C": []string{"c"}},
|
|
keys: []string{"B"},
|
|
want: http.Header{"A": []string{"a"}, "B": []string{"b"}},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
// copy dst map
|
|
got := make(http.Header)
|
|
for k, v := range tt.dst {
|
|
got[k] = v
|
|
}
|
|
|
|
copyHeader(got, tt.src, tt.keys...)
|
|
if !reflect.DeepEqual(got, tt.want) {
|
|
t.Errorf("copyHeader(%v, %v, %v) returned %v, want %v", tt.dst, tt.src, tt.keys, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAllowed(t *testing.T) {
|
|
allowHosts := []string{"good"}
|
|
key := [][]byte{
|
|
[]byte("c0ffee"),
|
|
}
|
|
multipleKey := [][]byte{
|
|
[]byte("c0ffee"),
|
|
[]byte("beer"),
|
|
}
|
|
|
|
genRequest := func(headers map[string]string) *http.Request {
|
|
req := &http.Request{Header: make(http.Header)}
|
|
for key, value := range headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
return req
|
|
}
|
|
|
|
tests := []struct {
|
|
url string
|
|
options Options
|
|
allowHosts []string
|
|
denyHosts []string
|
|
referrers []string
|
|
keys [][]byte
|
|
request *http.Request
|
|
allowed bool
|
|
}{
|
|
// no allowHosts or signature key
|
|
{"http://test/image", emptyOptions, nil, nil, nil, nil, nil, true},
|
|
|
|
// allowHosts
|
|
{"http://good/image", emptyOptions, allowHosts, nil, nil, nil, nil, true},
|
|
{"http://bad/image", emptyOptions, allowHosts, nil, nil, nil, nil, false},
|
|
|
|
// referrer
|
|
{"http://test/image", emptyOptions, nil, nil, allowHosts, nil, genRequest(map[string]string{"Referer": "http://good/foo"}), true},
|
|
{"http://test/image", emptyOptions, nil, nil, allowHosts, nil, genRequest(map[string]string{"Referer": "http://bad/foo"}), false},
|
|
{"http://test/image", emptyOptions, nil, nil, allowHosts, nil, genRequest(map[string]string{"Referer": "MALFORMED!!"}), false},
|
|
{"http://test/image", emptyOptions, nil, nil, allowHosts, nil, genRequest(map[string]string{}), false},
|
|
|
|
// signature key
|
|
{"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, nil, nil, nil, key, nil, true},
|
|
{"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, nil, nil, nil, multipleKey, nil, true}, // signed with key "c0ffee"
|
|
{"http://test/image", Options{Signature: "FWIawYV4SEyI4zKJMeGugM-eJM1eI_jXPEQ20ZgRe4A="}, nil, nil, nil, multipleKey, nil, true}, // signed with key "beer"
|
|
{"http://test/image", Options{Signature: "deadbeef"}, nil, nil, nil, key, nil, false},
|
|
{"http://test/image", Options{Signature: "deadbeef"}, nil, nil, nil, multipleKey, nil, false},
|
|
{"http://test/image", emptyOptions, nil, nil, nil, key, nil, false},
|
|
|
|
// allowHosts and signature
|
|
{"http://good/image", emptyOptions, allowHosts, nil, nil, key, nil, true},
|
|
{"http://bad/image", Options{Signature: "gWivrPhXBbsYEwpmWAKjbJEiAEgZwbXbltg95O2tgNI="}, nil, nil, nil, key, nil, true},
|
|
{"http://bad/image", emptyOptions, allowHosts, nil, nil, key, nil, false},
|
|
|
|
// deny requests that match denyHosts, even if signature is valid or also matches allowHosts
|
|
{"http://test/image", emptyOptions, nil, []string{"test"}, nil, nil, nil, false},
|
|
{"http://test:3000/image", emptyOptions, nil, []string{"test"}, nil, nil, nil, false},
|
|
{"http://test/image", emptyOptions, []string{"test"}, []string{"test"}, nil, nil, nil, false},
|
|
{"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, nil, []string{"test"}, nil, key, nil, false},
|
|
{"http://127.0.0.1/image", emptyOptions, nil, []string{"127.0.0.0/8"}, nil, nil, nil, false},
|
|
{"http://127.0.0.1:3000/image", emptyOptions, nil, []string{"127.0.0.0/8"}, nil, nil, nil, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
p := NewProxy(nil, nil)
|
|
p.AllowHosts = tt.allowHosts
|
|
p.DenyHosts = tt.denyHosts
|
|
p.SignatureKeys = tt.keys
|
|
p.Referrers = tt.referrers
|
|
|
|
u, err := url.Parse(tt.url)
|
|
if err != nil {
|
|
t.Errorf("error parsing url %q: %v", tt.url, err)
|
|
}
|
|
req := &Request{u, tt.options, tt.request}
|
|
if got, want := p.allowed(req), tt.allowed; (got == nil) != want {
|
|
t.Errorf("allowed(%q) returned %v, want %v.\nTest struct: %#v", req, got, want, tt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHostMatches(t *testing.T) {
|
|
hosts := []string{"a.test", "*.b.test", "*c.test"}
|
|
|
|
tests := []struct {
|
|
url string
|
|
valid bool
|
|
}{
|
|
{"http://a.test/image", true},
|
|
{"http://x.a.test/image", false},
|
|
|
|
{"http://b.test/image", true},
|
|
{"http://x.b.test/image", true},
|
|
{"http://x.y.b.test/image", true},
|
|
|
|
{"http://c.test/image", false},
|
|
{"http://xc.test/image", false},
|
|
{"/image", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
u, err := url.Parse(tt.url)
|
|
if err != nil {
|
|
t.Errorf("error parsing url %q: %v", tt.url, err)
|
|
}
|
|
if got, want := hostMatches(hosts, u), tt.valid; got != want {
|
|
t.Errorf("hostMatches(%v, %q) returned %v, want %v", hosts, u, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReferrerMatches(t *testing.T) {
|
|
hosts := []string{"a.test"}
|
|
|
|
tests := []struct {
|
|
referrer string
|
|
valid bool
|
|
}{
|
|
{"", false},
|
|
{"%", false},
|
|
{"http://a.test/", true},
|
|
{"http://b.test/", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
r, _ := http.NewRequest("GET", "/", nil)
|
|
r.Header.Set("Referer", tt.referrer)
|
|
if got, want := referrerMatches(hosts, r), tt.valid; got != want {
|
|
t.Errorf("referrerMatches(%v, %v) returned %v, want %v", hosts, r, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidSignature(t *testing.T) {
|
|
key := []byte("c0ffee")
|
|
|
|
tests := []struct {
|
|
url string
|
|
options Options
|
|
valid bool
|
|
}{
|
|
{"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, true},
|
|
{"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ"}, true},
|
|
{"http://test/image", emptyOptions, false},
|
|
// url-only signature with options
|
|
{"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ", Rotate: 90}, true},
|
|
// signature calculated from url plus options
|
|
{"http://test/image", Options{Signature: "ZGTzEm32o4iZ7qcChls3EVYaWyrDd9u0etySo0-WkF8=", Rotate: 90}, true},
|
|
// invalid base64 encoded signature
|
|
{"http://test/image", Options{Signature: "!!"}, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
u, err := url.Parse(tt.url)
|
|
if err != nil {
|
|
t.Errorf("error parsing url %q: %v", tt.url, err)
|
|
}
|
|
req := &Request{u, tt.options, &http.Request{}}
|
|
if got, want := validSignature(key, req), tt.valid; got != want {
|
|
t.Errorf("validSignature(%v, %v) returned %v, want %v", key, req, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShould304(t *testing.T) {
|
|
tests := []struct {
|
|
req, resp string
|
|
is304 bool
|
|
}{
|
|
{ // etag match
|
|
"GET / HTTP/1.1\nIf-None-Match: \"v\"\n\n",
|
|
"HTTP/1.1 200 OK\nEtag: \"v\"\n\n",
|
|
true,
|
|
},
|
|
{ // last-modified before
|
|
"GET / HTTP/1.1\nIf-Modified-Since: Sun, 02 Jan 2000 00:00:00 GMT\n\n",
|
|
"HTTP/1.1 200 OK\nLast-Modified: Sat, 01 Jan 2000 00:00:00 GMT\n\n",
|
|
true,
|
|
},
|
|
{ // last-modified match
|
|
"GET / HTTP/1.1\nIf-Modified-Since: Sat, 01 Jan 2000 00:00:00 GMT\n\n",
|
|
"HTTP/1.1 200 OK\nLast-Modified: Sat, 01 Jan 2000 00:00:00 GMT\n\n",
|
|
true,
|
|
},
|
|
|
|
// mismatches
|
|
{
|
|
"GET / HTTP/1.1\n\n",
|
|
"HTTP/1.1 200 OK\n\n",
|
|
false,
|
|
},
|
|
{
|
|
"GET / HTTP/1.1\n\n",
|
|
"HTTP/1.1 200 OK\nEtag: \"v\"\n\n",
|
|
false,
|
|
},
|
|
{
|
|
"GET / HTTP/1.1\nIf-None-Match: \"v\"\n\n",
|
|
"HTTP/1.1 200 OK\n\n",
|
|
false,
|
|
},
|
|
{
|
|
"GET / HTTP/1.1\nIf-None-Match: \"a\"\n\n",
|
|
"HTTP/1.1 200 OK\nEtag: \"b\"\n\n",
|
|
false,
|
|
},
|
|
{ // last-modified match
|
|
"GET / HTTP/1.1\n\n",
|
|
"HTTP/1.1 200 OK\nLast-Modified: Sat, 01 Jan 2000 00:00:00 GMT\n\n",
|
|
false,
|
|
},
|
|
{ // last-modified match
|
|
"GET / HTTP/1.1\nIf-Modified-Since: Sun, 02 Jan 2000 00:00:00 GMT\n\n",
|
|
"HTTP/1.1 200 OK\n\n",
|
|
false,
|
|
},
|
|
{ // last-modified match
|
|
"GET / HTTP/1.1\nIf-Modified-Since: Fri, 31 Dec 1999 00:00:00 GMT\n\n",
|
|
"HTTP/1.1 200 OK\nLast-Modified: Sat, 01 Jan 2000 00:00:00 GMT\n\n",
|
|
false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
buf := bufio.NewReader(strings.NewReader(tt.req))
|
|
req, err := http.ReadRequest(buf)
|
|
if err != nil {
|
|
t.Errorf("http.ReadRequest(%q) returned error: %v", tt.req, err)
|
|
}
|
|
|
|
buf = bufio.NewReader(strings.NewReader(tt.resp))
|
|
resp, err := http.ReadResponse(buf, req)
|
|
if err != nil {
|
|
t.Errorf("http.ReadResponse(%q) returned error: %v", tt.resp, err)
|
|
}
|
|
|
|
if got, want := should304(req, resp), tt.is304; got != want {
|
|
t.Errorf("should304(%q, %q) returned: %v, want %v", tt.req, tt.resp, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// testTransport is an http.RoundTripper that returns certained canned
|
|
// responses for particular requests.
|
|
type testTransport struct {
|
|
replyNotModified bool
|
|
}
|
|
|
|
func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
var raw string
|
|
|
|
switch req.URL.Path {
|
|
case "/plain":
|
|
raw = "HTTP/1.1 200 OK\n\n"
|
|
case "/error":
|
|
return nil, errors.New("http protocol error")
|
|
case "/nocontent":
|
|
raw = "HTTP/1.1 204 No Content\n\n"
|
|
case "/etag":
|
|
raw = "HTTP/1.1 200 OK\nEtag: \"tag\"\n\n"
|
|
case "/png":
|
|
m := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
|
img := new(bytes.Buffer)
|
|
_ = png.Encode(img, m)
|
|
|
|
raw = fmt.Sprintf("HTTP/1.1 200 OK\nContent-Length: %d\nContent-Type: image/png\n\n%s", len(img.Bytes()), img.Bytes())
|
|
case "/redirect-to-notmodified":
|
|
parts := []string{
|
|
"HTTP/1.1 303\nLocation: http://notmodified.test/notmodified?X-Security-Token=",
|
|
uuid.NewString(),
|
|
"#_=_\nCache-Control: no-store\n\n",
|
|
}
|
|
raw = strings.Join(parts, "")
|
|
case "/notmodified":
|
|
if t.replyNotModified {
|
|
raw = "HTTP/1.1 304 Not modified\nEtag: \"abcdef\"\n\n"
|
|
} else {
|
|
raw = "HTTP/1.1 200 OK\nEtag: \"abcdef\"\n\nOriginal response\n"
|
|
}
|
|
default:
|
|
redirectRegexp := regexp.MustCompile(`/redirects-(\d+)`)
|
|
if redirectRegexp.MatchString(req.URL.Path) {
|
|
redirectsLeft, _ := strconv.ParseUint(redirectRegexp.FindStringSubmatch(req.URL.Path)[1], 10, 8)
|
|
if redirectsLeft == 0 {
|
|
raw = "HTTP/1.1 200 OK\n\n"
|
|
} else {
|
|
raw = fmt.Sprintf("HTTP/1.1 302\nLocation: /http://redirect.test/redirects-%d\n\n", redirectsLeft-1)
|
|
}
|
|
} else {
|
|
raw = "HTTP/1.1 404 Not Found\n\n"
|
|
}
|
|
}
|
|
|
|
buf := bufio.NewReader(bytes.NewBufferString(raw))
|
|
return http.ReadResponse(buf, req)
|
|
}
|
|
|
|
func TestProxy_UpdateCacheHeaders(t *testing.T) {
|
|
date := "Mon, 02 Jan 2006 15:04:05 MST"
|
|
exp := "Mon, 02 Jan 2006 16:04:05 MST"
|
|
|
|
tests := []struct {
|
|
name string
|
|
minDuration time.Duration
|
|
forceCache bool
|
|
headers http.Header
|
|
want http.Header
|
|
}{
|
|
{
|
|
name: "zero",
|
|
headers: http.Header{},
|
|
want: http.Header{},
|
|
},
|
|
{
|
|
name: "no min duration",
|
|
headers: http.Header{
|
|
"Date": {date},
|
|
"Expires": {exp},
|
|
"Cache-Control": {"max-age=600"},
|
|
},
|
|
want: http.Header{
|
|
"Date": {date},
|
|
"Expires": {exp},
|
|
"Cache-Control": {"max-age=600"},
|
|
},
|
|
},
|
|
{
|
|
name: "min duration, no header",
|
|
minDuration: 30 * time.Second,
|
|
headers: http.Header{},
|
|
want: http.Header{
|
|
"Cache-Control": {"max-age=30"},
|
|
},
|
|
},
|
|
{
|
|
name: "cache control exceeds min duration",
|
|
minDuration: 30 * time.Second,
|
|
headers: http.Header{
|
|
"Cache-Control": {"max-age=600"},
|
|
},
|
|
want: http.Header{
|
|
"Cache-Control": {"max-age=600"},
|
|
},
|
|
},
|
|
{
|
|
name: "cache control exceeds min duration, expires",
|
|
minDuration: 30 * time.Second,
|
|
headers: http.Header{
|
|
"Date": {date},
|
|
"Expires": {exp},
|
|
"Cache-Control": {"max-age=86400"},
|
|
},
|
|
want: http.Header{
|
|
"Date": {date},
|
|
"Cache-Control": {"max-age=86400"},
|
|
},
|
|
},
|
|
{
|
|
name: "min duration exceeds cache control",
|
|
minDuration: 1 * time.Hour,
|
|
headers: http.Header{
|
|
"Cache-Control": {"max-age=600"},
|
|
},
|
|
want: http.Header{
|
|
"Cache-Control": {"max-age=3600"},
|
|
},
|
|
},
|
|
{
|
|
name: "min duration exceeds cache control, expires",
|
|
minDuration: 2 * time.Hour,
|
|
headers: http.Header{
|
|
"Date": {date},
|
|
"Expires": {exp},
|
|
"Cache-Control": {"max-age=600"},
|
|
},
|
|
want: http.Header{
|
|
"Date": {date},
|
|
"Cache-Control": {"max-age=7200"},
|
|
},
|
|
},
|
|
{
|
|
name: "expires exceeds min duration, cache control",
|
|
minDuration: 30 * time.Minute,
|
|
headers: http.Header{
|
|
"Date": {date},
|
|
"Expires": {exp},
|
|
"Cache-Control": {"max-age=600"},
|
|
},
|
|
want: http.Header{
|
|
"Date": {date},
|
|
"Cache-Control": {"max-age=3600"},
|
|
},
|
|
},
|
|
{
|
|
name: "respect no-store",
|
|
headers: http.Header{
|
|
"Cache-Control": {"max-age=600, no-store"},
|
|
},
|
|
want: http.Header{
|
|
"Cache-Control": {"max-age=600, no-store"},
|
|
},
|
|
},
|
|
{
|
|
name: "respect private",
|
|
headers: http.Header{
|
|
"Cache-Control": {"max-age=600, private"},
|
|
},
|
|
want: http.Header{
|
|
"Cache-Control": {"max-age=600, no-store, private"},
|
|
},
|
|
},
|
|
{
|
|
name: "force cache, normalize directives",
|
|
forceCache: true,
|
|
headers: http.Header{
|
|
"Cache-Control": {"MAX-AGE=600, no-store, private"},
|
|
},
|
|
want: http.Header{
|
|
"Cache-Control": {"max-age=600"},
|
|
},
|
|
},
|
|
{
|
|
name: "force cache with min duration",
|
|
minDuration: 1 * time.Hour,
|
|
forceCache: true,
|
|
headers: http.Header{
|
|
"Cache-Control": {"max-age=600, private, no-store"},
|
|
},
|
|
want: http.Header{
|
|
"Cache-Control": {"max-age=3600"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := &Proxy{
|
|
MinimumCacheDuration: tt.minDuration,
|
|
ForceCache: tt.forceCache,
|
|
}
|
|
hdr := maps.Clone(tt.headers)
|
|
p.updateCacheHeaders(hdr)
|
|
|
|
if !reflect.DeepEqual(hdr, tt.want) {
|
|
t.Errorf("updateCacheHeaders(%v) returned %v, want %v", tt.headers, hdr, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProxy_ServeHTTP(t *testing.T) {
|
|
p := &Proxy{
|
|
Client: &http.Client{
|
|
Transport: &testTransport{},
|
|
},
|
|
AllowHosts: []string{"good.test"},
|
|
ContentTypes: []string{"image/*"},
|
|
}
|
|
|
|
tests := []struct {
|
|
url string // request URL
|
|
code int // expected response status code
|
|
}{
|
|
{"/favicon.ico", http.StatusOK},
|
|
{"//foo", http.StatusBadRequest}, // invalid request URL
|
|
{"/http://bad.test/", http.StatusForbidden}, // Disallowed host
|
|
{"/http://good.test/error", http.StatusInternalServerError}, // HTTP protocol error
|
|
{"/http://good.test/nocontent", http.StatusNoContent}, // non-OK response
|
|
{"/100/http://good.test/png", http.StatusOK},
|
|
{"/100/http://good.test/plain", http.StatusForbidden}, // non-image response
|
|
|
|
// health-check URLs
|
|
{"/", http.StatusOK},
|
|
{"/health-check", http.StatusOK},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
req, _ := http.NewRequest("GET", "http://localhost"+tt.url, nil)
|
|
resp := httptest.NewRecorder()
|
|
p.ServeHTTP(resp, req)
|
|
|
|
if got, want := resp.Code, tt.code; got != want {
|
|
t.Errorf("ServeHTTP(%v) returned status %d, want %d", req, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// test that 304 Not Modified responses are returned properly.
|
|
func TestProxy_ServeHTTP_is304(t *testing.T) {
|
|
p := &Proxy{
|
|
Client: &http.Client{
|
|
Transport: &testTransport{},
|
|
},
|
|
}
|
|
|
|
req, _ := http.NewRequest("GET", "http://localhost/http://good.test/etag", nil)
|
|
req.Header.Add("If-None-Match", `"tag"`)
|
|
resp := httptest.NewRecorder()
|
|
p.ServeHTTP(resp, req)
|
|
|
|
if got, want := resp.Code, http.StatusNotModified; got != want {
|
|
t.Errorf("ServeHTTP(%v) returned status %d, want %d", req, got, want)
|
|
}
|
|
if got, want := resp.Header().Get("Etag"), `"tag"`; got != want {
|
|
t.Errorf("ServeHTTP(%v) returned etag header %v, want %v", req, got, want)
|
|
}
|
|
}
|
|
|
|
func TestProxy_ServeHTTP_cached304(t *testing.T) {
|
|
cache := lrucache.New(1024*1024*8, 0)
|
|
client := new(http.Client)
|
|
tt := testTransport{}
|
|
client.Transport = &httpcache.Transport{
|
|
Transport: &TransformingTransport{
|
|
Transport: &tt,
|
|
CachingClient: client,
|
|
},
|
|
Cache: cache,
|
|
MarkCachedResponses: true,
|
|
}
|
|
|
|
p := &Proxy{
|
|
Client: client,
|
|
FollowRedirects: true,
|
|
}
|
|
|
|
// prime the cache
|
|
req := httptest.NewRequest("GET", "http://localhost//http://good.test/redirect-to-notmodified", nil)
|
|
recorder := httptest.NewRecorder()
|
|
p.ServeHTTP(recorder, req)
|
|
|
|
resp := recorder.Result()
|
|
if got, want := resp.StatusCode, http.StatusOK; got != want {
|
|
t.Errorf("ServeHTTP(%v) returned status %d, want %d", req, got, want)
|
|
}
|
|
if _, found := cache.Get("http://good.test/redirect-to-notmodified#0x0"); !found {
|
|
t.Errorf("Response to http://good.test/redirect-to-notmodified#0x0 should be cached")
|
|
}
|
|
|
|
// now make the same request again, but this time make sure the server responds with a 304
|
|
tt.replyNotModified = true
|
|
req = httptest.NewRequest("GET", "http://localhost//http://good.test/redirect-to-notmodified", nil)
|
|
recorder = httptest.NewRecorder()
|
|
p.ServeHTTP(recorder, req)
|
|
|
|
resp = recorder.Result()
|
|
if got, want := resp.StatusCode, http.StatusOK; got != want {
|
|
t.Errorf("ServeHTTP(%v) returned status %d, want %d", req, got, want)
|
|
}
|
|
|
|
if recorder.Body.String() != "Original response\n" {
|
|
t.Errorf("Response isn't what we expected: %v", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestProxy_ServeHTTP_maxRedirects(t *testing.T) {
|
|
p := &Proxy{
|
|
Client: &http.Client{
|
|
Transport: &testTransport{},
|
|
},
|
|
FollowRedirects: true,
|
|
}
|
|
|
|
tests := []struct {
|
|
url string
|
|
code int
|
|
}{
|
|
{"/http://redirect.test/redirects-0", http.StatusOK},
|
|
{"/http://redirect.test/redirects-2", http.StatusOK},
|
|
{"/http://redirect.test/redirects-11", http.StatusInternalServerError}, // too many redirects
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
req, _ := http.NewRequest("GET", "http://localhost"+tt.url, nil)
|
|
resp := httptest.NewRecorder()
|
|
p.ServeHTTP(resp, req)
|
|
|
|
if got, want := resp.Code, tt.code; got != want {
|
|
t.Errorf("ServeHTTP(%v) returned status %d, want %d", req, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProxy_log(t *testing.T) {
|
|
var b strings.Builder
|
|
|
|
p := &Proxy{
|
|
Logger: log.New(&b, "", 0),
|
|
}
|
|
p.log("Test")
|
|
|
|
if got, want := b.String(), "Test\n"; got != want {
|
|
t.Errorf("log wrote %s, want %s", got, want)
|
|
}
|
|
|
|
b.Reset()
|
|
p.logf("Test %v", 123)
|
|
|
|
if got, want := b.String(), "Test 123\n"; got != want {
|
|
t.Errorf("logf wrote %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProxy_log_default(t *testing.T) {
|
|
var b strings.Builder
|
|
|
|
defer func(flags int) {
|
|
log.SetOutput(os.Stderr)
|
|
log.SetFlags(flags)
|
|
}(log.Flags())
|
|
|
|
log.SetOutput(&b)
|
|
log.SetFlags(0)
|
|
|
|
p := &Proxy{}
|
|
p.log("Test")
|
|
|
|
if got, want := b.String(), "Test\n"; got != want {
|
|
t.Errorf("log wrote %s, want %s", got, want)
|
|
}
|
|
|
|
b.Reset()
|
|
p.logf("Test %v", 123)
|
|
|
|
if got, want := b.String(), "Test 123\n"; got != want {
|
|
t.Errorf("logf wrote %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestTransformingTransport(t *testing.T) {
|
|
client := new(http.Client)
|
|
tr := &TransformingTransport{
|
|
Transport: &testTransport{},
|
|
CachingClient: client,
|
|
}
|
|
client.Transport = tr
|
|
|
|
tests := []struct {
|
|
url string
|
|
code int
|
|
expectError bool
|
|
}{
|
|
{"http://good.test/png#1", http.StatusOK, false},
|
|
{"http://good.test/error#1", http.StatusInternalServerError, true},
|
|
// TODO: test more than just status code... verify that image
|
|
// is actually transformed and returned properly and that
|
|
// non-image responses are returned as-is
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
req, _ := http.NewRequest("GET", tt.url, nil)
|
|
|
|
resp, err := tr.RoundTrip(req)
|
|
if err != nil {
|
|
if !tt.expectError {
|
|
t.Errorf("RoundTrip(%v) returned unexpected error: %v", tt.url, err)
|
|
}
|
|
continue
|
|
} else if tt.expectError {
|
|
t.Errorf("RoundTrip(%v) did not return expected error", tt.url)
|
|
}
|
|
if got, want := resp.StatusCode, tt.code; got != want {
|
|
t.Errorf("RoundTrip(%v) returned status code %d, want %d", tt.url, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContentTypeMatches(t *testing.T) {
|
|
tests := []struct {
|
|
patterns []string
|
|
contentType string
|
|
valid bool
|
|
}{
|
|
// no patterns
|
|
{nil, "", true},
|
|
{nil, "text/plain", true},
|
|
{[]string{}, "", true},
|
|
{[]string{}, "text/plain", true},
|
|
|
|
// empty pattern
|
|
{[]string{""}, "", true},
|
|
{[]string{""}, "text/plain", false},
|
|
|
|
// exact match
|
|
{[]string{"text/plain"}, "", false},
|
|
{[]string{"text/plain"}, "text", false},
|
|
{[]string{"text/plain"}, "text/html", false},
|
|
{[]string{"text/plain"}, "text/plain", true},
|
|
{[]string{"text/plain"}, "text/plaintext", false},
|
|
{[]string{"text/plain"}, "text/plain+foo", false},
|
|
|
|
// wildcard match
|
|
{[]string{"text/*"}, "", false},
|
|
{[]string{"text/*"}, "text", false},
|
|
{[]string{"text/*"}, "text/html", true},
|
|
{[]string{"text/*"}, "text/plain", true},
|
|
{[]string{"text/*"}, "image/jpeg", false},
|
|
|
|
{[]string{"image/svg*"}, "image/svg", true},
|
|
{[]string{"image/svg*"}, "image/svg+html", true},
|
|
|
|
// complete wildcard does not match
|
|
{[]string{"*"}, "text/foobar", false},
|
|
|
|
// multiple patterns
|
|
{[]string{"text/*", "image/*"}, "image/jpeg", true},
|
|
}
|
|
for _, tt := range tests {
|
|
got := contentTypeMatches(tt.patterns, tt.contentType)
|
|
if want := tt.valid; got != want {
|
|
t.Errorf("contentTypeMatches(%q, %q) returned %v, want %v", tt.patterns, tt.contentType, got, want)
|
|
}
|
|
}
|
|
}
|