Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
513
test/tarakan_web/git_http_test.exs
Normal file
513
test/tarakan_web/git_http_test.exs
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
defmodule TarakanWeb.GitHTTPTest do
|
||||
@moduledoc """
|
||||
End-to-end smart-HTTP tests driven by a real git client against a real
|
||||
Bandit listener serving the full endpoint.
|
||||
"""
|
||||
|
||||
use Tarakan.DataCase, async: false
|
||||
|
||||
import Tarakan.AccountsFixtures
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.HostedRepositories
|
||||
|
||||
@moduletag :git_client
|
||||
|
||||
setup do
|
||||
on_exit(fn ->
|
||||
File.rm_rf(Application.fetch_env!(:tarakan, Tarakan.HostedRepositories)[:root])
|
||||
end)
|
||||
|
||||
Tarakan.RepositoryCode.Cache.clear()
|
||||
|
||||
port = free_port()
|
||||
|
||||
start_supervised!(
|
||||
{Bandit, plug: TarakanWeb.Endpoint, scheme: :http, ip: {127, 0, 0, 1}, port: port}
|
||||
)
|
||||
|
||||
account = account_fixture()
|
||||
scope = Accounts.scope_for_account(account)
|
||||
{:ok, repository} = HostedRepositories.create(scope, %{"name" => "hosted"})
|
||||
|
||||
{:ok, token, _credential} =
|
||||
Accounts.ApiCredentials.create(account, %{
|
||||
"name" => "git",
|
||||
"scopes" => ["repo:read", "repo:write"]
|
||||
})
|
||||
|
||||
%{
|
||||
port: port,
|
||||
account: account,
|
||||
repository: repository,
|
||||
token: token,
|
||||
base_url: "http://127.0.0.1:#{port}"
|
||||
}
|
||||
end
|
||||
|
||||
defp free_port do
|
||||
{:ok, socket} = :gen_tcp.listen(0, ip: {127, 0, 0, 1})
|
||||
{:ok, port} = :inet.port(socket)
|
||||
:gen_tcp.close(socket)
|
||||
port
|
||||
end
|
||||
|
||||
defp git_env do
|
||||
[
|
||||
{"GIT_TERMINAL_PROMPT", "0"},
|
||||
{"GIT_CONFIG_GLOBAL", "/dev/null"},
|
||||
{"GIT_CONFIG_SYSTEM", "/dev/null"},
|
||||
{"GIT_AUTHOR_NAME", "t"},
|
||||
{"GIT_AUTHOR_EMAIL", "t@example.com"},
|
||||
{"GIT_COMMITTER_NAME", "t"},
|
||||
{"GIT_COMMITTER_EMAIL", "t@example.com"}
|
||||
]
|
||||
end
|
||||
|
||||
defp git(args, opts \\ []) do
|
||||
System.cmd("git", args, [stderr_to_stdout: true, env: git_env()] ++ opts)
|
||||
end
|
||||
|
||||
defp clone_url(%{base_url: base_url, account: account}, name),
|
||||
do: "#{base_url}/#{account.handle}/#{name}.git"
|
||||
|
||||
defp authed_url(%{token: token} = context, name) do
|
||||
%URI{URI.parse(clone_url(context, name)) | userinfo: "git:#{token}"} |> URI.to_string()
|
||||
end
|
||||
|
||||
defp seed_local_repository(tmp_dir) do
|
||||
work = Path.join(tmp_dir, "seed")
|
||||
File.mkdir_p!(work)
|
||||
{_out, 0} = git(["init", "--quiet", "-b", "main", work])
|
||||
File.write!(Path.join(work, "README.md"), "# Pushed\n")
|
||||
{_out, 0} = git(["-C", work, "add", "."])
|
||||
{_out, 0} = git(["-C", work, "commit", "--quiet", "-m", "seed"])
|
||||
work
|
||||
end
|
||||
|
||||
defp list_repository(repository) do
|
||||
repository
|
||||
|> Ecto.Changeset.change(listing_status: "listed")
|
||||
|> Repo.update!()
|
||||
end
|
||||
|
||||
describe "push over HTTP" do
|
||||
@tag :tmp_dir
|
||||
test "steward pushes with a repo:write credential", context do
|
||||
%{repository: repository, tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{output, status} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
assert status == 0, output
|
||||
|
||||
repository = Repo.reload!(repository)
|
||||
assert repository.default_branch == "main"
|
||||
assert %DateTime{} = repository.pushed_at
|
||||
assert repository.disk_size_bytes > 0
|
||||
|
||||
assert %{action: "repository_pushed"} =
|
||||
Repo.get_by(Tarakan.Audit.Event, action: "repository_pushed")
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "push is denied without credentials, with read-only scope, and for non-stewards",
|
||||
context do
|
||||
%{tmp_dir: tmp_dir, account: account} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
# Anonymous: the server answers 401 so git asks for credentials, and
|
||||
# with prompts disabled the push dies without touching the repository.
|
||||
{:ok, {{_http, 401, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{~c"#{clone_url(context, "hosted")}/info/refs?service=git-receive-pack", []},
|
||||
[autoredirect: false],
|
||||
[]
|
||||
)
|
||||
|
||||
{output, status} =
|
||||
git(["-C", work, "push", "--quiet", clone_url(context, "hosted"), "main"])
|
||||
|
||||
assert status != 0
|
||||
assert output =~ "Authentication" or output =~ "401" or output =~ "prompts disabled"
|
||||
|
||||
# Authenticated but read-only scope.
|
||||
{:ok, read_token, _credential} =
|
||||
Accounts.ApiCredentials.create(account, %{
|
||||
"name" => "read-only",
|
||||
"scopes" => ["repo:read"]
|
||||
})
|
||||
|
||||
read_url =
|
||||
%URI{URI.parse(clone_url(context, "hosted")) | userinfo: "git:#{read_token}"}
|
||||
|> URI.to_string()
|
||||
|
||||
{output, status} = git(["-C", work, "push", "--quiet", read_url, "main"])
|
||||
assert status != 0
|
||||
assert output =~ "404" or output =~ "not found"
|
||||
|
||||
# A different account with a valid repo:write credential but no
|
||||
# steward membership.
|
||||
other = account_fixture()
|
||||
|
||||
{:ok, other_token, _credential} =
|
||||
Accounts.ApiCredentials.create(other, %{
|
||||
"name" => "other",
|
||||
"scopes" => ["repo:write"]
|
||||
})
|
||||
|
||||
other_url =
|
||||
%URI{URI.parse(clone_url(context, "hosted")) | userinfo: "git:#{other_token}"}
|
||||
|> URI.to_string()
|
||||
|
||||
{output, status} = git(["-C", work, "push", "--quiet", other_url, "main"])
|
||||
assert status != 0
|
||||
assert output =~ "404" or output =~ "not found"
|
||||
|
||||
assert Repo.reload!(context.repository).pushed_at == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "clone over HTTP" do
|
||||
@tag :tmp_dir
|
||||
test "anonymous clone of a listed repository round-trips content", context do
|
||||
%{repository: repository, tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
list_repository(repository)
|
||||
|
||||
dest = Path.join(tmp_dir, "clone")
|
||||
{output, status} = git(["clone", "--quiet", clone_url(context, "hosted"), dest])
|
||||
assert status == 0, output
|
||||
assert File.read!(Path.join(dest, "README.md")) == "# Pushed\n"
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "anonymous clone succeeds at creation and is refused after quarantine", context do
|
||||
%{repository: repository, tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
# Hosted repositories are listed at creation, so an anonymous clone
|
||||
# works without any moderation step.
|
||||
dest = Path.join(tmp_dir, "clone")
|
||||
{output, status} = git(["clone", "--quiet", clone_url(context, "hosted"), dest])
|
||||
assert status == 0, output
|
||||
assert File.read!(Path.join(dest, "README.md")) == "# Pushed\n"
|
||||
|
||||
# A moderator quarantine is the only thing that hides the repository.
|
||||
repository
|
||||
|> Tarakan.Repositories.Repository.listing_changeset(%{listing_status: "quarantined"})
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, {{_http, 401, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack", []},
|
||||
[autoredirect: false],
|
||||
[]
|
||||
)
|
||||
|
||||
quarantined_dest = Path.join(tmp_dir, "clone-quarantined")
|
||||
|
||||
{output, status} =
|
||||
git(["clone", "--quiet", clone_url(context, "hosted"), quarantined_dest])
|
||||
|
||||
assert status != 0
|
||||
assert output =~ "Authentication" or output =~ "401" or output =~ "prompts disabled"
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "the owner clones a repository with a credential", context do
|
||||
%{tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
dest = Path.join(tmp_dir, "clone")
|
||||
{output, status} = git(["clone", "--quiet", authed_url(context, "hosted"), dest])
|
||||
assert status == 0, output
|
||||
assert File.read!(Path.join(dest, "README.md")) == "# Pushed\n"
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "fetch after a new push exercises negotiation", context do
|
||||
%{repository: repository, tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
list_repository(repository)
|
||||
|
||||
dest = Path.join(tmp_dir, "clone")
|
||||
{_output, 0} = git(["clone", "--quiet", clone_url(context, "hosted"), dest])
|
||||
|
||||
File.write!(Path.join(work, "second.txt"), "more\n")
|
||||
{_out, 0} = git(["-C", work, "add", "."])
|
||||
{_out, 0} = git(["-C", work, "commit", "--quiet", "-m", "second"])
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
{output, status} = git(["-C", dest, "pull", "--quiet", "origin", "main"])
|
||||
assert status == 0, output
|
||||
assert File.read!(Path.join(dest, "second.txt")) == "more\n"
|
||||
end
|
||||
|
||||
test "unknown repositories 404 without an existence oracle", context do
|
||||
%{base_url: base_url} = context
|
||||
|
||||
{:ok, {{_http, 404, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{~c"#{base_url}/nobody/nothing.git/info/refs?service=git-upload-pack", []},
|
||||
[],
|
||||
[]
|
||||
)
|
||||
end
|
||||
|
||||
test "non-git routes still reach Phoenix", context do
|
||||
{:ok, {{_http, 200, _reason}, _headers, body}} =
|
||||
:httpc.request(:get, {~c"#{context.base_url}/", []}, [], [])
|
||||
|
||||
assert to_string(body) =~ "<html"
|
||||
end
|
||||
end
|
||||
|
||||
describe "protocol details" do
|
||||
@tag :tmp_dir
|
||||
test "info/refs advertisement uses the smart content type", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
url =
|
||||
~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack"
|
||||
|
||||
{:ok, {{_http, 200, _reason}, headers, body}} = :httpc.request(:get, {url, []}, [], [])
|
||||
|
||||
content_type =
|
||||
headers
|
||||
|> Enum.find_value(fn
|
||||
{~c"content-type", value} -> to_string(value)
|
||||
_other -> nil
|
||||
end)
|
||||
|
||||
assert content_type == "application/x-git-upload-pack-advertisement"
|
||||
assert to_string(body) =~ "# service=git-upload-pack"
|
||||
assert String.starts_with?(to_string(body), "001e# service=git-upload-pack\n0000")
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "a truncated RPC body cannot wedge the subprocess", context do
|
||||
%{repository: repository, tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
list_repository(repository)
|
||||
|
||||
# A syntactically incomplete upload-pack request: git waits for the
|
||||
# rest of the negotiation, and only the wall-clock deadline ends it.
|
||||
original = Application.get_env(:tarakan, TarakanWeb.GitHTTP.Service, [])
|
||||
|
||||
Application.put_env(
|
||||
:tarakan,
|
||||
TarakanWeb.GitHTTP.Service,
|
||||
Keyword.put(original, :upload_pack_timeout_ms, 300)
|
||||
)
|
||||
|
||||
on_exit(fn -> Application.put_env(:tarakan, TarakanWeb.GitHTTP.Service, original) end)
|
||||
|
||||
truncated = "00a4want "
|
||||
|
||||
{time_us, {:ok, {{_http, 200, _reason}, _headers, _body}}} =
|
||||
:timer.tc(fn ->
|
||||
:httpc.request(
|
||||
:post,
|
||||
{~c"#{clone_url(context, "hosted")}/git-upload-pack",
|
||||
[{~c"content-type", ~c"application/x-git-upload-pack-request"}],
|
||||
~c"application/x-git-upload-pack-request", truncated},
|
||||
[timeout: 5_000],
|
||||
[]
|
||||
)
|
||||
end)
|
||||
|
||||
# Bounded by the configured deadline, not the httpc timeout.
|
||||
assert div(time_us, 1000) < 4_000
|
||||
end
|
||||
|
||||
test "a gzip bomb is refused without inflating it", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
original = Application.get_env(:tarakan, TarakanWeb.GitHTTP.Service, [])
|
||||
|
||||
# A small cap makes the limit trip deterministically, before git has a
|
||||
# chance to reject the garbage on its own and end the request first.
|
||||
Application.put_env(
|
||||
:tarakan,
|
||||
TarakanWeb.GitHTTP.Service,
|
||||
Keyword.put(original, :max_upload_pack_request_bytes, 64 * 1024)
|
||||
)
|
||||
|
||||
on_exit(fn -> Application.put_env(:tarakan, TarakanWeb.GitHTTP.Service, original) end)
|
||||
|
||||
# ~64MB of compressible input in well under a megabyte on the wire.
|
||||
zstream = :zlib.open()
|
||||
:ok = :zlib.deflateInit(zstream, 9, :deflated, 31, 8, :default)
|
||||
|
||||
bomb =
|
||||
zstream
|
||||
|> :zlib.deflate(:binary.copy("A", 64 * 1024 * 1024), :finish)
|
||||
|> IO.iodata_to_binary()
|
||||
|
||||
:ok = :zlib.deflateEnd(zstream)
|
||||
:ok = :zlib.close(zstream)
|
||||
|
||||
assert byte_size(bomb) < 1_000_000, "the bomb must be small on the wire to be a bomb"
|
||||
|
||||
memory_before = :erlang.memory(:total)
|
||||
|
||||
{:ok, {{_http, status, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:post,
|
||||
{~c"#{clone_url(context, "hosted")}/git-upload-pack",
|
||||
[
|
||||
{~c"content-type", ~c"application/x-git-upload-pack-request"},
|
||||
{~c"content-encoding", ~c"gzip"}
|
||||
], ~c"application/x-git-upload-pack-request", bomb},
|
||||
[timeout: 30_000],
|
||||
[]
|
||||
)
|
||||
|
||||
assert status == 413
|
||||
|
||||
# The cap is enforced during expansion, so the whole 64MB is never
|
||||
# materialized. Allow generous headroom for unrelated test allocation.
|
||||
growth = :erlang.memory(:total) - memory_before
|
||||
assert growth < 32 * 1024 * 1024, "inflated #{div(growth, 1024 * 1024)}MB before refusing"
|
||||
end
|
||||
|
||||
test "a well-formed gzip request body is still accepted", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
zstream = :zlib.open()
|
||||
:ok = :zlib.deflateInit(zstream, 9, :deflated, 31, 8, :default)
|
||||
|
||||
# A truncated but legitimately compressed negotiation body: the point is
|
||||
# that gzip decoding still works, not that git likes the contents.
|
||||
body = zstream |> :zlib.deflate("0000", :finish) |> IO.iodata_to_binary()
|
||||
:ok = :zlib.deflateEnd(zstream)
|
||||
:ok = :zlib.close(zstream)
|
||||
|
||||
{:ok, {{_http, status, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:post,
|
||||
{~c"#{clone_url(context, "hosted")}/git-upload-pack",
|
||||
[
|
||||
{~c"content-type", ~c"application/x-git-upload-pack-request"},
|
||||
{~c"content-encoding", ~c"gzip"}
|
||||
], ~c"application/x-git-upload-pack-request", body},
|
||||
[timeout: 10_000],
|
||||
[]
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
end
|
||||
|
||||
test "a corrupt gzip body is rejected as a bad request", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
{:ok, {{_http, status, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:post,
|
||||
{~c"#{clone_url(context, "hosted")}/git-upload-pack",
|
||||
[
|
||||
{~c"content-type", ~c"application/x-git-upload-pack-request"},
|
||||
{~c"content-encoding", ~c"gzip"}
|
||||
], ~c"application/x-git-upload-pack-request", "not actually gzip at all"},
|
||||
[timeout: 10_000],
|
||||
[]
|
||||
)
|
||||
|
||||
assert status == 400
|
||||
end
|
||||
|
||||
test "dumb-protocol requests are refused", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
{:ok, {{_http, 400, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{~c"#{clone_url(context, "hosted")}/info/refs", []},
|
||||
[],
|
||||
[]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "rate limits" do
|
||||
test "failed basic authentications are throttled to 429 per client IP", context do
|
||||
original = Application.get_env(:tarakan, TarakanWeb.GitHTTP, [])
|
||||
|
||||
Application.put_env(
|
||||
:tarakan,
|
||||
TarakanWeb.GitHTTP,
|
||||
Keyword.put(original, :auth_failure_rate_limit, {2, 60})
|
||||
)
|
||||
|
||||
on_exit(fn -> Application.put_env(:tarakan, TarakanWeb.GitHTTP, original) end)
|
||||
|
||||
authorization = ~c"Basic " ++ String.to_charlist(Base.encode64("git:bad-token"))
|
||||
url = ~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack"
|
||||
|
||||
request = fn ->
|
||||
:httpc.request(
|
||||
:get,
|
||||
{url, [{~c"authorization", authorization}]},
|
||||
[autoredirect: false],
|
||||
[]
|
||||
)
|
||||
end
|
||||
|
||||
{:ok, {{_http, 401, _reason}, _headers, _body}} = request.()
|
||||
{:ok, {{_http, 401, _reason}, _headers, _body}} = request.()
|
||||
{:ok, {{_http, 429, _reason}, _headers, _body}} = request.()
|
||||
end
|
||||
|
||||
test "info/refs answers 503 when the git subprocess cap is exhausted", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
checked_out =
|
||||
Stream.repeatedly(fn -> Tarakan.Git.Concurrency.checkout() end)
|
||||
|> Enum.take_while(&(&1 == :ok))
|
||||
|
||||
on_exit(fn -> for _ <- checked_out, do: Tarakan.Git.Concurrency.checkin() end)
|
||||
|
||||
{:ok, {{_http, 503, _reason}, headers, body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack", []},
|
||||
[autoredirect: false],
|
||||
[]
|
||||
)
|
||||
|
||||
assert Enum.find(headers, fn {name, _value} -> name == ~c"retry-after" end)
|
||||
assert to_string(body) =~ "busy"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue