diff --git a/src/haapi-react-app/previewer/Previewer.tsx b/src/haapi-react-app/previewer/Previewer.tsx index bb82a497..5d13bc24 100644 --- a/src/haapi-react-app/previewer/Previewer.tsx +++ b/src/haapi-react-app/previewer/Previewer.tsx @@ -55,7 +55,7 @@ const baseContextValue: Omit = { history: [], config: { bootstrap: mockAppConfig, - pollingInterval: 0, + defaultPollingInterval: 0, bankIdAutostart: false, webAuthnAutostart: false, autoRedirectOnAuthenticationComplete: false, diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index 943459c5..1e612a8a 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -303,10 +303,10 @@ describe('HaapiStepper', () => { }); it('should handle polling steps with PENDING status and automatically poll until DONE', async () => { - const pollingInterval = 2000; + const defaultPollingInterval = 2000; render( - + ); @@ -333,7 +333,7 @@ describe('HaapiStepper', () => { // Mock the next poll request to still return PENDING and advance timers // This is an automatic poll request (setTimeout, no user action) mockHaapiFetchStep(HAAPI_STEPS.POLLING); - await vi.advanceTimersByTimeAsync(pollingInterval); + await vi.advanceTimersByTimeAsync(defaultPollingInterval); // eslint-disable-next-line @typescript-eslint/no-floating-promises vi.runAllTimersAsync(); @@ -348,7 +348,7 @@ describe('HaapiStepper', () => { // This is an automatic poll request (setTimeout, no user action) mockHaapiFetchStep(HAAPI_STEPS.POLLING, { status: HAAPI_POLLING_STATUS.DONE }); mockHaapiFetchStep(HAAPI_STEPS.AUTHENTICATION); - await vi.advanceTimersByTimeAsync(pollingInterval); + await vi.advanceTimersByTimeAsync(defaultPollingInterval); // eslint-disable-next-line @typescript-eslint/no-floating-promises vi.runAllTimersAsync(); @@ -366,10 +366,10 @@ describe('HaapiStepper', () => { describe('BankID Polling Step', () => { it('should display message, start bankid button, cancel button and QR code', async () => { - const pollingInterval = 2000; + const defaultPollingInterval = 2000; render( - + ); @@ -399,7 +399,7 @@ describe('HaapiStepper', () => { describe('config.bankIdAutostart = true', () => { it('should call openBankIdApp automatically only once', async () => { render( - + ); @@ -432,10 +432,10 @@ describe('HaapiStepper', () => { describe('config.bankIdAutostart = false', () => { it('should not call openBankIdApp automatically', async () => { - const pollingInterval = 2000; + const defaultPollingInterval = 2000; render( - + ); @@ -464,10 +464,10 @@ describe('HaapiStepper', () => { }); it('should call openBankIdApp when "Start BankID" button is clicked', async () => { - const pollingInterval = 2000; + const defaultPollingInterval = 2000; render( - + ); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.tsx b/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.tsx index 8a1c6f4f..1cdbe2e6 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper.tsx @@ -101,7 +101,7 @@ type SetCurrentStepAndUpdateHistoryFn = (nextStepData: HaapiStepperNextStepData) * > throws (see {@link useHaapiFetch}). * * Both modes can be combined with `config` overrides for other tunables - * (e.g. `pollingInterval`, `bankIdAutostart`); see {@link HaapiStepperConfig} + * (e.g. `defaultPollingInterval`, `bankIdAutostart`); see {@link HaapiStepperConfig} * for the full set. * * ## HAAPI stepper API @@ -557,7 +557,7 @@ function getInitialStepLink(initialUrl: string) { function resolveStepperConfig(config: Partial | undefined): Required { const { bootstrap, ...configResult } = { - pollingInterval: 3000, + defaultPollingInterval: 3000, bankIdAutostart: true, webAuthnAutostart: true, autoRedirectOnAuthenticationComplete: true, diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts index 4f988ab9..1c479824 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts @@ -78,7 +78,14 @@ export interface HaapiStepperAPI { */ export interface HaapiStepperConfig { bootstrap: HaapiStepperBootstrapConfig; - pollingInterval: number; + /** + * Polling interval in ms, used when the polling step does not carry one in + * `properties.interval` (see {@link HaapiPollingStep}). A server-provided value + * always wins, because the authenticator knows its backend's rate limits. + * + * Defaults to 3000. + */ + defaultPollingInterval: number; bankIdAutostart: boolean; webAuthnAutostart: boolean; autoRedirectOnAuthenticationComplete: boolean; diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/polling-step.spec.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/polling-step.spec.ts new file mode 100644 index 00000000..96a5bcd1 --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/polling-step.spec.ts @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2026 Curity AB. All rights reserved. + * + * The contents of this file are the property of Curity AB. + * You may not copy or use this file, in either source code + * or executable form, except in compliance with terms + * set by Curity AB. + * + * For further information, please contact Curity AB. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handlePollingStep } from './polling-step'; +import { pollingBankIdStep, pollingPendingStep } from '../../../util/tests/api-responses'; +import { createMockFormAction, createPollingStep, defaultStepperAPI } from '../../../util/tests/mocks'; +import { HAAPI_POLLING_STATUS, HaapiPollingStep } from '../../../data-access/types/haapi-step.types'; +import { HAAPI_ACTION_TYPES, HAAPI_FORM_ACTION_KINDS } from '../../../data-access/types/haapi-action.types'; +import type { + HaapiStepperConfig, + HaapiStepperHistoryEntry, + HaapiStepperPollingStep, + HaapiStepperStep, +} from '../haapi-stepper.types'; + +const mockOpenBankIdApp = vi.hoisted(() => vi.fn()); +vi.mock('../../actions/client-operation/operations/bankid/open-bankid-app', () => ({ + openBankIdApp: mockOpenBankIdApp, +})); + +const DEFAULT_POLLING_INTERVAL = 3000; +// The shared mock turns BankID autostart off; the SDK default is on, so mirror the default here. +const config: HaapiStepperConfig = { + ...defaultStepperAPI.config, + defaultPollingInterval: DEFAULT_POLLING_INTERVAL, + bankIdAutostart: true, +}; + +describe('handlePollingStep', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + mockOpenBankIdApp.mockReset(); + }); + + describe('Polling Status', () => { + describe('PENDING', () => { + it('triggers the poll action after the interval to request the next step', () => { + const { nextStep } = handleStep(getTestPollingStep()); + + vi.runAllTimers(); + + expect(nextStep).toHaveBeenCalledTimes(1); + expect(nextStep).toHaveBeenCalledWith(expect.objectContaining({ kind: HAAPI_FORM_ACTION_KINDS.POLL })); + }); + + it('returns the step without its poll action so the UI does not render it', () => { + const { resultStep } = handleStep(getTestPollingStep()); + + const kinds = (resultStep as HaapiStepperPollingStep).actions.map(action => action.kind); + expect(kinds).not.toContain(HAAPI_FORM_ACTION_KINDS.POLL); + expect(kinds).toContain(HAAPI_FORM_ACTION_KINDS.CANCEL); + }); + + it('keeps the scheduled timer in pendingOperation so the stepper can cancel it', () => { + const { pendingOperation } = handleStep(getTestPollingStep()); + + expect(pendingOperation.current).not.toBeNull(); + }); + + it('throws when the step carries no poll action', () => { + const stepWithoutPollAction = createPollingStep({ + actions: [createMockFormAction({ kind: HAAPI_FORM_ACTION_KINDS.CANCEL })], + }); + + expect(() => handleStep(stepWithoutPollAction)).toThrow( + 'Polling step must have a poll action when status is pending' + ); + }); + + describe('polling interval', () => { + it('uses the server interval when the step carries one', () => { + const stepPollingInterval = '500'; + const testPollingStep = getTestPollingStep(stepPollingInterval); + + expectNextPollScheduledAfter(stepPollingInterval, testPollingStep); + }); + + it('falls back to defaultPollingInterval when the step carries none', () => { + const testPollingStep = getTestPollingStep(); + + expectNextPollScheduledAfter(DEFAULT_POLLING_INTERVAL, testPollingStep); + }); + + it.each(['abc', '0', '-1', '', 'Infinity'])( + 'falls back to defaultPollingInterval for a malformed server interval (%j)', + stepPollingInterval => { + const testPollingStep = getTestPollingStep(stepPollingInterval); + + expectNextPollScheduledAfter(DEFAULT_POLLING_INTERVAL, testPollingStep); + } + ); + }); + }); + + describe('DONE', () => { + it('triggers the first form action right away to request the next step', () => { + const doneStep = pollingPendingStep('/polling-url', HAAPI_POLLING_STATUS.DONE); + + const { nextStep } = handleStep(doneStep); + + expect(nextStep).toHaveBeenCalledTimes(1); + expect(nextStep).toHaveBeenCalledWith( + expect.objectContaining({ kind: HAAPI_FORM_ACTION_KINDS.AUTHENTICATOR_SELECTOR }) + ); + }); + + it('does not trigger any action when the step carries none', () => { + const doneStepWithoutActions = createPollingStep({ status: HAAPI_POLLING_STATUS.DONE, actions: [] }); + + const { nextStep, resultStep } = handleStep(doneStepWithoutActions); + + expect(nextStep).not.toHaveBeenCalled(); + expect(resultStep.type).toBe(doneStepWithoutActions.type); + }); + }); + + describe('FAILED', () => { + it.each([HAAPI_FORM_ACTION_KINDS.REDIRECT, HAAPI_FORM_ACTION_KINDS.CONTINUE])( + 'triggers the only action to request the next step when it is a %s', + kind => { + const failedStep = createPollingStep({ + status: HAAPI_POLLING_STATUS.FAILED, + actions: [createMockFormAction({ kind })], + }); + + const { nextStep } = handleStep(failedStep); + + expect(nextStep).toHaveBeenCalledTimes(1); + expect(nextStep).toHaveBeenCalledWith(expect.objectContaining({ kind })); + } + ); + + it('does not trigger the only action when it needs the user, such as cancel', () => { + const failedStep = createPollingStep({ + status: HAAPI_POLLING_STATUS.FAILED, + actions: [createMockFormAction({ kind: HAAPI_FORM_ACTION_KINDS.CANCEL })], + }); + + const { nextStep } = handleStep(failedStep); + + expect(nextStep).not.toHaveBeenCalled(); + }); + + it('does not trigger any action when the user has several to choose from', () => { + const failedStep = createPollingStep({ + status: HAAPI_POLLING_STATUS.FAILED, + actions: [ + createMockFormAction({ kind: HAAPI_FORM_ACTION_KINDS.REDIRECT }), + createMockFormAction({ kind: HAAPI_FORM_ACTION_KINDS.CANCEL }), + ], + }); + + const { nextStep } = handleStep(failedStep); + + expect(nextStep).not.toHaveBeenCalled(); + }); + }); + }); + + describe('BankID', () => { + describe('autostart', () => { + it('opens the BankID app on the first pending BankID step', () => { + handleStep(pollingBankIdStep('/polling-url')); + + expect(mockOpenBankIdApp).toHaveBeenCalledTimes(1); + expect(mockOpenBankIdApp).toHaveBeenCalledWith( + expect.objectContaining({ template: HAAPI_ACTION_TYPES.CLIENT_OPERATION }) + ); + }); + + it('does not open the BankID app when autostart is off', () => { + handleStep(pollingBankIdStep('/polling-url'), { config: { ...config, bankIdAutostart: false } }); + + expect(mockOpenBankIdApp).not.toHaveBeenCalled(); + }); + + it('opens the BankID app only once across consecutive polling steps', () => { + const { resultStep: firstPollingStep } = handleStep(pollingBankIdStep('/polling-url')); + const historyWithPreviousPollingStep = { history: [historyEntryFor(firstPollingStep)] }; + + handleStep(pollingBankIdStep('/polling-url'), historyWithPreviousPollingStep); + + expect(mockOpenBankIdApp).toHaveBeenCalledTimes(1); + }); + + it('does not open the BankID app for a polling step of another authenticator', () => { + handleStep(getTestPollingStep()); + + expect(mockOpenBankIdApp).not.toHaveBeenCalled(); + }); + }); + }); +}); + +function getTestPollingStep(stepPollingInterval?: string): HaapiPollingStep { + return createPollingStep({ + interval: stepPollingInterval, + actions: [ + createMockFormAction({ kind: HAAPI_FORM_ACTION_KINDS.POLL }), + createMockFormAction({ kind: HAAPI_FORM_ACTION_KINDS.CANCEL }), + ], + }); +} + +// Mirrors what the stepper records after handling a step, so the follow-up poll sees it as its predecessor. +function historyEntryFor(step: HaapiStepperStep): HaapiStepperHistoryEntry { + return { step, timestamp: new Date() } as HaapiStepperHistoryEntry; +} + +function handleStep( + pollingStep: HaapiPollingStep, + options: { config?: HaapiStepperConfig; history?: HaapiStepperHistoryEntry[] } = {} +) { + const nextStep = vi.fn(); + const pendingOperation: { current: AbortController | NodeJS.Timeout | null } = { current: null }; + const resultStep = handlePollingStep( + pollingStep, + pendingOperation, + nextStep, + options.config ?? config, + options.history ?? [] + ); + return { nextStep, pendingOperation, resultStep }; +} + +function expectNextPollScheduledAfter(expectedInterval: string | number, pollingStep: HaapiPollingStep): void { + const { nextStep } = handleStep(pollingStep); + + const ms = Number(expectedInterval); + vi.advanceTimersByTime(ms - 1); + expect(nextStep).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(nextStep).toHaveBeenCalledTimes(1); +} diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/polling-step.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/polling-step.ts index d4e183c0..614d8f0f 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/polling-step.ts +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/step-handlers/polling-step.ts @@ -31,9 +31,7 @@ export function handlePollingStep( ): HaapiStepperStep { const formattedPollingStep = formatStepData(pollingStep); const pollingStatus = pollingStep.properties.status; - const pollingInterval = pollingStep.properties.interval - ? Number(pollingStep.properties.interval) - : config.pollingInterval; + const pollingInterval = resolvePollingInterval(pollingStep.properties.interval, config.defaultPollingInterval); switch (pollingStatus) { case HAAPI_POLLING_STATUS.DONE: { @@ -89,6 +87,11 @@ export function handlePollingStep( } } +function resolvePollingInterval(serverInterval: string | undefined, defaultInterval: number): number { + const parsed = Number(serverInterval); + return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultInterval; +} + function isBankIdPollingSession(pollingStep: HaapiStepperPollingStep): boolean { return !!pollingStep.actions.find(isBankIdClientOperation); } diff --git a/src/haapi-react-sdk/haapi-stepper/util/tests/mocks.ts b/src/haapi-react-sdk/haapi-stepper/util/tests/mocks.ts index 2c4a5f17..f4c0bffb 100644 --- a/src/haapi-react-sdk/haapi-stepper/util/tests/mocks.ts +++ b/src/haapi-react-sdk/haapi-stepper/util/tests/mocks.ts @@ -197,7 +197,7 @@ export const defaultStepperAPI: HaapiStepperAPI = { }, theme: { stepSymbols: { default: MockStepSymbolPath } }, }, - pollingInterval: 0, + defaultPollingInterval: 0, bankIdAutostart: false, webAuthnAutostart: false, autoRedirectOnAuthenticationComplete: false, @@ -310,6 +310,7 @@ export const createPollingStep = ( viewName?: string; maxWaitTime?: string; maxWaitRemainingTime?: string; + interval?: string; viewDataMessages?: Record; } = {} ): HaapiStepperPollingStep => { @@ -323,6 +324,7 @@ export const createPollingStep = ( status: overrides.status ?? HAAPI_POLLING_STATUS.PENDING, ...(overrides.maxWaitTime !== undefined && { maxWaitTime: overrides.maxWaitTime }), ...(overrides.maxWaitRemainingTime !== undefined && { maxWaitRemainingTime: overrides.maxWaitRemainingTime }), + ...(overrides.interval !== undefined && { interval: overrides.interval }), }, ...(overrides.links !== undefined && { links: overrides.links }), ...(overrides.actions !== undefined && { actions: overrides.actions }),