16.4 KiB · text History b868907
defmodule GitGudWeb.CargoRegistryTest do
  @moduledoc """
  Phase 3: the Cargo sparse index and publish API, over HTTP.

  The interesting assertions are the ones where a mistake is silent
  rather than loud — the prefix shard, the dependency rename
  translation, `features2`/`v: 2`, and the 304.
  """
  use GitGudWeb.ConnCase, async: false

  alias GitGud.Packages
  alias GitGud.Packages.Formats.Cargo
  alias GitGud.Repositories.Storage

  import GitGud.ForgeFixtures

  setup do
    {user, repo} = repository_fixture(%{visibility: "public"})
    {:ok, _t, token} = Packages.create_registry_token(repo, "ci", ["pull", "push"])

    {:ok,
     user: user,
     repo: repo,
     token: token,
     root: "/pkg/cargo/#{Storage.owner_handle(user)}/#{repo.name}"}
  end

  # `cargo publish`'s body: u32 LE length, JSON metadata, u32 LE
  # length, the .crate tarball.
  defp publish_body(metadata, crate) do
    json = Jason.encode!(metadata)

    <<byte_size(json)::little-unsigned-32, json::binary, byte_size(crate)::little-unsigned-32,
      crate::binary>>
  end

  defp publish(conn, root, token, metadata, crate \\ "crate bytes") do
    conn =
      if token, do: put_req_header(conn, "authorization", token), else: conn

    conn
    # Cargo really does label this JSON. It is not JSON.
    |> put_req_header("content-type", "application/json")
    |> put_req_header("accept", "application/json")
    |> put(root <> "/api/v1/crates/new", publish_body(metadata, crate))
  end

  defp meta(name, vers, extra \\ %{}) do
    Map.merge(%{"name" => name, "vers" => vers, "deps" => [], "features" => %{}}, extra)
  end

  defp index_lines(conn, root, name) do
    path = Enum.join(Cargo.index_path(name), "/")
    conn = get(conn, root <> "/" <> path)

    {conn,
     conn.resp_body
     |> String.split("\n", trim: true)
     |> Enum.map(&Jason.decode!/1)}
  end

  describe "index_path/1" do
    test "shards by the lowercased name's length" do
      assert Cargo.index_path("a") == ["1", "a"]
      assert Cargo.index_path("ab") == ["2", "ab"]
      assert Cargo.index_path("abc") == ["3", "a", "abc"]
      assert Cargo.index_path("cargo") == ["ca", "rg", "cargo"]
      assert Cargo.index_path("Serde") == ["se", "rd", "serde"]
    end
  end

  describe "config.json" do
    test "points cargo at the download and API roots", %{conn: conn, root: root} do
      conn = get(conn, root <> "/config.json")

      assert conn.status == 200
      config = Jason.decode!(conn.resp_body)
      assert String.ends_with?(config["dl"], "/api/v1/crates")
      assert String.ends_with?(config["api"], root)
    end

    test "a public registry does not demand a token for every fetch", %{conn: conn, root: root} do
      assert Jason.decode!(get(conn, root <> "/config.json").resp_body)["auth-required"] == false
    end

    test "a private one does" do
      {user, private} = repository_fixture(%{visibility: "private"})
      {:ok, _t, token} = Packages.create_registry_token(private, "ci", ["pull"])
      root = "/pkg/cargo/#{Storage.owner_handle(user)}/#{private.name}"

      conn =
        build_conn()
        |> put_req_header("authorization", token)
        |> get(root <> "/config.json")

      assert Jason.decode!(conn.resp_body)["auth-required"] == true
    end
  end

  describe "the index" do
    test "serves one JSON object per version, newline-delimited", %{
      conn: conn,
      root: root,
      token: token
    } do
      publish(conn, root, token, meta("widget", "1.0.0"))
      publish(build_conn(), root, token, meta("widget", "1.1.0"))

      {conn, lines} = index_lines(build_conn(), root, "widget")

      assert conn.status == 200
      assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"]
      assert Enum.map(lines, & &1["vers"]) == ["1.0.0", "1.1.0"]
    end

    test "cksum is the sha256 of the .crate", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0"), "the real crate")

      {_conn, [entry]} = index_lines(build_conn(), root, "widget")

      assert entry["cksum"] ==
               Base.encode16(:crypto.hash(:sha256, "the real crate"), case: :lower)
    end

    test "a crate served from the wrong shard is a 404", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0"))

      # widget's real shard is wi/dg/widget.
      assert get(build_conn(), root <> "/wi/dg/widget").status == 200
      assert get(build_conn(), root <> "/3/w/widget").status == 404
      assert get(build_conn(), root <> "/xx/yy/widget").status == 404
    end

    test "404 for a crate nobody published", %{conn: conn, root: root} do
      assert get(conn, root <> "/no/th/nothing").status == 404
    end

    test "only `yanked` changes after publication", %{
      conn: conn,
      repo: repo,
      root: root,
      token: token
    } do
      publish(conn, root, token, meta("widget", "1.0.0"))
      {_conn, [before]} = index_lines(build_conn(), root, "widget")

      pkg = Packages.get_package(repo, "cargo", "widget")
      {:ok, _} = Packages.set_yanked(Packages.get_version(pkg, "1.0.0"), true)

      {_conn, [after_yank]} = index_lines(build_conn(), root, "widget")

      assert before["yanked"] == false
      assert after_yank["yanked"] == true
      assert Map.delete(before, "yanked") == Map.delete(after_yank, "yanked")
    end
  end

  describe "features2 and v" do
    test "a crate with only old-style features stays at v: 1", %{
      conn: conn,
      root: root,
      token: token
    } do
      publish(
        conn,
        root,
        token,
        meta("widget", "1.0.0", %{"features" => %{"default" => ["std"], "std" => []}})
      )

      {_conn, [entry]} = index_lines(build_conn(), root, "widget")

      assert entry["v"] == 1
      refute Map.has_key?(entry, "features2")
      assert entry["features"] == %{"default" => ["std"], "std" => []}
    end

    test "`dep:` and `pkg?/feat` move to features2 and bump v to 2", %{
      conn: conn,
      root: root,
      token: token
    } do
      features = %{
        "default" => ["std"],
        "serde" => ["dep:serde"],
        "json" => ["serde?/derive"]
      }

      publish(conn, root, token, meta("widget", "1.0.0", %{"features" => features}))

      {_conn, [entry]} = index_lines(build_conn(), root, "widget")

      assert entry["v"] == 2
      # Old cargo reads `features` and ignores `features2` — so the
      # features it can still understand must stay where it looks.
      assert entry["features"] == %{"default" => ["std"]}
      assert entry["features2"] == %{"serde" => ["dep:serde"], "json" => ["serde?/derive"]}
    end

    test "split_features/1 partitions on the new syntax" do
      assert Cargo.split_features(%{"a" => ["b"], "c" => ["dep:d"], "e" => ["f?/g"]}) ==
               {%{"a" => ["b"]}, %{"c" => ["dep:d"], "e" => ["f?/g"]}}
    end
  end

  describe "dependencies" do
    test "a renamed dependency swaps name and package", %{
      conn: conn,
      root: root,
      token: token
    } do
      deps = [
        %{
          "name" => "serde",
          "explicit_name_in_toml" => "serde1",
          "version_req" => "^1.0",
          "features" => ["derive"],
          "optional" => true,
          "default_features" => false,
          "target" => "cfg(unix)",
          "kind" => "normal",
          "registry" => "https://example.invalid"
        }
      ]

      publish(conn, root, token, meta("widget", "1.0.0", %{"deps" => deps}))

      {_conn, [entry]} = index_lines(build_conn(), root, "widget")

      assert [dep] = entry["deps"]
      # The index names the dependency as the crate refers to it…
      assert dep["name"] == "serde1"
      # …and says which crate that actually is.
      assert dep["package"] == "serde"
      # `version_req` on the way in, `req` on the way out.
      assert dep["req"] == "^1.0"
      assert dep["features"] == ["derive"]
      assert dep["optional"] == true
      assert dep["default_features"] == false
      assert dep["target"] == "cfg(unix)"
      assert dep["kind"] == "normal"
    end

    test "an unrenamed dependency has a null package", %{conn: conn, root: root, token: token} do
      deps = [%{"name" => "serde", "version_req" => "^1.0"}]
      publish(conn, root, token, meta("widget", "1.0.0", %{"deps" => deps}))

      {_conn, [entry]} = index_lines(build_conn(), root, "widget")

      assert [%{"name" => "serde", "package" => nil, "default_features" => true}] = entry["deps"]
    end
  end

  describe "rust_version" do
    test "is stored without an operator", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0", %{"rust_version" => "=1.60"}))

      {_conn, [entry]} = index_lines(build_conn(), root, "widget")

      assert entry["rust_version"] == "1.60"
    end

    test "is absent when the crate doesn't declare one", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0"))

      {_conn, [entry]} = index_lines(build_conn(), root, "widget")

      refute Map.has_key?(entry, "rust_version")
    end
  end

  describe "caching" do
    test "an unchanged index revalidates to 304", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0"))

      {first, _} = index_lines(build_conn(), root, "widget")
      assert [etag] = get_resp_header(first, "etag")

      revalidated =
        build_conn()
        |> put_req_header("if-none-match", etag)
        |> get(root <> "/wi/dg/widget")

      assert revalidated.status == 304
      assert revalidated.resp_body == ""
    end

    test "a weak validator from a proxy still matches", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0"))
      {first, _} = index_lines(build_conn(), root, "widget")
      [etag] = get_resp_header(first, "etag")

      revalidated =
        build_conn()
        |> put_req_header("if-none-match", "W/" <> etag)
        |> get(root <> "/wi/dg/widget")

      assert revalidated.status == 304
    end

    test "publishing a new version invalidates it", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0"))
      {first, _} = index_lines(build_conn(), root, "widget")
      [etag] = get_resp_header(first, "etag")

      publish(build_conn(), root, token, meta("widget", "1.1.0"))

      refreshed =
        build_conn()
        |> put_req_header("if-none-match", etag)
        |> get(root <> "/wi/dg/widget")

      assert refreshed.status == 200
    end
  end

  describe "download" do
    test "serves the .crate bytes", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0"), "the real crate")

      conn = get(build_conn(), root <> "/api/v1/crates/widget/1.0.0/download")

      assert conn.status == 200
      assert conn.resp_body == "the real crate"
    end

    test "404 for a version that was never published", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0"))

      assert get(build_conn(), root <> "/api/v1/crates/widget/9.9.9/download").status == 404
    end
  end

  describe "publish" do
    test "answers with the warnings object cargo expects", %{
      conn: conn,
      root: root,
      token: token
    } do
      conn = publish(conn, root, token, meta("widget", "1.0.0"))

      assert conn.status == 200

      assert %{"warnings" => %{"invalid_categories" => [], "invalid_badges" => [], "other" => []}} =
               Jason.decode!(conn.resp_body)
    end

    test "a truncated metadata prefix is a cargo-shaped error", %{
      conn: conn,
      root: root,
      token: token
    } do
      conn =
        conn
        |> put_req_header("authorization", token)
        |> put_req_header("content-type", "application/json")
        |> put(root <> "/api/v1/crates/new", <<255, 255, 255, 255, 1, 2>>)

      assert conn.status == 400
      assert %{"errors" => [%{"detail" => detail}]} = Jason.decode!(conn.resp_body)
      assert detail =~ "metadata length prefix"
    end

    test "a truncated crate prefix is caught too", %{conn: conn, root: root, token: token} do
      json = Jason.encode!(meta("widget", "1.0.0"))

      conn =
        conn
        |> put_req_header("authorization", token)
        |> put_req_header("content-type", "application/json")
        |> put(
          root <> "/api/v1/crates/new",
          <<byte_size(json)::little-unsigned-32, json::binary, 255, 255, 255, 255>>
        )

      assert conn.status == 400
      assert Jason.decode!(conn.resp_body)["errors"] != []
    end

    test "409 on republishing the same version with different bytes", %{
      conn: conn,
      root: root,
      token: token
    } do
      publish(conn, root, token, meta("widget", "1.0.0"), "first")
      conn = publish(build_conn(), root, token, meta("widget", "1.0.0"), "second")

      assert conn.status == 409
    end

    test "an anonymous publish is refused", %{conn: conn, root: root} do
      assert publish(conn, root, nil, meta("widget", "1.0.0")).status == 401
    end
  end

  describe "yank and unyank" do
    test "round-trip without removing the bytes", %{
      conn: conn,
      repo: repo,
      root: root,
      token: token
    } do
      publish(conn, root, token, meta("widget", "1.0.0"), "the real crate")

      yanked =
        build_conn()
        |> put_req_header("authorization", token)
        |> delete(root <> "/api/v1/crates/widget/1.0.0/yank")

      assert yanked.status == 200
      assert Jason.decode!(yanked.resp_body) == %{"ok" => true}

      pkg = Packages.get_package(repo, "cargo", "widget")
      assert Packages.get_version(pkg, "1.0.0").yanked

      # A build that already pinned this version must still resolve it.
      assert get(build_conn(), root <> "/api/v1/crates/widget/1.0.0/download").resp_body ==
               "the real crate"

      unyanked =
        build_conn()
        |> put_req_header("authorization", token)
        |> put(root <> "/api/v1/crates/widget/1.0.0/unyank")

      assert unyanked.status == 200
      refute Packages.get_version(pkg, "1.0.0").yanked
    end

    test "yanking needs write access", %{conn: conn, repo: repo, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0"))
      {:ok, _t, readonly} = Packages.create_registry_token(repo, "ro", ["pull"])

      conn =
        build_conn()
        |> put_req_header("authorization", readonly)
        |> delete(root <> "/api/v1/crates/widget/1.0.0/yank")

      assert conn.status == 403
    end

    test "404 for an unpublished version", %{conn: conn, root: root, token: token} do
      conn =
        conn
        |> put_req_header("authorization", token)
        |> delete(root <> "/api/v1/crates/ghost/1.0.0/yank")

      assert conn.status == 404
    end
  end

  describe "search and owners" do
    test "search matches on the normalized name", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0", %{"description" => "a widget"}))
      publish(build_conn(), root, token, meta("gadget", "2.0.0"))

      body = get(build_conn(), root <> "/api/v1/crates?q=WID").resp_body |> Jason.decode!()

      assert [%{"name" => "widget", "max_version" => "1.0.0", "description" => "a widget"}] =
               body["crates"]

      assert body["meta"]["total"] == 1
    end

    test "search with no query lists everything, capped by per_page", %{
      conn: conn,
      root: root,
      token: token
    } do
      for n <- ["alpha", "beta", "gamma"],
          do: publish(build_conn(), root, token, meta(n, "1.0.0"))

      body = get(conn, root <> "/api/v1/crates?per_page=2").resp_body |> Jason.decode!()

      assert length(body["crates"]) == 2
    end

    test "owners reports the repository's owner", %{conn: conn, root: root, token: token} do
      publish(conn, root, token, meta("widget", "1.0.0"))

      body =
        get(build_conn(), root <> "/api/v1/crates/widget/owners").resp_body |> Jason.decode!()

      assert [%{"login" => login}] = body["users"]
      assert is_binary(login)
    end

    test "changing owners is refused — the forge is the source of truth", %{
      conn: conn,
      root: root,
      token: token
    } do
      publish(conn, root, token, meta("widget", "1.0.0"))

      conn =
        build_conn()
        |> put_req_header("authorization", token)
        |> put_req_header("content-type", "application/json")
        |> put(root <> "/api/v1/crates/widget/owners", Jason.encode!(%{"users" => ["someone"]}))

      assert conn.status == 403
      assert Jason.decode!(conn.resp_body)["errors"] != []
    end
  end
end