Projet
Chaque exemple sépare le code source de l’action, placé danssrc/, de ses tests unitaires, placés dans test/, en suivant la même structure pour les projets JavaScript et TypeScript.
- JavaScript
- TypeScript
/
src
test
/
src
test
Action
L’action Password Reset Post Challenge suivante redirige l’utilisateur vers une page externe de vérification d’identité lorsque son pointage de risque dépasse un seuil donné, puis valide le jeton de session renvoyé ainsi que le résultat de la vérification avant de laisser la réinitialisation du mot de passe se poursuivre.- 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;
/**
* Gestionnaire appelé pendant l’exécution d’un flux PasswordResetPostChallenge.
* Redirige l’utilisateur vers une page externe de vérification d’identité lorsque le score
* d’évaluation complémentaire des risques dépasse le seuil configuré.
*
* @param {Event} event - Détails sur l’utilisateur et la transaction de réinitialisation du mot de passe.
* @param {PasswordResetPostChallengeAPI} api - Interface dont les méthodes permettent de modifier le comportement de la réinitialisation du mot de passe.
*/
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 },
});
};
/**
* Gestionnaire appelé lorsque l’utilisateur revient de la redirection effectuée par
* onExecutePostChallenge. Valide le jeton de session avant de permettre au flux de
* réinitialisation du mot de passe de se poursuivre.
*
* @param {Event} event - Détails sur l’utilisateur et la transaction de réinitialisation du mot de passe.
* @param {PasswordResetPostChallengeAPI} api - Interface dont les méthodes permettent de modifier le comportement de la réinitialisation du mot de passe.
*/
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;
/**
* Gestionnaire qui sera appelé lors de l'exécution d'un flux PasswordResetPostChallenge.
* Redirige l'utilisateur vers une page externe de vérification d'identité lorsque le pointage
* d'évaluation du risque supplémentaire dépasse le seuil configuré.
*
* @param {Event} event - Détails sur l'utilisateur et sur la transaction de réinitialisation du mot de passe.
* @param {PasswordResetPostChallengeAPI} api - Interface dont les méthodes permettent de modifier le comportement de la réinitialisation du mot de passe.
*/
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 },
});
};
/**
* Gestionnaire qui sera appelé lorsque l'utilisateur revient de la redirection émise par
* onExecutePostChallenge. Valide le jeton de session avant de laisser le flux de
* réinitialisation du mot de passe se poursuivre.
*
* @param {Event} event - Détails sur l'utilisateur et sur la transaction de réinitialisation du mot de passe.
* @param {PasswordResetPostChallengeAPI} api - Interface dont les méthodes permettent de modifier le comportement de la réinitialisation du mot de passe.
*/
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.');
}
};
Unit Test
Les tests unitaires simulent les objetsevent et api, ainsi que l’encodage et la validation du jeton de redirection, afin de vérifier que la redirection ne se déclenche qu’au-delà du seuil de risque et que l’accès est refusé dans le cas de jetons invalides, d’utilisateurs non concordants ou d’une vérification incomplète.
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"
}
}
jsconfig.json de sorte que l’alias d’importation actions: pointe vers src/ :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() (de @auth0/actions/*\/test) lit son fichier cible sur le disque et
* l'exécute au moyen de vm.compileFunction; il ne passe donc jamais par la
* transformation TS de ts-node/Vitest. Les sources d'actions (et tout module
* enregistré avec actions:) doivent d'abord être transpilées en JS ordinaire sur le disque.
*/
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 :jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
tsconfig.json de sorte que l’alias d’importation actions: pointe vers src/ :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 :.mocharc.json
{
"spec": "src/**/*.spec.js"
}
jsconfig.json de sorte que l’alias d’importation actions: pointe vers src/ :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() (de @auth0/actions/*\/test) lit son fichier cible sur le disque et
* l'exécute au moyen de vm.compileFunction : il ne passe donc jamais par la transformation
* TS propre à ts-node/Vitest. Les sources des actions (et tout module enregistré avec
* actions:) doivent d'abord être transpilées en JS pur sur le disque.
*/
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 :.mocharc.json
{
"require": "ts-node/register",
"extension": ["ts"],
"spec": "src/**/*.test.ts"
}
tsconfig.json de sorte que l’alias d’importation actions: pointe vers src/ :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 Test Runner
Node.js Test Runner
- 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"
}
}
jsconfig.json de sorte que l’alias d’importation actions: pointe vers src/ :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() (de @auth0/actions/*\/test) lit son fichier cible sur le disque et
* l'exécute avec vm.compileFunction; il ne passe donc jamais par le retrait
* natif des types TS de node. Les sources d'actions (et tout module enregistré
* avec actions:) doivent d'abord être transpilées en JS pur sur le disque.
*/
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"
}
}
tsconfig.json de sorte que l’alias d’importation actions: pointe vers src/ :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
}
}