Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma into develop
[akkoma] / test / reverse_proxy_test.exs
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.ReverseProxyTest do
6 use Pleroma.Web.ConnCase, async: true
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} ->
33 case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do
34 [{_, 0}] ->
35 Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1))
36 {:ok, json}
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 describe "max_body " do
82 test "length returns error if content-length more than option", %{conn: conn} do
83 user_agent_mock("hackney/1.15.1", 0)
84
85 assert capture_log(fn ->
86 ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
87 end) =~
88 "[error] Elixir.Pleroma.ReverseProxy: request to \"/huge-file\" failed: :body_too_large"
89
90 assert {:ok, true} == Cachex.get(:failed_proxy_url_cache, "/huge-file")
91
92 assert capture_log(fn ->
93 ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
94 end) == ""
95 end
96
97 defp stream_mock(invokes, with_close? \\ false) do
98 ClientMock
99 |> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ ->
100 Registry.register(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length, 0)
101
102 {:ok, 200, [{"content-type", "application/octet-stream"}],
103 %{url: "/stream-bytes/" <> length}}
104 end)
105 |> expect(:stream_body, invokes, fn %{url: "/stream-bytes/" <> length} ->
106 max = String.to_integer(length)
107
108 case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) do
109 [{_, current}] when current < max ->
110 Registry.update_value(
111 Pleroma.ReverseProxy.ClientMock,
112 "/stream-bytes/" <> length,
113 &(&1 + 10)
114 )
115
116 {:ok, "0123456789"}
117
118 [{_, ^max}] ->
119 Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length)
120 :done
121 end
122 end)
123
124 if with_close? do
125 expect(ClientMock, :close, fn _ -> :ok end)
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} ->
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})}
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"} ->
309 case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/disposition") do
310 [{_, 0}] ->
311 Registry.update_value(Pleroma.ReverseProxy.ClientMock, "/disposition", &(&1 + 1))
312 {:ok, ""}
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 end