ElixirDesktopで作るスマホアプリ Part 3 セッション管理周りの改修

# はじめに
この記事はElixirアドベントカレンダー2024のシリーズ２、7日目の記事です

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

本記事では、前回実装した認証機能に関して、セッション周りの改修を行っていきます

今回の作業ブランチを作成します

```
git checkout -b feature/session_for_desktop
```

# ログイン有無でのリダイレクト処理

アプリを起動した時に、ログインしていたら設定ページ、していなかったらログインページにリダイレクトするようにします。

```diff_elixir:lib/trarecord_web/controllers/page_controller.ex
defmodule TrarecordWeb.PageController do
  use TrarecordWeb, :controller
+ alias Trarecord.Accounts.User

  def home(conn, _params) do
    # The home page is often custom made,
    # so skip the default app layout.
    render(conn, :home, layout: false)
  end

+ def index(conn, _params) do
+   redirect_to(conn, conn.assigns.current_user)
+ end

+ defp redirect_to(conn, %User{}) do
+   redirect(conn, to: ~p"/users/settings")
+ end

+ defp redirect_to(conn, nil) do
+   redirect(conn, to: ~p"/users/log_in")
+ end
end
```

トップページで開くパスを変更します。

```diff_elixir:lib/trarecord_web/router.ex:L20
  scope "/", TrarecordWeb do
    pipe_through :browser

-   get "/", PageController, :home
+   get "/", PageController, :index
  end
```



# セッション保持

`ElixirDesktop`はセッション情報を`ets`というインメモリDBで管理するのでアプリを終了するとセッション情報は消えてしまい、起動毎にログインを要求されるので、セッション情報を保持するようにします。

トークンのパスは頻繁に使うので関数化しておきます

```diff_elixir:lib/trarecord.ex:L4
  @env Mix.env()
  def config_dir() do
    case(@env) do
      :test ->
        "tmp"

      _ ->
        {path, _} =
          Code.eval_string("Path.join([Desktop.OS.home(), dir, app])",
            dir: ".config",
            app: "trarecord"
          )

        path
    end
  end

+ def token_path() do
+   config_dir() <> "/token"
+ end
```

 トークンが生成された時に端末にもテキストデータとして保存するようにします

```diff_elixir:lib/trarecord_web/user_auth.ex:L28
  def log_in_user(conn, user, params \\ %{}) do
    token = Accounts.generate_user_session_token(user)
+   File.write!(Trarecord.token_path(), Base.encode64(token))

    user_return_to = get_session(conn, :user_return_to)

    conn
    |> renew_session()
    |> put_token_in_session(token)
    |> maybe_write_remember_me_cookie(token, params)
    |> redirect(to: user_return_to || signed_in_path(conn))
  end
```

# セッション復活

起動時にセッションを復活できるようにします。

`user_auth.ex`でページを表示する前に実行されている、`fetch_current_user/2`関数内の`ensure_user_token/1`関数をセッションにトークンがなかった場合はCookieを見に行っているのを、トークンファイルを見に行くように変更します。

```diff_elixir:lib/trarecord_web/user_auth.ex:L98
  defp ensure_user_token(conn) do
    if token = get_session(conn, :user_token) do
      {token, conn}
    else
-     conn = fetch_cookies(conn, signed: [@remember_me_cookie])
-
-     if token = conn.cookies[@remember_me_cookie] do
-       {token, put_token_in_session(conn, token)}
-     else
-       {nil, conn}
+     case File.read(Trarecord.token_path()) do
+       {:ok, token} ->
+         {token, put_token_in_session(conn, Base.decode64!(token))}
+ 
+       {:error, :enoent} ->
+         {nil, conn}
      end      
    end
  end
```

これで起動時にセッションを復活できるようになりました。

# ログアウト時にトークンを削除する

ログアウトをしてもトークンファイルが残り続けて、起動の度にログインされるので、ログアウト時にトークンファイルを削除するようにします。


```diff_elixir:lib/trarecord_web/user_auth.ex:L77
  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    user_token && Accounts.delete_user_session_token(user_token)
+   File.rm(Trarecord.token_path())

    if live_socket_id = get_session(conn, :live_socket_id) do
      TrarecordWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

    conn
    |> renew_session()
    |> delete_resp_cookie(@remember_me_cookie)
    |> redirect(to: ~p"/")
  end
```

実装が完了したのでコミットしておきます

```
git add .
git commit -m 'update session logic'
```

# 動作確認
以下の動作確認を行います
- 新規作成
- 再起動時のログイン保持
- ログアウト
- ログアウト後の再起動でログインされない

![b03fd6b91e1a559dd3d91f44004a5830.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/a2302b8c-fde0-c853-4037-95290cfb10d8.gif)

# テストの修正

セッション周りの改修でテストが通らなくなっているので修正していきます

## トークンファイルのパス

テスト実行前にtmpディレクトリを掘る処理追加

```diff_elixir:lib/trarecord/application.ex:L9
  def start(_type, _args) do
+   File.mkdir_p!(Trarecord.config_dir())
    ...
  end
```

## page_controller_test

未ログイン時はログインページにリダイレクトされるようになったので変更

```diff_elixir:test/trarecord_web/controllers/page_controller_test.exs
defmodule TrarecordWeb.PageControllerTest do
  use TrarecordWeb.ConnCase

  test "GET /", %{conn: conn} do
    conn = get(conn, ~p"/")
-   assert html_response(conn, 200) =~ "Peace of mind from prototype to production"    
+   assert html_response(conn, 302) =~ "/users/log_in"
  end
end
```

## user_session_controller_test

ログイン状態で`/`にアクセスすると設定ページにリダイレクトされるようになったので変更

```diff_elixir:test/trarecord_web/controllers/user_session_controller_test.exs
    test "logs the user in", %{conn: conn, user: user} do
      conn =
        post(conn, ~p"/users/log_in", %{
          "user" => %{"email" => user.email, "password" => valid_user_password()}
        })

      assert get_session(conn, :user_token)
      assert redirected_to(conn) == ~p"/"

      # Now do a logged in request and assert on the menu
      conn = get(conn, ~p"/")
-     response = html_response(conn, 200)
-     assert response =~ user.email
-     assert response =~ ~p"/users/settings"
-     assert response =~ ~p"/users/log_out"      
+     response = html_response(conn, 302)
+     assert response =~ "/users/settings"
    end
```

## user_confirmation_instructions_live_test

トークンファイルが残っていてテストがコケる場合があるので初期化処理追加

```diff_elixir:test/trarecord_web/live/user_confirmation_instructions_live_test.exs:L9
  setup do
+   File.rm(Trarecord.token_path())
    %{user: insert(:user)}
  end
```

## user_registration_live_test.exs

ログイン状態で`/`にアクセスすると設定ページにリダイレクトされるようになったので変更

```diff_elixir:test/trarecord_web/live/user_registration_live_test.exs:L39
   test "creates account and logs the user in", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/users/register")

      email = unique_user_email()
      form = form(lv, "#registration_form", user: params_for(:user_form_data, email: email))
      render_submit(form)
      conn = follow_trigger_action(form, conn)

      assert redirected_to(conn) == ~p"/"

      # Now do a logged in request and assert on the menu
      conn = get(conn, "/")
-     response = html_response(conn, 200)
-     assert response =~ email
-     assert response =~ "Settings"
-     assert response =~ "Log out"
+     response = html_response(conn, 302)
+     assert response =~ "/users/settings"
    end
```

## user_reset_password_live_test

トークンファイルが残っていてテストがコケる場合があるので初期化処理追加

```diff_elixir:test/trarecord_web/live/user_reset_password_live_test.exs
  setup do
+   File.rm(Trarecord.token_path())
    user = insert(:user)

    token =
      extract_user_token(fn url ->
        Accounts.deliver_user_reset_password_instructions(user, url)
      end)

    %{token: token, user: user}
  end

```

## user_auth_test

トークンファイルが残っていてテストがコケる場合があるので初期化処理追加

```diff_elixir:test/trarecord_web/user_auth_test.exs:L10
  setup %{conn: conn} do
    conn =
      conn
      |> Map.replace!(:secret_key_base, TrarecordWeb.Endpoint.config(:secret_key_base))
      |> init_test_session(%{})

+   File.rm(Trarecord.token_path())
    %{user: insert(:user), conn: conn}
  end

```

クッキー周りを使用したテストを削除します

```diff_elixir:test/trarecord_web/user_auth_test.exs:L86
  describe "fetch_current_user/2" do
    test "authenticates user from session", %{conn: conn, user: user} do
      user_token = Accounts.generate_user_session_token(user)
      conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([])
      assert conn.assigns.current_user.id == user.id
    end

-   test "authenticates user from cookies", %{conn: conn, user: user} do
-     logged_in_conn =
-       conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
-
-     user_token = logged_in_conn.cookies[@remember_me_cookie]
-     %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
-
-     conn =
-       conn
-       |> put_req_cookie(@remember_me_cookie, signed_token)
-       |> UserAuth.fetch_current_user([])
-
-     assert conn.assigns.current_user.id == user.id
-     assert get_session(conn, :user_token) == user_token
-
-     assert get_session(conn, :live_socket_id) ==
-              "users_sessions:#{Base.url_encode64(user_token)}"
-   end
-
-   test "does not authenticate if data is missing", %{conn: conn, user: user} do
-     _ = Accounts.generate_user_session_token(user)
-     conn = UserAuth.fetch_current_user(conn, [])
-     refute get_session(conn, :user_token)
-     refute conn.assigns.current_user
-   end
- end
```

テストの修正が完了したのでコミットします
```
git add .
git commit -m 'fix test'
```

# CI通過 & Merge
pushしてPRを作成します

```
git push origin feature/session_for_desktop
```

![スクリーンショット 2024-12-02 23.49.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/ad9eed5e-f7bd-0bc1-778e-b81212673202.png)

CIが無事通ったのでマージして完了です

# 最後に

セッション周りを改修して、アプリに近い挙動をするようになりました
次は起動時に開くWelcomeページとオンボーディングを実装していきます

本記事は以上になりますありがとうございました