defmodule GitGud.ImportsTest do
use GitGud.DataCase, async: false
use Oban.Testing, repo: GitGud.Repo
alias GitGud.Imports
alias GitGud.Imports.Import
alias GitGud.Imports.ImportItem
alias GitGud.Imports.Provider
alias GitGud.Imports.Providers.GitUrl
alias GitGud.Organizations
alias GitGud.Repositories
import GitGud.AccountsFixtures
import GitGud.ForgeFixtures
setup do
on_exit(fn -> Application.delete_env(:git_gud, :imports_req_options) end)
:ok
end
# Stub the provider HTTP layer with a Plug function. `pages` is a map of
# page number => decoded JSON body.
defp stub_api(handler) when is_function(handler, 1) do
Application.put_env(:git_gud, :imports_req_options,
plug: fn conn ->
conn = Plug.Conn.fetch_query_params(conn)
handler.(conn)
end
)
end
defp json(conn, status, body, headers \\ []) do
conn = Enum.reduce(headers, conn, fn {k, v}, c -> Plug.Conn.put_resp_header(c, k, v) end)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(status, Jason.encode!(body))
end
defp gh_repo(name, overrides \\ %{}) do
Map.merge(
%{
"name" => name,
"full_name" => "acme/#{name}",
"clone_url" => "https://github.com/acme/#{name}.git",
"description" => "the #{name}",
"default_branch" => "main",
"private" => false,
"fork" => false,
"archived" => false
},
overrides
)
end
describe "Provider" do
test "resolves adapters and rejects unknown ones" do
assert {:ok, GitGud.Imports.Providers.GitHub} = Provider.adapter("github")
assert {:ok, GitGud.Imports.Providers.GitLab} = Provider.adapter("gitlab")
assert {:ok, GitUrl} = Provider.adapter("git")
assert {:error, :unknown_provider} = Provider.adapter("bitbucket")
end
test "options are ordered and cover every provider slug" do
slugs = Enum.map(Provider.options(), fn {_label, slug} -> slug end)
assert slugs == ["github", "gitlab", "git"]
assert Enum.sort(slugs) == Enum.sort(Import.providers())
end
end
describe "GitHub discovery" do
test "lists an organization's repos" do
stub_api(fn conn ->
assert conn.request_path == "/orgs/acme/repos"
json(conn, 200, [gh_repo("alpha"), gh_repo("beta", %{"private" => true})])
end)
assert {:ok, [alpha, beta]} =
Imports.discover(%{"provider" => "github", "remote_owner" => "acme"})
assert alpha.name == "alpha"
assert alpha.clone_url == "https://github.com/acme/alpha.git"
refute alpha.private
assert beta.private
end
test "falls back to the user endpoint when the org 404s" do
stub_api(fn conn ->
case conn.request_path do
"/orgs/someone/repos" -> json(conn, 404, %{"message" => "Not Found"})
"/users/someone/repos" -> json(conn, 200, [gh_repo("personal")])
end
end)
assert {:ok, [repo]} =
Imports.discover(%{"provider" => "github", "remote_owner" => "someone"})
assert repo.name == "personal"
end
test "paginates until a short page comes back" do
page1 = Enum.map(1..100, &gh_repo("r#{&1}"))
stub_api(fn conn ->
case conn.query_params["page"] do
"1" -> json(conn, 200, page1)
"2" -> json(conn, 200, [gh_repo("last")])
end
end)
assert {:ok, repos} = Imports.discover(%{"provider" => "github", "remote_owner" => "acme"})
assert length(repos) == 101
end
test "surfaces auth failures rather than returning a partial list" do
stub_api(fn conn -> json(conn, 401, %{"message" => "Bad credentials"}) end)
assert {:error, {:unauthorized, 401}} =
Imports.discover(%{"provider" => "github", "remote_owner" => "acme"}, "bad-token")
end
test "sends the token as a bearer credential" do
stub_api(fn conn ->
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer sekrit"]
json(conn, 200, [gh_repo("alpha")])
end)
assert {:ok, [_]} =
Imports.discover(%{"provider" => "github", "remote_owner" => "acme"}, "sekrit")
end
end
describe "GitLab discovery" do
test "lists a group's projects, including subgroups" do
stub_api(fn conn ->
assert conn.request_path == "/api/v4/groups/acme/projects"
assert conn.query_params["include_subgroups"] == "true"
json(
conn,
200,
[
%{
"path" => "alpha",
"path_with_namespace" => "acme/team/alpha",
"http_url_to_repo" => "https://gitlab.com/acme/team/alpha.git",
"description" => nil,
"default_branch" => "trunk",
"visibility" => "private",
"archived" => false
}
],
[{"x-next-page", ""}]
)
end)
assert {:ok, [repo]} =
Imports.discover(%{"provider" => "gitlab", "remote_owner" => "acme"})
assert repo.name == "alpha"
assert repo.full_name == "acme/team/alpha"
assert repo.default_branch == "trunk"
assert repo.private
end
test "encodes nested group paths as a single path segment" do
stub_api(fn conn ->
assert conn.request_path == "/api/v4/groups/acme%2Fplatform/projects"
json(conn, 200, [], [{"x-next-page", ""}])
end)
assert {:ok, []} =
Imports.discover(%{"provider" => "gitlab", "remote_owner" => "acme/platform"})
end
test "follows x-next-page across pages" do
stub_api(fn conn ->
case conn.query_params["page"] do
"1" ->
json(conn, 200, [gitlab_project("a")], [{"x-next-page", "2"}])
"2" ->
json(conn, 200, [gitlab_project("b")], [{"x-next-page", ""}])
end
end)
assert {:ok, repos} = Imports.discover(%{"provider" => "gitlab", "remote_owner" => "acme"})
assert Enum.map(repos, & &1.name) == ["a", "b"]
end
test "sends the token in the private-token header" do
stub_api(fn conn ->
assert Plug.Conn.get_req_header(conn, "private-token") == ["glpat-x"]
json(conn, 200, [], [{"x-next-page", ""}])
end)
assert {:ok, []} =
Imports.discover(%{"provider" => "gitlab", "remote_owner" => "acme"}, "glpat-x")
end
end
defp gitlab_project(name) do
%{
"path" => name,
"path_with_namespace" => "acme/#{name}",
"http_url_to_repo" => "https://gitlab.com/acme/#{name}.git",
"visibility" => "public",
"archived" => false
}
end
describe "arbitrary git URL" do
test "derives the repo name from the URL and assumes private" do
assert {:ok, [repo]} =
Imports.discover(%{
"provider" => "git",
"remote_owner" => "https://example.com/team/project.git"
})
assert repo.name == "project"
assert repo.clone_url == "https://example.com/team/project.git"
assert repo.private, "unknown provenance should default to the closed setting"
end
test "handles a URL with no .git suffix or trailing slash" do
assert {:ok, [repo]} = GitUrl.list_repositories("https://example.com/team/project", [])
assert repo.name == "project"
end
test "refuses a URL with embedded credentials" do
assert {:error, :credentials_in_url} =
GitUrl.list_repositories("https://user:token@example.com/a/b.git", [])
end
test "rejects ssh and other non-http schemes" do
for url <- [
"git@github.com:acme/thing.git",
"ssh://git@example.com/acme/thing.git",
"file:///etc/passwd",
"ext::sh -c whoami",
"https://",
"not a url"
] do
assert {:error, :invalid_clone_url} = GitUrl.list_repositories(url, []),
"expected #{url} to be rejected"
end
end
end
describe "filter_discovered/2" do
test "excludes forks and archived repos unless opted in" do
repos = [
%{name: "plain", fork: false, archived: false},
%{name: "forked", fork: true, archived: false},
%{name: "old", fork: false, archived: true}
]
assert [%{name: "plain"}] = Imports.filter_discovered(repos, %{})
assert ["forked", "plain"] =
repos
|> Imports.filter_discovered(%{"include_forks" => "true"})
|> Enum.map(& &1.name)
|> Enum.sort()
assert length(
Imports.filter_discovered(repos, %{
"include_forks" => "true",
"include_archived" => "true"
})
) == 3
end
end
describe "target_name/1" do
test "coerces remote names that GitGud wouldn't accept" do
assert Imports.target_name("plain-name") == "plain-name"
assert Imports.target_name(".dotfiles") == "dotfiles"
assert Imports.target_name("my repo!") == "my-repo-"
assert Imports.target_name("---") == "repository"
assert Imports.target_name(nil) == "repository"
assert String.length(Imports.target_name(String.duplicate("a", 300))) == 100
end
end
describe "start_import/5" do
test "creates items, enqueues a job each, and stores the token encrypted" do
user = user_fixture()
repos = [
%{
name: "alpha",
full_name: "acme/alpha",
clone_url: "https://github.com/acme/alpha.git",
description: "a",
default_branch: "main",
private: true,
fork: false,
archived: false
}
]
assert {:ok, import_row} =
Imports.start_import(
user,
user,
%{"provider" => "github", "remote_owner" => "acme"},
repos,
"sekrit"
)
assert import_row.status == "running"
assert [item] = import_row.items
assert item.target_name == "alpha"
assert item.status == "pending"
# The token round-trips but is never stored in the clear.
refute import_row.token_ciphertext == "sekrit"
assert Imports.token(import_row) == "sekrit"
assert %{username: "x-access-token", token: "sekrit"} = Imports.credentials(import_row)
assert_enqueued(worker: GitGud.Imports.Workers.CloneRepo, args: %{"item_id" => item.id})
end
test "an empty selection completes immediately and wipes the token" do
user = user_fixture()
assert {:ok, import_row} =
Imports.start_import(
user,
user,
%{"provider" => "github", "remote_owner" => "acme"},
[],
"sekrit"
)
assert import_row.status == "completed"
assert Imports.get_import(import_row.id).token_ciphertext == nil
end
test "de-duplicates repos that appear twice in the selection" do
user = user_fixture()
repo = discovered("alpha")
assert {:ok, import_row} =
Imports.start_import(user, user, base_attrs(), [repo, repo], nil)
assert length(import_row.items) == 1
end
test "refuses to import into someone else's namespace" do
actor = user_fixture()
other = user_fixture()
assert {:error, :forbidden} =
Imports.start_import(actor, other, base_attrs(), [discovered("alpha")], nil)
end
test "refuses to import into an org the actor doesn't administer" do
admin = user_fixture()
{:ok, org} =
Organizations.create_organization(admin, %{
"handle" => "acme#{System.unique_integer([:positive])}",
"name" => "Acme"
})
outsider = user_fixture()
assert {:error, :forbidden} =
Imports.start_import(outsider, org, base_attrs(), [discovered("alpha")], nil)
end
test "rejects a non-http clone URL at the item boundary" do
user = user_fixture()
bad = %{discovered("alpha") | clone_url: "ext::sh -c whoami"}
assert {:error, _} = Imports.start_import(user, user, base_attrs(), [bad], nil)
end
test "validates the provider slug" do
user = user_fixture()
assert {:error, %Ecto.Changeset{}} =
Imports.start_import(
user,
user,
%{"provider" => "bitbucket", "remote_owner" => "acme"},
[],
nil
)
end
end
describe "credentials/1" do
test "uses each provider's conventional basic-auth username" do
assert %{username: "x-access-token"} = creds_for("github", nil)
assert %{username: "oauth2"} = creds_for("gitlab", nil)
assert %{username: "git"} = creds_for("git", nil)
assert %{username: "custom"} = creds_for("git", "custom")
end
test "is nil when the import is anonymous" do
user = user_fixture()
{:ok, import_row} = Imports.start_import(user, user, base_attrs(), [], nil)
assert Imports.credentials(import_row) == nil
end
end
defp creds_for(provider, username) do
user = user_fixture()
{:ok, import_row} =
Imports.start_import(
user,
user,
%{
"provider" => provider,
"remote_owner" => if(provider == "git", do: "https://x.example/a.git", else: "acme"),
"remote_username" => username
},
[],
"tok"
)
# start_import wipes the token on an empty batch, so read credentials
# from the pre-wipe struct it returned.
Imports.credentials(import_row)
end
describe "import_item/2" do
test "skips a repo whose target name is already taken locally" do
{user, existing} = repository_fixture()
{:ok, import_row} =
Imports.start_import(user, user, base_attrs(), [discovered(existing.name)], nil)
[item] = import_row.items
assert {:skip, message} = Imports.import_item(item, import_row)
assert message =~ existing.name
end
end
describe "settle/1 and cancel/1" do
test "settle marks the batch completed and wipes the token once nothing is outstanding" do
user = user_fixture()
{:ok, import_row} =
Imports.start_import(user, user, base_attrs(), [discovered("alpha")], "sekrit")
[item] = import_row.items
assert Imports.token(import_row) == "sekrit"
_ = Imports.mark_item(item, "imported")
settled = Imports.settle(Imports.get_import(import_row.id))
assert settled.status == "completed"
assert settled.token_ciphertext == nil
assert settled.token_cleared_at != nil
end
test "settle reports failed when any item failed" do
user = user_fixture()
{:ok, import_row} =
Imports.start_import(user, user, base_attrs(), [discovered("a"), discovered("b")], nil)
[one, two] = import_row.items
_ = Imports.mark_item(one, "imported")
_ = Imports.mark_item(two, "failed", %{error: "boom"})
assert Imports.settle(Imports.get_import(import_row.id)).status == "failed"
end
test "settle leaves the batch alone while work is outstanding" do
user = user_fixture()
{:ok, import_row} =
Imports.start_import(user, user, base_attrs(), [discovered("a"), discovered("b")], nil)
[one, _two] = import_row.items
_ = Imports.mark_item(one, "imported")
assert Imports.settle(Imports.get_import(import_row.id)).status == "running"
end
test "cancel skips pending items, wipes the token, and leaves cloning ones be" do
user = user_fixture()
{:ok, import_row} =
Imports.start_import(
user,
user,
base_attrs(),
[discovered("a"), discovered("b")],
"sekrit"
)
[one, two] = import_row.items
_ = Imports.mark_item(one, "cloning")
assert {:ok, canceled} = Imports.cancel(import_row)
assert canceled.status == "canceled"
assert canceled.token_ciphertext == nil
assert Repo.get(ImportItem, one.id).status == "cloning"
assert Repo.get(ImportItem, two.id).status == "skipped"
end
end
describe "get_import!/2 scoping" do
test "another user can't read someone else's import" do
user = user_fixture()
{:ok, import_row} = Imports.start_import(user, user, base_attrs(), [], nil)
assert Imports.get_import!(user, import_row.id).id == import_row.id
assert_raise Ecto.NoResultsError, fn ->
Imports.get_import!(user_fixture(), import_row.id)
end
end
end
describe "progress/1" do
test "counts items by status" do
user = user_fixture()
{:ok, import_row} =
Imports.start_import(user, user, base_attrs(), [discovered("a"), discovered("b")], nil)
[one, _] = import_row.items
_ = Imports.mark_item(one, "imported")
assert Imports.progress(import_row) == %{"imported" => 1, "pending" => 1}
end
end
describe "repositories carry their upstream" do
@tag :git_required
test "sync_upstream refuses a repo that wasn't imported" do
if System.find_executable("git") do
{_user, repo} = repository_fixture()
assert {:error, :no_upstream} = Repositories.sync_upstream(repo)
end
end
end
defp base_attrs, do: %{"provider" => "github", "remote_owner" => "acme"}
defp discovered(name) do
%{
name: name,
full_name: "acme/#{name}",
clone_url: "https://github.com/acme/#{name}.git",
description: nil,
default_branch: "main",
private: false,
fork: false,
archived: false
}
end
end
neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
17.3 KiB · text
History
69753e1