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=">=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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.7 KiB · text
History
b868907