Project
Each example separates the action’s source code undersrc/ from its unit tests under test/, following the same structure across JavaScript and TypeScript projects.
- JavaScript
- TypeScript
/
src
test
/
src
test
Action
The following Post Login action renders a marketing consent Form to users who haven’t answered it yet, then handles the submitted response on the following login attempt: denying access if the Form was skipped, or storing the consent choice inuser_metadata otherwise.
- JavaScript
- TypeScript
mock-form-render.js
/** @import {Event, PostLoginAPI} from "@auth0/actions/post-login/v3" */
const CONSENT_FORM_ID = 'marketing-consent-form';
/**
* Handler that will be called during the execution of a PostLogin flow.
* Renders a custom Form asking for marketing consent when the user hasn't
* answered it yet. `api.prompt.render` cannot be combined with
* `api.redirect.sendUserTo` in the same execution - pick one per action.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onExecutePostLogin = async (event, api) => {
const hasAnsweredConsent = typeof event.user.user_metadata.marketing_consent === 'boolean';
if (hasAnsweredConsent) {
return;
}
api.prompt.render(CONSENT_FORM_ID, {
vars: { firstName: event.user.given_name ?? event.user.name },
});
};
/**
* Handler that will be called when the user submits the Form rendered by
* onExecutePostLogin.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onContinuePostLogin = async (event, api) => {
const consent = event.prompt?.fields?.consent;
if (typeof consent !== 'boolean') {
api.access.deny('Marketing consent response was not submitted.');
return;
}
api.user.setUserMetadata('marketing_consent', consent);
};
mock-form-render.ts
import type { Event, PostLoginAPI } from '@auth0/actions/post-login/v3';
const CONSENT_FORM_ID = 'marketing-consent-form';
/**
* Handler that will be called during the execution of a PostLogin flow.
* Renders a custom Form asking for marketing consent when the user hasn't
* answered it yet. `api.prompt.render` cannot be combined with
* `api.redirect.sendUserTo` in the same execution - pick one per action.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onExecutePostLogin = async (event: Event, api: PostLoginAPI) => {
const hasAnsweredConsent = typeof event.user.user_metadata.marketing_consent === 'boolean';
if (hasAnsweredConsent) {
return;
}
api.prompt.render(CONSENT_FORM_ID, {
vars: { firstName: event.user.given_name ?? event.user.name },
});
};
/**
* Handler that will be called when the user submits the Form rendered by
* onExecutePostLogin.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onContinuePostLogin = async (event: Event, api: PostLoginAPI) => {
const consent = event.prompt?.fields?.consent;
if (typeof consent !== 'boolean') {
api.access.deny('Marketing consent response was not submitted.');
return;
}
api.user.setUserMetadata('marketing_consent', consent);
};
Unit Test
The unit tests mock theevent and api objects to verify the Form renders only when consent hasn’t been answered, and that both accepted and declined consent submissions are stored correctly while missing submissions are denied.
Jest
Jest
- JavaScript
- TypeScript
mock-form-render.spec.js
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
const path = require('path');
const DIRNAME = path.dirname('../');
const ACTION_PATH = path.resolve(DIRNAME, './src/mock-form-render.js');
const CONSENT_FORM_ID = 'marketing-consent-form';
describe('onExecutePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
jest.resetAllMocks();
loader = await loadAction(ACTION_PATH);
[event, api] = getDefaultArguments();
jest.spyOn(api.prompt, 'render');
});
afterEach(() => {
jest.resetAllMocks();
});
it('renders the consent form when the user has not answered yet', async () => {
await loader.execute('onExecutePostLogin', event, api);
expect(api.prompt.render).toHaveBeenCalledWith(CONSENT_FORM_ID, {
vars: { firstName: event.user.given_name },
});
});
it('skips rendering when the user has already answered', async () => {
event.user.user_metadata.marketing_consent = true;
await loader.execute('onExecutePostLogin', event, api);
expect(api.prompt.render).not.toHaveBeenCalled();
});
it('skips rendering when the user has already declined', async () => {
event.user.user_metadata.marketing_consent = false;
await loader.execute('onExecutePostLogin', event, api);
expect(api.prompt.render).not.toHaveBeenCalled();
});
});
describe('onContinuePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
jest.resetAllMocks();
loader = await loadAction(ACTION_PATH);
[event, api] = getDefaultArguments();
jest.spyOn(api.access, 'deny');
jest.spyOn(api.user, 'setUserMetadata');
});
afterEach(() => {
jest.resetAllMocks();
});
it('denies access when the form response is missing', async () => {
await loader.execute('onContinuePostLogin', event, api);
expect(api.access.deny).toHaveBeenCalledWith('Marketing consent response was not submitted.');
expect(api.user.setUserMetadata).not.toHaveBeenCalled();
});
it('stores an accepted consent answer submitted by the form', async () => {
event.prompt.fields.consent = true;
await loader.execute('onContinuePostLogin', event, api);
expect(api.user.setUserMetadata).toHaveBeenCalledWith('marketing_consent', true);
expect(api.access.deny).not.toHaveBeenCalled();
});
it('stores a declined consent answer submitted by the form', async () => {
event.prompt.fields.consent = false;
await loader.execute('onContinuePostLogin', event, api);
expect(api.user.setUserMetadata).toHaveBeenCalledWith('marketing_consent', false);
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 so the actions: import alias resolves to src/:jsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"checkJs": false,
"baseUrl": ".",
"paths": {
"actions:*": [
"src/*"
]
}
},
"include": [
"src/**/*.js"
]
}
mock-form-render.test.ts
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/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-form-render.ts');
const CONSENT_FORM_ID = 'marketing-consent-form';
describe('onExecutePostLogin', () => {
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();
jest.spyOn(api.prompt, 'render');
});
afterEach(() => {
jest.resetAllMocks();
});
it('renders the consent form when the user has not answered yet', async () => {
await loader.execute('onExecutePostLogin', event, api);
expect(api.prompt.render).toHaveBeenCalledWith(CONSENT_FORM_ID, {
vars: { firstName: event.user.given_name },
});
});
it('skips rendering when the user has already answered', async () => {
event.user.user_metadata.marketing_consent = true;
await loader.execute('onExecutePostLogin', event, api);
expect(api.prompt.render).not.toHaveBeenCalled();
});
it('skips rendering when the user has already declined', async () => {
event.user.user_metadata.marketing_consent = false;
await loader.execute('onExecutePostLogin', event, api);
expect(api.prompt.render).not.toHaveBeenCalled();
});
});
describe('onContinuePostLogin', () => {
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();
jest.spyOn(api.access, 'deny');
jest.spyOn(api.user, 'setUserMetadata');
});
afterEach(() => {
jest.resetAllMocks();
});
it('denies access when the form response is missing', async () => {
await loader.execute('onContinuePostLogin', event, api);
expect(api.access.deny).toHaveBeenCalledWith('Marketing consent response was not submitted.');
expect(api.user.setUserMetadata).not.toHaveBeenCalled();
});
it('stores an accepted consent answer submitted by the form', async () => {
event.prompt.fields.consent = true;
await loader.execute('onContinuePostLogin', event, api);
expect(api.user.setUserMetadata).toHaveBeenCalledWith('marketing_consent', true);
expect(api.access.deny).not.toHaveBeenCalled();
});
it('stores a declined consent answer submitted by the form', async () => {
event.prompt.fields.consent = false;
await loader.execute('onContinuePostLogin', event, api);
expect(api.user.setUserMetadata).toHaveBeenCalledWith('marketing_consent', false);
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() (from @auth0/actions/*\/test) reads its target file from disk and
* runs it via vm.compileFunction, so it never goes through ts-node/Vitest's own
* TS transform. Action sources (and any actions:-registered modules) must be
* transpiled to plain JS on disk first.
*/
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 so the actions: import alias resolves to 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-form-render.spec.js
const sinon = require('sinon');
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
const path = require('path');
const DIRNAME = path.dirname('../');
const ACTION_PATH = path.resolve(DIRNAME, './src/mock-form-render.js');
const CONSENT_FORM_ID = 'marketing-consent-form';
describe('onExecutePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
loader = await loadAction(ACTION_PATH);
[event, api] = getDefaultArguments();
sinon.spy(api.prompt, 'render');
});
afterEach(() => {
sinon.restore();
});
it('renders the consent form when the user has not answered yet', async () => {
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.calledWith(api.prompt.render, CONSENT_FORM_ID, {
vars: { firstName: event.user.given_name },
});
});
it('skips rendering when the user has already answered', async () => {
event.user.user_metadata.marketing_consent = true;
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.notCalled(api.prompt.render);
});
it('skips rendering when the user has already declined', async () => {
event.user.user_metadata.marketing_consent = false;
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.notCalled(api.prompt.render);
});
});
describe('onContinuePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
loader = await loadAction(ACTION_PATH);
[event, api] = getDefaultArguments();
sinon.spy(api.access, 'deny');
sinon.spy(api.user, 'setUserMetadata');
});
afterEach(() => {
sinon.restore();
});
it('denies access when the form response is missing', async () => {
await loader.execute('onContinuePostLogin', event, api);
sinon.assert.calledWith(api.access.deny, 'Marketing consent response was not submitted.');
sinon.assert.notCalled(api.user.setUserMetadata);
});
it('stores an accepted consent answer submitted by the form', async () => {
event.prompt.fields.consent = true;
await loader.execute('onContinuePostLogin', event, api);
sinon.assert.calledWith(api.user.setUserMetadata, 'marketing_consent', true);
sinon.assert.notCalled(api.access.deny);
});
it('stores a declined consent answer submitted by the form', async () => {
event.prompt.fields.consent = false;
await loader.execute('onContinuePostLogin', event, api);
sinon.assert.calledWith(api.user.setUserMetadata, 'marketing_consent', false);
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 so the actions: import alias resolves to src/:jsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"checkJs": false,
"baseUrl": ".",
"paths": {
"actions:*": [
"src/*"
]
}
},
"include": [
"src/**/*.js"
]
}
mock-form-render.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/post-login/v3/test');
const DIRNAME = path.dirname('../');
const ACTION_PATH = path.resolve(DIRNAME, './src/mock-form-render.ts');
const CONSENT_FORM_ID = 'marketing-consent-form';
describe('onExecutePostLogin', () => {
let loader: any;
let event: any;
let api: any;
beforeEach(async () => {
const { compiledActionPath } = compileActionModules(ACTION_PATH);
loader = await loadAction(compiledActionPath);
[event, api] = getDefaultArguments();
sinon.spy(api.prompt, 'render');
});
afterEach(() => {
sinon.restore();
});
it('renders the consent form when the user has not answered yet', async () => {
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.calledWith(api.prompt.render, CONSENT_FORM_ID, {
vars: { firstName: event.user.given_name },
});
});
it('skips rendering when the user has already answered', async () => {
event.user.user_metadata.marketing_consent = true;
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.notCalled(api.prompt.render);
});
it('skips rendering when the user has already declined', async () => {
event.user.user_metadata.marketing_consent = false;
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.notCalled(api.prompt.render);
});
});
describe('onContinuePostLogin', () => {
let loader: any;
let event: any;
let api: any;
beforeEach(async () => {
const { compiledActionPath } = compileActionModules(ACTION_PATH);
loader = await loadAction(compiledActionPath);
[event, api] = getDefaultArguments();
sinon.spy(api.access, 'deny');
sinon.spy(api.user, 'setUserMetadata');
});
afterEach(() => {
sinon.restore();
});
it('denies access when the form response is missing', async () => {
await loader.execute('onContinuePostLogin', event, api);
sinon.assert.calledWith(api.access.deny, 'Marketing consent response was not submitted.');
sinon.assert.notCalled(api.user.setUserMetadata);
});
it('stores an accepted consent answer submitted by the form', async () => {
event.prompt.fields.consent = true;
await loader.execute('onContinuePostLogin', event, api);
sinon.assert.calledWith(api.user.setUserMetadata, 'marketing_consent', true);
sinon.assert.notCalled(api.access.deny);
});
it('stores a declined consent answer submitted by the form', async () => {
event.prompt.fields.consent = false;
await loader.execute('onContinuePostLogin', event, api);
sinon.assert.calledWith(api.user.setUserMetadata, 'marketing_consent', false);
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() (from @auth0/actions/*\/test) reads its target file from disk and
* runs it via vm.compileFunction, so it never goes through ts-node/Vitest's own
* TS transform. Action sources (and any actions:-registered modules) must be
* transpiled to plain JS on disk first.
*/
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 so the actions: import alias resolves to 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-form-render.spec.js
const assert = require('node:assert');
const { describe, it, beforeEach, afterEach, mock } = require('node:test');
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
const path = require('path');
const DIRNAME = path.dirname('../');
const ACTION_PATH = path.resolve(DIRNAME, './src/mock-form-render.js');
const CONSENT_FORM_ID = 'marketing-consent-form';
describe('onExecutePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
loader = await loadAction(ACTION_PATH);
[event, api] = getDefaultArguments();
mock.method(api.prompt, 'render');
});
afterEach(() => {
mock.reset();
});
it('renders the consent form when the user has not answered yet', async () => {
await loader.execute('onExecutePostLogin', event, api);
assert.deepEqual(api.prompt.render.mock.calls[0].arguments, [CONSENT_FORM_ID, {
vars: { firstName: event.user.given_name },
}]);
});
it('skips rendering when the user has already answered', async () => {
event.user.user_metadata.marketing_consent = true;
await loader.execute('onExecutePostLogin', event, api);
assert.strictEqual(api.prompt.render.mock.calls.length, 0);
});
it('skips rendering when the user has already declined', async () => {
event.user.user_metadata.marketing_consent = false;
await loader.execute('onExecutePostLogin', event, api);
assert.strictEqual(api.prompt.render.mock.calls.length, 0);
});
});
describe('onContinuePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
loader = await loadAction(ACTION_PATH);
[event, api] = getDefaultArguments();
mock.method(api.access, 'deny');
mock.method(api.user, 'setUserMetadata');
});
afterEach(() => {
mock.reset();
});
it('denies access when the form response is missing', async () => {
await loader.execute('onContinuePostLogin', event, api);
assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Marketing consent response was not submitted.']);
assert.strictEqual(api.user.setUserMetadata.mock.calls.length, 0);
});
it('stores an accepted consent answer submitted by the form', async () => {
event.prompt.fields.consent = true;
await loader.execute('onContinuePostLogin', event, api);
assert.deepEqual(api.user.setUserMetadata.mock.calls[0].arguments, ['marketing_consent', true]);
assert.strictEqual(api.access.deny.mock.calls.length, 0);
});
it('stores a declined consent answer submitted by the form', async () => {
event.prompt.fields.consent = false;
await loader.execute('onContinuePostLogin', event, api);
assert.deepEqual(api.user.setUserMetadata.mock.calls[0].arguments, ['marketing_consent', false]);
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 so the actions: import alias resolves to src/:jsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"checkJs": false,
"baseUrl": ".",
"paths": {
"actions:*": [
"src/*"
]
}
},
"include": [
"src/**/*.js"
]
}
mock-form-render.test.ts
const assert = require('node:assert');
const { describe, it, beforeEach, afterEach, mock } = require('node:test');
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/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-form-render.ts');
const CONSENT_FORM_ID = 'marketing-consent-form';
describe('onExecutePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
const { compiledActionPath } = compileActionModules(ACTION_PATH);
loader = await loadAction(compiledActionPath);
[event, api] = getDefaultArguments();
mock.method(api.prompt, 'render');
});
afterEach(() => {
mock.reset();
});
it('renders the consent form when the user has not answered yet', async () => {
await loader.execute('onExecutePostLogin', event, api);
assert.deepEqual(api.prompt.render.mock.calls[0].arguments, [CONSENT_FORM_ID, {
vars: { firstName: event.user.given_name },
}]);
});
it('skips rendering when the user has already answered', async () => {
event.user.user_metadata.marketing_consent = true;
await loader.execute('onExecutePostLogin', event, api);
assert.strictEqual(api.prompt.render.mock.calls.length, 0);
});
it('skips rendering when the user has already declined', async () => {
event.user.user_metadata.marketing_consent = false;
await loader.execute('onExecutePostLogin', event, api);
assert.strictEqual(api.prompt.render.mock.calls.length, 0);
});
});
describe('onContinuePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
const { compiledActionPath } = compileActionModules(ACTION_PATH);
loader = await loadAction(compiledActionPath);
[event, api] = getDefaultArguments();
mock.method(api.access, 'deny');
mock.method(api.user, 'setUserMetadata');
});
afterEach(() => {
mock.reset();
});
it('denies access when the form response is missing', async () => {
await loader.execute('onContinuePostLogin', event, api);
assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Marketing consent response was not submitted.']);
assert.strictEqual(api.user.setUserMetadata.mock.calls.length, 0);
});
it('stores an accepted consent answer submitted by the form', async () => {
event.prompt.fields.consent = true;
await loader.execute('onContinuePostLogin', event, api);
assert.deepEqual(api.user.setUserMetadata.mock.calls[0].arguments, ['marketing_consent', true]);
assert.strictEqual(api.access.deny.mock.calls.length, 0);
});
it('stores a declined consent answer submitted by the form', async () => {
event.prompt.fields.consent = false;
await loader.execute('onContinuePostLogin', event, api);
assert.deepEqual(api.user.setUserMetadata.mock.calls[0].arguments, ['marketing_consent', false]);
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() (from @auth0/actions/*\/test) reads its target file from disk and
* runs it via vm.compileFunction, so it never goes through node's native TS type
* stripping. Action sources (and any actions:-registered modules) must be
* transpiled to plain JS on disk first.
*/
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 so the actions: import alias resolves to 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
}
}