5.5 KiB · text History 4b2ca85
defmodule GitGud.AvatarsTest do
  @moduledoc """
  Uploaded avatars, and the identicon that stands in when there isn't
  one.

  The property worth defending is that `render/1` always answers with an
  image. Every call site — the profile page, an `<img>`, the F-Droid
  repository icon — is written against that, so a caller that has to
  handle "no avatar" is a caller that will get it wrong somewhere.
  """
  use GitGud.DataCase, async: false

  import GitGud.ForgeFixtures

  alias GitGud.Avatars

  # Minimal valid images, by magic bytes. The context sniffs the body
  # rather than trusting a header, so these have to be real signatures.
  @png <<137, 80, 78, 71, 13, 10, 26, 10>> <> :binary.copy(<<0>>, 32)
  @jpeg <<0xFF, 0xD8, 0xFF, 0xE0>> <> :binary.copy(<<0>>, 32)
  @gif "GIF89a" <> :binary.copy(<<0>>, 32)
  @webp "RIFF" <> <<40::little-32>> <> "WEBP" <> :binary.copy(<<0>>, 32)

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

  describe "render/1 without an upload" do
    test "returns the identicon rather than nothing", %{user: user} do
      assert {:ok, "image/png", png} = Avatars.render(user)
      assert <<137, 80, 78, 71, 13, 10, 26, 10, _::binary>> = png
    end

    test "is the same grid the profile page draws", %{user: user} do
      {:ok, _, png} = Avatars.render(user)

      assert png == GitGud.Avatars.Identicon.png(Avatars.seed(user))
    end

    test "repositories and users get different faces", %{user: user, repo: repo} do
      {:ok, _, for_user} = Avatars.render(user)
      {:ok, _, for_repo} = Avatars.render(repo)

      refute for_user == for_repo
    end
  end

  describe "put/2" do
    test "stores an upload and render/1 serves it back", %{user: user} do
      assert {:ok, avatar} = Avatars.put(user, @png)
      assert avatar.content_type == "image/png"
      assert avatar.size == byte_size(@png)

      assert {:ok, "image/png", @png} = Avatars.render(user)
    end

    test "detects the type from the bytes, not from a claim", %{user: user} do
      for {body, expected} <- [
            {@png, "image/png"},
            {@jpeg, "image/jpeg"},
            {@gif, "image/gif"},
            {@webp, "image/webp"}
          ] do
        {:ok, avatar} = Avatars.put(user, body)
        assert avatar.content_type == expected
      end
    end

    test "refuses something that isn't an image", %{user: user} do
      # Including the case that matters: a browser sniffs the body, so
      # "it said PNG" is how an HTML file gets served from our origin.
      assert {:error, :unsupported_type} = Avatars.put(user, "<html><script>alert(1)</script>")
      assert {:error, :unsupported_type} = Avatars.put(user, "<svg xmlns='...'></svg>")
    end

    test "refuses an empty upload", %{user: user} do
      assert {:error, :empty} = Avatars.put(user, "")
    end

    test "refuses one that's too large", %{user: user} do
      oversized = :binary.copy(<<0>>, Avatars.max_bytes() + 1)

      assert {:error, :too_large} = Avatars.put(user, oversized)
    end

    test "replacing keeps one row and one object", %{user: user} do
      {:ok, first} = Avatars.put(user, @png)
      {:ok, second} = Avatars.put(user, @jpeg)

      assert first.id == second.id
      assert second.content_type == "image/jpeg"

      # The old object goes only after the row points at the new one.
      assert {:error, _} = GitGud.Storage.get(first.storage_bucket, first.storage_key)
      assert {:ok, @jpeg} = GitGud.Storage.get(second.storage_bucket, second.storage_key)
    end

    test "re-uploading identical bytes doesn't delete the object", %{user: user} do
      {:ok, first} = Avatars.put(user, @png)
      {:ok, second} = Avatars.put(user, @png)

      # Same digest means the same key — discarding "the old one" here
      # would remove the object the new row points at.
      assert first.storage_key == second.storage_key
      assert {:ok, "image/png", @png} = Avatars.render(user)
    end

    test "works for repositories too", %{repo: repo} do
      assert {:ok, _} = Avatars.put(repo, @png)
      assert {:ok, "image/png", @png} = Avatars.render(repo)
    end
  end

  describe "delete/1" do
    test "hands the identicon back", %{user: user} do
      {:ok, avatar} = Avatars.put(user, @png)
      assert :ok = Avatars.delete(user)

      assert {:ok, "image/png", png} = Avatars.render(user)
      assert png == GitGud.Avatars.Identicon.png(Avatars.seed(user))
      assert {:error, _} = GitGud.Storage.get(avatar.storage_bucket, avatar.storage_key)
    end

    test "is fine with no avatar", %{user: user} do
      assert :ok = Avatars.delete(user)
    end
  end

  describe "url/1" do
    test "carries a version so a replacement is a different URL", %{user: user} do
      before = Avatars.url(user)
      {:ok, _} = Avatars.put(user, @png)
      after_upload = Avatars.url(user)

      assert before =~ "/u/#{user.handle}/avatar?v="
      refute before == after_upload
    end

    test "a repository's URL is under the repository", %{repo: repo, user: user} do
      assert Avatars.url(repo) =~ "/r/#{user.handle}/#{repo.name}/avatar?v="
    end
  end

  describe "a missing object" do
    test "falls back rather than serving a broken image", %{user: user} do
      {:ok, avatar} = Avatars.put(user, @png)
      # The row says there is an upload; the store disagrees.
      :ok = GitGud.Storage.delete(avatar.storage_bucket, avatar.storage_key)

      assert {:ok, "image/png", png} = Avatars.render(user)
      assert png == GitGud.Avatars.Identicon.png(Avatars.seed(user))
    end
  end
end