Auth0 React SDK と Cypress で e2e テストを考えてみる

2020.07.12

SPAで誰もが見れるルートと保護されたルートを分離させるのはよくある設計です.
また保護されたルート配下ではログインしたユーザの情報を利用するのもよくある設計です.

コンポーネントレベルでの結合テストであればモックを利用することで簡単にテストをかけます. ですがe2eテストとなると実際にログインを経てセッション情報などを保持した状態でのテストが求められ, 前提の難易度が上がります.
この記事ではAuth0を利用した認証を含むe2eテストをCypressで行う場合に起こり得る問題とワークアラウンドを書いていきます.

環境

主に下記のライブラリとバージョンで動かしています.

"dependencies": {
  "@auth0/auth0-react": "^1.0.0",
  "@testing-library/cypress": "^6.0.0",
  "cypress": "^4.9.0",
  "puppeteer": "^5.0.0",
  "react": "^16.13.1",
  "react-dom": "^16.13.1",
  "react-router-dom": "^5.2.0"
}

起こり得る問題について

React SDKを利用した場合, つまりSPA SDKを利用した場合のe2eテストを実装する場合の問題点をまずは把握しましょう.
Auth0で認証を行う場合にはUniversal Loginを経由してユーザを認証してトークンの発行やセッションの作成を行います. そのため認証のためにリダイレクトが発生します.
通常であればUniversal LoginはContents Security Policyにより保護されています. そのためCypressでUniversal Loginにリダイレクトをして認証情報を入力する...といったことはできません.

なので下記のテストコードを実行しようとすると「because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'none'".」といったエラーが発生します.

describe('cypress working well', () => {
  it('working well', () => {
    {
      // loginボタンをクリックしてloginWithRedirectを発火
      // Universal Loginにリダイレクト
      cy.visit('/');
      cy.findByText(/login/i).click();
    }
  });
});

CSPが問題なので保護を外せばうまく動きます.
Universal LoginをClassicにした上でTenant SettingsのAdvancedにある「Disable clickjacking protection for Classic Universal Login」を有効にすれば先ほどのエラーが回避できます.
なので設定をした上で下記のようなテストコードを書けばうまく動かせるでしょう.

describe('cypress working well', () => {
  it('working well', () => {
    {
      cy.visit('/');
      cy.findByText(/login/i).click();

      // universal loginでユーザ認証をかける
      cy.findByPlaceholderText('yours@example.com')
        .click()
        .type('yours@example.com');
      cy.findByPlaceholderText('your password').click().type(`your password`);
      cy.get('.auth0-lock-submit').click();
    }
  });
});

テストのためにテナントのセキュリティに関する設定を変更するのはあまり好ましくないでしょう.
また, Cypressは管理していないサーバへのアクセスや操作をアンチパターンとしています. API経由でのリクエストが必要な場合は常にcy.request() を利用するのがベストです.
なので別の回避策を考えましょう.

回避策について

ここからは私が考えるワークアラウンドについて書いていきます.

まずAuth0 React SDKを利用した場合のログインという状態を考えてみましょう. 大雑把にいうとトークンを保持しており, 適切な保管場所, メモリやlocalStorageに保持している状態がログイン状態です.
つまりUniversal Loginを経由しなくてもトークンを保持して入ればログイン状態と見なすことができます.
今回はこのログイン状態を活用してCypressでのe2eテストケースを「非ログイン状態」と「ログイン状態」で大分すること, リダイレクトでエラーが発生することを回避します.

次はどのようにトークンやCookiesを発行するかを考えます. 今回はPuppeteerを利用してトークンとCookiesを発行します.
つまりUniversal Login経由での処理をCypressで行わずにPuppeteerを利用して行います. Puppeteerで発行したトークンやCookiesをCypress側に受け渡して差し込むことでテストを開始する前にログイン状態を作り上げることができます.

作業イメージは下記の通りです. 実際に内容をみていきましょう.

  1. Auth0Providerの設定変更
  2. Puppeteerを利用してトークンの発行
  3. CypressでlocalStorageにトークンを挿入する

Auth0Providerの設定変更

まずはAuth0Providerの設定を変更し, トークンの保存先をメモリからlocalStorageに変更します.
これはトークンを発行した後に差し込む必要があるために変更をします.

cacheLocationの値をデフォルトのmemoryからlocalstorageに切り替えます.
今回はハードコーディングしています.
実際はテスト環境はlocalstorage, それ以外はmemoryに切り替えられるようにするべきでしょう. トークンがlocalstorageに入っていることはセキュリティ的に好ましくないからです.

src/index.jsx

ReactDOM.render(
  <Auth0Provider
    cacheLocation="localstorage"
    domain="YOUR TENANT DOMAIN"
    clientId="YOUR CLIENT ID"
    redirectUri={window.location.origin}
    onRedirectCallback={onRedirectCallback}>
    <App />
  </Auth0Provider>,
  document.querySelector('#root')
);

Puppeteerを利用してトークンの発行

次にPuppeteerを利用してトークンを発行できるように, Cypressのpluginsにコードを書いていきます.
テストコードにpuppeteerのコードを直接書いても動かないこともありplugins配下に書いています.
コードの全体像をみてから細部をみていきます.

/cypress/plugins/auth0.js

const puppeteer = require('puppeteer');

module.exports = async function Login(options = {}) {
  const browser = await puppeteer.launch({
    headless: false,
    args: options.args || ['--no-sandbox', '--disable-setuid-sandbox'],
  });
  const page = await browser.newPage();

  await page.goto('http://localhost:3000');
  await page.waitForSelector('button[name="login"]');
  await page.click('button[name="login"]');

  await page.waitForSelector('input[name="username"]');
  await page.type('input[name="username"]', 'email@example.com');
  await page.waitForSelector('input[name="password"]');
  await page.type('input[name="password"]', 'password');

  await page.waitForSelector('button[name="action"]');
  await page.click('button[name="action"]');
  await page.waitFor(3000);

  const storage = await page.evaluate(() => {
    return Object.keys(localStorage).map(key => {
      return {
        name: key,
        value: localStorage.getItem(key),
      };
    });
  });
  const cookies = await page.cookies();
  return {
    storage,
    cookies,
  };
};

まず冒頭の部分でpuppeteerを起動させます.

const puppeteer = require('puppeteer');

module.exports = async function Login(options = {}) {
  const browser = await puppeteer.launch({
    headless: false,
    args: options.args || ['--no-sandbox', '--disable-setuid-sandbox'],
  });
  const page = await browser.newPage();
  // ...
};

次にこの部分ではアプリケーションに遷移してログインボタンを押下します.
これでUniversal Loginにリダイレクトされます.

module.exports = async function Login(options = {}) {
  // ...
  await page.goto('http://localhost:3000');
  await page.waitForSelector('button[name="login"]');
  await page.click('button[name="login"]');
  // ...
};

Universal Loginにリダイレクトした後の処理になります. つまり実際にメールアドレスやパスワードを入力して認証を行います.

module.exports = async function Login(options = {}) {
  // ...
  await page.waitForSelector('input[name="username"]');
  await page.type('input[name="username"]', 'email@example.com');
  await page.waitForSelector('input[name="password"]');
  await page.type('input[name="password"]', 'password');

  await page.waitForSelector('button[name="action"]');
  await page.click('button[name="action"]');
  await page.waitFor(3000);
  // ...
};

最後にPuppeteerで取得したアクセストークンとCookiesを全て返します.

module.exports = async function Login(options = {}) {
  // ...
  const storage = await page.evaluate(() => {
    return Object.keys(localStorage).map(key => {
      return {
        name: key,
        value: localStorage.getItem(key),
      };
    });
  });
  const cookies = await page.cookies();
  return {
    storage,
    cookies,
  };
};

最後にPluginsに登録しましょう.

cypress/plugins/index.js

const Login = require('./auth0');

/**
 * @type {Cypress.PluginConfig}
 */
module.exports = (on, config) => {
  on('task', {
    LoginPuppeteer: Login,
  });
};

CypressでlocalStorageにトークンを挿入する

最後にテストコードで先ほどまで書いたpluginsをタスクとして実行し, localStorageとCookiesに値を挿入します.

/cypress/e2e/login.js

describe('cypress working well', () => {
  it('working well', () => {
    {
      cy.visit('/');
      cy.wait(5000);

      cy.task('LoginPuppeteer', {}).then(res => {
        console.log(res);
        res.cookies.forEach(cookie => {
          cy.setCookie(cookie.name, cookie.value);
        });

        res.storage.forEach(item => {
          localStorage.setItem(item.name, item.value);
        });
      });

      cy.visit('/');
      cy.visit('/secure');
    }
  });
});

ここで動作を確認してみましょう.
一番最初のアクセス時には匿名ユーザのために画面にはユーザ情報が表示されていません.

img

Puppeteerでの処理が終わった後に, トークンなどをうけとってから再度アクセスすると下記のように情報が表示されます.

img

最後に保護されたルートである/secureにアクセスしても問題なく表示されることも確認できますね.

img

さいごに

できないことに対してトレードオフを考えて回避策を作るのは面白いですね.
まだまだ改良の余地があるとは思いますが, お役に立てたら幸いです. また何かございましたらご一報ください.

参考