はじめに
今回は、Remix Stacksとremix-auth
を使ってアプリを開発している方向けに、ログイン状態を維持したテストを行う方法について、remix-auth
のフローを紐解きながら解説していこうと思います。
既存のRemix Stackテンプレートにおけるログイン状態の維持の方法
Remix Indie StackやRemix Blues Stackをテンプレートとしてアプリケーションを開発する場合、デフォルトで用意されているcreateUserSession()
を使うことでログイン状態を再現しています。
このメソッドでは、Set-Cookie
ヘッダーにsessionStoratge.commitSession()
の戻り値を設定することで、ログイン状態を維持しています。
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"
をキーにして値を設定しています。
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()
);
}
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()
メソッドのみをオーバーライドすることがほとんどです。
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()
メソッドが呼び出されます。
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
が設定されることになります。
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
の初期化時にジェネリクスで指定した型になります。
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を一度体験してみることをオススメします。