9.7 KiB · text History 9f00bdd
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} = 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}

      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 "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?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

  defp job_token(repo) do
    RunnerJwt.mint(
      %GitGud.Workflows.WorkflowJob{id: 1, workflow_run_id: 1},
      %GitGud.Workflows.WorkflowRun{repository_id: repo.id}
    )
  end
end