Merge branch 'develop' into gun
[akkoma] / test / reverse_proxy / reverse_proxy_test.exs
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.ReverseProxyTest do
6 use Pleroma.Web.ConnCase
7 import ExUnit.CaptureLog
8 import Mox
9 alias Pleroma.ReverseProxy
10 alias Pleroma.ReverseProxy.ClientMock
11
12 setup_all do
13 {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.ReverseProxy.ClientMock)
14 :ok
15 end
16
17 setup :verify_on_exit!
18
19 defp user_agent_mock(user_agent, invokes) do
20 json = Jason.encode!(%{"user-agent": user_agent})
21
22 ClientMock
23 |> expect(:request, fn :get, url, _, _, _ ->
24 Registry.register(Pleroma.ReverseProxy.ClientMock, url, 0)
25
26 {:ok, 200,
27 [
28 {"content-type", "application/json"},
29 {"content-length", byte_size(json) |> to_string()}
30 ], %{url: url}}
31 end)
32 |> expect(:stream_body, invokes, fn %{url: url} = client ->
33 case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do
34 [{_, 0}] ->
35 Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1))
36 {:ok, json, client}
37
38 [{_, 1}] ->
39 Registry.unregister(Pleroma.ReverseProxy.ClientMock, url)
40 :done
41 end
42 end)
43 end
44
45 describe "reverse proxy" do
46 test "do not track successful request", %{conn: conn} do
47 user_agent_mock("hackney/1.15.1", 2)
48 url = "/success"
49
50 conn = ReverseProxy.call(conn, url)
51
52 assert conn.status == 200
53 assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, nil}
54 end
55 end
56
57 describe "user-agent" do
58 test "don't keep", %{conn: conn} do
59 user_agent_mock("hackney/1.15.1", 2)
60 conn = ReverseProxy.call(conn, "/user-agent")
61 assert json_response(conn, 200) == %{"user-agent" => "hackney/1.15.1"}
62 end
63
64 test "keep", %{conn: conn} do
65 user_agent_mock(Pleroma.Application.user_agent(), 2)
66 conn = ReverseProxy.call(conn, "/user-agent-keep", keep_user_agent: true)
67 assert json_response(conn, 200) == %{"user-agent" => Pleroma.Application.user_agent()}
68 end
69 end
70
71 test "closed connection", %{conn: conn} do
72 ClientMock
73 |> expect(:request, fn :get, "/closed", _, _, _ -> {:ok, 200, [], %{}} end)
74 |> expect(:stream_body, fn _ -> {:error, :closed} end)
75 |> expect(:close, fn _ -> :ok end)
76
77 conn = ReverseProxy.call(conn, "/closed")
78 assert conn.halted
79 end
80
81 defp stream_mock(invokes, with_close? \\ false) do
82 ClientMock
83 |> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ ->
84 Registry.register(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length, 0)
85
86 {:ok, 200, [{"content-type", "application/octet-stream"}],
87 %{url: "/stream-bytes/" <> length}}
88 end)
89 |> expect(:stream_body, invokes, fn %{url: "/stream-bytes/" <> length} = client ->
90 max = String.to_integer(length)
91
92 case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) do
93 [{_, current}] when current < max ->
94 Registry.update_value(
95 Pleroma.ReverseProxy.ClientMock,
96 "/stream-bytes/" <> length,
97 &(&1 + 10)
98 )
99
100 {:ok, "0123456789", client}
101
102 [{_, ^max}] ->
103 Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length)
104 :done
105 end
106 end)
107
108 if with_close? do
109 expect(ClientMock, :close, fn _ -> :ok end)
110 end
111 end
112
113 describe "max_body" do
114 test "length returns error if content-length more than option", %{conn: conn} do
115 user_agent_mock("hackney/1.15.1", 0)
116
117 assert capture_log(fn ->
118 ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
119 end) =~
120 "[error] Elixir.Pleroma.ReverseProxy: request to \"/huge-file\" failed: :body_too_large"
121
122 assert {:ok, true} == Cachex.get(:failed_proxy_url_cache, "/huge-file")
123
124 assert capture_log(fn ->
125 ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
126 end) == ""
127 end
128
129 test "max_body_length returns error if streaming body more than that option", %{conn: conn} do
130 stream_mock(3, true)
131
132 assert capture_log(fn ->
133 ReverseProxy.call(conn, "/stream-bytes/50", max_body_length: 30)
134 end) =~
135 "[warn] Elixir.Pleroma.ReverseProxy request to /stream-bytes/50 failed while reading/chunking: :body_too_large"
136 end
137 end
138
139 describe "HEAD requests" do
140 test "common", %{conn: conn} do
141 ClientMock
142 |> expect(:request, fn :head, "/head", _, _, _ ->
143 {:ok, 200, [{"content-type", "text/html; charset=utf-8"}]}
144 end)
145
146 conn = ReverseProxy.call(Map.put(conn, :method, "HEAD"), "/head")
147 assert html_response(conn, 200) == ""
148 end
149 end
150
151 defp error_mock(status) when is_integer(status) do
152 ClientMock
153 |> expect(:request, fn :get, "/status/" <> _, _, _, _ ->
154 {:error, status}
155 end)
156 end
157
158 describe "returns error on" do
159 test "500", %{conn: conn} do
160 error_mock(500)
161 url = "/status/500"
162
163 capture_log(fn -> ReverseProxy.call(conn, url) end) =~
164 "[error] Elixir.Pleroma.ReverseProxy: request to /status/500 failed with HTTP status 500"
165
166 assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true}
167
168 {:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url)
169 assert ttl <= 60_000
170 end
171
172 test "400", %{conn: conn} do
173 error_mock(400)
174 url = "/status/400"
175
176 capture_log(fn -> ReverseProxy.call(conn, url) end) =~
177 "[error] Elixir.Pleroma.ReverseProxy: request to /status/400 failed with HTTP status 400"
178
179 assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true}
180 assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil}
181 end
182
183 test "403", %{conn: conn} do
184 error_mock(403)
185 url = "/status/403"
186
187 capture_log(fn ->
188 ReverseProxy.call(conn, url, failed_request_ttl: :timer.seconds(120))
189 end) =~
190 "[error] Elixir.Pleroma.ReverseProxy: request to /status/403 failed with HTTP status 403"
191
192 {:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url)
193 assert ttl > 100_000
194 end
195
196 test "204", %{conn: conn} do
197 url = "/status/204"
198 expect(ClientMock, :request, fn :get, _url, _, _, _ -> {:ok, 204, [], %{}} end)
199
200 capture_log(fn ->
201 conn = ReverseProxy.call(conn, url)
202 assert conn.resp_body == "Request failed: No Content"
203 assert conn.halted
204 end) =~
205 "[error] Elixir.Pleroma.ReverseProxy: request to \"/status/204\" failed with HTTP status 204"
206
207 assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true}
208 assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil}
209 end
210 end
211
212 test "streaming", %{conn: conn} do
213 stream_mock(21)
214 conn = ReverseProxy.call(conn, "/stream-bytes/200")
215 assert conn.state == :chunked
216 assert byte_size(conn.resp_body) == 200
217 assert Plug.Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
218 end
219
220 defp headers_mock(_) do
221 ClientMock
222 |> expect(:request, fn :get, "/headers", headers, _, _ ->
223 Registry.register(Pleroma.ReverseProxy.ClientMock, "/headers", 0)
224 {:ok, 200, [{"content-type", "application/json"}], %{url: "/headers", headers: headers}}
225 end)
226 |> expect(:stream_body, 2, fn %{url: url, headers: headers} = client ->
227 case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do
228 [{_, 0}] ->
229 Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1))
230 headers = for {k, v} <- headers, into: %{}, do: {String.capitalize(k), v}
231 {:ok, Jason.encode!(%{headers: headers}), client}
232
233 [{_, 1}] ->
234 Registry.unregister(Pleroma.ReverseProxy.ClientMock, url)
235 :done
236 end
237 end)
238
239 :ok
240 end
241
242 describe "keep request headers" do
243 setup [:headers_mock]
244
245 test "header passes", %{conn: conn} do
246 conn =
247 Plug.Conn.put_req_header(
248 conn,
249 "accept",
250 "text/html"
251 )
252 |> ReverseProxy.call("/headers")
253
254 %{"headers" => headers} = json_response(conn, 200)
255 assert headers["Accept"] == "text/html"
256 end
257
258 test "header is filtered", %{conn: conn} do
259 conn =
260 Plug.Conn.put_req_header(
261 conn,
262 "accept-language",
263 "en-US"
264 )
265 |> ReverseProxy.call("/headers")
266
267 %{"headers" => headers} = json_response(conn, 200)
268 refute headers["Accept-Language"]
269 end
270 end
271
272 test "returns 400 on non GET, HEAD requests", %{conn: conn} do
273 conn = ReverseProxy.call(Map.put(conn, :method, "POST"), "/ip")
274 assert conn.status == 400
275 end
276
277 describe "cache resp headers" do
278 test "returns headers", %{conn: conn} do
279 ClientMock
280 |> expect(:request, fn :get, "/cache/" <> ttl, _, _, _ ->
281 {:ok, 200, [{"cache-control", "public, max-age=" <> ttl}], %{}}
282 end)
283 |> expect(:stream_body, fn _ -> :done end)
284
285 conn = ReverseProxy.call(conn, "/cache/10")
286 assert {"cache-control", "public, max-age=10"} in conn.resp_headers
287 end
288
289 test "add cache-control", %{conn: conn} do
290 ClientMock
291 |> expect(:request, fn :get, "/cache", _, _, _ ->
292 {:ok, 200, [{"ETag", "some ETag"}], %{}}
293 end)
294 |> expect(:stream_body, fn _ -> :done end)
295
296 conn = ReverseProxy.call(conn, "/cache")
297 assert {"cache-control", "public"} in conn.resp_headers
298 end
299 end
300
301 defp disposition_headers_mock(headers) do
302 ClientMock
303 |> expect(:request, fn :get, "/disposition", _, _, _ ->
304 Registry.register(Pleroma.ReverseProxy.ClientMock, "/disposition", 0)
305
306 {:ok, 200, headers, %{url: "/disposition"}}
307 end)
308 |> expect(:stream_body, 2, fn %{url: "/disposition"} = client ->
309 case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/disposition") do
310 [{_, 0}] ->
311 Registry.update_value(Pleroma.ReverseProxy.ClientMock, "/disposition", &(&1 + 1))
312 {:ok, "", client}
313
314 [{_, 1}] ->
315 Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/disposition")
316 :done
317 end
318 end)
319 end
320
321 describe "response content disposition header" do
322 test "not atachment", %{conn: conn} do
323 disposition_headers_mock([
324 {"content-type", "image/gif"},
325 {"content-length", 0}
326 ])
327
328 conn = ReverseProxy.call(conn, "/disposition")
329
330 assert {"content-type", "image/gif"} in conn.resp_headers
331 end
332
333 test "with content-disposition header", %{conn: conn} do
334 disposition_headers_mock([
335 {"content-disposition", "attachment; filename=\"filename.jpg\""},
336 {"content-length", 0}
337 ])
338
339 conn = ReverseProxy.call(conn, "/disposition")
340
341 assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers
342 end
343 end
344
345 describe "tesla client using gun integration" do
346 @describetag :integration
347
348 clear_config(Pleroma.ReverseProxy.Client) do
349 Pleroma.Config.put(Pleroma.ReverseProxy.Client, Pleroma.ReverseProxy.Client.Tesla)
350 end
351
352 clear_config(Pleroma.Gun) do
353 Pleroma.Config.put(Pleroma.Gun, Pleroma.Gun.API)
354 end
355
356 setup do
357 adapter = Application.get_env(:tesla, :adapter)
358 Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun)
359
360 on_exit(fn ->
361 Application.put_env(:tesla, :adapter, adapter)
362 end)
363 end
364
365 test "common", %{conn: conn} do
366 conn = ReverseProxy.call(conn, "http://httpbin.org/stream-bytes/10")
367 assert byte_size(conn.resp_body) == 10
368 assert conn.state == :chunked
369 assert conn.status == 200
370 end
371
372 test "ssl", %{conn: conn} do
373 conn = ReverseProxy.call(conn, "https://httpbin.org/stream-bytes/10")
374 assert byte_size(conn.resp_body) == 10
375 assert conn.state == :chunked
376 assert conn.status == 200
377 end
378
379 test "follow redirects", %{conn: conn} do
380 conn = ReverseProxy.call(conn, "https://httpbin.org/redirect/5")
381 assert conn.state == :chunked
382 assert conn.status == 200
383 end
384 end
385 end