test/openagents/notifications/email_channel_test.exs

58e6347eeb72 · 7 KB

defmodule OpenAgents.Notifications.EmailChannelTest do
  @moduledoc """
  Proofs for the gate an address has to pass before anything is sent to it.

  The claim under test is narrow and absolute: `verified_address/1` is the only
  read any send resolves its recipient through, and it returns `nil` for every
  address a code has not come back from. The database says the same thing
  independently, so the last test here goes around this module entirely and
  asks PostgreSQL.
  """

  use OpenAgents.DataCase, async: false

  import OpenAgents.AccountsFixtures
  import Swoosh.TestAssertions

  alias OpenAgents.Accounts.User
  alias OpenAgents.Notifications.EmailChannel

  setup do
    %{user: repository_user_fixture("addressee-#{System.unique_integer([:positive])}")}
  end

  describe "recording an address" do
    test "stores it unverified, and an unverified address is not a recipient", %{user: user} do
      assert {:ok, updated} = EmailChannel.set_address(user, "Someone@Example.com")

      assert updated.notification_email == "someone@example.com"
      assert is_nil(updated.notification_email_verified_at)
      assert EmailChannel.verified_address(updated) == nil

      assert EmailChannel.state(updated) == %{
               address: "someone@example.com",
               verified?: false,
               pending?: true
             }
    end

    test "mails the code to the address, and holds only its digest", %{user: user} do
      assert {:ok, updated} = EmailChannel.set_address(user, "someone@example.com")

      assert_email_sent(fn email ->
        assert email.to == [{"", "someone@example.com"}]
        assert [code] = Regex.run(~r/[0-9A-Z]{8}/, email.subject)
        assert email.text_body =~ code
        assert updated.notification_email_code_digest == :crypto.hash(:sha256, code)
      end)
    end

    test "refuses an address that is not one", %{user: user} do
      for bad <- ["", "someone", "someone@example", "two@parts@example.com", "a b@example.com"] do
        assert EmailChannel.set_address(user, bad) == {:error, :invalid_address}
      end

      assert_no_email_sent()
    end

    test "refuses to collect an address at all where nothing can be sent", %{user: user} do
      configured = Application.get_env(:openagents, EmailChannel)
      Application.put_env(:openagents, EmailChannel, Keyword.put(configured, :deliverable, false))
      on_exit(fn -> Application.put_env(:openagents, EmailChannel, configured) end)

      refute EmailChannel.deliverable?()
      assert EmailChannel.set_address(user, "someone@example.com") == {:error, :not_deliverable}
      assert_no_email_sent()
    end
  end

  describe "confirming an address" do
    setup %{user: user} do
      {:ok, pending} = EmailChannel.set_address(user, "someone@example.com")
      %{pending: pending, code: sent_code()}
    end

    test "the right code makes it a recipient", %{pending: pending, code: code} do
      assert {:ok, verified} = EmailChannel.verify(pending, code)

      assert EmailChannel.verified_address(verified) == "someone@example.com"
      assert verified.notification_email_verified_at != nil
      assert EmailChannel.state(verified).verified?
    end

    test "the code is spent, so the same one cannot be replayed", %{pending: pending, code: code} do
      assert {:ok, verified} = EmailChannel.verify(pending, code)
      assert EmailChannel.verify(verified, code) == {:error, :nothing_pending}
    end

    test "case and surrounding space do not matter", %{pending: pending, code: code} do
      assert {:ok, verified} = EmailChannel.verify(pending, "  #{String.downcase(code)} ")
      assert EmailChannel.verified_address(verified) == "someone@example.com"
    end

    test "a wrong code is counted, and the fifth retires the code", %{pending: pending} do
      user =
        Enum.reduce(1..4, pending, fn attempt, current ->
          assert EmailChannel.verify(current, "00000000") == {:error, :incorrect_code}
          reloaded = Repo.get!(User, current.id)
          assert reloaded.notification_email_code_attempts == attempt
          reloaded
        end)

      assert EmailChannel.verify(user, "00000000") == {:error, :too_many_attempts}

      spent = Repo.get!(User, user.id)
      assert is_nil(spent.notification_email_code_digest)
      assert EmailChannel.verified_address(spent) == nil
    end

    test "an expired code is refused, and the address stays unverified", %{
      pending: pending,
      code: code
    } do
      stale =
        pending
        |> Ecto.Changeset.change(
          notification_email_code_sent_at: DateTime.add(DateTime.utc_now(), -1_801, :second)
        )
        |> Repo.update!()

      assert EmailChannel.verify(stale, code) == {:error, :expired}
      assert EmailChannel.verified_address(Repo.get!(User, stale.id)) == nil
    end

    test "another code cannot be asked for within the minute", %{pending: pending} do
      assert EmailChannel.resend_code(pending) == {:error, :too_soon}
    end

    test "a fresh code retires the old one and resets the guesses", %{
      pending: pending,
      code: code
    } do
      assert {:error, :incorrect_code} = EmailChannel.verify(pending, "00000000")

      guessed =
        User
        |> Repo.get!(pending.id)
        |> Ecto.Changeset.change(
          notification_email_code_sent_at: DateTime.add(DateTime.utc_now(), -120, :second)
        )
        |> Repo.update!()

      assert {:ok, reissued} = EmailChannel.resend_code(guessed)
      reissued_code = sent_code()

      assert reissued.notification_email_code_attempts == 0
      assert EmailChannel.verify(reissued, code) == {:error, :incorrect_code}

      assert {:ok, verified} = EmailChannel.verify(Repo.get!(User, pending.id), reissued_code)
      assert EmailChannel.verified_address(verified) == "someone@example.com"
    end
  end

  describe "changing and removing an address" do
    setup %{user: user} do
      {:ok, pending} = EmailChannel.set_address(user, "first@example.com")
      {:ok, verified} = EmailChannel.verify(pending, sent_code())
      %{verified: verified}
    end

    test "a new address is not a recipient until it is confirmed too", %{verified: verified} do
      assert {:ok, changed} = EmailChannel.set_address(verified, "second@example.com")

      assert changed.notification_email == "second@example.com"
      assert EmailChannel.verified_address(changed) == nil

      assert {:ok, reverified} = EmailChannel.verify(changed, sent_code())
      assert EmailChannel.verified_address(reverified) == "second@example.com"
    end

    test "retyping the confirmed address is not a withdrawal of the proof", %{verified: verified} do
      assert {:ok, unchanged} = EmailChannel.set_address(verified, "FIRST@example.com")

      assert EmailChannel.verified_address(unchanged) == "first@example.com"
      assert_no_email_sent()
    end

    test "removal takes the address, the proof, and any outstanding code", %{verified: verified} do
      assert {:ok, removed} = EmailChannel.remove_address(verified)

      assert is_nil(removed.notification_email)
      assert is_nil(removed.notification_email_verified_at)
      assert is_nil(removed.notification_email_code_digest)
      assert EmailChannel.verified_address(removed) == nil
      assert EmailChannel.resend_code(removed) == {:error, :nothing_pending}
    end
  end

  describe "the database's own half of the gate" do
    test "a verified timestamp cannot stand on a row that names no address", %{user: user} do
      changeset =
        Ecto.Changeset.change(user, notification_email_verified_at: DateTime.utc_now())

      assert_raise Ecto.ConstraintError, ~r/users_notification_email_state_check/, fn ->
        Repo.update(changeset)
      end
    end
  end

  # The code exists in exactly one place a test may read it: the message that
  # was sent. Reading it from the row would prove nothing, because the row holds
  # a digest.
  defp sent_code do
    assert_receive {:email, %Swoosh.Email{subject: subject}}
    hd(Regex.run(~r/[0-9A-Z]{8}/, subject))
  end
end