defmodule GitGudWeb.GitJobTokenTest do
@moduledoc """
A CI job's token over git smart HTTP: what `permissions: contents:`
lets it do to its own repository, that it reaches no other, that it
stops working when its job is over, and that a push made with it is
marked so it starts no workflows.
The push goes through `git http-backend` for real; the repository's
hooks are pointed at stand-ins (`GITGUD_PRE_RECEIVE_BIN` /
`GITGUD_POST_RECEIVE_BIN`, which the installed hooks honour) because
the real ones call back into a running server. Branch protection is
checked through the endpoint the real pre-receive hook calls.
"""
use GitGudWeb.ConnCase, async: false
import GitGud.ForgeFixtures
alias GitGud.BranchProtections
alias GitGud.Repositories.Storage
alias GitGud.Workflows.RunnerJwt
alias GitGud.Workflows.WorkflowJob
alias GitGud.Workflows.WorkflowRun
@moduletag :git_required
@zero String.duplicate("0", 40)
setup do
if System.find_executable("git") == nil do
{:skip, "git not on PATH"}
else
{user, repo} = repository_fixture(%{visibility: "private"})
{:ok, user: user, repo: repo, path: "/#{Storage.owner_handle(user)}/#{repo.name}.git"}
end
end
defp job_token(repo, perms, opts \\ []) do
run =
%WorkflowRun{repository_id: repo.id}
|> WorkflowRun.create_changeset(%{
number: System.unique_integer([:positive]),
workflow_path: ".forgejo/workflows/ci.yml",
trigger: "push",
head_sha: :crypto.strong_rand_bytes(20),
triggered_by_id: opts[:triggered_by_id]
})
|> GitGud.Repo.insert!()
job =
%WorkflowJob{workflow_run_id: run.id}
|> WorkflowJob.create_changeset(%{job_id: "build", definition: %{}})
|> Ecto.Changeset.change(status: "running", started_at: DateTime.utc_now(:second))
|> GitGud.Repo.insert!()
{job, RunnerJwt.mint(job, run, perms)}
end
defp perms(contents), do: %{"contents" => contents, "packages" => :write, "actions" => :write}
defp basic(token), do: "Basic " <> Base.encode64("x-access-token:" <> token)
defp info_refs(path, service, token) do
build_conn()
|> put_req_header("authorization", basic(token))
|> get(path <> "/info/refs?service=" <> service)
end
describe "contents: read (the default)" do
test "fetches its own repository and can't push to it", %{repo: repo, path: path} do
{_job, token} = job_token(repo, perms(:read))
assert info_refs(path, "git-upload-pack", token).status == 200
assert info_refs(path, "git-receive-pack", token).status == 403
end
end
describe "contents: none" do
test "can't even fetch", %{repo: repo, path: path} do
{_job, token} = job_token(repo, perms(:none))
assert info_refs(path, "git-upload-pack", token).status == 403
end
end
describe "contents: write" do
test "may push to its own repository, not to another", %{repo: repo, path: path} do
{_job, token} = job_token(repo, perms(:write))
assert info_refs(path, "git-receive-pack", token).status == 200
{other_user, other} = repository_fixture(%{visibility: "private"})
other_path = "/#{Storage.owner_handle(other_user)}/#{other.name}.git"
assert info_refs(other_path, "git-receive-pack", token).status == 403
assert info_refs(other_path, "git-upload-pack", token).status == 403
end
test "a push lands, attributed to the run's author and marked as the job's", %{
user: user,
repo: repo,
path: path
} do
{job, token} = job_token(repo, perms(:write), triggered_by_id: user.id)
env_file = stub_hooks!()
{sha, pack} = commit_pack!()
conn =
build_conn()
|> put_req_header("authorization", basic(token))
|> put_req_header("content-type", "application/x-git-receive-pack-request")
|> post(
path <> "/git-receive-pack",
receive_pack_request(@zero, sha, "refs/heads/main", pack)
)
assert conn.status == 200
assert conn.resp_body =~ "ok refs/heads/main"
{head, 0} = System.cmd("git", ["-C", repo.disk_path, "rev-parse", "refs/heads/main"])
assert String.trim(head) == sha
hook_env = File.read!(env_file)
assert hook_env =~ "GITGUD_JOB_ID=#{job.id}\n"
assert hook_env =~ "GITGUD_PUSHER_ID=#{user.id}\n"
# And the secret its call back into /_hooks needs.
assert hook_env =~ "GITGUD_HOOK_SECRET=#{GitGudWeb.Plugs.HookAuth.secret()}\n"
end
test "branch protection judges it like any direct push", %{repo: repo} do
{:ok, _} =
BranchProtections.create_rule(repo, %{"pattern" => "main", "required_approvals" => 1})
{sha, _pack} = commit_pack!()
conn =
build_conn()
|> put_req_header("x-repo-path", repo.disk_path)
|> put_req_header("content-type", "text/plain")
|> put_req_header("x-gitgud-hook-token", GitGudWeb.Plugs.HookAuth.secret())
|> post("/_hooks/pre-receive", "#{@zero} #{sha} refs/heads/main\n")
assert "DENY\n" <> reason = conn.resp_body
assert reason =~ "approvals"
end
end
test "a finished job's token is refused", %{repo: repo, path: path} do
{job, token} = job_token(repo, perms(:write))
job
|> Ecto.Changeset.change(
status: "success",
completed_at: DateTime.add(DateTime.utc_now(:second), -600, :second)
)
|> GitGud.Repo.update!()
assert info_refs(path, "git-upload-pack", token).status == 401
end
# Point the repository's hooks at stand-ins: pre-receive allows, and
# post-receive records the environment the transport gave it.
defp stub_hooks! do
dir = Path.join(System.tmp_dir!(), "gitgud-hooks-#{System.unique_integer([:positive])}")
File.mkdir_p!(dir)
env_file = Path.join(dir, "post-receive.env")
post = Path.join(dir, "post-receive")
File.write!(post, """
#!/bin/sh
cat >/dev/null
env | grep '^GITGUD_' > '#{env_file}'
""")
File.chmod!(post, 0o755)
previous =
{System.get_env("GITGUD_PRE_RECEIVE_BIN"), System.get_env("GITGUD_POST_RECEIVE_BIN")}
System.put_env("GITGUD_PRE_RECEIVE_BIN", System.find_executable("true"))
System.put_env("GITGUD_POST_RECEIVE_BIN", post)
on_exit(fn ->
restore_env("GITGUD_PRE_RECEIVE_BIN", elem(previous, 0))
restore_env("GITGUD_POST_RECEIVE_BIN", elem(previous, 1))
File.rm_rf!(dir)
end)
env_file
end
defp restore_env(name, nil), do: System.delete_env(name)
defp restore_env(name, value), do: System.put_env(name, value)
# One commit in a scratch repository, and a pack of everything in it.
defp commit_pack! do
dir = Path.join(System.tmp_dir!(), "gitgud-src-#{System.unique_integer([:positive])}")
on_exit(fn -> File.rm_rf!(dir) end)
git = fn args -> System.cmd("git", ["-C", dir | args], stderr_to_stdout: true) end
{_, 0} = System.cmd("git", ["init", "-q", dir])
File.write!(Path.join(dir, "README"), "hello\n")
{_, 0} = git.(["add", "README"])
{_, 0} =
git.(["-c", "user.name=ci", "-c", "user.email=ci@example.com", "commit", "-q", "-m", "c"])
{sha, 0} = git.(["rev-parse", "HEAD"])
sha = String.trim(sha)
list = Path.join(dir, ".git/pack-list")
File.write!(list, sha <> "\n")
{pack, 0} =
System.cmd("sh", ["-c", "git -C '#{dir}' pack-objects --revs --stdout -q < '#{list}'"])
{sha, pack}
end
defp receive_pack_request(old, new, ref, pack) do
pkt_line("#{old} #{new} #{ref}\0report-status\n") <> "0000" <> pack
end
defp pkt_line(data) do
(byte_size(data) + 4)
|> Integer.to_string(16)
|> String.downcase()
|> String.pad_leading(4, "0")
|> Kernel.<>(data)
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
7.6 KiB · text
History
b868907