|
| 1 | +// Copyright 2026 The ThunderID Authors |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +import {beforeEach, describe, expect, it, Mock, vi} from 'vitest'; |
| 5 | +import ThunderIDAPIError from '../../../errors/ThunderIDAPIError'; |
| 6 | +import {EmbeddedFlowType} from '../../../models/embedded-flow'; |
| 7 | +import { |
| 8 | + EmbeddedSelfServiceFlowRequest, |
| 9 | + EmbeddedSelfServiceFlowResponse, |
| 10 | + EmbeddedSelfServiceFlowStatus, |
| 11 | +} from '../../../models/embedded-self-service-flow'; |
| 12 | +import executeEmbeddedSelfServiceFlow from '../executeEmbeddedSelfServiceFlow'; |
| 13 | + |
| 14 | +const URL = 'https://localhost:8090/flow/execute'; |
| 15 | + |
| 16 | +const initiate: EmbeddedSelfServiceFlowRequest = { |
| 17 | + flowType: EmbeddedFlowType.SelfService, |
| 18 | + handle: 'default-self-attribute-verification-flow', |
| 19 | + inputs: {attribute: 'email'}, |
| 20 | +}; |
| 21 | + |
| 22 | +type Fetcher = (url: string, config: RequestInit) => Promise<Response>; |
| 23 | + |
| 24 | +const flowResponse: EmbeddedSelfServiceFlowResponse = { |
| 25 | + data: {additionalData: {emailSent: 'true', maskedDestination: 'a•••@example.com'}}, |
| 26 | + executionId: 'exec-1', |
| 27 | + flowStatus: EmbeddedSelfServiceFlowStatus.Incomplete, |
| 28 | +}; |
| 29 | + |
| 30 | +const lastRequest = (fetcher: Mock<Fetcher>): [string, RequestInit] => |
| 31 | + fetcher.mock.calls[fetcher.mock.calls.length - 1] as [string, RequestInit]; |
| 32 | + |
| 33 | +describe('executeEmbeddedSelfServiceFlow', (): void => { |
| 34 | + let fetcher: Mock<Fetcher>; |
| 35 | + |
| 36 | + beforeEach((): void => { |
| 37 | + fetcher = vi.fn<Fetcher>().mockResolvedValue({json: () => Promise.resolve(flowResponse), ok: true} as Response); |
| 38 | + }); |
| 39 | + |
| 40 | + it('starts the flow by type and handle with its inputs and returns the step', async (): Promise<void> => { |
| 41 | + await expect(executeEmbeddedSelfServiceFlow({fetcher, payload: initiate, url: URL})).resolves.toEqual(flowResponse); |
| 42 | + |
| 43 | + const [url, init] = lastRequest(fetcher); |
| 44 | + expect(url).toBe(URL); |
| 45 | + expect(init.method).toBe('POST'); |
| 46 | + expect(JSON.parse(init.body as string)).toEqual({ |
| 47 | + flowType: 'SELF_SERVICE', |
| 48 | + handle: 'default-self-attribute-verification-flow', |
| 49 | + inputs: {attribute: 'email'}, |
| 50 | + verbose: true, |
| 51 | + }); |
| 52 | + expect(init.headers).toMatchObject({Accept: 'application/json', 'Content-Type': 'application/json'}); |
| 53 | + }); |
| 54 | + |
| 55 | + it('continues an execution without naming the handle again', async (): Promise<void> => { |
| 56 | + await executeEmbeddedSelfServiceFlow({ |
| 57 | + fetcher, |
| 58 | + payload: {action: 'verify', challengeToken: 'ct-1', executionId: 'exec-1', inputs: {otp: '123456'}}, |
| 59 | + url: URL, |
| 60 | + }); |
| 61 | + |
| 62 | + expect(JSON.parse(lastRequest(fetcher)[1].body as string)).toEqual({ |
| 63 | + action: 'verify', |
| 64 | + challengeToken: 'ct-1', |
| 65 | + executionId: 'exec-1', |
| 66 | + inputs: {otp: '123456'}, |
| 67 | + verbose: true, |
| 68 | + }); |
| 69 | + }); |
| 70 | + |
| 71 | + it('builds the endpoint from baseUrl when url is not given', async (): Promise<void> => { |
| 72 | + await executeEmbeddedSelfServiceFlow({baseUrl: 'https://localhost:8090', fetcher, payload: initiate}); |
| 73 | + |
| 74 | + expect(lastRequest(fetcher)[0]).toBe('https://localhost:8090/flow/execute'); |
| 75 | + }); |
| 76 | + |
| 77 | + it('keeps caller headers while setting the JSON headers', async (): Promise<void> => { |
| 78 | + await executeEmbeddedSelfServiceFlow({ |
| 79 | + fetcher, |
| 80 | + headers: {'X-Request-Id': 'req-1'}, |
| 81 | + payload: initiate, |
| 82 | + url: URL, |
| 83 | + }); |
| 84 | + |
| 85 | + expect(lastRequest(fetcher)[1].headers).toMatchObject({ |
| 86 | + 'Content-Type': 'application/json', |
| 87 | + 'X-Request-Id': 'req-1', |
| 88 | + }); |
| 89 | + }); |
| 90 | + |
| 91 | + it('uses native fetch when no fetcher is given', async (): Promise<void> => { |
| 92 | + const nativeFetch = vi.fn().mockResolvedValue({json: () => Promise.resolve(flowResponse), ok: true}); |
| 93 | + vi.stubGlobal('fetch', nativeFetch); |
| 94 | + |
| 95 | + try { |
| 96 | + await executeEmbeddedSelfServiceFlow({payload: initiate, url: URL}); |
| 97 | + } finally { |
| 98 | + vi.unstubAllGlobals(); |
| 99 | + } |
| 100 | + |
| 101 | + expect(nativeFetch).toHaveBeenCalledWith(URL, expect.objectContaining({method: 'POST'})); |
| 102 | + }); |
| 103 | + |
| 104 | + describe('validation', (): void => { |
| 105 | + it.each([ |
| 106 | + ['no url', {}], |
| 107 | + ['an invalid url', {url: 'not-a-url'}], |
| 108 | + ['an invalid baseUrl', {baseUrl: 'invalid'}], |
| 109 | + ])('refuses %s before sending', async (_name: string, endpoint: Record<string, string>): Promise<void> => { |
| 110 | + const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({ |
| 111 | + ...endpoint, |
| 112 | + fetcher, |
| 113 | + payload: initiate, |
| 114 | + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; |
| 115 | + |
| 116 | + expect(error).toBeInstanceOf(ThunderIDAPIError); |
| 117 | + expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ValidationError-001'); |
| 118 | + expect(error.statusCode).toBe(400); |
| 119 | + expect(fetcher).not.toHaveBeenCalled(); |
| 120 | + }); |
| 121 | + |
| 122 | + it('refuses a missing payload before sending', async (): Promise<void> => { |
| 123 | + const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({ |
| 124 | + fetcher, |
| 125 | + payload: undefined as unknown as EmbeddedSelfServiceFlowRequest, |
| 126 | + url: URL, |
| 127 | + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; |
| 128 | + |
| 129 | + expect(error).toBeInstanceOf(ThunderIDAPIError); |
| 130 | + expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ValidationError-002'); |
| 131 | + expect(fetcher).not.toHaveBeenCalled(); |
| 132 | + }); |
| 133 | + |
| 134 | + it.each<[string, EmbeddedSelfServiceFlowRequest]>([ |
| 135 | + ['no handle', {flowType: EmbeddedFlowType.SelfService, inputs: {attribute: 'email'}}], |
| 136 | + ['an empty handle', {flowType: EmbeddedFlowType.SelfService, handle: '', inputs: {attribute: 'email'}}], |
| 137 | + ])( |
| 138 | + 'refuses a new flow with %s before sending', |
| 139 | + async (_name: string, payload: EmbeddedSelfServiceFlowRequest): Promise<void> => { |
| 140 | + const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({ |
| 141 | + fetcher, |
| 142 | + payload, |
| 143 | + url: URL, |
| 144 | + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; |
| 145 | + |
| 146 | + expect(error).toBeInstanceOf(ThunderIDAPIError); |
| 147 | + expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ValidationError-003'); |
| 148 | + expect(fetcher).not.toHaveBeenCalled(); |
| 149 | + }, |
| 150 | + ); |
| 151 | + }); |
| 152 | + |
| 153 | + describe('response errors', (): void => { |
| 154 | + it('maps a failed response to an API error with its status', async (): Promise<void> => { |
| 155 | + fetcher.mockResolvedValue({ |
| 156 | + ok: false, |
| 157 | + status: 401, |
| 158 | + statusText: 'Unauthorized', |
| 159 | + text: () => Promise.resolve('{"code":"AUTH-1001","message":"Unauthorized","description":"invalid token"}'), |
| 160 | + } as unknown as Response); |
| 161 | + |
| 162 | + const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({ |
| 163 | + fetcher, |
| 164 | + payload: initiate, |
| 165 | + url: URL, |
| 166 | + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; |
| 167 | + |
| 168 | + expect(error).toBeInstanceOf(ThunderIDAPIError); |
| 169 | + expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ResponseError-001'); |
| 170 | + expect(error.statusCode).toBe(401); |
| 171 | + expect(error.statusText).toBe('Unauthorized'); |
| 172 | + expect(error.message).toMatch(/^Self-service flow request failed: /); |
| 173 | + }); |
| 174 | + |
| 175 | + it('maps a network failure to a network error', async (): Promise<void> => { |
| 176 | + fetcher.mockRejectedValue(new TypeError('Failed to fetch')); |
| 177 | + |
| 178 | + const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({ |
| 179 | + fetcher, |
| 180 | + payload: initiate, |
| 181 | + url: URL, |
| 182 | + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; |
| 183 | + |
| 184 | + expect(error).toBeInstanceOf(ThunderIDAPIError); |
| 185 | + expect(error.code).toBe('executeEmbeddedSelfServiceFlow-NetworkError-001'); |
| 186 | + expect(error.statusCode).toBe(0); |
| 187 | + expect(error.message).toContain('Failed to fetch'); |
| 188 | + }); |
| 189 | + |
| 190 | + it('maps an unreadable body to a network error', async (): Promise<void> => { |
| 191 | + fetcher.mockResolvedValue({ |
| 192 | + json: () => Promise.reject(new SyntaxError('Unexpected token')), |
| 193 | + ok: true, |
| 194 | + } as unknown as Response); |
| 195 | + |
| 196 | + const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({ |
| 197 | + fetcher, |
| 198 | + payload: initiate, |
| 199 | + url: URL, |
| 200 | + }).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError; |
| 201 | + |
| 202 | + expect(error.code).toBe('executeEmbeddedSelfServiceFlow-NetworkError-001'); |
| 203 | + }); |
| 204 | + |
| 205 | + it('returns a flow error step as data rather than throwing', async (): Promise<void> => { |
| 206 | + const refused: EmbeddedSelfServiceFlowResponse = { |
| 207 | + error: {code: 'FET-1112'} as EmbeddedSelfServiceFlowResponse['error'], |
| 208 | + executionId: 'exec-1', |
| 209 | + flowStatus: EmbeddedSelfServiceFlowStatus.Error, |
| 210 | + }; |
| 211 | + fetcher.mockResolvedValue({json: () => Promise.resolve(refused), ok: true} as unknown as Response); |
| 212 | + |
| 213 | + await expect(executeEmbeddedSelfServiceFlow({fetcher, payload: initiate, url: URL})).resolves.toEqual(refused); |
| 214 | + }); |
| 215 | + }); |
| 216 | +}); |
0 commit comments