2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-03-13

はじめに

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

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

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

このメソッドでは、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"をキーにして値を設定しています。

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 テストでログイン状態を維持するためにはどうすればいいのでしょうか?

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

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

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

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

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

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テストを行う際に、ログイン状態を維持した上でのテストを行いたい場合は、以下の部分をテスト前に模倣すれば良いということになります。

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

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

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

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

// 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コマンドを使ってログイン状態を維持したテストを書けば良いということになります。

...
cy.login();
...

まとめ

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

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

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?