6.9 KiB · text History b868907
defmodule GitGudWeb.AvatarUploadTest do
  @moduledoc """
  The avatar section on the three settings pages.

  Driven through the LiveView rather than by calling the context,
  because the context already has its own tests and what's untested
  here is the wiring: that the component is actually mounted on each
  page, that an upload reaches `Avatars.put/2`, and that the permission
  gate on a repository is real rather than decorative.
  """
  use GitGudWeb.ConnCase, async: false

  import GitGud.AccountsFixtures
  import Phoenix.LiveViewTest

  alias GitGud.Avatars

  @png <<137, 80, 78, 71, 13, 10, 26, 10>> <> :binary.copy(<<0>>, 64)

  setup %{conn: conn} do
    user = user_fixture()
    %{conn: log_in_user(conn, user), user: user}
  end

  describe "user settings" do
    test "shows the generated avatar with an upload control", %{conn: conn} do
      {:ok, _view, html} = live(conn, ~p"/users/settings")

      assert html =~ "Avatar"
      assert html =~ "Generated from the name"
      assert html =~ "/avatar?v="
    end

    test "uploading stores it and the control becomes Replace", %{conn: conn, user: user} do
      {:ok, view, _html} = live(conn, ~p"/users/settings")

      # The form id is namespaced per page; the user page's is this one.
      entry = %{name: "a.png", content: @png, type: "image/png", size: byte_size(@png)}
      photo = file_input(view, "#user-avatar-form", :avatar, [entry])
      render_upload(photo, "a.png")

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

    test "removing brings the identicon back", %{conn: conn, user: user} do
      {:ok, _} = Avatars.put(user, @png)
      {:ok, view, _html} = live(conn, ~p"/users/settings")

      view |> element(~s{button[phx-click="remove"]}) |> render_click()

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

    test "refusing a non-image says so instead of storing it", %{conn: conn, user: user} do
      {:ok, view, _html} = live(conn, ~p"/users/settings")

      # A .png name over bytes that aren't one — which is exactly what
      # the server-side sniff exists for, since the picker only ever
      # looked at the extension.
      body = "<html><script>alert(1)</script></html>"
      entry = %{name: "a.png", content: body, type: "image/png", size: byte_size(body)}
      photo = file_input(view, "#user-avatar-form", :avatar, [entry])
      render_upload(photo, "a.png")

      assert Avatars.get(user) == nil
      assert render(view) =~ "doesn&#39;t look like a PNG"
    end
  end

  describe "repository settings" do
    test "an admin can upload", %{conn: conn, user: user} do
      {:ok, repo} =
        GitGud.Repositories.create_repository(user, %{
          "name" => "avatared",
          "visibility" => "public"
        })

      {:ok, view, html} = live(conn, ~p"/r/#{user.handle}/#{repo.name}/settings")
      assert html =~ "Avatar"

      entry = %{name: "a.png", content: @png, type: "image/png", size: byte_size(@png)}
      photo = file_input(view, "#repo-avatar-form", :avatar, [entry])
      render_upload(photo, "a.png")

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

  describe "showing up everywhere the identicon did" do
    test "a profile shows the upload once there is one", %{conn: conn, user: user} do
      {:ok, _view, before} = live(conn, ~p"/u/#{user.handle}")
      # No upload yet: the themed SVG, which recolours with the theme.
      assert before =~ "<svg"
      refute before =~ ~s(src="/u/#{user.handle}/avatar")

      {:ok, _} = Avatars.put(user, @png)

      {:ok, _view, after_upload} = live(conn, ~p"/u/#{user.handle}")
      assert after_upload =~ ~s(src="/u/#{user.handle}/avatar")
    end

    test "an org listing picks it up too", %{conn: conn, user: user} do
      {:ok, org} =
        GitGud.Organizations.create_organization(user, %{
          "handle" => "avatarorg",
          "name" => "Avatar Org"
        })

      {:ok, _} = Avatars.put(org, @png)

      {:ok, _view, html} = live(conn, ~p"/orgs/#{org.handle}")
      assert html =~ ~s(src="/u/avatarorg/avatar")
    end

    test "everyone without an upload keeps the themed identicon", %{conn: conn, user: user} do
      {:ok, _view, html} = live(conn, ~p"/u/#{user.handle}")

      # The majority never upload anything; swapping them to a static
      # PNG to avoid a branch would lose theming for most to benefit
      # the few.
      assert html =~ "var(--color-"
    end

    test "an upload is visible immediately, not after a TTL", %{conn: conn, user: user} do
      # Warm the cache with the pre-upload answer.
      {:ok, _view, _html} = live(conn, ~p"/u/#{user.handle}")
      refute MapSet.member?(Avatars.uploaded_handles(), user.handle)

      {:ok, _} = Avatars.put(user, @png)

      # Invalidated on write. Getting this backwards is how an avatar
      # feature earns "I changed it and nothing happened".
      assert MapSet.member?(Avatars.uploaded_handles(), user.handle)
      {:ok, _view, html} = live(conn, ~p"/u/#{user.handle}")
      assert html =~ ~s(src="/u/#{user.handle}/avatar")
    end

    test "removing goes back to the identicon", %{conn: conn, user: user} do
      {:ok, _} = Avatars.put(user, @png)
      :ok = Avatars.delete(user)

      refute MapSet.member?(Avatars.uploaded_handles(), user.handle)
      {:ok, _view, html} = live(conn, ~p"/u/#{user.handle}")
      assert html =~ "var(--color-"
    end
  end

  describe "serving" do
    test "returns the identicon for an owner with no upload", %{conn: conn, user: user} do
      conn = get(conn, ~p"/u/#{user.handle}/avatar")

      assert response_content_type(conn, :png) =~ "image/png"
      assert <<137, 80, 78, 71, 13, 10, 26, 10, _::binary>> = response(conn, 200)
    end

    test "returns the upload once there is one", %{conn: conn, user: user} do
      {:ok, _} = Avatars.put(user, @png)

      conn = get(conn, ~p"/u/#{user.handle}/avatar")
      assert response(conn, 200) == @png
    end

    test "is immutable only when the URL carries a version", %{conn: conn, user: user} do
      versioned = get(conn, ~p"/u/#{user.handle}/avatar?v=abc123")

      assert get_resp_header(versioned, "cache-control") == [
               "public, max-age=31536000, immutable"
             ]

      # A bare URL has to stay revalidatable, or someone who followed a
      # link without one is stuck with a stale face for a year.
      bare = get(build_conn(), ~p"/u/#{user.handle}/avatar")
      assert get_resp_header(bare, "cache-control") == ["public, max-age=300"]
    end

    test "refuses to let a browser re-sniff user-supplied bytes", %{conn: conn, user: user} do
      conn = get(conn, ~p"/u/#{user.handle}/avatar")

      assert get_resp_header(conn, "x-content-type-options") == ["nosniff"]
    end

    test "404s an unknown handle", %{conn: conn} do
      assert conn |> get(~p"/u/nobody-here/avatar") |> response(404)
    end
  end
end