> ## Documentation Index
> Fetch the complete documentation index at: https://auth0.com/llms.txt
> Use this file to discover all available pages before exploring further.

# ユーザーをリダイレクトするアクションのユニットテスト

> リスクの高いユーザーを本人確認のためにリダイレクトし、処理を続行する前に返されたトークンを検証する Password Reset Post Challenge アクションのユニットテストを、JavaScript と TypeScript の Jest、Mocha、Node.js Test Runner で実装します。

<h2 id="project">
  プロジェクト
</h2>

各サンプルでは、アクションのソースコードを `src/` に、そのユニットテストを `test/` に分けて配置しており、JavaScript プロジェクトと TypeScript プロジェクトのいずれも同じ構造になっています。

<Tabs>
  <Tab title="JavaScript">
    <Tree>
      <Tree.Folder name="/" defaultOpen>
        <Tree.Folder name="src" />

        <Tree.Folder name="test" />
      </Tree.Folder>
    </Tree>
  </Tab>

  <Tab title="TypeScript">
    <Tree>
      <Tree.Folder name="/" defaultOpen>
        <Tree.Folder name="src" />

        <Tree.Folder name="test">
          <Tree.Folder name="test-utils" />
        </Tree.Folder>
      </Tree.Folder>
    </Tree>
  </Tab>
</Tabs>

<h2 id="action">
  アクション
</h2>

以下の Password Reset Post Challenge アクションは、リスクスコアがしきい値を超えた場合にユーザーを外部の本人確認ページへリダイレクトし、返されたセッショントークンと検証結果を検証したうえで、パスワードリセットの続行を許可します。

<Tabs>
  <Tab title="JavaScript">
    ```js title="mock-redirects.js" theme={null}
    /** @import {Event, PasswordResetPostChallengeAPI} from "@auth0/actions/password-reset-post-challenge/v1" */

    const VERIFICATION_URL = 'https://verify.example.com/identity';
    const RISK_SCORE_THRESHOLD = 50;

    /**
    * PasswordResetPostChallenge フローの実行中に呼び出されるハンドラーです。
    * 補足的なリスク評価スコアが設定したしきい値を超えた場合、
    * ユーザーを外部の本人確認ページにリダイレクトします。
    *
    * @param {Event} event - ユーザーとパスワードリセット取引に関する詳細。
    * @param {PasswordResetPostChallengeAPI} api - パスワードリセットの動作を変更するために使用できるメソッドを提供するインターフェース。
    */
    exports.onExecutePostChallenge = async (event, api) => {
      const riskScore = event.authentication.riskAssessment?.supplemental?.akamai?.akamaiUserRisk?.score ?? 0;

      if (riskScore < RISK_SCORE_THRESHOLD) {
        return;
      }

      const sessionToken = api.redirect.encodeToken({
        secret: event.secrets.REDIRECT_SECRET,
        payload: { userId: event.user.user_id },
      });

      api.redirect.sendUserTo(VERIFICATION_URL, {
        query: { session_token: sessionToken },
      });
    };

    /**
    * onExecutePostChallenge によるリダイレクトからユーザーが戻ったときに呼び出されるハンドラーです。
    * パスワードリセットフローの続行を許可する前に、セッショントークンを検証します。
    *
    * @param {Event} event - ユーザーとパスワードリセット取引に関する詳細。
    * @param {PasswordResetPostChallengeAPI} api - パスワードリセットの動作を変更するために使用できるメソッドを提供するインターフェース。
    */
    exports.onContinuePostChallenge = async (event, api) => {
      let payload;

      try {
        payload = api.redirect.validateToken({ secret: event.secrets.REDIRECT_SECRET });
      } catch (err) {
        api.access.deny(`Unable to verify redirect response: ${err.message}`);
        return;
      }

      if (payload.userId !== event.user.user_id) {
        api.access.deny('Verification response does not match the current user.');
        return;
      }

      if (event.request.query.verified !== 'true') {
        api.access.deny('Identity verification was not completed.');
      }
    };

    ```
  </Tab>

  <Tab title="TypeScript">
    ```ts title="mock-redirects.ts" theme={null}
    import type { Event, PasswordResetPostChallengeAPI } from '@auth0/actions/password-reset-post-challenge/v1';

    const VERIFICATION_URL = 'https://verify.example.com/identity';
    const RISK_SCORE_THRESHOLD = 50;

    /**
    * PasswordResetPostChallenge フローの実行時に呼び出されるハンドラーです。
    * 補足的なリスク評価スコアが設定したしきい値を超えた場合、ユーザーを外部の本人確認ページに
    * リダイレクトします。
    *
    * @param {Event} event - ユーザーとパスワードリセット取引に関する詳細。
    * @param {PasswordResetPostChallengeAPI} api - パスワードリセットの動作を変更するためのメソッドを提供するインターフェース。
    */
    exports.onExecutePostChallenge = async (event: Event, api: PasswordResetPostChallengeAPI) => {
      const riskScore = event.authentication.riskAssessment?.supplemental?.akamai?.akamaiUserRisk?.score ?? 0;

      if (riskScore < RISK_SCORE_THRESHOLD) {
        return;
      }

      const sessionToken = api.redirect.encodeToken({
        secret: event.secrets.REDIRECT_SECRET,
        payload: { userId: event.user.user_id },
      });

      api.redirect.sendUserTo(VERIFICATION_URL, {
        query: { session_token: sessionToken },
      });
    };

    /**
    * onExecutePostChallenge によるリダイレクトからユーザーが戻ったときに呼び出される
    * ハンドラーです。パスワードリセット フローの続行を許可する前に、セッション トークンを
    * 検証します。
    *
    * @param {Event} event - ユーザーとパスワードリセット取引に関する詳細。
    * @param {PasswordResetPostChallengeAPI} api - パスワードリセットの動作を変更するためのメソッドを提供するインターフェース。
    */
    exports.onContinuePostChallenge = async (event: Event, api: PasswordResetPostChallengeAPI) => {
      let payload;

      try {
        payload = api.redirect.validateToken({ secret: event.secrets.REDIRECT_SECRET });
      } catch (err) {
        api.access.deny(`Unable to verify redirect response: ${(err as Error).message}`);
        return;
      }

      if (payload.userId !== event.user.user_id) {
        api.access.deny('Verification response does not match the current user.');
        return;
      }

      if (event.request.query.verified !== 'true') {
        api.access.deny('Identity verification was not completed.');
      }
    };

    ```
  </Tab>
</Tabs>

<h2 id="unit-test">
  ユニットテスト
</h2>

ユニットテストでは、`event` オブジェクトと `api` オブジェクト、およびリダイレクトトークンのエンコードと検証をモックし、リスクのしきい値を超えた場合にのみリダイレクトが実行されること、また無効なトークン、ユーザーの不一致、検証の未完了といった場合にはアクセスが拒否されることを確認します。

<AccordionGroup>
  <Accordion title="Jest">
    <Tabs>
      <Tab title="JavaScript">
        ```js title="mock-redirects.spec.js" theme={null}
        const { getDefaultArguments, loadAction } = require('@auth0/actions/password-reset-post-challenge/v1/test');
        const path = require('path');

        const DIRNAME = path.dirname('../');
        const ACTION_PATH = path.resolve(DIRNAME, './src/mock-redirects.js');
        const VERIFICATION_URL = 'https://verify.example.com/identity';

        describe('onExecutePostChallenge', () => {
          let loader;
          let event;
          let api;

          beforeEach(async () => {
            jest.resetAllMocks();
            loader = await loadAction(ACTION_PATH);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            jest.spyOn(api.redirect, 'encodeToken').mockReturnValue('signed.jwt.token');
            jest.spyOn(api.redirect, 'sendUserTo');
          });

          afterEach(() => {
            jest.resetAllMocks();
          });

          it('does not redirect when the risk score is below the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 10;

            await loader.execute('onExecutePostChallenge', event, api);

            expect(api.redirect.encodeToken).not.toHaveBeenCalled();
            expect(api.redirect.sendUserTo).not.toHaveBeenCalled();
          });

          it('redirects to the identity verification page when the risk score exceeds the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 80;

            await loader.execute('onExecutePostChallenge', event, api);

            expect(api.redirect.encodeToken).toHaveBeenCalledWith({
              secret: 'test-secret',
              payload: { userId: event.user.user_id },
            });
            expect(api.redirect.sendUserTo).toHaveBeenCalledWith(VERIFICATION_URL, {
              query: { session_token: 'signed.jwt.token' },
            });
          });
        });

        describe('onContinuePostChallenge', () => {
          let loader;
          let event;
          let api;

          beforeEach(async () => {
            jest.resetAllMocks();
            loader = await loadAction(ACTION_PATH);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            jest.spyOn(api.access, 'deny');
          });

          afterEach(() => {
            jest.resetAllMocks();
          });

          it('denies access when the redirect token cannot be validated', async () => {
            jest.spyOn(api.redirect, 'validateToken').mockImplementation(() => {
              throw new Error('invalid signature');
            });

            await loader.execute('onContinuePostChallenge', event, api);

            expect(api.redirect.validateToken).toHaveBeenCalledWith({ secret: 'test-secret' });
            expect(api.access.deny).toHaveBeenCalledWith('Unable to verify redirect response: invalid signature');
          });

          it('denies access when the token belongs to a different user', async () => {
            jest.spyOn(api.redirect, 'validateToken').mockReturnValue({ userId: 'someone-else' });

            await loader.execute('onContinuePostChallenge', event, api);

            expect(api.access.deny).toHaveBeenCalledWith('Verification response does not match the current user.');
          });

          it('denies access when the verification was not completed', async () => {
            jest.spyOn(api.redirect, 'validateToken').mockReturnValue({ userId: event.user.user_id });
            event.request.query.verified = 'false';

            await loader.execute('onContinuePostChallenge', event, api);

            expect(api.access.deny).toHaveBeenCalledWith('Identity verification was not completed.');
          });

          it('allows the flow to continue when verification succeeded', async () => {
            jest.spyOn(api.redirect, 'validateToken').mockReturnValue({ userId: event.user.user_id });
            event.request.query.verified = 'true';

            await loader.execute('onContinuePostChallenge', event, api);

            expect(api.access.deny).not.toHaveBeenCalled();
          });
        });

        ```

        テスト用の依存関係を `package.json` に追加します。

        ```json title="package.json" theme={null}
        {
          "name": "actions-npm-example-js-jest",
          "version": "1.0.0",
          "description": "Auth0 Actions unit testing example using Jest",
          "license": "MIT",
          "author": "Auth0",
          "type": "commonjs",
          "main": "module-usage.js",
          "scripts": {
            "test": "jest"
          },
          "devDependencies": {
            "@auth0/actions": "^0.33.0",
            "jest": "^30.4.2"
          },
          "jest": {
            "testEnvironment": "node"
          }
        }

        ```

        `actions:` インポートエイリアスが `src/` に解決されるように `jsconfig.json` を設定します。

        ```json title="jsconfig.json" theme={null}
        {
          "compilerOptions": {
            "target": "ES2020",
            "module": "commonjs",
            "checkJs": false,
            "baseUrl": ".",
            "paths": {
              "actions:*": [
                "src/*"
              ]
            }
          },
          "include": [
            "src/**/*.js"
          ]
        }

        ```
      </Tab>

      <Tab title="TypeScript">
        ```ts title="mock-redirects.test.ts" theme={null}
        const { getDefaultArguments, loadAction } = require('@auth0/actions/password-reset-post-challenge/v1/test');
        const path = require('path');
        const { compileActionModules } = require('./test-utils/load-compiled-action');

        const DIRNAME = path.dirname('../');
        const ACTION_PATH = path.resolve(DIRNAME, './src/mock-redirects.ts');
        const VERIFICATION_URL = 'https://verify.example.com/identity';

        describe('onExecutePostChallenge', () => {
          let loader: any;
          let event: any;
          let api: any;

          beforeEach(async () => {
            jest.resetAllMocks();
            const { compiledActionPath } = compileActionModules(ACTION_PATH);
            loader = await loadAction(compiledActionPath);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            jest.spyOn(api.redirect, 'encodeToken').mockReturnValue('signed.jwt.token');
            jest.spyOn(api.redirect, 'sendUserTo');
          });

          afterEach(() => {
            jest.resetAllMocks();
          });

          it('does not redirect when the risk score is below the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 10;

            await loader.execute('onExecutePostChallenge', event, api);

            expect(api.redirect.encodeToken).not.toHaveBeenCalled();
            expect(api.redirect.sendUserTo).not.toHaveBeenCalled();
          });

          it('redirects to the identity verification page when the risk score exceeds the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 80;

            await loader.execute('onExecutePostChallenge', event, api);

            expect(api.redirect.encodeToken).toHaveBeenCalledWith({
              secret: 'test-secret',
              payload: { userId: event.user.user_id },
            });
            expect(api.redirect.sendUserTo).toHaveBeenCalledWith(VERIFICATION_URL, {
              query: { session_token: 'signed.jwt.token' },
            });
          });
        });

        describe('onContinuePostChallenge', () => {
          let loader: any;
          let event: any;
          let api: any;

          beforeEach(async () => {
            jest.resetAllMocks();
            const { compiledActionPath } = compileActionModules(ACTION_PATH);
            loader = await loadAction(compiledActionPath);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            jest.spyOn(api.access, 'deny');
          });

          afterEach(() => {
            jest.resetAllMocks();
          });

          it('denies access when the redirect token cannot be validated', async () => {
            jest.spyOn(api.redirect, 'validateToken').mockImplementation(() => {
              throw new Error('invalid signature');
            });

            await loader.execute('onContinuePostChallenge', event, api);

            expect(api.redirect.validateToken).toHaveBeenCalledWith({ secret: 'test-secret' });
            expect(api.access.deny).toHaveBeenCalledWith('Unable to verify redirect response: invalid signature');
          });

          it('denies access when the token belongs to a different user', async () => {
            jest.spyOn(api.redirect, 'validateToken').mockReturnValue({ userId: 'someone-else' });

            await loader.execute('onContinuePostChallenge', event, api);

            expect(api.access.deny).toHaveBeenCalledWith('Verification response does not match the current user.');
          });

          it('denies access when the verification was not completed', async () => {
            jest.spyOn(api.redirect, 'validateToken').mockReturnValue({ userId: event.user.user_id });
            event.request.query.verified = 'false';

            await loader.execute('onContinuePostChallenge', event, api);

            expect(api.access.deny).toHaveBeenCalledWith('Identity verification was not completed.');
          });

          it('allows the flow to continue when verification succeeded', async () => {
            jest.spyOn(api.redirect, 'validateToken').mockReturnValue({ userId: event.user.user_id });
            event.request.query.verified = 'true';

            await loader.execute('onContinuePostChallenge', event, api);

            expect(api.access.deny).not.toHaveBeenCalled();
          });
        });

        ```

        次のテストヘルパーは、実行時に読み込めるように TypeScript のアクションを一時ファイルにコンパイルします。

        ```ts title="load-compiled-action.ts" theme={null}
        import * as fs from 'fs';
        import * as os from 'os';
        import * as path from 'path';
        import * as ts from 'typescript';

        export interface ModuleToCompile {
          name: string;
          filename: string;
        }

        function transpileToTemp(sourcePath: string): string {
          const source = fs.readFileSync(sourcePath, 'utf8');
          const { outputText } = ts.transpileModule(source, {
            compilerOptions: {
              module: ts.ModuleKind.CommonJS,
              target: ts.ScriptTarget.ES2020,
              esModuleInterop: true,
            },
          });

          const tempPath = path.join(
            os.tmpdir(),
            `${path.basename(sourcePath, path.extname(sourcePath))}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
          );
          fs.writeFileSync(tempPath, outputText);
          return tempPath;
        }

        /**
         * loadAction()（@auth0/actions/*\/test 提供）は対象ファイルをディスクから読み取り、
         * vm.compileFunction 経由で実行するため、ts-node や Vitest 自体の TS 変換を
         * 一切経由しません。したがって、アクションのソース（および actions: で登録された
         * モジュール）は、あらかじめディスク上でプレーンな JS にトランスパイルしておく必要があります。
         */
        export function compileActionModules(actionPath: string, modules: ModuleToCompile[] = []) {
          const compiledActionPath = transpileToTemp(actionPath);
          const compiledModules = modules.map((m) => ({
            name: m.name,
            filename: transpileToTemp(m.filename),
          }));

          return { compiledActionPath, compiledModules };
        }

        ```

        テスト用の依存関係を `package.json` に追加します。

        ```json title="package.json" theme={null}
        {
          "name": "actions-npm-example-ts-jest",
          "version": "1.0.0",
          "description": "Auth0 Actions unit testing example using Jest and TypeScript",
          "main": "example.ts",
          "scripts": {
            "test": "jest"
          },
          "author": "Auth0",
          "license": "MIT",
          "devDependencies": {
            "@auth0/actions": "^0.33.0",
            "@types/jest": "^29.5.12",
            "@types/node": "22.14.0",
            "jest": "^29.7.0",
            "ts-jest": "^29.1.2",
            "typescript": "^5.9.2"
          }
        }

        ```

        `jest.config.js` で TypeScript をコンパイルするように Jest を設定します。

        ```js title="jest.config.js" theme={null}
        module.exports = {
          preset: 'ts-jest',
          testEnvironment: 'node',
        };
        ```

        `actions:` インポートエイリアスが `src/` に解決されるように `tsconfig.json` を設定します。

        ```json title="tsconfig.json" theme={null}
        {
          "compilerOptions": {
            "target": "ES2020",
            "module": "NodeNext",
            "moduleResolution": "nodenext",
            "esModuleInterop": true,
            "allowSyntheticDefaultImports": true,
            "strict": true,
            "outDir": "dist",
            "declaration": true,
            "sourceMap": true,
            "allowJs": true,
            "checkJs": false,
            "resolveJsonModule": true,
            "skipLibCheck": true,
            "forceConsistentCasingInFileNames": true,
            "isolatedModules": true,
            "noEmit": true,
            "paths": {
              "actions:*": [
                "./src/*"
              ]
            }
          },
          "exclude": [
            "node_modules",
            "dist"
          ],
          "include": [
            "**/*.ts"
          ],
          "ts-node": {
            "transpileOnly": true
          }
        }

        ```
      </Tab>
    </Tabs>
  </Accordion>

  <Accordion title="Mocha">
    <Tabs>
      <Tab title="JavaScript">
        ```js title="mock-redirects.spec.js" theme={null}
        const { expect } = require('chai');
        const sinon = require('sinon');
        const { getDefaultArguments, loadAction } = require('@auth0/actions/password-reset-post-challenge/v1/test');
        const path = require('path');

        const DIRNAME = path.dirname('../');
        const ACTION_PATH = path.resolve(DIRNAME, './src/mock-redirects.js');
        const VERIFICATION_URL = 'https://verify.example.com/identity';

        describe('onExecutePostChallenge', () => {
          let loader;
          let event;
          let api;

          beforeEach(async () => {
            loader = await loadAction(ACTION_PATH);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            sinon.stub(api.redirect, 'encodeToken').returns('signed.jwt.token');
            sinon.spy(api.redirect, 'sendUserTo');
          });

          afterEach(() => {
            sinon.restore();
          });

          it('does not redirect when the risk score is below the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 10;

            await loader.execute('onExecutePostChallenge', event, api);

            sinon.assert.notCalled(api.redirect.encodeToken);
            sinon.assert.notCalled(api.redirect.sendUserTo);
          });

          it('redirects to the identity verification page when the risk score exceeds the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 80;

            await loader.execute('onExecutePostChallenge', event, api);

            sinon.assert.calledWith(api.redirect.encodeToken, {
              secret: 'test-secret',
              payload: { userId: event.user.user_id },
            });
            sinon.assert.calledWith(api.redirect.sendUserTo, VERIFICATION_URL, {
              query: { session_token: 'signed.jwt.token' },
            });
          });
        });

        describe('onContinuePostChallenge', () => {
          let loader;
          let event;
          let api;

          beforeEach(async () => {
            loader = await loadAction(ACTION_PATH);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            sinon.spy(api.access, 'deny');
          });

          afterEach(() => {
            sinon.restore();
          });

          it('denies access when the redirect token cannot be validated', async () => {
            sinon.stub(api.redirect, 'validateToken').callsFake(() => {
              throw new Error('invalid signature');
            });

            await loader.execute('onContinuePostChallenge', event, api);

            sinon.assert.calledWith(api.redirect.validateToken, { secret: 'test-secret' });
            sinon.assert.calledWith(api.access.deny, 'Unable to verify redirect response: invalid signature');
          });

          it('denies access when the token belongs to a different user', async () => {
            sinon.stub(api.redirect, 'validateToken').returns({ userId: 'someone-else' });

            await loader.execute('onContinuePostChallenge', event, api);

            sinon.assert.calledWith(api.access.deny, 'Verification response does not match the current user.');
          });

          it('denies access when the verification was not completed', async () => {
            sinon.stub(api.redirect, 'validateToken').returns({ userId: event.user.user_id });
            event.request.query.verified = 'false';

            await loader.execute('onContinuePostChallenge', event, api);

            sinon.assert.calledWith(api.access.deny, 'Identity verification was not completed.');
          });

          it('allows the flow to continue when verification succeeded', async () => {
            sinon.stub(api.redirect, 'validateToken').returns({ userId: event.user.user_id });
            event.request.query.verified = 'true';

            await loader.execute('onContinuePostChallenge', event, api);

            sinon.assert.notCalled(api.access.deny);
          });
        });

        ```

        テスト用の依存関係を `package.json` に追加します。

        ```json title="package.json" theme={null}
        {
          "name": "actions-npm-example-js-mocha",
          "version": "1.0.0",
          "description": "Auth0 Actions unit testing example using Mocha",
          "license": "MIT",
          "author": "Auth0",
          "type": "commonjs",
          "main": "module-usage.js",
          "scripts": {
            "test": "mocha"
          },
          "devDependencies": {
            "@auth0/actions": "^0.33.0",
            "chai": "^4.5.0",
            "mocha": "^11.0.0",
            "sinon": "^19.0.0"
          }
        }

        ```

        `.mocharc.json` で Mocha を設定します。

        ```json title=".mocharc.json" theme={null}
        {
          "spec": "src/**/*.spec.js"
        }

        ```

        `actions:` インポートエイリアスが `src/` に解決されるように `jsconfig.json` を設定します。

        ```json title="jsconfig.json" theme={null}
        {
          "compilerOptions": {
            "target": "ES2020",
            "module": "commonjs",
            "checkJs": false,
            "baseUrl": ".",
            "paths": {
              "actions:*": [
                "src/*"
              ]
            }
          },
          "include": [
            "src/**/*.js"
          ]
        }

        ```
      </Tab>

      <Tab title="TypeScript">
        ```ts title="mock-redirects.test.ts" theme={null}
        import * as path from 'path';
        import sinon from 'sinon';
        import { compileActionModules } from './test-utils/load-compiled-action';

        const { getDefaultArguments, loadAction } = require('@auth0/actions/password-reset-post-challenge/v1/test');

        const DIRNAME = path.dirname('../');
        const ACTION_PATH = path.resolve(DIRNAME, './src/mock-redirects.ts');
        const VERIFICATION_URL = 'https://verify.example.com/identity';

        describe('onExecutePostChallenge', () => {
          let loader: any;
          let event: any;
          let api: any;

          beforeEach(async () => {
            const { compiledActionPath } = compileActionModules(ACTION_PATH);
            loader = await loadAction(compiledActionPath);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            sinon.stub(api.redirect, 'encodeToken').returns('signed.jwt.token');
            sinon.spy(api.redirect, 'sendUserTo');
          });

          afterEach(() => {
            sinon.restore();
          });

          it('does not redirect when the risk score is below the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 10;

            await loader.execute('onExecutePostChallenge', event, api);

            sinon.assert.notCalled(api.redirect.encodeToken);
            sinon.assert.notCalled(api.redirect.sendUserTo);
          });

          it('redirects to the identity verification page when the risk score exceeds the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 80;

            await loader.execute('onExecutePostChallenge', event, api);

            sinon.assert.calledWith(api.redirect.encodeToken, {
              secret: 'test-secret',
              payload: { userId: event.user.user_id },
            });
            sinon.assert.calledWith(api.redirect.sendUserTo, VERIFICATION_URL, {
              query: { session_token: 'signed.jwt.token' },
            });
          });
        });

        describe('onContinuePostChallenge', () => {
          let loader: any;
          let event: any;
          let api: any;

          beforeEach(async () => {
            const { compiledActionPath } = compileActionModules(ACTION_PATH);
            loader = await loadAction(compiledActionPath);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            sinon.spy(api.access, 'deny');
          });

          afterEach(() => {
            sinon.restore();
          });

          it('denies access when the redirect token cannot be validated', async () => {
            sinon.stub(api.redirect, 'validateToken').callsFake(() => {
              throw new Error('invalid signature');
            });

            await loader.execute('onContinuePostChallenge', event, api);

            sinon.assert.calledWith(api.redirect.validateToken, { secret: 'test-secret' });
            sinon.assert.calledWith(api.access.deny, 'Unable to verify redirect response: invalid signature');
          });

          it('denies access when the token belongs to a different user', async () => {
            sinon.stub(api.redirect, 'validateToken').returns({ userId: 'someone-else' });

            await loader.execute('onContinuePostChallenge', event, api);

            sinon.assert.calledWith(api.access.deny, 'Verification response does not match the current user.');
          });

          it('denies access when the verification was not completed', async () => {
            sinon.stub(api.redirect, 'validateToken').returns({ userId: event.user.user_id });
            event.request.query.verified = 'false';

            await loader.execute('onContinuePostChallenge', event, api);

            sinon.assert.calledWith(api.access.deny, 'Identity verification was not completed.');
          });

          it('allows the flow to continue when verification succeeded', async () => {
            sinon.stub(api.redirect, 'validateToken').returns({ userId: event.user.user_id });
            event.request.query.verified = 'true';

            await loader.execute('onContinuePostChallenge', event, api);

            sinon.assert.notCalled(api.access.deny);
          });
        });

        ```

        次のテストヘルパーは、実行時に読み込めるように TypeScript のアクションを一時ファイルにコンパイルします。

        ```ts title="load-compiled-action.ts" theme={null}
        import * as fs from 'fs';
        import * as os from 'os';
        import * as path from 'path';
        import * as ts from 'typescript';

        export interface ModuleToCompile {
          name: string;
          filename: string;
        }

        function transpileToTemp(sourcePath: string): string {
          const source = fs.readFileSync(sourcePath, 'utf8');
          const { outputText } = ts.transpileModule(source, {
            compilerOptions: {
              module: ts.ModuleKind.CommonJS,
              target: ts.ScriptTarget.ES2020,
              esModuleInterop: true,
            },
          });

          const tempPath = path.join(
            os.tmpdir(),
            `${path.basename(sourcePath, path.extname(sourcePath))}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
          );
          fs.writeFileSync(tempPath, outputText);
          return tempPath;
        }

        /**
         * loadAction()（@auth0/actions/*\/test 提供）は対象ファイルをディスクから読み取り、
         * vm.compileFunction で実行するため、ts-node や Vitest 自身の TS 変換を経由しません。
         * そのため、アクションのソース（および actions: で登録されたモジュール）は、
         * あらかじめディスク上でプレーンな JS にトランスパイルしておく必要があります。
         */
        export function compileActionModules(actionPath: string, modules: ModuleToCompile[] = []) {
          const compiledActionPath = transpileToTemp(actionPath);
          const compiledModules = modules.map((m) => ({
            name: m.name,
            filename: transpileToTemp(m.filename),
          }));

          return { compiledActionPath, compiledModules };
        }

        ```

        テスト用の依存関係を `package.json` に追加します。

        ```json title="package.json" theme={null}
        {
          "name": "actions-npm-example-ts-mocha",
          "version": "1.0.0",
          "description": "Auth0 Actions unit testing example using Mocha and TypeScript",
          "license": "MIT",
          "author": "Auth0",
          "scripts": {
            "test": "NODE_OPTIONS=--no-experimental-strip-types mocha"
          },
          "devDependencies": {
            "@auth0/actions": "^0.33.0",
            "@types/chai": "^4.3.16",
            "@types/mocha": "^10.0.6",
            "@types/node": "22.14.0",
            "@types/sinon": "^17.0.3",
            "chai": "^4.5.0",
            "mocha": "^11.0.0",
            "sinon": "^19.0.0",
            "ts-node": "^10.9.2",
            "typescript": "^5.9.2"
          }
        }

        ```

        `.mocharc.json` で Mocha を設定します。

        ```json title=".mocharc.json" theme={null}
        {
          "require": "ts-node/register",
          "extension": ["ts"],
          "spec": "src/**/*.test.ts"
        }

        ```

        `actions:` インポートエイリアスが `src/` に解決されるように `tsconfig.json` を設定します。

        ```json title="tsconfig.json" theme={null}
        {
          "compilerOptions": {
            "target": "ES2020",
            "module": "NodeNext",
            "moduleResolution": "nodenext",
            "esModuleInterop": true,
            "allowSyntheticDefaultImports": true,
            "strict": true,
            "outDir": "dist",
            "declaration": true,
            "sourceMap": true,
            "allowJs": true,
            "checkJs": false,
            "resolveJsonModule": true,
            "skipLibCheck": true,
            "forceConsistentCasingInFileNames": true,
            "isolatedModules": true,
            "noEmit": true,
            "paths": {
              "actions:*": [
                "./src/*"
              ]
            }
          },
          "exclude": [
            "node_modules",
            "dist"
          ],
          "include": [
            "**/*.ts"
          ],
          "ts-node": {
            "transpileOnly": true
          }
        }

        ```
      </Tab>
    </Tabs>
  </Accordion>

  <Accordion title="Node.js テストランナー">
    <Tabs>
      <Tab title="JavaScript">
        ```js title="mock-redirects.spec.js" theme={null}
        const assert = require('node:assert');
        const { describe, it, beforeEach, afterEach, mock } = require('node:test');
        const { getDefaultArguments, loadAction } = require('@auth0/actions/password-reset-post-challenge/v1/test');
        const path = require('path');

        const DIRNAME = path.dirname('../');
        const ACTION_PATH = path.resolve(DIRNAME, './src/mock-redirects.js');
        const VERIFICATION_URL = 'https://verify.example.com/identity';

        describe('onExecutePostChallenge', () => {
          let loader;
          let event;
          let api;

          beforeEach(async () => {
            loader = await loadAction(ACTION_PATH);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            mock.method(api.redirect, 'encodeToken', () => 'signed.jwt.token');
            mock.method(api.redirect, 'sendUserTo');
          });

          afterEach(() => {
            mock.reset();
          });

          it('does not redirect when the risk score is below the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 10;

            await loader.execute('onExecutePostChallenge', event, api);

            assert.strictEqual(api.redirect.encodeToken.mock.calls.length, 0);
            assert.strictEqual(api.redirect.sendUserTo.mock.calls.length, 0);
          });

          it('redirects to the identity verification page when the risk score exceeds the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 80;

            await loader.execute('onExecutePostChallenge', event, api);

            assert.deepEqual(api.redirect.encodeToken.mock.calls[0].arguments, [{
              secret: 'test-secret',
              payload: { userId: event.user.user_id },
            }]);
            assert.deepEqual(api.redirect.sendUserTo.mock.calls[0].arguments, [VERIFICATION_URL, {
              query: { session_token: 'signed.jwt.token' },
            }]);
          });
        });

        describe('onContinuePostChallenge', () => {
          let loader;
          let event;
          let api;

          beforeEach(async () => {
            loader = await loadAction(ACTION_PATH);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            mock.method(api.access, 'deny');
          });

          afterEach(() => {
            mock.reset();
          });

          it('denies access when the redirect token cannot be validated', async () => {
            mock.method(api.redirect, 'validateToken', () => {
              throw new Error('invalid signature');
            });

            await loader.execute('onContinuePostChallenge', event, api);

            assert.deepEqual(api.redirect.validateToken.mock.calls[0].arguments, [{ secret: 'test-secret' }]);
            assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Unable to verify redirect response: invalid signature']);
          });

          it('denies access when the token belongs to a different user', async () => {
            mock.method(api.redirect, 'validateToken', () => ({ userId: 'someone-else' }));

            await loader.execute('onContinuePostChallenge', event, api);

            assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Verification response does not match the current user.']);
          });

          it('denies access when the verification was not completed', async () => {
            mock.method(api.redirect, 'validateToken', () => ({ userId: event.user.user_id }));
            event.request.query.verified = 'false';

            await loader.execute('onContinuePostChallenge', event, api);

            assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Identity verification was not completed.']);
          });

          it('allows the flow to continue when verification succeeded', async () => {
            mock.method(api.redirect, 'validateToken', () => ({ userId: event.user.user_id }));
            event.request.query.verified = 'true';

            await loader.execute('onContinuePostChallenge', event, api);

            assert.strictEqual(api.access.deny.mock.calls.length, 0);
          });
        });

        ```

        テスト用の依存関係を `package.json` に追加します。

        ```json title="package.json" theme={null}
        {
          "name": "actions-npm-example-js-node-test",
          "version": "1.0.0",
          "description": "Auth0 Actions unit testing example using the Node.js built-in test runner",
          "license": "MIT",
          "author": "Auth0",
          "type": "commonjs",
          "main": "module-usage.js",
          "scripts": {
            "test": "node --test src/*.spec.js"
          },
          "devDependencies": {
            "@auth0/actions": "^0.33.0"
          }
        }

        ```

        `actions:` インポートエイリアスが `src/` に解決されるように `jsconfig.json` を設定します。

        ```json title="jsconfig.json" theme={null}
        {
          "compilerOptions": {
            "target": "ES2020",
            "module": "commonjs",
            "checkJs": false,
            "baseUrl": ".",
            "paths": {
              "actions:*": [
                "src/*"
              ]
            }
          },
          "include": [
            "src/**/*.js"
          ]
        }

        ```
      </Tab>

      <Tab title="TypeScript">
        ```ts title="mock-redirects.test.ts" theme={null}
        const assert = require('node:assert');
        const { describe, it, beforeEach, afterEach, mock } = require('node:test');
        const { getDefaultArguments, loadAction } = require('@auth0/actions/password-reset-post-challenge/v1/test');
        const path = require('path');
        const { compileActionModules } = require('./test-utils/load-compiled-action.ts');

        const DIRNAME = path.dirname('../');
        const ACTION_PATH = path.resolve(DIRNAME, './src/mock-redirects.ts');
        const VERIFICATION_URL = 'https://verify.example.com/identity';

        describe('onExecutePostChallenge', () => {
          let loader;
          let event;
          let api;

          beforeEach(async () => {
            const { compiledActionPath } = compileActionModules(ACTION_PATH);
            loader = await loadAction(compiledActionPath);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            mock.method(api.redirect, 'encodeToken', () => 'signed.jwt.token');
            mock.method(api.redirect, 'sendUserTo');
          });

          afterEach(() => {
            mock.reset();
          });

          it('does not redirect when the risk score is below the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 10;

            await loader.execute('onExecutePostChallenge', event, api);

            assert.strictEqual(api.redirect.encodeToken.mock.calls.length, 0);
            assert.strictEqual(api.redirect.sendUserTo.mock.calls.length, 0);
          });

          it('redirects to the identity verification page when the risk score exceeds the threshold', async () => {
            event.authentication.riskAssessment.supplemental.akamai.akamaiUserRisk.score = 80;

            await loader.execute('onExecutePostChallenge', event, api);

            assert.deepEqual(api.redirect.encodeToken.mock.calls[0].arguments, [{
              secret: 'test-secret',
              payload: { userId: event.user.user_id },
            }]);
            assert.deepEqual(api.redirect.sendUserTo.mock.calls[0].arguments, [VERIFICATION_URL, {
              query: { session_token: 'signed.jwt.token' },
            }]);
          });
        });

        describe('onContinuePostChallenge', () => {
          let loader;
          let event;
          let api;

          beforeEach(async () => {
            const { compiledActionPath } = compileActionModules(ACTION_PATH);
            loader = await loadAction(compiledActionPath);
            [event, api] = getDefaultArguments();
            event.secrets.REDIRECT_SECRET = 'test-secret';
            mock.method(api.access, 'deny');
          });

          afterEach(() => {
            mock.reset();
          });

          it('denies access when the redirect token cannot be validated', async () => {
            mock.method(api.redirect, 'validateToken', () => {
              throw new Error('invalid signature');
            });

            await loader.execute('onContinuePostChallenge', event, api);

            assert.deepEqual(api.redirect.validateToken.mock.calls[0].arguments, [{ secret: 'test-secret' }]);
            assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Unable to verify redirect response: invalid signature']);
          });

          it('denies access when the token belongs to a different user', async () => {
            mock.method(api.redirect, 'validateToken', () => ({ userId: 'someone-else' }));

            await loader.execute('onContinuePostChallenge', event, api);

            assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Verification response does not match the current user.']);
          });

          it('denies access when the verification was not completed', async () => {
            mock.method(api.redirect, 'validateToken', () => ({ userId: event.user.user_id }));
            event.request.query.verified = 'false';

            await loader.execute('onContinuePostChallenge', event, api);

            assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Identity verification was not completed.']);
          });

          it('allows the flow to continue when verification succeeded', async () => {
            mock.method(api.redirect, 'validateToken', () => ({ userId: event.user.user_id }));
            event.request.query.verified = 'true';

            await loader.execute('onContinuePostChallenge', event, api);

            assert.strictEqual(api.access.deny.mock.calls.length, 0);
          });
        });

        ```

        次のテストヘルパーは、実行時に読み込めるように TypeScript のアクションを一時ファイルにコンパイルします。

        ```ts title="load-compiled-action.ts" theme={null}
        const fs = require('fs');
        const os = require('os');
        const path = require('path');
        const ts = require('typescript');

        interface ModuleToCompile {
          name: string;
          filename: string;
        }

        function transpileToTemp(sourcePath: string): string {
          const source = fs.readFileSync(sourcePath, 'utf8');
          const { outputText } = ts.transpileModule(source, {
            compilerOptions: {
              module: ts.ModuleKind.CommonJS,
              target: ts.ScriptTarget.ES2020,
              esModuleInterop: true,
            },
          });

          const tempPath = path.join(
            os.tmpdir(),
            `${path.basename(sourcePath, path.extname(sourcePath))}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
          );
          fs.writeFileSync(tempPath, outputText);
          return tempPath;
        }

        /**
         * loadAction()（@auth0/actions/*\/test のもの）は対象ファイルをディスクから読み取り、
         * vm.compileFunction で実行するため、node ネイティブの TS 型除去を経由することはありません。
         * そのため、アクションのソース（および actions: で登録されたモジュール）は、
         * あらかじめディスク上でプレーンな JS にトランスパイルしておく必要があります。
         */
        exports.compileActionModules = function compileActionModules(actionPath: string, modules: ModuleToCompile[] = []) {
          const compiledActionPath = transpileToTemp(actionPath);
          const compiledModules = modules.map((m: ModuleToCompile) => ({
            name: m.name,
            filename: transpileToTemp(m.filename),
          }));

          return { compiledActionPath, compiledModules };
        };

        ```

        テスト用の依存関係を `package.json` に追加します。

        ```json title="package.json" theme={null}
        {
          "name": "actions-npm-example-ts-node-test",
          "version": "1.0.0",
          "description": "Auth0 Actions unit testing example using the Node.js built-in test runner and TypeScript",
          "license": "MIT",
          "author": "Auth0",
          "scripts": {
            "test": "node --test src/*.test.ts"
          },
          "devDependencies": {
            "@auth0/actions": "^0.33.0",
            "@types/node": "22.14.0",
            "typescript": "^5.9.2"
          }
        }

        ```

        `actions:` インポートエイリアスが `src/` に解決されるように `tsconfig.json` を設定します。

        ```json title="tsconfig.json" theme={null}
        {
          "compilerOptions": {
            "target": "ES2020",
            "module": "NodeNext",
            "moduleResolution": "nodenext",
            "esModuleInterop": true,
            "allowSyntheticDefaultImports": true,
            "strict": true,
            "outDir": "dist",
            "declaration": true,
            "sourceMap": true,
            "allowJs": true,
            "checkJs": false,
            "resolveJsonModule": true,
            "skipLibCheck": true,
            "forceConsistentCasingInFileNames": true,
            "isolatedModules": true,
            "noEmit": true,
            "paths": {
              "actions:*": [
                "./src/*"
              ]
            }
          },
          "exclude": [
            "node_modules",
            "dist"
          ],
          "include": [
            "**/*.ts"
          ],
          "ts-node": {
            "transpileOnly": true
          }
        }

        ```
      </Tab>
    </Tabs>
  </Accordion>
</AccordionGroup>
