12.7 KiB · text History ed33862
defmodule GitGudWeb.PypiRegistryTest do
  @moduledoc """
  Phase 1: the PEP 503 / PEP 691 Simple Repository, over HTTP.

  Everything asserted here is something a real client depends on —
  pip's content negotiation, its trailing-slash assumptions, and
  twine's upload framing.
  """
  use GitGudWeb.ConnCase, async: false

  alias GitGud.Packages
  alias GitGud.Packages.Formats.Pypi
  alias GitGud.Repositories.Storage

  import GitGud.ForgeFixtures

  @json "application/vnd.pypi.simple.v1+json"
  @html "application/vnd.pypi.simple.v1+html"

  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/pypi/#{Storage.owner_handle(user)}/#{repo.name}"}
  end

  defp publish(repo, name, version, filename, body \\ "artifact bytes") do
    {:ok, _p, _v, f} =
      Packages.publish_file(repo, "pypi", name, version, %{filename: filename, body: body})

    f
  end

  describe "normalisation (PEP 503)" do
    test "collapses runs of -_. and lowercases" do
      assert Pypi.normalize("Flask_Login") == "flask-login"
      assert Pypi.normalize("zope.interface") == "zope-interface"
      assert Pypi.normalize("A--_.-B") == "a-b"
      assert Pypi.normalize("already-normal") == "already-normal"
    end
  end

  describe "GET /simple/ (project list)" do
    test "lists normalised project names as HTML by default", %{
      conn: conn,
      repo: repo,
      root: root
    } do
      publish(repo, "Flask_Login", "1.0", "Flask_Login-1.0.tar.gz")

      conn = get(conn, root <> "/simple/")

      assert conn.status == 200
      assert get_resp_header(conn, "content-type") == ["text/html"]
      assert conn.resp_body =~ ~s(<a href="flask-login/">flask-login</a>)
    end

    test "serves the JSON serialisation when asked for it", %{
      conn: conn,
      repo: repo,
      root: root
    } do
      publish(repo, "widget", "1.0", "widget-1.0.tar.gz")

      conn =
        conn
        |> put_req_header("accept", @json)
        |> get(root <> "/simple/")

      assert get_resp_header(conn, "content-type") == [@json]

      assert %{
               "meta" => %{"api-version" => "1.0"},
               "projects" => [%{"name" => "widget"}]
             } = Jason.decode!(conn.resp_body)
    end

    test "honours pip's real Accept header, which prefers JSON", %{
      conn: conn,
      repo: repo,
      root: root
    } do
      publish(repo, "widget", "1.0", "widget-1.0.tar.gz")

      conn =
        conn
        |> put_req_header(
          "accept",
          "#{@json}, #{@html};q=0.2, text/html;q=0.01"
        )
        |> get(root <> "/simple/")

      assert get_resp_header(conn, "content-type") == [@json]
    end

    test "406 when the client accepts nothing we serve", %{conn: conn, root: root} do
      conn =
        conn
        |> put_req_header("accept", "application/vnd.pypi.simple.v99+xml")
        |> get(root <> "/simple/")

      assert conn.status == 406
    end

    test "a q=0 range is a refusal, not a weak preference", %{conn: conn, root: root} do
      conn =
        conn
        |> put_req_header("accept", "text/html;q=0, #{@html};q=0")
        |> get(root <> "/simple/")

      assert conn.status == 406
    end

    test "redirects the unslashed URL", %{conn: conn, root: root} do
      conn = get(conn, root <> "/simple")

      assert conn.status == 301
      assert [location] = get_resp_header(conn, "location")
      assert String.ends_with?(location, "/simple/")
    end

    test "the repository root sends you to the index", %{conn: conn, root: root} do
      conn = get(conn, root <> "/")

      assert conn.status == 301
      assert [location] = get_resp_header(conn, "location")
      assert String.ends_with?(location, "/simple/")
    end
  end

  describe "GET /simple/<project>/ (file list)" do
    test "anchors the filename and carries the sha256 fragment", %{
      conn: conn,
      repo: repo,
      root: root
    } do
      file = publish(repo, "widget", "1.0", "widget-1.0.tar.gz")

      conn = get(conn, root <> "/simple/widget/")

      assert conn.status == 200
      assert conn.resp_body =~ "widget-1.0.tar.gz</a>"
      assert conn.resp_body =~ "#sha256=#{file.sha256}"
    end

    test "JSON file entries always carry `hashes`", %{conn: conn, repo: repo, root: root} do
      file = publish(repo, "widget", "1.0", "widget-1.0.tar.gz")

      conn =
        conn
        |> put_req_header("accept", @json)
        |> get(root <> "/simple/widget/")

      assert %{"name" => "widget", "files" => [entry]} = Jason.decode!(conn.resp_body)
      assert entry["filename"] == "widget-1.0.tar.gz"
      assert entry["hashes"] == %{"sha256" => file.sha256}
      assert entry["yanked"] == false
      assert entry["url"] =~ "/files/widget-1.0.tar.gz#sha256=#{file.sha256}"
    end

    test "an unnormalised name redirects to the canonical URL", %{
      conn: conn,
      repo: repo,
      root: root
    } do
      publish(repo, "Flask_Login", "1.0", "Flask_Login-1.0.tar.gz")

      conn = get(conn, root <> "/simple/Flask_Login/")

      assert conn.status == 301
      assert [location] = get_resp_header(conn, "location")
      assert String.ends_with?(location, "/simple/flask-login/")
    end

    test "an unslashed project URL redirects", %{conn: conn, repo: repo, root: root} do
      publish(repo, "widget", "1.0", "widget-1.0.tar.gz")

      conn = get(conn, root <> "/simple/widget")

      assert conn.status == 301
      assert [location] = get_resp_header(conn, "location")
      assert String.ends_with?(location, "/simple/widget/")
    end

    test "404 for a project nobody has published", %{conn: conn, root: root} do
      assert get(conn, root <> "/simple/nothing-here/").status == 404
    end

    test "a yanked release reports its reason (PEP 592)", %{conn: conn, repo: repo, root: root} do
      publish(repo, "widget", "1.0", "widget-1.0.tar.gz")
      pkg = Packages.get_package(repo, "pypi", "widget")
      {:ok, _} = Packages.set_yanked(Packages.get_version(pkg, "1.0"), true, "bad build")

      json =
        conn
        |> put_req_header("accept", @json)
        |> get(root <> "/simple/widget/")
        |> Map.get(:resp_body)
        |> Jason.decode!()

      assert [%{"yanked" => "bad build"}] = json["files"]

      assert build_conn()
             |> get(root <> "/simple/widget/")
             |> Map.get(:resp_body) =~ ~s(data-yanked="bad build")
    end

    test "requires-python rides along in both serialisations", %{
      conn: conn,
      repo: repo,
      root: root
    } do
      {:ok, _, _, _} =
        Packages.publish_file(repo, "pypi", "widget", "1.0", %{
          filename: "widget-1.0.tar.gz",
          body: "sdist",
          metadata: %{"requires_python" => ">=3.9"}
        })

      html = get(conn, root <> "/simple/widget/").resp_body
      assert html =~ ~s(data-requires-python="&gt;=3.9")

      json =
        build_conn()
        |> put_req_header("accept", @json)
        |> get(root <> "/simple/widget/")
        |> Map.get(:resp_body)
        |> Jason.decode!()

      assert [%{"requires-python" => ">=3.9"}] = json["files"]
    end
  end

  describe "GET /files/<filename>" do
    test "serves the stored bytes", %{conn: conn, repo: repo, root: root} do
      publish(repo, "widget", "1.0", "widget-1.0.tar.gz", "the real sdist")

      conn = get(conn, root <> "/files/widget-1.0.tar.gz")

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

    test "404 for a filename that was never published", %{conn: conn, root: root} do
      assert get(conn, root <> "/files/ghost-9.9.tar.gz").status == 404
    end
  end

  describe "POST / (twine upload)" do
    test "publishes a distribution", %{conn: conn, repo: repo, root: root, token: token} do
      conn = upload(conn, root, token, "widget", "1.0.0", "widget-1.0.0.tar.gz", "sdist bytes")

      assert conn.status == 200

      pkg = Packages.get_package(repo, "pypi", "widget")
      assert pkg.latest_version == "1.0.0"

      assert [file] = Packages.list_files(Packages.get_version(pkg, "1.0.0"))
      assert file.filename == "widget-1.0.0.tar.gz"
      assert {:ok, "sdist bytes"} = Packages.read_file(file)
    end

    test "rejects an upload whose sha256_digest doesn't match", %{
      conn: conn,
      root: root,
      token: token
    } do
      conn =
        upload(conn, root, token, "widget", "1.0.0", "widget-1.0.0.tar.gz", "sdist bytes",
          sha256_digest: String.duplicate("0", 64)
        )

      assert conn.status == 400
      assert conn.resp_body =~ "sha256_digest"
    end

    test "accepts the digest twine actually computes", %{conn: conn, root: root, token: token} do
      digest = Base.encode16(:crypto.hash(:sha256, "sdist bytes"), case: :lower)

      conn =
        upload(conn, root, token, "widget", "1.0.0", "widget-1.0.0.tar.gz", "sdist bytes",
          sha256_digest: digest
        )

      assert conn.status == 200
    end

    test "409 on re-uploading a filename with different bytes", %{
      conn: conn,
      root: root,
      token: token
    } do
      upload(conn, root, token, "widget", "1.0.0", "widget-1.0.0.tar.gz", "first")

      conn = upload(build_conn(), root, token, "widget", "1.0.0", "widget-1.0.0.tar.gz", "second")

      assert conn.status == 409
    end

    test "400 without a name", %{conn: conn, root: root, token: token} do
      conn =
        conn
        |> put_req_header("authorization", "Basic " <> Base.encode64("__token__:#{token}"))
        |> post(root <> "/", %{"version" => "1.0.0"})

      assert conn.status == 400
      assert conn.resp_body =~ "name"
    end

    test "an anonymous upload is challenged, not silently dropped", %{conn: conn, root: root} do
      conn = upload(conn, root, nil, "widget", "1.0.0", "widget-1.0.0.tar.gz", "sdist")

      assert conn.status == 401
      assert [challenge] = get_resp_header(conn, "www-authenticate")
      assert challenge =~ "Basic realm="
    end

    test "a pull-only token may read but not publish", %{conn: conn, repo: repo, root: root} do
      {:ok, _t, ro} = Packages.create_registry_token(repo, "readonly", ["pull"])

      assert upload(conn, root, ro, "widget", "1.0.0", "widget-1.0.0.tar.gz", "sdist").status ==
               403

      assert build_conn()
             |> put_req_header("authorization", "Basic " <> Base.encode64("__token__:#{ro}"))
             |> get(root <> "/simple/")
             |> Map.get(:status) == 200
    end
  end

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

      {:ok,
       private: private,
       private_token: token,
       private_root: "/pkg/pypi/#{Storage.owner_handle(user)}/#{private.name}"}
    end

    test "a private index challenges an anonymous client", %{
      conn: conn,
      private_root: root
    } do
      conn = get(conn, root <> "/simple/")

      assert conn.status == 401
      assert [_] = get_resp_header(conn, "www-authenticate")
    end

    test "and serves it to a token holder — this is what pip does next", %{
      conn: conn,
      private: private,
      private_root: root,
      private_token: token
    } do
      publish(private, "secret", "1.0", "secret-1.0.tar.gz")

      conn =
        conn
        |> put_req_header("authorization", "Basic " <> Base.encode64("__token__:#{token}"))
        |> get(root <> "/simple/")

      assert conn.status == 200
      assert conn.resp_body =~ "secret"
    end
  end

  test "an unknown format 404s rather than reaching an adapter", %{conn: conn, root: root} do
    assert get(conn, String.replace(root, "/pypi/", "/nonsense/") <> "/simple/").status == 404
  end

  test "a missing repository 404s", %{conn: conn} do
    assert get(conn, "/pkg/pypi/nobody/nothing/simple/").status == 404
  end

  # twine's framing: multipart/form-data with the distribution in
  # `content` and the core metadata in sibling fields.
  defp upload(conn, root, token, name, version, filename, body, opts \\ []) do
    path = Path.join(System.tmp_dir!(), "upload-#{System.unique_integer([:positive])}")
    File.write!(path, body)

    params =
      %{
        ":action" => "file_upload",
        "protocol_version" => "1",
        "name" => name,
        "version" => version,
        "filetype" => "sdist",
        "metadata_version" => "2.1",
        "content" => %Plug.Upload{
          path: path,
          filename: filename,
          content_type: "application/octet-stream"
        }
      }
      |> Map.merge(Map.new(opts, fn {k, v} -> {to_string(k), v} end))

    conn =
      if token,
        do:
          put_req_header(conn, "authorization", "Basic " <> Base.encode64("__token__:#{token}")),
        else: conn

    post(conn, root <> "/", params)
  end
end