26.7 KiB · text History b868907
defmodule GitGud.Packages.Formats.FdroidTest do
  @moduledoc """
  The F-Droid adapter: publishing an APK and serving the index it
  produces.

  Uploads go through a synthesised APK rather than a real one — a real
  one is 19 MB and the parsers it exercises have their own tests. What
  this file is about is the adapter: that a publish lands as a
  `PackageFile` with the metadata the index needs, that the index
  document has the shape F-Droid expects, and that `_all` aggregates
  namespaces the way apt's does.
  """
  use GitGud.DataCase, async: false

  import GitGud.ForgeFixtures

  alias GitGud.Packages
  alias GitGud.Packages.Formats.Fdroid

  @cert GitGud.ApkFixtures.signing_cert()

  # A tiny but real PNG, so what comes back out is checkable.
  @icon_png GitGud.ApkFixtures.icon_png()

  setup do
    {user, repo} = repository_fixture()
    %{user: user, repo: repo}
  end

  defp build_apk(extra_entries \\ []), do: GitGud.ApkFixtures.signed_apk(extra_entries)

  defp ctx(overrides) do
    Map.merge(
      %{
        path: [],
        conn: Plug.Test.conn(:get, "/"),
        method: "GET",
        trailing_slash?: false,
        base_url: "https://forge.test/pkg/fdroid/acme/_all",
        principal: :anonymous
      },
      overrides
    )
  end

  defp publish(repo, body) do
    Fdroid.store(repo, ctx(%{method: "POST", path: ["upload"]}), body)
  end

  defp decode({:ok, "application/json", body}),
    do: body |> IO.iodata_to_binary() |> Jason.decode!()

  # ── tests ────────────────────────────────────────────────────────────

  describe "publishing" do
    test "stores an APK with the metadata the index needs", %{repo: repo} do
      assert {:ok, "application/json", body} = publish(repo, build_apk())
      reply = body |> IO.iodata_to_binary() |> Jason.decode!()

      assert reply["package"] == "org.neiam.gitgud.app"
      assert reply["versionCode"] == 1
      assert reply["versionName"] == "0.1.0"
      assert reply["filename"] == "org.neiam.gitgud.app_1.apk"

      # The signer is read out of the APK, never asserted by the client.
      assert reply["signer"] ==
               :sha256 |> :crypto.hash(@cert) |> Base.encode16(case: :lower)

      assert [package] = Packages.list_packages(repo, "fdroid")
      assert package.name == "org.neiam.gitgud.app"
      assert [file] = Packages.list_package_files(package)
      assert file.metadata["min_sdk"] == 26
      assert file.metadata["nativecode"] == ["arm64-v8a"]
    end

    test "republishing a versionCode says which one, and why", %{repo: repo} do
      apk = build_apk()
      {:ok, _, _} = publish(repo, apk)

      # Same versionCode, different bytes — which is what a CI build
      # with a hardcoded versionCode produces on its second run.
      different = build_apk([{~c"extra.txt", "changes the digest"}])

      assert {:error, {409, message}} = publish(repo, different)
      assert message =~ "org.neiam.gitgud.app_1.apk"
      assert message =~ "versionCode 1"
      assert message =~ "won't install an update"
    end

    test "republishing identical bytes is fine", %{repo: repo} do
      apk = build_apk()
      assert {:ok, _, _} = publish(repo, apk)
      # A re-run that produced the same artifact is not a conflict.
      assert {:ok, _, _} = publish(repo, apk)
    end

    test "refuses something that isn't an APK", %{repo: repo} do
      assert {:error, {422, _}} = publish(repo, "definitely not a zip")
    end

    test "refuses anything but POST", %{repo: repo} do
      assert {:error, {405, _}} = Fdroid.store(repo, ctx(%{method: "DELETE"}), "")
    end
  end

  describe "index-v2" do
    setup %{repo: repo} do
      {:ok, _, _} = publish(repo, build_apk())
      :ok
    end

    test "describes the repository and its packages", %{repo: repo} do
      assert %{"repo" => r, "packages" => packages} =
               decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})))

      assert r["address"] == "https://forge.test/pkg/fdroid/acme/_all/repo"
      # Milliseconds. Seconds would read as 1970 and every client would
      # treat the repository as permanently stale.
      assert r["timestamp"] > 1_700_000_000_000

      assert %{"org.neiam.gitgud.app" => app} = packages
      assert [{sha, version}] = Map.to_list(app["versions"])
      assert String.match?(sha, ~r/\A[0-9a-f]{64}\z/)

      assert version["file"]["name"] == "/org.neiam.gitgud.app_1.apk"
      assert version["file"]["sha256"] == sha
      assert version["manifest"]["versionCode"] == 1
      assert version["manifest"]["versionName"] == "0.1.0"
      assert version["manifest"]["usesSdk"] == %{"minSdkVersion" => 26, "targetSdkVersion" => 34}
      assert version["manifest"]["nativecode"] == ["arm64-v8a"]
    end

    test "carries the signing certificate as a list", %{repo: repo} do
      assert %{"packages" => %{"org.neiam.gitgud.app" => app}} =
               decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})))

      [{_sha, version}] = Map.to_list(app["versions"])
      expected = :sha256 |> :crypto.hash(@cert) |> Base.encode16(case: :lower)

      assert version["manifest"]["signer"] == %{"sha256" => [expected]}
    end

    test "lists the permissions the manifest declares", %{repo: repo} do
      assert %{"packages" => %{"org.neiam.gitgud.app" => app}} =
               decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})))

      [{_sha, version}] = Map.to_list(app["versions"])
      names = Enum.map(version["manifest"]["usesPermission"], & &1["name"])

      assert "android.permission.INTERNET" in names
      assert "android.permission.CAMERA" in names
    end
  end

  describe "entry" do
    test "pins index-v2.json by hash and size", %{repo: repo} do
      {:ok, _, _} = publish(repo, build_apk())

      entry = decode(Fdroid.index(repo, ctx(%{path: ["entry.json"]})))
      index = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})) |> decode_raw()

      assert entry["version"] == 20_002
      assert entry["index"]["name"] == "/index-v2.json"
      assert entry["index"]["numPackages"] == 1
      assert entry["index"]["size"] == byte_size(index)

      # The signature on entry.jar covers the index transitively, so
      # this hash is the only thing making that true.
      assert entry["index"]["sha256"] ==
               :sha256 |> :crypto.hash(index) |> Base.encode16(case: :lower)
    end

    test "the index is byte-identical across requests", %{repo: repo} do
      {:ok, _, _} = publish(repo, build_apk())

      first = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})) |> decode_raw()
      second = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})) |> decode_raw()

      # Not a nicety. entry.json pins this document by hash, so a
      # timestamp of `now` would make every fetch look like tampering to
      # a client that had just been handed the hash — and would make the
      # sign-at-publish design impossible, since there'd be nothing
      # stable to sign.
      assert first == second
    end

    test "an empty repository reports a zero timestamp, not the current time", %{repo: repo} do
      # Claiming "updated just now" on every request is both false and a
      # rollback that the first real publish has to climb out of.
      assert %{"repo" => %{"timestamp" => 0}} =
               decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})))
    end

    test "entry.jar is a signed JAR carrying entry.json", %{repo: repo} do
      {:ok, _, _} = publish(repo, build_apk())

      assert {:ok, "application/java-archive", jar} =
               Fdroid.index(repo, ctx(%{path: ["entry.jar"]}))

      {:ok, members} = :zip.unzip(IO.iodata_to_binary(jar), [:memory])
      names = members |> Enum.map(fn {n, _} -> List.to_string(n) end) |> MapSet.new()

      assert "META-INF/MANIFEST.MF" in names
      assert "META-INF/CERT.RSA" in names
      assert "entry.json" in names

      # The JAR's payload has to be the same document /entry.json
      # serves, or the hash it pins would be signing one index while
      # clients fetched another.
      {_, packed} = Enum.find(members, fn {n, _} -> n == ~c"entry.json" end)
      assert Jason.decode!(packed) == decode(Fdroid.index(repo, ctx(%{path: ["entry.json"]})))
    end

    test "the repository reports a pinnable fingerprint", %{user: user} do
      assert {:ok, fingerprint} = Fdroid.fingerprint(user)
      assert String.match?(fingerprint, ~r/\A[0-9a-f]{64}\z/)

      # Stable, because it is what a user pins when adding the repo.
      assert {:ok, ^fingerprint} = Fdroid.fingerprint(user)
    end
  end

  describe "index-v1" do
    setup %{repo: repo} do
      {:ok, _, _} = publish(repo, build_apk())
      :ok
    end

    test "is served, because a v2-only repository reads as broken", %{repo: repo} do
      # A client that can't speak v2 falls back here silently after
      # fetching a perfectly good entry.jar, and reports the 404 —
      # "invalid repository / NotFoundException", pointing at nothing.
      assert {:ok, "application/java-archive", jar} =
               Fdroid.index(repo, ctx(%{path: ["index-v1.jar"]}))

      {:ok, members} = :zip.unzip(IO.iodata_to_binary(jar), [:memory])
      names = members |> Enum.map(fn {n, _} -> List.to_string(n) end) |> MapSet.new()

      assert "index-v1.json" in names
      assert "META-INF/CERT.RSA" in names
    end

    test "carries the flat shape v1 expects", %{repo: repo} do
      index = decode(Fdroid.index(repo, ctx(%{path: ["index-v1.json"]})))

      assert index["repo"]["version"] == 20_001
      assert index["repo"]["address"] == "https://forge.test/pkg/fdroid/acme/_all/repo"
      # Read unconditionally by the client even when empty.
      assert index["requests"] == %{"install" => [], "uninstall" => []}

      assert [app] = index["apps"]
      assert app["packageName"] == "org.neiam.gitgud.app"
      assert app["suggestedVersionCode"] == "1"

      assert [pkg] = index["packages"]["org.neiam.gitgud.app"]
      assert pkg["apkName"] == "org.neiam.gitgud.app_1.apk"
      assert pkg["hashType"] == "sha256"
      assert pkg["versionCode"] == 1
      assert pkg["minSdkVersion"] == 26
      assert pkg["signer"] == :sha256 |> :crypto.hash(@cert) |> Base.encode16(case: :lower)
    end

    test "spells permissions as [name, maxSdk] pairs, not v2's objects", %{repo: repo} do
      index = decode(Fdroid.index(repo, ctx(%{path: ["index-v1.json"]})))
      [pkg] = index["packages"]["org.neiam.gitgud.app"]

      assert ["android.permission.CAMERA", nil] in pkg["uses-permission"]
    end
  end

  describe "serving APKs" do
    test "hands back the stored file by the name the index points at", %{repo: repo} do
      {:ok, _, _} = publish(repo, build_apk())

      assert {:blob, file} = Fdroid.index(repo, ctx(%{path: ["org.neiam.gitgud.app_1.apk"]}))
      assert file.filename == "org.neiam.gitgud.app_1.apk"
    end

    test "404s an unknown file", %{repo: repo} do
      assert {:error, {404, _}} = Fdroid.index(repo, ctx(%{path: ["nope.apk"]}))
    end

    test "404s the root with something actionable", %{repo: repo} do
      assert {:error, {404, message}} = Fdroid.index(repo, ctx(%{path: []}))
      assert message =~ "add this URL as a repository"
    end
  end

  describe "app links and description" do
    setup %{repo: repo} do
      {:ok, described} =
        GitGud.Repositories.update_repository(repo, %{
          "description" =>
            "A GitGud client for Android, with a CI dashboard you can watch a build from, " <>
              "issues you can triage, and pull requests you can merge from a bus stop."
        })

      assert {:ok, _, _} = publish(described, build_apk())
      %{repo: described}
    end

    test "index-v2 carries the repository's description", %{repo: repo} do
      metadata = v2_metadata(repo)

      assert metadata["description"]["en-US"] =~ "A GitGud client for Android"
    end

    test "the summary is short enough for a list", %{repo: repo} do
      metadata = v2_metadata(repo)
      summary = metadata["summary"]["en-US"]

      # F-Droid puts it under the app's name in a list, where the whole
      # paragraph doesn't fit.
      assert String.length(summary) <= 81
      assert String.starts_with?(summary, "A GitGud client for Android")
      assert String.ends_with?(summary, "…")
      # Cut on a word boundary, not mid-word.
      refute summary =~ ~r/\w…$/u
    end

    test "a description that already fits is the summary", %{user: user} do
      {_user, short} = repository_fixture(%{owner: user})

      {:ok, short} =
        GitGud.Repositories.update_repository(short, %{
          "description" => "Watch CI from your phone."
        })

      assert {:ok, _, _} = publish(short, build_apk())

      metadata = v2_metadata(short)

      assert metadata["summary"]["en-US"] == "Watch CI from your phone."
      assert metadata["description"]["en-US"] == "Watch CI from your phone."
    end

    test "index-v2 links back to the repository the app is published from", %{repo: repo} do
      metadata = v2_metadata(repo)

      handle =
        GitGud.Repositories.Storage.repo_handle(
          GitGud.Repo.preload(repo, [:owner, :organization])
        )

      base = GitGudWeb.Endpoint.url() <> "/r/#{handle}/#{repo.name}"

      assert metadata["sourceCode"] == base
      assert metadata["issueTracker"] == base <> "/issues"
      assert metadata["changelog"] == base <> "/commits/" <> repo.default_branch
      assert metadata["authorName"] == handle
      assert metadata["authorWebSite"] == GitGudWeb.Endpoint.url() <> "/u/#{handle}"
    end

    test "the detected licence is published", %{repo: repo} do
      # Detection writes it on the repository; the index reports what
      # was found and says nothing when nothing was.
      {1, _} =
        GitGud.Repo.update_all(
          from(r in GitGud.Repositories.Repository, where: r.id == ^repo.id),
          set: [license: "AGPL-3.0"]
        )

      # Reloaded: the index reports what the repository row says, and
      # the struct this test has been holding predates the write.
      assert v2_metadata(GitGud.Repo.get!(GitGud.Repositories.Repository, repo.id))["license"] ==
               "AGPL-3.0"
    end

    test "an undetected licence is absent from v2 and Unknown in v1", %{user: user} do
      {_user, unlicensed} = repository_fixture(%{owner: user})
      assert {:ok, _, _} = publish(unlicensed, build_apk())

      refute Map.has_key?(v2_metadata(unlicensed), "license")

      {:ok, _type, json} = Fdroid.index(unlicensed, ctx(%{path: ["index-v1.json"]}))
      [app] = Jason.decode!(json)["apps"]

      # v1 has no way to say "we don't know".
      assert app["license"] == "Unknown"
    end

    test "index-v1 carries the same links, unlocalized", %{repo: repo} do
      {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v1.json"]}))
      [app] = Jason.decode!(json)["apps"]

      assert app["description"] =~ "A GitGud client for Android"
      assert app["summary"] =~ "A GitGud client"
      assert app["sourceCode"] =~ "/r/"
      assert app["issueTracker"] =~ "/issues"
    end

    defp v2_metadata(repo) do
      {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))

      Jason.decode!(json)["packages"]["org.neiam.gitgud.app"]["metadata"]
    end
  end

  describe "an explicit description" do
    defp publish_with(repo, headers) do
      conn =
        Enum.reduce(headers, Plug.Test.conn(:post, "/upload"), fn {k, v}, conn ->
          Plug.Conn.put_req_header(conn, k, v)
        end)

      Fdroid.store(repo, ctx(%{method: "POST", path: ["upload"], conn: conn}), build_apk())
    end

    defp app_metadata(repo) do
      {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))
      Jason.decode!(json)["packages"]["org.neiam.gitgud.app"]["metadata"]
    end

    setup %{repo: repo} do
      # The repository says something different, so it's clear which
      # one the index used.
      {:ok, repo} =
        GitGud.Repositories.update_repository(repo, %{"description" => "The whole forge."})

      %{repo: repo}
    end

    test "wins over the repository's, from the upload's headers", %{repo: repo} do
      description = "Issues, pull requests and CI.\n\nFrom your phone."

      assert {:ok, _, _} =
               publish_with(repo, [
                 {"x-package-summary", "The forge, in your pocket"},
                 {"x-package-description", URI.encode(description, &URI.char_unreserved?/1)}
               ])

      metadata = app_metadata(repo)
      assert metadata["summary"]["en-US"] == "The forge, in your pocket"
      assert metadata["description"]["en-US"] == description

      {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v1.json"]}))
      [app] = Jason.decode!(json)["apps"]
      assert app["summary"] == "The forge, in your pocket"
    end

    test "set on the package later, applies to the published index", %{repo: repo} do
      assert {:ok, _, _} = publish(repo, build_apk())
      package = Packages.get_package(repo, "fdroid", "org.neiam.gitgud.app")
      {:ok, _} = Packages.set_about(package, %{"summary" => "Set on the packages page"})

      assert app_metadata(repo)["summary"]["en-US"] == "Set on the packages page"
      # No explicit description: the repository's still stands in.
      assert app_metadata(repo)["description"]["en-US"] == "The whole forge."
    end

    test "a summary that fails validation refuses the upload and says why", %{repo: repo} do
      assert {:error, {400, message}} =
               publish_with(repo, [{"x-package-summary", String.duplicate("x", 200)}])

      assert message =~ "X-Package-Summary"
      assert Packages.get_package(repo, "fdroid", "org.neiam.gitgud.app") == nil
    end
  end

  describe "a summary that would only repeat the app's name" do
    test "is left out", %{repo: repo} do
      label = "GitGud"
      {:ok, repo} = GitGud.Repositories.update_repository(repo, %{"description" => label})
      assert {:ok, _, _} = publish(repo, build_apk())

      {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))
      metadata = Jason.decode!(json)["packages"]["org.neiam.gitgud.app"]["metadata"]

      assert metadata["name"]["en-US"] == label
      refute Map.has_key?(metadata, "summary")
    end
  end

  describe "an app whose repository has no description" do
    test "omits the description rather than emitting an empty one", %{repo: repo} do
      assert {:ok, _, _} = publish(repo, build_apk())

      {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))
      metadata = Jason.decode!(json)["packages"]["org.neiam.gitgud.app"]["metadata"]

      # A key holding null is a key a client has to interpret.
      refute Map.has_key?(metadata, "description")
      refute Map.has_key?(metadata, "summary")

      # The links don't depend on a description and stay.
      assert metadata["sourceCode"] =~ "/r/"
    end
  end

  describe "the repository icon" do
    setup %{repo: repo} do
      {:ok, _, _} = publish(repo, build_apk())
      :ok
    end

    test "is served as a PNG", %{repo: repo} do
      assert {:ok, "image/png", png} = Fdroid.index(repo, ctx(%{path: ["icons", "icon.png"]}))

      png = IO.iodata_to_binary(png)
      assert <<137, 80, 78, 71, 13, 10, 26, 10, _::binary>> = png
      # 480x480, from the IHDR that follows the signature.
      assert <<_::binary-size(16), 480::32, 480::32, _::binary>> = png
    end

    test "is the namespace's avatar", %{repo: repo, user: user} do
      {:ok, _, png} = Fdroid.index(repo, ctx(%{path: ["icons", "icon.png"]}))

      # Through GitGud.Avatars, so it is whatever the profile shows —
      # the identicon here, and an upload the moment there is one.
      {:ok, _type, expected} = GitGud.Avatars.render(user)
      assert IO.iodata_to_binary(png) == expected
    end

    test "follows an uploaded avatar, extension and all", %{repo: repo, user: user} do
      # A 1x1 GIF. The index names the icon by filename, so the
      # extension has to follow the bytes — a JPEG served at icon.png
      # is a file some clients fetch and refuse to draw.
      gif =
        <<"GIF89a", 1::little-16, 1::little-16, 0x80, 0, 0, 0, 0, 0, 255, 255, 255, 0x21, 0xF9, 4,
          1, 0, 0, 0, 0, 0x2C, 0, 0, 0, 0, 1::little-16, 1::little-16, 0, 2, 2, 0x44, 1, 0, 0x3B>>

      {:ok, _} = GitGud.Avatars.put(user, gif)

      assert {:ok, "image/gif", ^gif} = Fdroid.index(repo, ctx(%{path: ["icons", "icon.gif"]}))
      assert {:error, {404, _}} = Fdroid.index(repo, ctx(%{path: ["icons", "icon.png"]}))

      index = decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})))
      assert index["repo"]["icon"]["en-US"]["name"] == "/icons/icon.gif"
    end

    test "index-v2 pins it by hash and size", %{repo: repo} do
      index = decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})))
      {:ok, _, png} = Fdroid.index(repo, ctx(%{path: ["icons", "icon.png"]}))
      png = IO.iodata_to_binary(png)

      icon = index["repo"]["icon"]["en-US"]
      assert icon["name"] == "/icons/icon.png"
      assert icon["size"] == byte_size(png)
      assert icon["sha256"] == :sha256 |> :crypto.hash(png) |> Base.encode16(case: :lower)
    end

    test "index-v1 names it as a bare filename", %{repo: repo} do
      # v1 resolves the icon against <address>/icons/, so it carries the
      # filename rather than a path.
      index = decode(Fdroid.index(repo, ctx(%{path: ["index-v1.json"]})))

      assert index["repo"]["icon"] == "icon.png"
    end

    test "404s an icon that isn't the one", %{repo: repo} do
      assert {:error, {404, _}} = Fdroid.index(repo, ctx(%{path: ["icons", "nope.png"]}))
    end
  end

  describe "app icons" do
    setup %{repo: repo} do
      {:ok, _, _} = publish(repo, build_apk())
      :ok
    end

    test "the launcher icon is served under its package and versionCode", %{repo: repo} do
      assert {:ok, "image/png", @icon_png} =
               Fdroid.index(repo, ctx(%{path: ["icons", "org.neiam.gitgud.app.1.png"]}))
    end

    test "picks the highest density, and never an adaptive layer", %{repo: repo} do
      {:ok, _, png} = Fdroid.index(repo, ctx(%{path: ["icons", "org.neiam.gitgud.app.1.png"]}))

      # ic_launcher_foreground is a glyph on transparency; shipping it
      # as the icon looks like a bug rather than a design.
      assert png == @icon_png
      refute png == "a layer, not the icon"
      refute png == "low density, should lose"
    end

    test "index-v2 pins it by hash and size", %{repo: repo} do
      index = decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})))
      icon = index["packages"]["org.neiam.gitgud.app"]["metadata"]["icon"]["en-US"]

      assert icon["name"] == "/icons/org.neiam.gitgud.app.1.png"
      assert icon["size"] == byte_size(@icon_png)
      assert icon["sha256"] == :sha256 |> :crypto.hash(@icon_png) |> Base.encode16(case: :lower)
    end

    test "the app is named by its label, not its package name", %{repo: repo} do
      {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))
      index = Jason.decode!(json)

      metadata = index["packages"]["org.neiam.gitgud.app"]["metadata"]

      assert metadata["name"]["en-US"] == "GitGud"
    end

    test "index-v1 carries the same name", %{repo: repo} do
      {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v1.json"]}))
      index = Jason.decode!(json)

      assert [app] = index["apps"]
      assert app["name"] == "GitGud"
      assert app["packageName"] == "org.neiam.gitgud.app"
    end

    test "index-v1 names it relative to /icons/", %{repo: repo} do
      index = decode(Fdroid.index(repo, ctx(%{path: ["index-v1.json"]})))

      assert [app] = index["apps"]
      assert app["icon"] == "org.neiam.gitgud.app.1.png"
    end

    test "an APK with no icon publishes fine and claims none", %{repo: repo} do
      {:ok, other} =
        GitGud.Repositories.create_repository(
          GitGud.Repo.preload(repo, :owner).owner,
          %{"name" => "iconless", "visibility" => "public"}
        )

      # No res/mipmap entries at all, and no resource table either.
      bare =
        GitGud.ApkFixtures.signed_zip([
          {~c"AndroidManifest.xml", GitGud.ApkFixtures.compiled_manifest()}
        ])

      assert {:ok, _, _} = publish(other, bare)

      index = decode(Fdroid.index(other, ctx(%{path: ["index-v2.json"]})))
      # Absent rather than null: an icon that isn't there shouldn't
      # show up as a key a client has to interpret.
      refute Map.has_key?(index["packages"]["org.neiam.gitgud.app"]["metadata"], "icon")
    end
  end

  describe "the /repo suffix F-Droid insists on" do
    setup %{repo: repo} do
      {:ok, _, _} = publish(repo, build_apk())
      :ok
    end

    test "serves the paths the client actually requests", %{repo: repo} do
      # RepoUriGetter appends /fdroid/repo to any address not already
      # ending in /repo or /archive, so these are the real request
      # paths — not a courtesy.
      for path <- [["entry.jar"], ["repo", "entry.jar"], ["fdroid", "repo", "entry.jar"]] do
        assert {:ok, "application/java-archive", _} =
                 Fdroid.index(repo, ctx(%{path: path})),
               "#{Enum.join(path, "/")} should serve entry.jar"
      end
    end

    test "advertises an address the client won't rewrite", %{repo: repo} do
      index = decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})))

      # Ends in /repo, so `pathSegments.last() == "repo"` and the
      # client uses it as given instead of appending /fdroid/repo.
      assert String.ends_with?(index["repo"]["address"], "/repo")
    end

    test "index-v1 advertises the same address", %{repo: repo} do
      index = decode(Fdroid.index(repo, ctx(%{path: ["index-v1.json"]})))

      assert String.ends_with?(index["repo"]["address"], "/repo")
    end

    test "APKs are reachable under the prefix too", %{repo: repo} do
      # The index points at /<file>.apk relative to an address that now
      # ends in /repo, so this is the path the download uses.
      assert {:blob, _} =
               Fdroid.index(repo, ctx(%{path: ["repo", "org.neiam.gitgud.app_1.apk"]}))
    end
  end

  describe "_all" do
    test "aggregates every repository in the namespace", %{user: user, repo: repo} do
      {:ok, other} =
        GitGud.Repositories.create_repository(user, %{
          "name" => "second-app",
          "visibility" => "public"
        })

      {:ok, _, _} = publish(repo, build_apk())
      {:ok, _, _} = publish(other, build_apk([{~c"extra.txt", "makes this a different apk"}]))

      assert %{"packages" => packages} =
               decode(
                 Fdroid.namespace_index(user, [repo, other], ctx(%{path: ["index-v2.json"]}))
               )

      # Same package name from two repositories: one app entry, two
      # versions keyed by their own digests.
      assert %{"org.neiam.gitgud.app" => app} = packages
      assert map_size(app["versions"]) == 2
    end
  end

  defp decode_raw({:ok, _type, body}), do: IO.iodata_to_binary(body)
end