defmodule GitGudWeb.PackageUsageTest do
@moduledoc """
The two facets the packages page has to document — a package manager
at a terminal, and a workflow in this repository — and the CI auth
that makes the second one a paste rather than a setup task.
"""
use GitGudWeb.ConnCase, async: false
alias GitGud.Packages
alias GitGud.Packages.Auth
alias GitGud.Packages.Usage
alias GitGud.Repositories.Storage
alias GitGud.Workflows.RunnerJwt
import GitGud.ForgeFixtures
import Phoenix.LiveViewTest
setup do
{user, repo} = repository_fixture(%{visibility: "public"})
handle = Storage.owner_handle(user)
{:ok,
user: user,
repo: repo,
handle: handle,
ctx: Usage.context(repo, "registry.example.com"),
path: "/r/#{handle}/#{repo.name}/packages"}
end
describe "CI publishes with the per-job token" do
setup %{repo: repo} do
{:ok, job_token: job_token(repo)}
end
test "a job token authenticates as its own repository", %{repo: repo, job_token: token} do
conn = Plug.Test.conn(:get, "/") |> Plug.Conn.put_req_header("authorization", token)
assert {:job, repo_id, _perm} = Auth.authenticate(conn)
assert repo_id == repo.id
end
test "and may publish there with no registry token in sight", %{
conn: conn,
repo: repo,
handle: handle,
job_token: token
} do
root = "/pkg/cargo/#{handle}/#{repo.name}"
json = Jason.encode!(%{"name" => "widget", "vers" => "1.0.0", "deps" => []})
body =
<<byte_size(json)::little-unsigned-32, json::binary,
byte_size("crate")::little-unsigned-32, "crate">>
conn =
conn
|> put_req_header("authorization", token)
|> put_req_header("content-type", "application/json")
|> put(root <> "/api/v1/crates/new", body)
assert conn.status == 200
assert Packages.get_package(repo, "cargo", "widget")
end
test "the same token reaches nothing in another repository", %{repo: repo} do
{other_user, other} = repository_fixture(%{visibility: "private"})
principal = {:job, repo.id, nil}
refute Auth.authorize(principal, other, :write)
refute Auth.authorize(principal, other, :read)
# …and over HTTP, the same answer.
root = "/pkg/pypi/#{Storage.owner_handle(other_user)}/#{other.name}"
conn =
build_conn()
|> put_req_header("authorization", job_token(repo))
|> get(root <> "/simple/")
assert conn.status in [401, 403]
end
test "an expired or forged job token is just anonymous", %{repo: repo} do
forged = job_token(repo) <> "tampered"
conn = Plug.Test.conn(:get, "/") |> Plug.Conn.put_req_header("authorization", forged)
assert Auth.authenticate(conn) == :anonymous
end
test "a registry token still wins where both would work", %{repo: repo} do
{:ok, _t, plaintext} = Packages.create_registry_token(repo, "ci", ["pull"])
conn = Plug.Test.conn(:get, "/") |> Plug.Conn.put_req_header("authorization", plaintext)
assert {:token, _, ["pull"]} = Auth.authenticate(conn)
end
end
describe "the job token's packages: permission" do
setup do
{user, repo} = repository_fixture(%{visibility: "private"})
{:ok, repo: repo, root: "/pkg/cargo/#{Storage.owner_handle(user)}/#{repo.name}"}
end
test "without packages it neither publishes nor reads", %{conn: conn, repo: repo, root: root} do
token = job_token(repo, %{"contents" => :read, "packages" => :none, "actions" => :none})
assert cargo_publish(conn, root, token).status in [401, 403]
refute Packages.get_package(repo, "cargo", "widget")
principal =
Auth.authenticate(Plug.Test.conn(:get, "/") |> put_req_header("authorization", token))
refute Auth.authorize(principal, repo, :read)
refute Auth.authorize(principal, repo, :write)
end
test "packages: read reads but doesn't publish", %{conn: conn, repo: repo, root: root} do
token = job_token(repo, %{"contents" => :read, "packages" => :read, "actions" => :none})
assert cargo_publish(conn, root, token).status in [401, 403]
principal =
Auth.authenticate(Plug.Test.conn(:get, "/") |> put_req_header("authorization", token))
assert Auth.authorize(principal, repo, :read)
refute Auth.authorize(principal, repo, :write)
end
test "the default still publishes", %{conn: conn, repo: repo, root: root} do
assert cargo_publish(conn, root, job_token(repo)).status == 200
assert Packages.get_package(repo, "cargo", "widget")
end
test "a token minted before tokens carried permissions has the default", %{repo: repo} do
assert Auth.authorize({:job, repo.id, nil}, repo, :write)
end
end
describe "Usage.for_format/2" do
# Derived from Format.formats/0 rather than listed here. A
# hardcoded list is how `fdroid` shipped with a registry, an
# adapter and a signed index, and a packages page that said "No
# instructions for this format" — nothing failed, because nothing
# was looking.
test "every format the forge serves has both facets", %{ctx: ctx} do
for format <- GitGud.Packages.Format.formats() do
usage = Usage.for_format(format, ctx)
assert usage, "#{format} has no instructions"
assert Usage.summary(format)
# Every step carries a label and something to paste. The type
# checker already knows the lists aren't empty, so checking
# their contents is the assertion with anything left to catch.
for facet <- [:consume, :publish, :ci], step <- Map.fetch!(usage, facet) do
assert String.trim(step.label) != "", "#{format}/#{facet} has an unlabelled step"
assert String.trim(step.code) != "", "#{format}/#{facet} has an empty step"
assert step.language in ~w(shell yaml toml elixir)
end
end
end
test "every CI snippet uses the per-job token, never a stored secret", %{ctx: ctx} do
for format <- GitGud.Packages.Format.formats() do
code = Enum.map_join(Usage.for_format(format, ctx).ci, "\n", & &1.code)
assert code =~ "github.token", "#{format}'s CI snippet doesn't use github.token"
refute code =~ "secrets.", "#{format}'s CI snippet asks for a stored secret"
end
end
test "every snippet names this repository's own registry", %{ctx: ctx, repo: repo} do
for format <- ~w(pypi hex cargo deb) do
usage = Usage.for_format(format, ctx)
code = Enum.map_join(usage.consume ++ usage.publish ++ usage.ci, "\n", & &1.code)
assert code =~ repo.name
assert code =~ Usage.base_url(ctx, format)
end
end
test "the Hex snippets agree with the name the registry signs", %{ctx: ctx} do
usage = Usage.for_format("hex", ctx)
code = Enum.map_join(usage.consume, "\n", & &1.code)
# `mix` rejects the registry if these disagree, so the add line
# and the dep line must use the same name.
assert code =~ "mix hex.repo add #{ctx.hex_repo} "
assert code =~ ~s(repo: "#{ctx.hex_repo}")
end
test "the Cargo CI snippet uses the env var name cargo actually reads" do
{_user, repo} = repository_fixture(%{name: "my-crate-repo"})
ctx = Usage.context(repo)
code = Enum.map_join(Usage.for_format("cargo", ctx).ci, "\n", & &1.code)
# registries.my-crate-repo → CARGO_REGISTRIES_MY_CRATE_REPO_TOKEN
assert code =~ "CARGO_REGISTRIES_MY_CRATE_REPO_TOKEN"
end
test "OCI instructions are withheld when no registry host is configured", %{repo: repo} do
assert Usage.for_format("oci", Usage.context(repo, nil)) == nil
end
test "an unknown format has nothing to say", %{ctx: ctx} do
assert Usage.for_format("nonsense", ctx) == nil
assert Usage.summary("nonsense") == nil
end
end
describe "the F-Droid QR" do
setup %{conn: conn, path: path} do
{:ok, view, _html} = live(conn, path)
%{html: view |> element(~s{button[phx-value-format="fdroid"]}) |> render_click()}
end
test "renders a scannable code, not a placeholder", %{html: html} do
# F-Droid's add-repository screen has a scanner, and the 64-hex
# fingerprint is the part nobody would type correctly by hand.
assert html =~ "<svg"
assert html =~ "Scan to add"
# A real fingerprint. The panel owns this URL precisely so no
# step anywhere has to print a `<fingerprint>` placeholder for a
# value nobody could fill in by hand.
assert html =~ ~r/\?fingerprint=[0-9a-f]{64}/
refute html =~ "<fingerprint>"
end
test "points at the namespace repository, not this one", %{html: html, handle: handle} do
# One entry per publisher, not one per Android project.
assert html =~ "/pkg/fdroid/#{handle}/_all/repo?fingerprint="
end
test "the code encodes the same URL it prints", %{html: html, user: user} do
{:ok, fingerprint} = GitGud.Packages.Formats.Fdroid.fingerprint(user)
assert html =~ fingerprint
end
end
describe "the packages page" do
test "documents both facets without anything published yet", %{conn: conn, path: path} do
{:ok, view, html} = live(conn, path)
assert html =~ "Nothing published yet"
assert html =~ "Using these registries"
assert html =~ "Package manager"
assert html =~ "CI workflow"
# Defaults to the package-manager facet.
assert render(view) =~ "twine upload"
end
test "switching facet shows the workflow snippet", %{conn: conn, path: path} do
{:ok, view, _html} = live(conn, path)
html = view |> element("button[phx-value-facet=ci]") |> render_click()
assert html =~ "github.token"
assert html =~ "Publish to the forge"
# The CI pane is about workflows, not terminals.
refute html =~ "GITGUD_TOKEN"
end
test "switching format shows that format's instructions", %{conn: conn, path: path} do
{:ok, view, _html} = live(conn, path)
html = view |> element("button[phx-value-format=cargo]") |> render_click()
assert html =~ "cargo publish --registry"
assert html =~ "sparse+"
refute html =~ "twine upload"
end
test "every format the forge serves is reachable from the tab strip", %{
conn: conn,
path: path
} do
{:ok, view, _html} = live(conn, path)
for format <- ~w(oci pypi hex cargo deb) do
assert has_element?(view, "button[phx-value-format=#{format}]"),
"#{format} is not offered in the format tabs"
end
end
test "points at registry tokens for the terminal case", %{conn: conn, path: path, repo: repo} do
{:ok, _view, html} = live(conn, path)
assert html =~ "GITGUD_TOKEN"
assert html =~ "/r/"
assert html =~ "#{repo.name}/settings/registry-tokens"
end
test "opens on a format that has packages", %{conn: conn, repo: repo, path: path} do
{:ok, _p, _v, _f} =
Packages.publish_file(repo, "deb", "widget", "1.0", %{
filename: "widget_1.0_amd64.deb",
body: "deb"
})
{:ok, _view, html} = live(conn, path)
# The Debian pane, not the default Python one.
assert html =~ "/etc/apt/keyrings"
refute html =~ "twine upload"
end
end
# A token for a claimed (running) job of `repo`, with `perms`.
defp job_token(repo, perms \\ GitGud.Workflows.Permissions.default()) do
run =
%GitGud.Workflows.WorkflowRun{repository_id: repo.id}
|> GitGud.Workflows.WorkflowRun.create_changeset(%{
number: System.unique_integer([:positive]),
workflow_path: ".forgejo/workflows/ci.yml",
trigger: "push",
head_sha: :crypto.strong_rand_bytes(20)
})
|> GitGud.Repo.insert!()
job =
%GitGud.Workflows.WorkflowJob{workflow_run_id: run.id}
|> GitGud.Workflows.WorkflowJob.create_changeset(%{job_id: "build", definition: %{}})
|> Ecto.Changeset.change(status: "running", started_at: DateTime.utc_now(:second))
|> GitGud.Repo.insert!()
RunnerJwt.mint(job, run, perms)
end
defp cargo_publish(conn, root, token) do
json = Jason.encode!(%{"name" => "widget", "vers" => "1.0.0", "deps" => []})
body =
<<byte_size(json)::little-unsigned-32, json::binary, byte_size("crate")::little-unsigned-32,
"crate">>
conn
|> put_req_header("authorization", token)
|> put_req_header("content-type", "application/json")
|> put(root <> "/api/v1/crates/new", body)
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.4 KiB · text
History
b868907