mediaproxy_test.mx raw

   1  // Package mediaproxy tests the pure decision surface of the relay's media
   2  // proxy: HTTP status-line parsing, redirect resolution, EOF/chunked body
   3  // reading under a byte limit, and the input-rejection branches of Fetch and
   4  // fetchOnce.
   5  //
   6  // No test dials out. Every fetchOnce case here is rejected at url.Parse or at
   7  // the scheme switch, both of which run before net.DialTimeout, and the body
   8  // readers are driven from bytes.Reader. The one peer is the scripted loopback
   9  // origin in mediaproxy_http_test.mx, which is a local socket, not a dial out.
  10  package mediaproxy
  11  
  12  import (
  13  	"bufio"
  14  	"bytes"
  15  	"io"
  16  	"testing"
  17  	"time"
  18  )
  19  
  20  // mpFailReader is an io.Reader whose only read fails with a non-EOF error, so
  21  // readToEOF's error return is exercised without a network peer.
  22  type mpFailReader struct{}
  23  
  24  func (r *mpFailReader) Read(b []byte) (n int32, err error) {
  25  	return 0, io.ErrUnexpectedEOF
  26  }
  27  
  28  func mpBytes(n int32, fill byte) (b []byte) {
  29  	b = []byte{:n}
  30  	for i := 0; i < n; i++ {
  31  		b[i] = fill
  32  	}
  33  	return
  34  }
  35  
  36  // mpHas is a local substring scan. bytes.Contains/bytes.Index can miss a
  37  // needle in a long haystack under this toolchain, and the point of the check
  38  // is the error mapping, so the search is done by hand.
  39  func mpHas(hay, needle string) (ok bool) {
  40  	if len(needle) == 0 {
  41  		return true
  42  	}
  43  	for i := 0; i+len(needle) <= len(hay); i++ {
  44  		match := true
  45  		for j := 0; j < len(needle); j++ {
  46  			if hay[i+j] != needle[j] {
  47  				match = false
  48  			}
  49  		}
  50  		if match {
  51  			return true
  52  		}
  53  	}
  54  	return
  55  }
  56  
  57  // mpCRLF appends CRLF to every part. The exact escape literals are miscompiled
  58  // by `moxie test`, so the bytes are built from explicit values.
  59  func mpCRLF(parts [][]byte) (out []byte) {
  60  	crlf := []byte{13, 10}
  61  	for i := 0; i < len(parts); i++ {
  62  		out = out | parts[i] | crlf
  63  	}
  64  	return
  65  }
  66  
  67  // mpChunkedReader wraps an assembled chunked body in the same reader kind
  68  // fetchOnce builds.
  69  func mpChunkedReader(parts [][]byte) (r *bufio.Reader) {
  70  	return bufio.NewReaderSize(bytes.NewReader(mpCRLF(parts)), 32768)
  71  }
  72  
  73  func TestConstants(t *testing.T) {
  74  	if DefaultMaxBytes != 32*1024*1024 {
  75  		t.Fatal("DefaultMaxBytes changed")
  76  	}
  77  	if maxRedirects != 5 {
  78  		t.Fatal("maxRedirects changed")
  79  	}
  80  	if DefaultTimeout != 8*time.Second {
  81  		t.Fatal("DefaultTimeout changed")
  82  	}
  83  }
  84  
  85  func TestParseStatus(t *testing.T) {
  86  	var err error
  87  	var got int32
  88  	cases := []struct {
  89  		line string
  90  		want int32
  91  	}{
  92  		{"HTTP/1.1 200 OK", 200},
  93  		{"HTTP/1.1 204 No Content", 204},
  94  		{"HTTP/1.0 301 Moved Permanently", 301},
  95  		{"HTTP/1.1 404 Not Found", 404},
  96  		{"HTTP/1.1 500", 500},
  97  		{"  HTTP/1.1 302 Found  ", 302},
  98  	}
  99  	for i := 0; i < len(cases); i++ {
 100  		got, err = parseStatus(cases[i].line)
 101  		if err != nil {
 102  			t.Fatalf("parseStatus(%q): %s", cases[i].line, err.Error())
 103  		}
 104  		if got != cases[i].want {
 105  			t.Fatalf("parseStatus(%q) = %d, want %d", cases[i].line, got, cases[i].want)
 106  		}
 107  	}
 108  	// A real status line carries CRLF; TrimSpace must absorb it.
 109  	line := []byte("HTTP/1.1 304 Not Modified") | []byte{13, 10}
 110  	got, err = parseStatus(string(line))
 111  	if err != nil {
 112  		t.Fatalf("parseStatus(CRLF line): %s", err.Error())
 113  	}
 114  	if got != 304 {
 115  		t.Fatalf("parseStatus(CRLF line) = %d, want 304", got)
 116  	}
 117  }
 118  
 119  func TestParseStatusRejects(t *testing.T) {
 120  	bad := []string{"", "HTTP/1.1", "HTTP/1.1 ", "HTTP/1.1 abc", "not a status"}
 121  	for i := 0; i < len(bad); i++ {
 122  		if _, err := parseStatus(bad[i]); err == nil {
 123  			t.Fatalf("parseStatus(%q) must fail", bad[i])
 124  		}
 125  	}
 126  }
 127  
 128  func TestResolveRedirectAbsolute(t *testing.T) {
 129  	// Absolute URLs are returned unchanged.
 130  	got, err := resolveRedirect("http://a/b/c", "http://x/y")
 131  	if err != nil || got != "http://x/y" {
 132  		t.Fatalf("absolute http redirect = %q err=%v", got, err != nil)
 133  	}
 134  	got2, err2 := resolveRedirect("http://a/b/c", "https://x/y")
 135  	if err2 != nil || got2 != "https://x/y" {
 136  		t.Fatalf("absolute https redirect = %q err=%v", got2, err2 != nil)
 137  	}
 138  	// A network-path reference inherits the base scheme but not its host/port.
 139  	got3, err3 := resolveRedirect("https://a:8443/b/c", "//x/y")
 140  	if err3 != nil || got3 != "https://x/y" {
 141  		t.Fatalf("network-path redirect = %q err=%v", got3, err3 != nil)
 142  	}
 143  	// An absolute path keeps scheme and authority.
 144  	got4, err4 := resolveRedirect("http://a:8080/b/c", "/z")
 145  	if err4 != nil || got4 != "http://a:8080/z" {
 146  		t.Fatalf("absolute-path redirect = %q err=%v", got4, err4 != nil)
 147  	}
 148  }
 149  
 150  func TestResolveRedirectRelative(t *testing.T) {
 151  	// Relative references replace the last path segment.
 152  	got, err := resolveRedirect("http://a/b/c.png", "d.png")
 153  	if err != nil || got != "http://a/b/d.png" {
 154  		t.Fatalf("relative redirect = %q err=%v", got, err != nil)
 155  	}
 156  	// A trailing-slash base keeps the directory.
 157  	got2, err2 := resolveRedirect("http://a/b/", "d.png")
 158  	if err2 != nil || got2 != "http://a/b/d.png" {
 159  		t.Fatalf("directory redirect = %q err=%v", got2, err2 != nil)
 160  	}
 161  	// A base with an empty path resolves from the root.
 162  	got3, err3 := resolveRedirect("http://a", "d.png")
 163  	if err3 != nil || got3 != "http://a/d.png" {
 164  		t.Fatalf("rootless redirect = %q err=%v", got3, err3 != nil)
 165  	}
 166  }
 167  
 168  func TestResolveRedirectRejects(t *testing.T) {
 169  	if _, err := resolveRedirect("http://a/b", ""); err == nil {
 170  		t.Fatal("an empty location must fail")
 171  	}
 172  	if _, err := resolveRedirect("://bad", "d.png"); err == nil {
 173  		t.Fatal("an unparseable base must fail")
 174  	}
 175  }
 176  
 177  func TestReadToEOF(t *testing.T) {
 178  	b, err := readToEOF(bytes.NewReader([]byte("abc")), 16)
 179  	if err != nil {
 180  		t.Fatalf("readToEOF(abc): %s", err.Error())
 181  	}
 182  	if string(b) != "abc" {
 183  		t.Fatalf("readToEOF(abc) = %q", b)
 184  	}
 185  
 186  	empty, eerr := readToEOF(bytes.NewReader([]byte{}), 16)
 187  	if eerr != nil {
 188  		t.Fatalf("readToEOF(empty): %s", eerr.Error())
 189  	}
 190  	if len(empty) != 0 {
 191  		t.Fatalf("readToEOF(empty) length = %d", int32(len(empty)))
 192  	}
 193  
 194  	// A body exactly at the limit is accepted.
 195  	exact, xerr := readToEOF(bytes.NewReader([]byte("abcde")), 5)
 196  	if xerr != nil {
 197  		t.Fatalf("readToEOF(exact limit): %s", xerr.Error())
 198  	}
 199  	if string(exact) != "abcde" {
 200  		t.Fatalf("readToEOF(exact limit) = %q", exact)
 201  	}
 202  
 203  	// One byte over the limit is rejected.
 204  	if _, oerr := readToEOF(bytes.NewReader([]byte("abcdef")), 5); oerr == nil {
 205  		t.Fatal("readToEOF must reject a body over the limit")
 206  	}
 207  
 208  	// A non-EOF read error propagates instead of looking like end of body.
 209  	if _, ferr := readToEOF(&mpFailReader{}, 16); ferr == nil {
 210  		t.Fatal("readToEOF must propagate a read error")
 211  	}
 212  }
 213  
 214  func TestReadChunkedZeroAndTrailers(t *testing.T) {
 215  	// A zero-size chunk terminates the body.
 216  	z, err := readChunked(mpChunkedReader([][]byte{[]byte("0"), []byte("")}), 1024)
 217  	if err != nil {
 218  		t.Fatalf("readChunked(zero): %s", err.Error())
 219  	}
 220  	if len(z) != 0 {
 221  		t.Fatalf("zero-chunk body length = %d", int32(len(z)))
 222  	}
 223  
 224  	// Trailers after the terminating chunk are discarded.
 225  	tr, terr := readChunked(mpChunkedReader([][]byte{[]byte("0"), []byte("X-Trailer: y"), []byte("")}), 1024)
 226  	if terr != nil {
 227  		t.Fatalf("readChunked(trailers): %s", terr.Error())
 228  	}
 229  	if len(tr) != 0 {
 230  		t.Fatalf("trailer-terminated body length = %d", int32(len(tr)))
 231  	}
 232  }
 233  
 234  func TestReadChunkedSizeLineHandling(t *testing.T) {
 235  	// A chunk extension (';') is stripped before the size is parsed.
 236  	r := mpChunkedReader([][]byte{[]byte("3;a=b"), []byte("abc"), []byte("0"), []byte("")})
 237  	ext, err := readChunked(r, 1024)
 238  	if err != nil {
 239  		t.Fatalf("readChunked(extension): %s", err.Error())
 240  	}
 241  	if string(ext) != "abc" {
 242  		t.Fatalf("readChunked(extension) = %q, want %q", ext, "abc")
 243  	}
 244  
 245  	// A hex size line selects the chunk length.
 246  	r2 := mpChunkedReader([][]byte{[]byte("5"), []byte("hello"), []byte("0"), []byte("")})
 247  	one, err2 := readChunked(r2, 1024)
 248  	if err2 != nil {
 249  		t.Fatalf("readChunked(non-zero size): %s", err2.Error())
 250  	}
 251  	if string(one) != "hello" {
 252  		t.Fatalf("readChunked(non-zero size) = %q, want %q", one, "hello")
 253  	}
 254  
 255  	// Several chunks concatenate, and a size line wider than one digit still
 256  	// parses as hex ("a" = 10).
 257  	r3 := mpChunkedReader([][]byte{[]byte("1"), []byte("a"), []byte("1"), []byte("b"), []byte("a"), []byte("cccccccccc"), []byte("0"), []byte("")})
 258  	many, err3 := readChunked(r3, 1024)
 259  	if err3 != nil {
 260  		t.Fatalf("readChunked(multi): %s", err3.Error())
 261  	}
 262  	if string(many) != "abcccccccccc" {
 263  		t.Fatalf("readChunked(multi) = %q, want %q", many, "abcccccccccc")
 264  	}
 265  
 266  	// A body over the limit is rejected, not truncated.
 267  	r4 := mpChunkedReader([][]byte{[]byte("5"), []byte("hello"), []byte("0"), []byte("")})
 268  	if _, err4 := readChunked(r4, 4); err4 == nil {
 269  		t.Fatal("readChunked must reject a body over the limit")
 270  	}
 271  
 272  	// A size line that is not hex is an error, not a terminator.
 273  	r5 := mpChunkedReader([][]byte{[]byte("zz"), []byte("")})
 274  	if _, err5 := readChunked(r5, 1024); err5 == nil {
 275  		t.Fatal("readChunked must reject a non-hex size line")
 276  	}
 277  
 278  	// A chunk body shorter than the announced size is an error.
 279  	r6 := mpChunkedReader([][]byte{[]byte("7"), []byte("abc")})
 280  	if _, err6 := readChunked(r6, 1024); err6 == nil {
 281  		t.Fatal("readChunked must reject a truncated chunk body")
 282  	}
 283  }
 284  
 285  func TestReadChunkedRejectsMissingSize(t *testing.T) {
 286  	// EOF before any size line is an I/O error, not a zero-length body.
 287  	r := bufio.NewReaderSize(bytes.NewReader([]byte{}), 32768)
 288  	if _, err := readChunked(r, 1024); err == nil {
 289  		t.Fatal("readChunked must fail on a missing size line")
 290  	}
 291  }
 292  
 293  func TestFetchOnceRejectsBadInput(t *testing.T) {
 294  	// A disallowed scheme is rejected before any DNS or dial.
 295  	_, _, _, err := fetchOnce("ftp://example.com/x", 1024)
 296  	if err == nil {
 297  		t.Fatal("fetchOnce must reject a non-http(s) scheme")
 298  	}
 299  	if !mpHas(err.Error(), "not allowed") {
 300  		t.Fatalf("scheme rejection must name the reason: %s", err.Error())
 301  	}
 302  
 303  	// url.Parse failures are rejected before the scheme switch.
 304  	bad := []string{"://bad", "http://host:bad/x", "http://[::1"}
 305  	for i := 0; i < len(bad); i++ {
 306  		if _, _, _, err2 := fetchOnce(bad[i], 1024); err2 == nil {
 307  			t.Fatalf("fetchOnce(%q) must reject an unparseable URL", bad[i])
 308  		}
 309  	}
 310  }
 311  
 312  func TestFetchRejectsBadInput(t *testing.T) {
 313  	// maxBytes <= 0 falls back to DefaultMaxBytes, then the scheme is checked.
 314  	if _, _, _, err := Fetch("ftp://example.com/x", 0); err == nil {
 315  		t.Fatal("Fetch must reject a non-http(s) scheme")
 316  	}
 317  	if _, _, _, err2 := Fetch("ftp://example.com/x", -1); err2 == nil {
 318  		t.Fatal("Fetch must reject a non-http(s) scheme under a negative limit")
 319  	}
 320  	// Parse failures also return before any connection attempt.
 321  	if _, _, _, err3 := Fetch("http://host:bad/x", 1024); err3 == nil {
 322  		t.Fatal("Fetch must reject an unparseable URL before dialing")
 323  	}
 324  	if _, _, _, err4 := Fetch("://bad", 1024); err4 == nil {
 325  		t.Fatal("Fetch must reject an unparseable URL")
 326  	}
 327  }
 328