プロジェクト
各サンプルでは、アクションのソースコードをsrc/ に、そのユニットテストを test/ に分けて配置しており、JavaScript プロジェクトと TypeScript プロジェクトのいずれも同じ構造になっています。
- JavaScript
- TypeScript
/
src
test
/
src
test
アクション
以下の Password Reset Post Challenge アクションは、リスクスコアがしきい値を超えた場合にユーザーを外部の本人確認ページへリダイレクトし、返されたセッショントークンと検証結果を検証したうえで、パスワードリセットの続行を許可します。- JavaScript
- TypeScript
mock-redirects.js
/** @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.');
}
};
mock-redirects.ts
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.');
}
};
ユニットテスト
ユニットテストでは、event オブジェクトと api オブジェクト、およびリダイレクトトークンのエンコードと検証をモックし、リスクのしきい値を超えた場合にのみリダイレクトが実行されること、また無効なトークン、ユーザーの不一致、検証の未完了といった場合にはアクセスが拒否されることを確認します。
Jest
Jest
- JavaScript
- TypeScript
mock-redirects.spec.js
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 に追加します。package.json
{
"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 を設定します。jsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"checkJs": false,
"baseUrl": ".",
"paths": {
"actions:*": [
"src/*"
]
}
},
"include": [
"src/**/*.js"
]
}
mock-redirects.test.ts
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();
});
});
load-compiled-action.ts
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 に追加します。package.json
{
"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 を設定します。jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
actions: インポートエイリアスが src/ に解決されるように tsconfig.json を設定します。tsconfig.json
{
"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
}
}
Mocha
Mocha
- JavaScript
- TypeScript
mock-redirects.spec.js
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 に追加します。package.json
{
"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 を設定します。.mocharc.json
{
"spec": "src/**/*.spec.js"
}
actions: インポートエイリアスが src/ に解決されるように jsconfig.json を設定します。jsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"checkJs": false,
"baseUrl": ".",
"paths": {
"actions:*": [
"src/*"
]
}
},
"include": [
"src/**/*.js"
]
}
mock-redirects.test.ts
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);
});
});
load-compiled-action.ts
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 に追加します。package.json
{
"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 を設定します。.mocharc.json
{
"require": "ts-node/register",
"extension": ["ts"],
"spec": "src/**/*.test.ts"
}
actions: インポートエイリアスが src/ に解決されるように tsconfig.json を設定します。tsconfig.json
{
"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
}
}
Node.js テストランナー
Node.js テストランナー
- JavaScript
- TypeScript
mock-redirects.spec.js
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 に追加します。package.json
{
"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 を設定します。jsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"checkJs": false,
"baseUrl": ".",
"paths": {
"actions:*": [
"src/*"
]
}
},
"include": [
"src/**/*.js"
]
}
mock-redirects.test.ts
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);
});
});
load-compiled-action.ts
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 に追加します。package.json
{
"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 を設定します。tsconfig.json
{
"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
}
}