remix-authを使ったアプリでE2Eテストを行うときにログイン状態を再現する方法

# はじめに

今回は、Remix Stacksと`remix-auth`を使ってアプリを開発している方向けに、ログイン状態を維持したテストを行う方法について、`remix-auth`のフローを紐解きながら解説していこうと思います。

# 既存のRemix Stackテンプレートにおけるログイン状態の維持の方法

**Remix Indie Stack**や**Remix Blues Stack**をテンプレートとしてアプリケーションを開発する場合、デフォルトで用意されている`createUserSession()`を使うことでログイン状態を再現しています。

https://github.com/remix-run/blues-stack/blob/main/app/session.server.ts

このメソッドでは、`Set-Cookie`ヘッダーに`sessionStoratge.commitSession()`の戻り値を設定することで、ログイン状態を維持しています。

```session.server.ts
export async function createUserSession({
  request,
  userId,
  remember,
  redirectTo,
}: {
  request: Request;
  userId: string;
  remember: boolean;
  redirectTo: string;
}) {
  const session = await getSession(request);
  session.set(USER_SESSION_KEY, userId);
  return redirect(redirectTo, {
    headers: {
      "Set-Cookie": await sessionStorage.commitSession(session, {
        maxAge: remember
          ? 60 * 60 * 24 * 7 // 7 days
          : undefined,
      }),
    },
  });
}
```

Cypressを使ったE2E テストにおいて、ログイン状態を維持した上でのテストを行うためには、この`Set-Cookie`ヘッダーに設定されているクッキーの値を取得する必要があります。

**Remix Blues Stack**では、`createUserSession()`を使ってレスポンスを取得し、そのレスポンスヘッダーから`Set-Cookie`ヘッダーの値を取得しています。

そして、`cy.setCookie()`を呼び出して`"__session"`をキーにして値を設定しています。

https://github.com/remix-run/blues-stack/blob/main/cypress/support/commands.ts

```create-user.ts
async function createAndLogin(email: string) {
  if (!email) {
    throw new Error("email required for login");
  }
  if (!email.endsWith("@example.com")) {
    throw new Error("All test emails must end in @example.com");
  }

  const user = await createUser(email, "myreallystrongpassword");

  const response = await createUserSession({
    request: new Request("test://test"),
    userId: user.id,
    remember: false,
    redirectTo: "/",
  });

  const cookieValue = response.headers.get("Set-Cookie");
  if (!cookieValue) {
    throw new Error("Cookie missing from createUserSession response");
  }
  const parsedCookie = parse(cookieValue);
  // we log it like this so our cypress command can parse it out and set it as
  // the cookie value.
  console.log(
    `
<cookie>
  ${parsedCookie.__session}
</cookie>
  `.trim()
  );
}
```

```commands.ts
function login({
  email = faker.internet.email(undefined, undefined, "example.com"),
}: {
  email?: string;
} = {}) {
  cy.then(() => ({ email })).as("user");
  cy.exec(
    `npx ts-node --require tsconfig-paths/register ./cypress/support/create-user.ts "${email}"`
  ).then(({ stdout }) => {
    const cookieValue = stdout
      .replace(/.*<cookie>(?<cookieValue>.*)<\/cookie>.*/s, "$<cookieValue>")
      .trim();
    cy.setCookie("__session", cookieValue);
  });
  return cy.get("@user");
}
```

# `remix-auth`におけるログイン状態の管理方法

それでは、`remix-auth`を使って認証を行なっている場合、E2E テストでログイン状態を維持するためにはどうすればいいのでしょうか？

https://github.com/sergiodxa/remix-auth

ざっくりとですが、`remix-auth`の状態遷移図を書いてみました。

:::note warn
本当におおまかに書いたに過ぎないので、ちゃんとしたフローを知りたい方は実際のリポジトリを参照していただくことをオススメします。
:::

```mermaid
stateDiagram-v2
 Authenticator --> インスタンス: 初期化
 インスタンス --> Strategy: use()を呼び出してStrategyを登録する
 インスタンス --> 認証: authenticate()を呼び出して認証を行う
 認証 --> Strategy: this.strategies[name]を呼び出してStrategyを取得する
 Strategy --> 認証2: authenticate()を呼び出して認証を行う
 認証2 --> 成功: success()を呼び出す
 認証2 --> 失敗: fail()を呼び出す
 成功 --> セッションを設定: session.set()を呼び出す
 セッションを設定 --> リダイレクト: commitSession()を呼び出してSet-Cookieヘッダーに設定する
 リダイレクト --> 認証2: 認証済みページに遷移する
 失敗 --> エラー: failure()を呼び出す
 エラー --> セッションを設定: session.flash()を呼び出す
 セッションを設定 --> リダイレクト: commitSession()を呼び出してSet-Cookieヘッダーに設定する
 リダイレクト --> 認証2: ログインページに遷移する
```

ログイン状態を維持するためには、遷移図における次の部分が大事になります。

```mermaid
stateDiagram-v2
  Strategy --> 認証: authenticate()を呼び出して認証を行う
  認証 --> 成功: success()を呼び出す
  成功 --> セッションを設定: session.set()を呼び出す
  セッションを設定 --> リダイレクト: commitSession()を呼び出してSet-Cookieヘッダーに設定する
  リダイレクト --> 認証: 認証済みページに遷移する
```

実際に、`session.set()`を呼び出している箇所は`Strategy`クラスの`success()`メソッドになります。

:::note warn
各ストラテジーは、この`Strategy`クラスを継承しているため、独自に`success()`や`failure()`はオーバーライドすることなく呼び出すことができるようになっています。実際に各ストラテジーは`authenticate()`メソッドのみをオーバーライドすることがほとんどです。
:::

https://github.com/sergiodxa/remix-auth/discussions/111

```strategy.ts
protected async success(
  user: User,
  request: Request,
  sessionStorage: SessionStorage,
  options: AuthenticateOptions
): Promise<User> {
  if (!options.successRedirect) return user;

  let session = await sessionStorage.getSession(
    request.headers.get("Cookie")
  );

  session.set(options.sessionKey, user);
  session.set(options.sessionStrategyKey, options.name ?? this.name);
  throw redirect(options.successRedirect, {
    headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
  });
}
```

`authenticate()`を呼び出して認証を行う際に、まず`Authenticator`クラスの`authenticate()`メソッドが呼び出されます。

```authenticator.ts
authenticate(
  strategy: string,
  request: Request,
  options: Pick<
    AuthenticateOptions,
    "successRedirect" | "failureRedirect" | "throwOnError" | "context"
  > = {}
): Promise<User> {
  const strategyObj = this.strategies.get(strategy);
  if (!strategyObj) throw new Error(`Strategy ${strategy} not found.`);
  return strategyObj.authenticate(
    new Request(request.url, request),
    this.sessionStorage,
    {
      throwOnError: this.throwOnError,
      ...options,
      name: strategy,
      sessionKey: this.sessionKey,
      sessionErrorKey: this.sessionErrorKey,
      sessionStrategyKey: this.sessionStrategyKey,
    }
  );
}
```

このメソッドでは、第三引数としてオプションを渡しています。
このオプションの`name`として`strategy`を渡しています。
そして、`sessionStrategyKey`として、`sessionStrategyKey`を渡していますので、`Authenticator`の初期化時にオプションを設定していない場合は、デフォルトの`strategy`が設定されることになります。

```authenticator.ts
export class Authenticator<User = unknown> {
  ...
  constructor(
    private sessionStorage: SessionStorage,
    options: AuthenticatorOptions = {}
  ) {
    this.sessionKey = options.sessionKey || "user";
    this.sessionErrorKey = options.sessionErrorKey || "auth:error";
    this.sessionStrategyKey = options.sessionStrategyKey || "strategy";
    this.throwOnError = options.throwOnError ?? false;
  }
  ...
}
```

これにより、`success()`メソッドが呼び出された際に`sessionStrategyKey`(`"strategy"`)に対して設定される値は各ストラテジーを登録したときに渡した値（例: `"auth0"`や`"user-pass"`など）ということになります。

そして、上記のコンストラクタの内容から、初期化時にオプションを設定していない場合は、`sessionKey`の値として`"user"`が設定されることになります。

各キーのデフォルト値と設定される値は以下の表の通りです。

| キーの名前         | デフォルト値 | キーに設定される値 |
| ------------------ | ------------ | ------------------ |
| sessionKey         | `"user"`     | `User`型の値           |
| sessionStrategyKey | `"strategy"` | 登録時に渡したストラテジーに対するキー |

この`User`型は、`Authenticator`の初期化時にジェネリクスで指定した型になります。

```auth.server.ts
export const authenticator = new Authenticator<User>(sessionStorage);
```

まとめると、`remix-auth`を使っているアプリケーションのE2Eテストを行う際に、ログイン状態を維持した上でのテストを行いたい場合は、以下の部分をテスト前に模倣すれば良いということになります。

```ts
// strategy.ts
session.set(options.sessionKey, user);
session.set(options.sessionStrategyKey, options.name ?? this.name);
```

# remix-authを前提にして`createUserSession()`を修正する

なので、先ほどの**create-user.ts**における`createUserSession()`関数の実装と呼び出しを以下のように変更します。

:::note warn
ここでは、`remix-auth-auth0`をストラテジーとして採用していることを前提とします。
:::

https://github.com/danestves/remix-auth-auth0

```ts
// auth.server.ts
export type User = {
  id: string;
};
export const authenticator = new Authenticator<User>(sessionStorage);

const auth0Strategy = new Auth0Strategy<User>(
  {...},
  async ({}) => {
    ...
    return {
      id: user.id,
    }
  }
);
authenticator.use(auth0Strategy, "auth0");

/** ------------------------------ */

// session.server.ts
...
import { sessionStorage } from "~/session.server";
...

const sessionKey = "user";
const sessionStrategyKey = "strategy";

...

export async function createUserSession({
  request,
  user,
  strategy,
  redirectTo,
}: {
  request: Request;
  user: User;
  strategy: string;
  redirectTo: string;
}) {
  let session = await sessionStorage.getSession(request.headers.get("Cookie"));

  session.set(sessionKey, user);
  session.set(sessionStrategyKey, strategy);
  return redirect(redirectTo, {
    headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
  });
}

/** ------------------------------ */

// create-user.ts
...
async function createAndLogin(id: string) {
  ...
  const response = await createUserSession({
    request: new Request("test://test"),
    user: {
      id: user.id,
    },
    "auth0",
    redirectTo: "/",
  });

  const cookieValue = response.headers.get("Set-Cookie");
  if (!cookieValue) {
    throw new Error("Cookie missing from createUserSession response");
  }
  const parsedCookie = parse(cookieValue);
  // we log it like this so our cypress command can parse it out and set it as
  // the cookie value.
  console.log(
    `
<cookie>
  ${parsedCookie.__session}
</cookie>
  `.trim()
  );
}

createAndLogin(process.argv[2]);

/** ------------------------------ */

// commands.ts
import { faker } from "@faker-js/faker";

declare global {
  namespace Cypress {
    interface Chainable {
      /**
       * Logs in with a random user. Yields the user and adds an alias to the user
       *
       * @returns {typeof login}
       * @memberof Chainable
       * @example
       *    cy.login()
       * @example
       *    cy.login({ id: 'xxx' })
       */
      login: typeof login;
    }
  }
}
...
function login({
  id = faker.datatype.uuid(),
}: {
  id?: string;
} = {}) {
  cy.then(() => ({ id })).as("user");
  cy.exec(
    `npx ts-node --require tsconfig-paths/register ./cypress/support/create-user.ts "${id}"`
  ).then(({ stdout }) => {
    const cookieValue = stdout
      .replace(/.*<cookie>(?<cookieValue>.*)<\/cookie>.*/s, "$<cookieValue>")
      .trim();
    cy.setCookie("__session", cookieValue);
  });
  return cy.get("@user");
}
```

あとは、`login`コマンドを使ってログイン状態を維持したテストを書けば良いということになります。

```ts
...
cy.login();
...
```

# まとめ

Remixを紹介するときReactの内容が前提となっていますが、コトの本質はWeb標準にあります。
なので、`Set-Cookie`などを使って自前でセッションを管理するといった方法がReact関係なく実装されています。

Remixを学べばWeb標準を学ぶことにつながるので、ぜひRemixを一度体験してみることをオススメします。