12.4 KiB · text History a9f5a09
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 =~ "&lt;fingerprint&gt;"
    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