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