Elixir の Ueberauth で無理やり単体テストするぞ！

この記事は Elixir Advent Calendar 2023 シリーズ 1 の 10 日目です。

https://qiita.com/advent-calendar/2023/elixir

@koyo-miyamura です！
最近は本業の傍ら、Elixirコミュニティの方で副業しており、実務でのElixir実装にハマっております。
「お仕事でも使える Elixir」をテーマに実務での実装例の紹介に励んでおりますb

最近では Bright という SaaS の開発に携わらせていただきました :pray: 
ぜひ使ってみてください！

https://bright-fun.org/

# Elixir で OAuth2 したいなら ... ueberauth !
Elixir で OAuth2 認証したい場合、 `ueberauth` というライブラリを使うことが多いかと思います。
ueberauth が本体で、各プロバイダ（GitHub など）ごとの Strategy を実装する設計になっています。
各プロバイダの Strategy もこのように公開されています。

https://github.com/ueberauth/ueberauth_github

簡単な例ですが、以下のようなリポジトリで例も公開されています。

https://github.com/ueberauth/ueberauth_example

上記を例にして実装すると大体ざっくりこんな感じになると思います。

コントローラーの `request` メソッドで OAuth2 プロバイダが提供する認可エンドポイントへのリクエストを処理し、`callback` メソッドで認可サーバーからのコールバックを処理します。
OAuth2 の細かい処理は `plug Ueberauth` や `Ueberauth.Strategy.Helpers` が、`config` に設定された Strategy に応じて処理してくれることで共通化されています。
なので使う側は `%Ueberauth.Failure{}` （失敗時）と `%Ueberauth.Auth{}` （成功時）の 2 通りを処理するだけでよいです！とっても簡単！

```elixir:config.exs
config :ueberauth, Ueberauth,
  providers: [
    github: {Ueberauth.Strategy.Github, []}
  ]

config :ueberauth, Ueberauth.Strategy.Github.OAuth,
  client_id: {:system, "GITHUB_CLIENT_ID"},
  client_secret: {:system, "GITHUB_CLIENT_SECRET"}
```

```elixir:router.ex
  pipeline :auth do
    plug Ueberauth
  end

  scope "/auth", OauthSampleWeb do
    pipe_through [:browser, :auth]

    get "/:provider", AuthController, :request
    get "/:provider/callback", AuthController, :callback
    post "/:provider/callback", AuthController, :callback
  end
```

```elixir:lib/oauth_sample_web/controllers/auth_controller.ex
defmodule OauthSampleWeb.AuthController do
  @moduledoc """
  Auth controller responsible for handling Ueberauth responses
  """

  use OauthSampleWeb, :controller

  alias Ueberauth.Strategy.Helpers

  def request(conn, _params) do
    redirect(conn, to: Helpers.request_url(conn))
  end

  def callback(%{assigns: %{ueberauth_failure: %Ueberauth.Failure{} = fails}} = conn, _params) do
    IO.inspect(fails)

    conn
    |> put_flash(:error, "Failed to authenticate.")
    |> redirect(to: "/")
  end

  def callback(%{assigns: %{ueberauth_auth: %Ueberauth.Auth{} = auth}} = conn, _params) do
    IO.inspect(auth)

    # セッションを生成
    conn
    |> put_flash(:info, "Succeed to authenticate!")
    |> put_session(:current_user, auth.uid)
    |> configure_session(renew: true)
    |> redirect(to: "/")
  end
end
```

# テストは...？
ここまでは順調ですが...

「これで認証できたぞ～！なんて簡単なんだ！」
「さてさて、プロダクションで運用するなら単体テストも書かないとね」
「...あれ？どうやって書くんだ！？」

となります（私はなりました笑）。

単体テスト中に実際に外部の認可サーバーにリクエスト飛ばすわけにもいきませんし、何かしらの手段でモックが必要そうです。
また、ライブラリの単体テストをしたいわけではないので、OAuth2 の処理はライブラリがうまく処理してくれるとして、自分が記述した処理（成功時にセッションに uid を入れたりするなど）が動作しているかをテストしたいです。
(実際に OAuth2 が成功して一連のユーザーストーリーが実行できるかどうかは E2E テストでやればよいという方針）

「...流石にテスト用の何かあるでしょ」と思い、ドキュメントを読むもない...。
ついには以下の issue を見つけてしまいました。

https://github.com/ueberauth/ueberauth/issues/70

なんと **2018年** の issue が open になっています！笑
つまり、**ない**ということ...。

「こ、これでは困る！！！」
ということで英語記事中心に色々調べて自分なりのベストプラクティスを構築したので紹介します。
（自分が四苦八苦した時に、もしこんな記事があれば...と思ったので悲劇を繰り返さないためにも笑）

まず ueberauth はうまく抽象化された作りになっており、各プロバイダ毎の差異は Strategy に吸収されています。ライブラリを使う側はシンプルに、成功時と失敗時のケースをハンドリングすればよいです。

なので `%Ueberauth.Auth{}` と `%Ueberauth.Failure{}` をテスト中に、好きに差し替えることが出来ればよさそうです。

また、`request` はリダイレクトをテストできればいいので、モックせずにそのままテストできると嬉しいです。

[issue](https://github.com/ueberauth/ueberauth/issues/70#issuecomment-1642639858) の方にもテスト用の Strategy を用意してみるとどうだ？と書いてあります。

ただ上記は少し色々やっているので、最低限必要な部分だけ抜き出してみようと思います。

# 自作のテスト用 Strategy を作る

ということで最低限の実装を抜き出したコードがこちらです。
これを test/support 以下に配置します。

```elixir:test/support/ueberauth/strategy/test.ex
defmodule OauthSample.Ueberauth.Strategy.Test do
  use Ueberauth.Strategy

  # NOTE: request は Strategy ごとにテストしたいので実際の Strategy モジュールを使用する
  def handle_request!(conn), do: aliased_strategy(conn).handle_request!(conn)

  defp aliased_strategy(%{private: %{ueberauth_request_options: %{options: opts}}} = _conn) do
    Keyword.fetch!(opts, :aliased_strategy)
  end

  # NOTE: callback は Strategy に関わらず成功・失敗ケースをテスト中に指定してテストできるようにしたい
  # また、実際の Strategy モジュールを使ってしまうと実際に OAuth プロバイダに HTTP リクエストしてしまうので避けたい
  # よって上記の要件を満たすために何もせず返す
  def handle_callback!(conn), do: conn
end
```

作成したテスト用の Strategy をテスト時のみ使うようにします。

```elixir:config/test.exs
# NOTE: テスト用に OauthSample.Ueberauth.Strategy.Test を作成して使用
config :ueberauth, Ueberauth,
  providers: [
    github:
      {OauthSample.Ueberauth.Strategy.Test,
       [aliased_strategy: Ueberauth.Strategy.Github, default_scope: ""]}
  ]

config :ueberauth, Ueberauth.Strategy.Github.OAuth,
  client_id: "dummy_client_id",
  client_secret: "dummy_client_secret"
```

これでテストが書けるようになりました！

# 実際にテストを書いてみる
それでは `auth_controller` のテストを書いてみましょう！

```elixir:test/oauth_sample_web/controllers/auth_controller_test.exs
defmodule OauthSampleWeb.AuthControllerTest do
  use OauthSampleWeb.ConnCase

  test "GET /auth/github", %{conn: conn} do
    conn = get(conn, ~p"/auth/github")

    # GitHub の OAuth パスにリダイレクトしていることをテスト
    assert %URI{
             path: "/login/oauth/authorize",
             query: query
           } = redirected_to(conn) |> URI.parse()

    assert %{
             "client_id" => "dummy_client_id",
             "redirect_uri" => "http://www.example.com/auth/github/callback",
             "response_type" => "code",
             "scope" => "",
             "state" => state
           } = URI.decode_query(query)

    # OAuth に必要な state パラメータが生成できていることを確認
    assert is_binary(state)
  end

  defp setup_for_github_auth(%{conn: conn}) do
    # NOTE: state パラメータ付きでリクエストしないと CSRF エラーになるので事前に生成しておく
    conn =
      conn
      |> get(~p"/auth/github")

    state =
      redirected_to(conn)
      |> URI.parse()
      |> Map.get(:query)
      |> URI.decode_query()
      |> Map.get("state")

    %{conn: conn |> recycle(), state: state, provider: :github}
  end

  describe "GET /auth/:provider/callback when provider is google" do
    setup [:setup_for_github_auth]

    # 失敗時のテスト
    test "redirects with error message when OAuth is failure", %{
      conn: conn,
      state: state,
      provider: provider
    } do
      ueberauth_failure = %Ueberauth.Failure{
        errors: %Ueberauth.Failure.Error{
          message: "error message",
          message_key: "error message_key"
        },
        provider: provider,
        strategy: Ueberauth.Strategy.Github
      }

      conn =
        conn
        |> assign(:ueberauth_failure, ueberauth_failure)
        |> get(~p"/auth/github/callback", %{"state" => state})

      # OAuth 失敗時の自前のロジックをここでテストできている！
      assert redirected_to(conn) == ~p"/"
      assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Failed to authenticate."
    end

    # 成功時のテスト
    test "redirects and generate session when OAuth is success",
         %{
           conn: conn,
           state: state,
           provider: provider
         } do
      identifier = "1"

      ueberauth_auth = %Ueberauth.Auth{
        provider: provider,
        uid: identifier
      }

      conn =
        conn
        |> assign(:ueberauth_auth, ueberauth_auth)
        |> get(~p"/auth/google/callback", %{"state" => state})

      # OAuth 成功時の自前のロジックをここでテストできている！
      assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Succeed to authenticate!"

      assert redirected_to(conn) == ~p"/"
      assert get_session(conn, :current_user) == identifier
    end
  end
end
```

state パラメータを用意したりしないといけないので、そこがハマりポイントなのでご注意ください！

テスト実行してみると成功しました :thumbsup: 
```bash
$ mix test test/oauth_sample_web/controllers/auth_controller_test.exs
.%Ueberauth.Auth{
  uid: "1",
  provider: :github,
  strategy: nil,
  info: %Ueberauth.Auth.Info{
    name: nil,
    first_name: nil,
    last_name: nil,
    nickname: nil,
    email: nil,
    location: nil,
    description: nil,
    image: nil,
    phone: nil,
    birthday: nil,
    urls: %{}
  },
  credentials: %Ueberauth.Auth.Credentials{
    token: nil,
    refresh_token: nil,
    token_type: nil,
    secret: nil,
    expires: nil,
    expires_at: nil,
    scopes: [],
    other: %{}
  },
  extra: %Ueberauth.Auth.Extra{raw_info: %{}}
}
.%Ueberauth.Failure{
  provider: :github,
  strategy: Ueberauth.Strategy.Github,
  errors: %Ueberauth.Failure.Error{
    message_key: "error message_key",
    message: "error message"
  }
}
.
Finished in 0.1 seconds (0.00s async, 0.1s sync)
3 tests, 0 failures
```

# まとめ

上記のようにテストすることで

- OAuth2 プロバイダが提供する認可エンドポイントへのリクエスト
- 認可サーバーからのコールバック（認証失敗時）
- 認可サーバーからのコールバック（認証成功時）

がテストできています。
OAuth2 の実装自体はライブラリ依存ですが、認証成功・失敗時にどう振舞うかなどはアプリケーション固有なのでテストしたいです（しかも認証が絡むので、なおさらテストはしっかり書きたい）。
本記事の手法を用いることで、上記の要件を満たしつつ `Ueberauth` を使ってお手軽に Elixir で OAuth2 の実装・テストが実現できました :thumbsup: 

本記事ではプロバイダに GitHub を使用していますが Google など他のプロバイダも Ueberauth から提供されていますので、ぜひトライしてみてください :pray: 

引き続き、Elixir Advent Calendar 2023 をお楽しみください。