diff --git a/src/mock/api/authentication.ts b/src/mock/api/authentication.ts new file mode 100644 index 0000000..6ccf531 --- /dev/null +++ b/src/mock/api/authentication.ts @@ -0,0 +1,65 @@ +import { Factor } from "../../types"; + +export interface FactorList { + allOptions: Factor[]; + default: Factor | undefined; +} + +export function authenticationMock(_flow: string, { userId }: { userId: string }) { + let numCallsToSetPrimaryUser = 0; + + const state = { + primaryUserId: userId, + challenge: false as false | FactorList, + enrollment: false as false | FactorList, + newlyRecordedMethods: [] as string[], + }; + + const build = (api: T) => ({ + challengeWith: (factor: Factor, options?: { additionalFactors?: Factor[] }) => { + const additionalFactors = options?.additionalFactors ?? []; + + state.challenge = { + allOptions: [factor, ...additionalFactors], + default: factor, + }; + }, + challengeWithAny(factors: Factor[]) { + state.challenge = { + allOptions: factors, + default: undefined, + }; + }, + enrollWith: (factor: Factor, options?: { additionalFactors?: Factor[] }) => { + const additionalFactors = options?.additionalFactors ?? []; + + state.enrollment = { + allOptions: [factor, ...additionalFactors], + default: factor, + }; + }, + enrollWithAny(factors: Factor[]) { + state.enrollment = { + allOptions: factors, + default: undefined, + }; + }, + setPrimaryUser: (primaryUserId: string) => { + numCallsToSetPrimaryUser++; + + if (numCallsToSetPrimaryUser > 1) { + throw new Error( + "`authentication.setPrimaryUser` can only be set once per transaction" + ); + } + + state.primaryUserId = primaryUserId; + }, + recordMethod: (providerUrl: string) => { + state.newlyRecordedMethods.push(providerUrl); + return api; + }, + }); + + return { state, build }; +} diff --git a/src/mock/api/post-login.ts b/src/mock/api/post-login.ts index 8f911e1..f4191f4 100644 --- a/src/mock/api/post-login.ts +++ b/src/mock/api/post-login.ts @@ -6,6 +6,7 @@ import { ok } from "node:assert"; import { encodeHS256JWT, signHS256 } from "../../jwt/hs256"; import { accessTokenMock } from "./access-token"; import { accessMock } from "./access"; +import { authenticationMock, FactorList } from "./authentication"; export interface PostLoginOptions { user?: Auth0.User; @@ -45,14 +46,8 @@ interface SamlResponseState { signingCert?: string; } -interface FactorList { - allOptions: Factor[]; - default: Factor | undefined; -} - export interface PostLoginState { user: Auth0.User; - primaryUserId: string; cache: Auth0.API.Cache; access: { denied: false } | { denied: { reason: string } }; accessToken: { @@ -60,6 +55,7 @@ export interface PostLoginState { scopes: string[]; }; authentication: { + primaryUserId: string; challenge: FactorList | false; enrollment: FactorList | false; newlyRecordedMethods: string[]; @@ -89,27 +85,22 @@ export function postLogin({ executedRules: optionallyExecutedRules, now: nowValue, }: PostLoginOptions = {}) { + const userValue = user ?? mockUser(); + const executedRules = optionallyExecutedRules ?? []; + const requestValue = request ?? mockRequest(); + const apiCache = mockCache(cache); const access = accessMock("PostLogin"); const accessToken = accessTokenMock("PostLogin"); - const executedRules = optionallyExecutedRules ?? []; - const userValue = user ?? mockUser(); - const requestValue = request ?? mockRequest(); + const authentication = authenticationMock("PostLogin", { userId: userValue.user_id }); const now = new Date(nowValue || Date.now()); - let numCallsToSetPrimaryUser = 0; - const state: PostLoginState = { user: userValue, - primaryUserId: userValue.user_id, access: access.state, accessToken: accessToken.state, - authentication: { - challenge: false, - enrollment: false, - newlyRecordedMethods: [], - }, + authentication: authentication.state, cache: apiCache, idToken: { claims: {}, @@ -187,50 +178,8 @@ export function postLogin({ return accessToken.build(api); }, - authentication: { - challengeWith: (factor, options) => { - const additionalFactors = options?.additionalFactors ?? []; - - state.authentication.challenge = { - allOptions: [factor, ...additionalFactors], - default: factor, - }; - }, - challengeWithAny(factors) { - state.authentication.challenge = { - allOptions: factors, - default: undefined, - }; - }, - enrollWith(factor, options) { - const additionalFactors = options?.additionalFactors ?? []; - - state.authentication.enrollment = { - allOptions: [factor, ...additionalFactors], - default: factor, - }; - }, - enrollWithAny(factors) { - state.authentication.enrollment = { - allOptions: factors, - default: undefined, - }; - }, - setPrimaryUser: (primaryUserId) => { - numCallsToSetPrimaryUser++; - - if (numCallsToSetPrimaryUser > 1) { - throw new Error( - "`authentication.setPrimaryUser` can only be set once per transaction" - ); - } - - state.primaryUserId = primaryUserId; - }, - recordMethod: (providerUrl) => { - state.authentication.newlyRecordedMethods.push(providerUrl); - return api; - }, + get authentication() { + return authentication.build(api); }, cache: apiCache, diff --git a/src/test/api/authentication.test.ts b/src/test/api/authentication.test.ts new file mode 100644 index 0000000..7955e55 --- /dev/null +++ b/src/test/api/authentication.test.ts @@ -0,0 +1,111 @@ +import test from "node:test"; +import { authenticationMock } from "../../mock/api/authentication"; +import { ok, strictEqual } from "node:assert"; + +test("authentication mock", async (t) => { + const baseApi = Symbol("Base API"); + + await t.test("challengeWith", async (t) => { + await t.test("factor", async (t) => { + const { build, state } = authenticationMock("Another Flow", { userId: "42" }); + const api = build(baseApi); + + api.challengeWith({ type: "email" }); + + ok(state.challenge, "Expected challenge to be set"); + strictEqual(state.challenge.default?.type, "email", "Expected default factor to be set"); + strictEqual(state.challenge.allOptions?.length, 1, "Expected additional factors to be set"); + strictEqual(state.challenge.allOptions?.[0].type, "email", "Expected additional factors to be email"); + }); + + await t.test("additional factors", async (t) => { + const { build, state } = authenticationMock("Another Flow", { userId: "42" }); + const api = build(baseApi); + + api.challengeWith({ type: "otp" }, { additionalFactors: [{ type: "email" }] }); + + ok(state.challenge, "Expected challenge to be set"); + strictEqual(state.challenge.default?.type, "otp", "Expected default factor to be set"); + strictEqual(state.challenge.allOptions?.length, 2, "Expected additional factors to be set"); + strictEqual(state.challenge.allOptions?.[0].type, "otp", "Expected additional factor to be otp"); + strictEqual(state.challenge.allOptions?.[1].type, "email", "Expected additional factor to be email"); + }); + }); + + await t.test('challengeWithAny', async (t) => { + const { build, state } = authenticationMock("Another Flow", { userId: "42" }); + const api = build(baseApi); + + api.challengeWithAny([{ type: "email" }, { type: "otp" }]); + + ok(state.challenge, "Expected challenge to be set"); + strictEqual(state.challenge.default, undefined, "Expected default factor to be undefined"); + strictEqual(state.challenge.allOptions?.length, 2, "Expected additional factors to be set"); + strictEqual(state.challenge.allOptions?.[0].type, "email", "Expected additional factor to be email"); + strictEqual(state.challenge.allOptions?.[1].type, "otp", "Expected additional factor to be otp"); + }); + + await t.test("enrollWith", async (t) => { + await t.test("factor", async (t) => { + const { build, state } = authenticationMock("Another Flow", { userId: "42" }); + const api = build(baseApi); + + api.enrollWith({ type: "email" }); + + ok(state.enrollment, "Expected enrollment to be set"); + strictEqual(state.enrollment.default?.type, "email", "Expected default factor to be set"); + strictEqual(state.enrollment.allOptions?.length, 1, "Expected additional factors to be set"); + strictEqual(state.enrollment.allOptions?.[0].type, "email", "Expected additional factors to be email"); + }); + + await t.test("additional factors", async (t) => { + const { build, state } = authenticationMock("Another Flow", { userId: "42" }); + const api = build(baseApi); + + api.enrollWith({ type: "otp" }, { additionalFactors: [{ type: "email" }] }); + + ok(state.enrollment, "Expected enrollment to be set"); + strictEqual(state.enrollment.default?.type, "otp", "Expected default factor to be set"); + strictEqual(state.enrollment.allOptions?.length, 2, "Expected additional factors to be set"); + strictEqual(state.enrollment.allOptions?.[0].type, "otp", "Expected additional factor to be otp"); + strictEqual(state.enrollment.allOptions?.[1].type, "email", "Expected additional factor to be email"); + }); + }); + + await t.test('enrollWithAny', async (t) => { + const { build, state } = authenticationMock("Another Flow", { userId: "42" }); + const api = build(baseApi); + + api.enrollWithAny([{ type: "email" }, { type: "otp" }]); + + ok(state.enrollment, "Expected enrollment to be set"); + strictEqual(state.enrollment.default, undefined, "Expected default factor to be undefined"); + strictEqual(state.enrollment.allOptions?.length, 2, "Expected additional factors to be set"); + strictEqual(state.enrollment.allOptions?.[0].type, "email", "Expected additional factor to be email"); + strictEqual(state.enrollment.allOptions?.[1].type, "otp", "Expected additional factor to be otp"); + }); + + await t.test("setPrimaryUser", async (t) => { + const { build, state } = authenticationMock("Another Flow", { userId: "42" }); + const api = build(baseApi); + + api.setPrimaryUser("43"); + + strictEqual(state.primaryUserId, "43", "Expected primary user to be set"); + }); + + await t.test("recordMethod", async (t) => { + const { build, state } = authenticationMock("Another Flow", { userId: "42" }); + const api = build(baseApi); + + api.recordMethod("https://example.com"); + + strictEqual(state.newlyRecordedMethods.length, 1, "Expected newly recorded methods to be set"); + strictEqual(state.newlyRecordedMethods[0], "https://example.com", "Expected newly recorded method to be set"); + + api.recordMethod("https://another.example.com"); + + strictEqual(state.newlyRecordedMethods.length, 2, "Expected newly recorded methods to be set"); + strictEqual(state.newlyRecordedMethods[1], "https://another.example.com", "Expected newly recorded method to be set"); + }); +}); diff --git a/src/test/api/post-login.test.ts b/src/test/api/post-login.test.ts index a4e78ad..f163d81 100644 --- a/src/test/api/post-login.test.ts +++ b/src/test/api/post-login.test.ts @@ -379,14 +379,14 @@ test("PostLogin API", async (t) => { await t.test("can change the primary user ID", async (t) => { const { implementation: api, state } = postLogin(); const originalUserId = state.user.user_id; - strictEqual(state.primaryUserId, originalUserId); + strictEqual(state.authentication.primaryUserId, originalUserId); strictEqual( api.authentication.setPrimaryUser("new-primary-user-id"), undefined ); - strictEqual(state.primaryUserId, "new-primary-user-id"); + strictEqual(state.authentication.primaryUserId, "new-primary-user-id"); strictEqual(state.user.user_id, originalUserId); });