| % Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| % use this file except in compliance with the License. You may obtain a copy of |
| % the License at |
| % |
| % http://www.apache.org/licenses/LICENSE-2.0 |
| % |
| % Unless required by applicable law or agreed to in writing, software |
| % distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| % WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| % License for the specific language governing permissions and limitations under |
| % the License. |
| |
| -module(couchdb_http_proxy_tests). |
| |
| -include_lib("couch/include/couch_eunit.hrl"). |
| |
| -record(req, {method=get, path="", headers=[], body="", opts=[]}). |
| |
| -define(CONFIG_FIXTURE_TEMP, |
| begin |
| FileName = filename:join([?TEMPDIR, ?tempfile() ++ ".ini"]), |
| {ok, Fd} = file:open(FileName, write), |
| ok = file:truncate(Fd), |
| ok = file:close(Fd), |
| FileName |
| end). |
| -define(TIMEOUT, 5000). |
| |
| |
| start() -> |
| % we have to write any config changes to temp ini file to not loose them |
| % when supervisor will kill all children due to reaching restart threshold |
| % (each httpd_global_handlers changes causes couch_httpd restart) |
| Ctx = test_util:start_couch(?CONFIG_CHAIN ++ [?CONFIG_FIXTURE_TEMP], []), |
| % 49151 is IANA Reserved, let's assume no one is listening there |
| test_util:with_process_restart(couch_httpd, fun() -> |
| config:set("httpd_global_handlers", "_error", |
| "{couch_httpd_proxy, handle_proxy_req, <<\"http://127.0.0.1:49151/\">>}" |
| ) |
| end), |
| Ctx. |
| |
| setup() -> |
| {ok, Pid} = test_web:start_link(), |
| Value = lists:flatten(io_lib:format( |
| "{couch_httpd_proxy, handle_proxy_req, ~p}", |
| [list_to_binary(proxy_url())])), |
| test_util:with_process_restart(couch_httpd, fun() -> |
| config:set("httpd_global_handlers", "_test", Value) |
| end), |
| Pid. |
| |
| teardown(Pid) -> |
| test_util:stop_sync_throw(Pid, fun() -> |
| test_web:stop() |
| end, {timeout, test_web_stop}, ?TIMEOUT). |
| |
| http_proxy_test_() -> |
| { |
| "HTTP Proxy handler tests", |
| { |
| setup, |
| fun start/0, fun test_util:stop_couch/1, |
| { |
| foreach, |
| fun setup/0, fun teardown/1, |
| [ |
| fun should_proxy_basic_request/1, |
| fun should_return_alternative_status/1, |
| fun should_respect_trailing_slash/1, |
| fun should_proxy_headers/1, |
| fun should_proxy_host_header/1, |
| fun should_pass_headers_back/1, |
| fun should_use_same_protocol_version/1, |
| fun should_proxy_body/1, |
| fun should_proxy_body_back/1, |
| fun should_proxy_chunked_body/1, |
| fun should_proxy_chunked_body_back/1, |
| fun should_rewrite_location_header/1, |
| fun should_not_rewrite_external_locations/1, |
| fun should_rewrite_relative_location/1, |
| fun should_refuse_connection_to_backend/1 |
| ] |
| } |
| |
| } |
| }. |
| |
| |
| should_proxy_basic_request(_) -> |
| Remote = fun(Req) -> |
| 'GET' = Req:get(method), |
| "/" = Req:get(path), |
| 0 = Req:get(body_length), |
| <<>> = Req:recv_body(), |
| {ok, {200, [{"Content-Type", "text/plain"}], "ok"}} |
| end, |
| Local = fun |
| ({ok, "200", _, "ok"}) -> |
| true; |
| (_) -> |
| false |
| end, |
| ?_test(check_request(#req{}, Remote, Local)). |
| |
| should_return_alternative_status(_) -> |
| Remote = fun(Req) -> |
| "/alternate_status" = Req:get(path), |
| {ok, {201, [], "ok"}} |
| end, |
| Local = fun |
| ({ok, "201", _, "ok"}) -> |
| true; |
| (_) -> |
| false |
| end, |
| Req = #req{path = "/alternate_status"}, |
| ?_test(check_request(Req, Remote, Local)). |
| |
| should_respect_trailing_slash(_) -> |
| Remote = fun(Req) -> |
| "/trailing_slash/" = Req:get(path), |
| {ok, {200, [], "ok"}} |
| end, |
| Local = fun |
| ({ok, "200", _, "ok"}) -> |
| true; |
| (_) -> |
| false |
| end, |
| Req = #req{path="/trailing_slash/"}, |
| ?_test(check_request(Req, Remote, Local)). |
| |
| should_proxy_headers(_) -> |
| Remote = fun(Req) -> |
| "/passes_header" = Req:get(path), |
| "plankton" = Req:get_header_value("X-CouchDB-Ralph"), |
| {ok, {200, [], "ok"}} |
| end, |
| Local = fun |
| ({ok, "200", _, "ok"}) -> |
| true; |
| (_) -> |
| false |
| end, |
| Req = #req{ |
| path="/passes_header", |
| headers=[{"X-CouchDB-Ralph", "plankton"}] |
| }, |
| ?_test(check_request(Req, Remote, Local)). |
| |
| should_proxy_host_header(_) -> |
| Remote = fun(Req) -> |
| "/passes_host_header" = Req:get(path), |
| "www.google.com" = Req:get_header_value("Host"), |
| {ok, {200, [], "ok"}} |
| end, |
| Local = fun |
| ({ok, "200", _, "ok"}) -> |
| true; |
| (_) -> |
| false |
| end, |
| Req = #req{ |
| path="/passes_host_header", |
| headers=[{"Host", "www.google.com"}] |
| }, |
| ?_test(check_request(Req, Remote, Local)). |
| |
| should_pass_headers_back(_) -> |
| Remote = fun(Req) -> |
| "/passes_header_back" = Req:get(path), |
| {ok, {200, [{"X-CouchDB-Plankton", "ralph"}], "ok"}} |
| end, |
| Local = fun |
| ({ok, "200", Headers, "ok"}) -> |
| lists:member({"X-CouchDB-Plankton", "ralph"}, Headers); |
| (_) -> |
| false |
| end, |
| Req = #req{path="/passes_header_back"}, |
| ?_test(check_request(Req, Remote, Local)). |
| |
| should_use_same_protocol_version(_) -> |
| Remote = fun(Req) -> |
| "/uses_same_version" = Req:get(path), |
| {1, 0} = Req:get(version), |
| {ok, {200, [], "ok"}} |
| end, |
| Local = fun |
| ({ok, "200", _, "ok"}) -> |
| true; |
| (_) -> |
| false |
| end, |
| Req = #req{ |
| path="/uses_same_version", |
| opts=[{http_vsn, {1, 0}}] |
| }, |
| ?_test(check_request(Req, Remote, Local)). |
| |
| should_proxy_body(_) -> |
| Remote = fun(Req) -> |
| 'PUT' = Req:get(method), |
| "/passes_body" = Req:get(path), |
| <<"Hooray!">> = Req:recv_body(), |
| {ok, {201, [], "ok"}} |
| end, |
| Local = fun |
| ({ok, "201", _, "ok"}) -> |
| true; |
| (_) -> |
| false |
| end, |
| Req = #req{ |
| method=put, |
| path="/passes_body", |
| body="Hooray!" |
| }, |
| ?_test(check_request(Req, Remote, Local)). |
| |
| should_proxy_body_back(_) -> |
| BodyChunks = [<<"foo">>, <<"bar">>, <<"bazinga">>], |
| Remote = fun(Req) -> |
| 'GET' = Req:get(method), |
| "/passes_eof_body" = Req:get(path), |
| {raw, {200, [{"Connection", "close"}], BodyChunks}} |
| end, |
| Local = fun |
| ({ok, "200", _, "foobarbazinga"}) -> |
| true; |
| (_) -> |
| false |
| end, |
| Req = #req{path="/passes_eof_body"}, |
| ?_test(check_request(Req, Remote, Local)). |
| |
| should_proxy_chunked_body(_) -> |
| BodyChunks = [<<"foo">>, <<"bar">>, <<"bazinga">>], |
| Remote = fun(Req) -> |
| 'POST' = Req:get(method), |
| "/passes_chunked_body" = Req:get(path), |
| RecvBody = fun |
| ({Length, Chunk}, [Chunk | Rest]) -> |
| Length = size(Chunk), |
| Rest; |
| ({0, []}, []) -> |
| ok |
| end, |
| ok = Req:stream_body(1024 * 1024, RecvBody, BodyChunks), |
| {ok, {201, [], "ok"}} |
| end, |
| Local = fun |
| ({ok, "201", _, "ok"}) -> |
| true; |
| (_) -> |
| false |
| end, |
| Req = #req{ |
| method=post, |
| path="/passes_chunked_body", |
| headers=[{"Transfer-Encoding", "chunked"}], |
| body=chunked_body(BodyChunks) |
| }, |
| ?_test(check_request(Req, Remote, Local)). |
| |
| should_proxy_chunked_body_back(_) -> |
| ?_test(begin |
| Remote = fun(Req) -> |
| 'GET' = Req:get(method), |
| "/passes_chunked_body_back" = Req:get(path), |
| BodyChunks = [<<"foo">>, <<"bar">>, <<"bazinga">>], |
| {chunked, {200, [{"Transfer-Encoding", "chunked"}], BodyChunks}} |
| end, |
| Req = #req{ |
| path="/passes_chunked_body_back", |
| opts=[{stream_to, self()}] |
| }, |
| |
| Resp = check_request(Req, Remote, no_local), |
| ?assertMatch({ibrowse_req_id, _}, Resp), |
| {_, ReqId} = Resp, |
| |
| % Grab headers from response |
| receive |
| {ibrowse_async_headers, ReqId, "200", Headers} -> |
| ?assertEqual("chunked", |
| proplists:get_value("Transfer-Encoding", Headers)), |
| ibrowse:stream_next(ReqId) |
| after 1000 -> |
| throw({error, timeout}) |
| end, |
| |
| ?assertEqual(<<"foobarbazinga">>, recv_body(ReqId, [])), |
| ?assertEqual(was_ok, test_web:check_last()) |
| end). |
| |
| should_refuse_connection_to_backend(_) -> |
| Local = fun |
| ({ok, "500", _, _}) -> |
| true; |
| (_) -> |
| false |
| end, |
| Req = #req{opts=[{url, server_url("/_error")}]}, |
| ?_test(check_request(Req, no_remote, Local)). |
| |
| should_rewrite_location_header(_) -> |
| { |
| "Testing location header rewrites", |
| do_rewrite_tests([ |
| {"Location", proxy_url() ++ "/foo/bar", |
| server_url() ++ "/foo/bar"}, |
| {"Content-Location", proxy_url() ++ "/bing?q=2", |
| server_url() ++ "/bing?q=2"}, |
| {"Uri", proxy_url() ++ "/zip#frag", |
| server_url() ++ "/zip#frag"}, |
| {"Destination", proxy_url(), |
| server_url() ++ "/"} |
| ]) |
| }. |
| |
| should_not_rewrite_external_locations(_) -> |
| { |
| "Testing no rewrite of external locations", |
| do_rewrite_tests([ |
| {"Location", external_url() ++ "/search", |
| external_url() ++ "/search"}, |
| {"Content-Location", external_url() ++ "/s?q=2", |
| external_url() ++ "/s?q=2"}, |
| {"Uri", external_url() ++ "/f#f", |
| external_url() ++ "/f#f"}, |
| {"Destination", external_url() ++ "/f?q=2#f", |
| external_url() ++ "/f?q=2#f"} |
| ]) |
| }. |
| |
| should_rewrite_relative_location(_) -> |
| { |
| "Testing relative rewrites", |
| do_rewrite_tests([ |
| {"Location", "/foo", |
| server_url() ++ "/foo"}, |
| {"Content-Location", "bar", |
| server_url() ++ "/bar"}, |
| {"Uri", "/zing?q=3", |
| server_url() ++ "/zing?q=3"}, |
| {"Destination", "bing?q=stuff#yay", |
| server_url() ++ "/bing?q=stuff#yay"} |
| ]) |
| }. |
| |
| |
| do_rewrite_tests(Tests) -> |
| lists:map(fun({Header, Location, Url}) -> |
| should_rewrite_header(Header, Location, Url) |
| end, Tests). |
| |
| should_rewrite_header(Header, Location, Url) -> |
| Remote = fun(Req) -> |
| "/rewrite_test" = Req:get(path), |
| {ok, {302, [{Header, Location}], "ok"}} |
| end, |
| Local = fun |
| ({ok, "302", Headers, "ok"}) -> |
| ?assertEqual(Url, couch_util:get_value(Header, Headers)), |
| true; |
| (E) -> |
| ?debugFmt("~p", [E]), |
| false |
| end, |
| Req = #req{path="/rewrite_test"}, |
| {Header, ?_test(check_request(Req, Remote, Local))}. |
| |
| |
| server_url() -> |
| server_url("/_test"). |
| |
| server_url(Resource) -> |
| Addr = config:get("httpd", "bind_address"), |
| Port = integer_to_list(mochiweb_socket_server:get(couch_httpd, port)), |
| lists:concat(["http://", Addr, ":", Port, Resource]). |
| |
| proxy_url() -> |
| "http://127.0.0.1:" ++ integer_to_list(test_web:get_port()). |
| |
| external_url() -> |
| "https://google.com". |
| |
| check_request(Req, Remote, Local) -> |
| case Remote of |
| no_remote -> |
| ok; |
| _ -> |
| test_web:set_assert(Remote) |
| end, |
| Url = case proplists:lookup(url, Req#req.opts) of |
| none -> |
| server_url() ++ Req#req.path; |
| {url, DestUrl} -> |
| DestUrl |
| end, |
| Opts = [{headers_as_is, true} | Req#req.opts], |
| Resp =ibrowse:send_req( |
| Url, Req#req.headers, Req#req.method, Req#req.body, Opts |
| ), |
| %?debugFmt("ibrowse response: ~p", [Resp]), |
| case Local of |
| no_local -> |
| ok; |
| _ -> |
| ?assert(Local(Resp)) |
| end, |
| case {Remote, Local} of |
| {no_remote, _} -> |
| ok; |
| {_, no_local} -> |
| ok; |
| _ -> |
| ?assertEqual(was_ok, test_web:check_last()) |
| end, |
| Resp. |
| |
| chunked_body(Chunks) -> |
| chunked_body(Chunks, []). |
| |
| chunked_body([], Acc) -> |
| iolist_to_binary(lists:reverse(Acc, "0\r\n\r\n")); |
| chunked_body([Chunk | Rest], Acc) -> |
| Size = to_hex(size(Chunk)), |
| chunked_body(Rest, ["\r\n", Chunk, "\r\n", Size | Acc]). |
| |
| to_hex(Val) -> |
| to_hex(Val, []). |
| |
| to_hex(0, Acc) -> |
| Acc; |
| to_hex(Val, Acc) -> |
| to_hex(Val div 16, [hex_char(Val rem 16) | Acc]). |
| |
| hex_char(V) when V < 10 -> $0 + V; |
| hex_char(V) -> $A + V - 10. |
| |
| recv_body(ReqId, Acc) -> |
| receive |
| {ibrowse_async_response, ReqId, Data} -> |
| recv_body(ReqId, [Data | Acc]); |
| {ibrowse_async_response_end, ReqId} -> |
| iolist_to_binary(lists:reverse(Acc)); |
| Else -> |
| throw({error, unexpected_mesg, Else}) |
| after ?TIMEOUT -> |
| throw({error, timeout}) |
| end. |