8.0 KiB · text History b868907
defmodule GitGudWeb.RpmNamespaceTest do
  @moduledoc """
  The namespace-wide RPM repository at `_all`, over HTTP.

  The adapter's own tests drive it directly; these go through the
  controller, because what is untested there is the wiring: that the
  route resolves, that `_all` aggregates, and — the part worth a
  controller test rather than a unit one — that the index a caller gets
  is filtered by what that caller can read.

  A dashboard listing a package a client can't fetch is a nuisance. An
  *index* listing one is worse: dnf resolves a transaction against it
  and then fails on the download, blaming the mirror.
  """
  use GitGudWeb.ConnCase, async: false

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures
  import GitGud.RpmFixtures

  alias GitGud.Packages
  alias GitGud.Packages.Formats.Rpm
  alias GitGud.Repositories

  setup do
    user = user_fixture()

    {:ok, org} =
      GitGud.Organizations.create_organization(user, %{
        "handle" => "org#{System.unique_integer([:positive])}",
        "name" => "Test Org"
      })

    %{
      user: user,
      org: org,
      tools: org_repo(org, user),
      agents: org_repo(org, user),
      root: "/pkg/rpm/#{org.handle}/_all"
    }
  end

  defp org_repo(org, user, visibility \\ "public") do
    {:ok, repo} =
      Repositories.create_repository_for_org(org, user, %{
        "name" => unique_repo_name(),
        "visibility" => visibility
      })

    GitGud.Repo.preload(repo, [:owner, :organization])
  end

  # Published through the adapter rather than assembled by hand: the
  # point here is the aggregate, and the adapter's own file is where
  # the header format is pinned down.
  defp publish(repo, name, version \\ "0.1.2") do
    body = build_rpm(%{name: name, version: version})

    {:ok, _type, _reply} =
      Rpm.store(
        repo,
        %{
          path: ["upload"],
          conn: Plug.Test.conn(:post, "/"),
          method: "POST",
          trailing_slash?: false,
          base_url: "https://forge.test",
          principal: :anonymous
        },
        body
      )

    body
  end

  defp primary(conn, root) do
    conn |> get(root <> "/repodata/primary.xml.gz") |> Map.fetch!(:resp_body) |> :zlib.gunzip()
  end

  describe "aggregation" do
    test "one index carries packages from every repository in the org", %{
      conn: conn,
      tools: tools,
      agents: agents,
      root: root
    } do
      publish(tools, "widget")
      publish(agents, "gadget")

      xml = primary(conn, root)

      assert xml =~ "<name>widget</name>"
      assert xml =~ "<name>gadget</name>"
      assert xml =~ ~s(packages="2")
    end

    test "each entry points into its own repository's tree", %{
      conn: conn,
      tools: tools,
      agents: agents,
      root: root
    } do
      publish(tools, "widget")
      publish(agents, "gadget")

      xml = primary(conn, root)

      assert xml =~ ~s(href="Packages/#{tools.name}/w/widget-0.1.2-1.x86_64.rpm")
      assert xml =~ ~s(href="Packages/#{agents.name}/g/gadget-0.1.2-1.x86_64.rpm")
    end

    test "two repositories publishing the same filename don't collide", %{
      conn: conn,
      tools: tools,
      agents: agents,
      root: root
    } do
      from_tools = publish(tools, "koco")
      from_agents = publish(agents, "koco", "0.1.3")

      # Same package name in two repositories. A stock rpm-md tree
      # would file both under `Packages/k/`, and one set of bytes would
      # quietly win.
      assert conn
             |> get(root <> "/Packages/#{tools.name}/k/koco-0.1.2-1.x86_64.rpm")
             |> response(200) == from_tools

      assert conn
             |> get(root <> "/Packages/#{agents.name}/k/koco-0.1.3-1.x86_64.rpm")
             |> response(200) == from_agents
    end

    test "repomd.xml is served and points at the documents", %{
      conn: conn,
      tools: tools,
      root: root
    } do
      publish(tools, "widget")

      body = conn |> get(root <> "/repodata/repomd.xml") |> response(200)

      assert body =~ ~s(<location href="repodata/primary.xml.gz"/>)
      assert body =~ "<revision>"
    end
  end

  describe "a version with a + in it" do
    test "round-trips from the index href to the blob", %{conn: conn, tools: tools, root: root} do
      # CI stamps untagged builds `<version>+dev.<unix>`, because RPM
      # forbids `-` in a version and `+` sorts after the bare release.
      # That puts a `+` in every filename a dev build publishes, and a
      # `+` is literal in a URI path but means a space in a query
      # string — so this is exactly the character that gets mangled by
      # one layer decoding a path as if it were a form.
      body = publish(tools, "koco", "0.1.2+dev.1757000000")

      xml = primary(conn, root)
      assert [_, href] = Regex.run(~r/<location href="([^"]+)"/, xml)
      assert href =~ "+dev."

      # Fetched as the index advertises it, not as a hand-written path.
      assert conn |> get(root <> "/" <> href) |> response(200) == body
    end
  end

  describe "visibility" do
    test "an anonymous client's index omits the private repository", %{
      conn: conn,
      org: org,
      user: user,
      tools: tools,
      root: root
    } do
      publish(tools, "widget")
      private = org_repo(org, user, "private")
      publish(private, "secret-thing")

      xml = primary(conn, root)

      assert xml =~ "<name>widget</name>"
      refute xml =~ "secret-thing"
      assert xml =~ ~s(packages="1")
    end

    test "a token for the private repository sees it in the same URL", %{
      conn: conn,
      org: org,
      user: user,
      root: root
    } do
      private = org_repo(org, user, "private")
      publish(private, "secret-thing")
      {:ok, _t, token} = Packages.create_registry_token(private, "ci", ["pull"])

      # A browser session is not a credential here: the registries
      # authenticate on the Authorization header only, because dnf and
      # apt have no session to offer.
      xml = conn |> put_req_header("authorization", token) |> primary(root)

      assert xml =~ "secret-thing"
    end

    test "a private package's path 404s for a caller who can't read it", %{
      conn: conn,
      org: org,
      user: user,
      root: root
    } do
      private = org_repo(org, user, "private")
      publish(private, "secret-thing")

      path = root <> "/Packages/#{private.name}/s/secret-thing-0.1.2-1.x86_64.rpm"

      # Not just absent from the index — unreachable. An index is a
      # convenience; the blob path is the access decision.
      assert get(conn, path).status in [401, 404]

      {:ok, _t, token} = Packages.create_registry_token(private, "ci", ["pull"])
      assert conn |> put_req_header("authorization", token) |> get(path) |> response(200)
    end
  end

  describe "the key" do
    test "is served unauthenticated at the namespace URL", %{conn: conn, org: org} do
      body = conn |> get("/pkg/_keys/rpm/#{org.handle}") |> response(200)

      # gpgkey= is the first line of a .repo file, before any
      # credential is configured.
      assert body =~ "BEGIN PGP PUBLIC KEY BLOCK"
    end

    test "and is the same key repomd.xml.key serves", %{conn: conn, org: org, root: root} do
      from_keys = conn |> get("/pkg/_keys/rpm/#{org.handle}") |> response(200)
      from_repo = conn |> get(root <> "/repodata/repomd.xml.key") |> response(200)

      assert from_keys == from_repo
    end

    test "the aggregate's signature verifies against it", %{
      conn: conn,
      org: org,
      tools: tools,
      root: root
    } do
      publish(tools, "widget")

      key = conn |> get("/pkg/_keys/rpm/#{org.handle}") |> response(200)
      document = conn |> get(root <> "/repodata/repomd.xml") |> response(200)
      signature = conn |> get(root <> "/repodata/repomd.xml.asc") |> response(200)

      assert {:ok, true} =
               GitGud.Packages.Apt.Native.verify_detached(key, document, signature)

      # And the signature covers the filtered view, not some canonical
      # one: a caller who sees fewer repositories still gets a document
      # that verifies.
      assert Packages.get_signing_key(org, "rpm")
    end
  end
end