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