Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ import {
createMockWebAuthnRegistrationAction,
externalBrowserFlowActionTitle,
webAuthnAnyDeviceActionTitle,
webAuthnPlatformOnlyAnyDeviceActionTitle,
webAuthnRegistrationActionTitle,
} from '../../../util/tests/mocks';
import { HaapiStepperActionsUI } from '../../../ui/actions/HaapiStepperActionsUI';
import { HaapiStepperClientOperationUI } from './HaapiStepperClientOperationUI';
import { WebAuthnRegistrationAttachmentKind } from '../../stepper/haapi-stepper.types';
import type { HaapiStepperStep } from '../../stepper/haapi-stepper.types';
import { useHaapiStepper } from '../../stepper/HaapiStepperHook';
import { useIsWebAuthnPlatformAuthenticatorAvailable } from './operations/webauthn/useIsWebAuthnPlatformAuthenticatorAvailable';

const PLATFORM_TITLE = 'Built-in';
Expand All @@ -38,7 +38,6 @@ const CROSS_PLATFORM_TITLE = 'Security key';
const CROSS_PLATFORM_DESCRIPTION =
'A security key, such as a portable key attached through USB or wirelessly through NFC.';
const CROSS_PLATFORM_ICON_VIEW_BOX = '0 0 49 72.6';
const REGISTER_VIEW_NAME = 'authenticator/webauthn/register/get';
const MESSAGE_PREFIX = 'authenticator.webauthn.register.view.';
const VIEW_DATA_MESSAGES = {
[`${MESSAGE_PREFIX}button.platform`]: PLATFORM_TITLE,
Expand All @@ -51,11 +50,25 @@ vi.mock('./operations/webauthn/useIsWebAuthnPlatformAuthenticatorAvailable', ()
useIsWebAuthnPlatformAuthenticatorAvailable: vi.fn(() => undefined),
}));

// The attachment card resolves its copy from the current step's viewData messages via
// `useHaapiStepper`; tests drive that copy through the mocked hook.
vi.mock('../../stepper/HaapiStepperHook', () => ({ useHaapiStepper: vi.fn() }));

const mockCurrentStepMessages = (messages?: Record<string, string>) =>
vi.mocked(useHaapiStepper).mockReturnValue({
currentStep: (messages ? { metadata: { viewData: { messages } } } : null) as HaapiStepperStep | null,
} as ReturnType<typeof useHaapiStepper>);

describe('HaapiStepperClientOperationUI', () => {
let user: ReturnType<typeof userEvent.setup>;

beforeEach(() => {
user = userEvent.setup();
mockCurrentStepMessages(VIEW_DATA_MESSAGES);
});

afterEach(() => {
vi.mocked(useHaapiStepper).mockReset();
});

describe('Default rendering', () => {
Expand Down Expand Up @@ -113,52 +126,45 @@ describe('HaapiStepperClientOperationUI', () => {
expect(screen.getByRole('button', { name: webAuthnRegistrationActionTitle })).toBeDisabled();
});

it('disables a platform-only any-device registration when no platform authenticator is available', () => {
vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential());
vi.mocked(useIsWebAuthnPlatformAuthenticatorAvailable).mockReturnValue(false);
const action = createMockWebAuthnPlatformOnlyAnyDeviceAction();
it('renders a default button (not a card) for passkeys-mode registration', () => {
const action = createMockWebAuthnRegistrationAction();

render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} />);

expect(screen.getByRole('button', { name: webAuthnPlatformOnlyAnyDeviceActionTitle })).toBeDisabled();
expect(screen.queryByTestId('webauthn-registration-attachment-card')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: webAuthnRegistrationActionTitle })).toBeInTheDocument();
});

it('renders one button per credential option for any-device-mode with both options, suffixing the original title', () => {
const action = createMockWebAuthnAnyDeviceBothOptionsAction();
const step = createMockStep(HAAPI_STEPS.AUTHENTICATION, { actions: [action] });
it('disables a platform-only any-device registration when no platform authenticator is available', () => {
vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential());
vi.mocked(useIsWebAuthnPlatformAuthenticatorAvailable).mockReturnValue(false);
const action = createMockWebAuthnPlatformOnlyAnyDeviceAction();

render(<HaapiStepperActionsUI actions={step.dataHelpers.actions?.all} onAction={vi.fn()} />);
render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} />);

expect(screen.getByRole('button', { name: `${webAuthnAnyDeviceActionTitle} (Built-in)` })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: `${webAuthnAnyDeviceActionTitle} (Security key)` })
).toBeInTheDocument();
expect(screen.getByTestId('webauthn-registration-attachment-card')).toBeDisabled();
});

describe('WebAuthn registration attachment card (any device mode (platform/cross-platform))', () => {
it.each([
{
name: 'platform',
makeAction: createMockWebAuthnPlatformOnlyAnyDeviceAction,
kind: WebAuthnRegistrationAttachmentKind.PLATFORM,
title: PLATFORM_TITLE,
description: PLATFORM_DESCRIPTION,
iconViewBox: PLATFORM_ICON_VIEW_BOX,
},
{
name: 'cross-platform',
makeAction: createMockWebAuthnCrossPlatformOnlyAnyDeviceAction,
kind: WebAuthnRegistrationAttachmentKind.CROSS_PLATFORM,
title: CROSS_PLATFORM_TITLE,
description: CROSS_PLATFORM_DESCRIPTION,
iconViewBox: CROSS_PLATFORM_ICON_VIEW_BOX,
},
])(
'renders the $name attachment option as a card (title, description and matching icon)',
({ makeAction, kind, title, description, iconViewBox }) => {
const action = { ...makeAction(), webauthn: { registrationAttachment: { kind, title, description } } };

const { container } = render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} />);
({ makeAction, title, description, iconViewBox }) => {
const { container } = render(<HaapiStepperClientOperationUI action={makeAction()} onAction={vi.fn()} />);

expect(screen.getByTestId('webauthn-registration-attachment-card')).toBeInTheDocument();
expect(screen.getByText(title)).toBeInTheDocument();
Expand All @@ -170,10 +176,9 @@ describe('HaapiStepperClientOperationUI', () => {
}
);

it('renders one card per option when a both-options any-device registration supplies messages', () => {
it('renders one card per option for a both-options any-device registration', () => {
const step = createMockStep(HAAPI_STEPS.REGISTRATION, {
actions: [createMockWebAuthnAnyDeviceBothOptionsAction()],
metadata: { viewName: REGISTER_VIEW_NAME, viewData: { messages: VIEW_DATA_MESSAGES } },
});

render(<HaapiStepperActionsUI actions={step.dataHelpers.actions?.all} onAction={vi.fn()} />);
Expand All @@ -184,6 +189,7 @@ describe('HaapiStepperClientOperationUI', () => {
});

it('renders the cards with the split action titles as fallback when the step carries no messages', () => {
mockCurrentStepMessages(undefined);
const step = createMockStep(HAAPI_STEPS.REGISTRATION, {
actions: [createMockWebAuthnAnyDeviceBothOptionsAction()],
});
Expand All @@ -197,16 +203,6 @@ describe('HaapiStepperClientOperationUI', () => {
expect(cards[0].querySelector('.haapi-stepper-webauthn-registration-attachment-description')).toBeNull();
expect(cards[1].querySelector('.haapi-stepper-webauthn-registration-attachment-description')).toBeNull();
});

it('renders the default button (no card) when the action carries no webauthn data', () => {
render(
<HaapiStepperClientOperationUI action={createMockWebAuthnPlatformOnlyAnyDeviceAction()} onAction={vi.fn()} />
);

expect(screen.queryByTestId('webauthn-registration-attachment-card')).not.toBeInTheDocument();
expect(screen.getByTestId('client-operation-action')).toBeInTheDocument();
expect(screen.getByRole('button', { name: webAuthnPlatformOnlyAnyDeviceActionTitle })).toBeInTheDocument();
});
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { HaapiStepperClientOperationAction, HaapiStepperFormAction } from '../../stepper/haapi-stepper.types';
import { HaapiStepperWebAuthnRegistrationAttachmentCard } from './operations/webauthn/HaapiStepperWebAuthnRegistrationAttachmentCard';
import { isAnyDeviceWebAuthnRegistrationClientOperation } from './operations/webauthn';
import { useIsClientOperationAvailable } from './useIsClientOperationAvailable';

interface HaapiStepperClientOperationUIProps {
Expand Down Expand Up @@ -46,10 +47,10 @@ interface HaapiStepperClientOperationUIProps {
export function HaapiStepperClientOperationUI({ action, onAction }: HaapiStepperClientOperationUIProps) {
const isAvailable = useIsClientOperationAvailable(action);

if (action.webauthn?.registrationAttachment) {
if (isAnyDeviceWebAuthnRegistrationClientOperation(action)) {
return (
<HaapiStepperWebAuthnRegistrationAttachmentCard
registrationAttachment={action.webauthn.registrationAttachment}
action={action}
disabled={!isAvailable}
onClick={() => onAction(action)}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,81 +11,100 @@

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
createMockWebAuthnCrossPlatformOnlyAnyDeviceAction,
createMockWebAuthnPlatformOnlyAnyDeviceAction,
webAuthnPlatformOnlyAnyDeviceActionTitle,
} from '../../../../../util/tests/mocks';
import { HaapiStepperWebAuthnRegistrationAttachmentCard } from './HaapiStepperWebAuthnRegistrationAttachmentCard';
import { WebAuthnRegistrationAttachmentKind } from '../../../../stepper/haapi-stepper.types';
import { useHaapiStepper } from '../../../../stepper/HaapiStepperHook';
import type { HaapiStepperStep } from '../../../../stepper/haapi-stepper.types';

vi.mock('../../../../stepper/HaapiStepperHook', () => ({ useHaapiStepper: vi.fn() }));

const PLATFORM_ICON_VIEW_BOX = '0 0 64.2 83.9';
const CROSS_PLATFORM_ICON_VIEW_BOX = '0 0 49 72.6';
const PREFIX = 'authenticator.webauthn.register.view.';
const PLATFORM_TITLE = 'Built-in';
const PLATFORM_DESCRIPTION = 'A non-removable built-in device.';
const CROSS_PLATFORM_TITLE = 'Security key';

// The card resolves its copy from the current step's viewData messages via `useHaapiStepper`.
const mockCurrentStepMessages = (messages?: Record<string, string>) =>
vi.mocked(useHaapiStepper).mockReturnValue({
currentStep: (messages ? { metadata: { viewData: { messages } } } : null) as HaapiStepperStep | null,
} as ReturnType<typeof useHaapiStepper>);

// Safe default so a test that forgets to configure the hook gets a clear assertion
// failure rather than an opaque "cannot destructure currentStep" crash.
beforeEach(() => mockCurrentStepMessages(undefined));

afterEach(() => vi.mocked(useHaapiStepper).mockReset());
Comment thread
aleixsuau marked this conversation as resolved.

describe('HaapiStepperWebAuthnRegistrationAttachmentCard', () => {
it('renders the title, description and matching icon resolved in the action webauthn data', () => {
const action = {
...createMockWebAuthnPlatformOnlyAnyDeviceAction(),
webauthn: {
registrationAttachment: {
kind: WebAuthnRegistrationAttachmentKind.PLATFORM,
title: 'Built-in',
description: 'A non-removable built-in device.',
},
},
};
it("renders the title, description and matching icon from the step's data", () => {
mockCurrentStepMessages({
[`${PREFIX}button.platform`]: PLATFORM_TITLE,
[`${PREFIX}authenticator-attachment.platform`]: PLATFORM_DESCRIPTION,
});

const { container } = render(
<HaapiStepperWebAuthnRegistrationAttachmentCard
registrationAttachment={action.webauthn.registrationAttachment}
action={createMockWebAuthnPlatformOnlyAnyDeviceAction()}
onClick={vi.fn()}
/>
);

expect(screen.getByText('Built-in')).toBeInTheDocument();
expect(screen.getByText('A non-removable built-in device.')).toBeInTheDocument();
expect(screen.getByText(PLATFORM_TITLE)).toBeInTheDocument();
expect(screen.getByText(PLATFORM_DESCRIPTION)).toBeInTheDocument();
expect(container.querySelector('.haapi-stepper-webauthn-registration-attachment-icon svg')).toHaveAttribute(
'viewBox',
PLATFORM_ICON_VIEW_BOX
);
});

it('omits the description when the webauthn data has none, still rendering the icon', () => {
const action = {
...createMockWebAuthnCrossPlatformOnlyAnyDeviceAction(),
webauthn: {
registrationAttachment: { kind: WebAuthnRegistrationAttachmentKind.CROSS_PLATFORM, title: 'Security key' },
},
};
it('omits the description when the step has no description message, still rendering the icon', () => {
mockCurrentStepMessages({ [`${PREFIX}button.cross-platform`]: CROSS_PLATFORM_TITLE });

const { container } = render(
<HaapiStepperWebAuthnRegistrationAttachmentCard
registrationAttachment={action.webauthn.registrationAttachment}
action={createMockWebAuthnCrossPlatformOnlyAnyDeviceAction()}
onClick={vi.fn()}
/>
);

const card = screen.getByTestId('webauthn-registration-attachment-card');
expect(screen.getByText('Security key')).toBeInTheDocument();
expect(screen.getByText(CROSS_PLATFORM_TITLE)).toBeInTheDocument();
expect(card.querySelector('.haapi-stepper-webauthn-registration-attachment-description')).toBeNull();
expect(container.querySelector('.haapi-stepper-webauthn-registration-attachment-icon svg')).toHaveAttribute(
'viewBox',
CROSS_PLATFORM_ICON_VIEW_BOX
);
});

it('falls back to the action title when the step carries no view-data', () => {
mockCurrentStepMessages(undefined);

render(
<HaapiStepperWebAuthnRegistrationAttachmentCard
action={createMockWebAuthnPlatformOnlyAnyDeviceAction()}
onClick={vi.fn()}
/>
);

expect(screen.getByText(webAuthnPlatformOnlyAnyDeviceActionTitle)).toBeInTheDocument();
});

it('calls onClick when the card is clicked', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const action = {
...createMockWebAuthnPlatformOnlyAnyDeviceAction(),
webauthn: { registrationAttachment: { kind: WebAuthnRegistrationAttachmentKind.PLATFORM, title: 'Built-in' } },
};
mockCurrentStepMessages(undefined);

render(
<HaapiStepperWebAuthnRegistrationAttachmentCard
registrationAttachment={action.webauthn.registrationAttachment}
action={createMockWebAuthnPlatformOnlyAnyDeviceAction()}
onClick={onClick}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
* For further information, please contact Curity AB.
*/

import { HaapiStepperWebAuthnRegistrationAttachment } from '../../../../stepper/haapi-stepper.types';
import { HaapiStepperWebAuthnAnyDeviceRegistrationAction } from '../../../../stepper/haapi-stepper.types';
import { useHaapiStepper } from '../../../../stepper/HaapiStepperHook';
import { getAnyDeviceWebAuthnRegistrationAttachment } from './webauthn-registration-attachment';
import { REGISTRATION_ATTACHMENT_ICON } from './webauthn-registration-attachment-icon-map';

export interface HaapiStepperWebAuthnRegistrationAttachmentCardProps {
/** The (split) any-device `webauthn-registration` action this option advances the flow with. */
registrationAttachment: HaapiStepperWebAuthnRegistrationAttachment;
action: HaapiStepperWebAuthnAnyDeviceRegistrationAction;
/** Disables the card (e.g. when the required authenticator capability is unavailable). */
disabled?: boolean;
onClick: () => void;
Expand All @@ -24,14 +26,18 @@ export interface HaapiStepperWebAuthnRegistrationAttachmentCardProps {
* A single WebAuthn registration attachment-selection option, rendered as a clickable card
* (icon + bold title + description).
*
* Exported so consumers building a custom WebAuthn registration UI can reuse it.
* Must be rendered within `HaapiStepper`, for the current active step's action.
*/
export const HaapiStepperWebAuthnRegistrationAttachmentCard = ({
registrationAttachment,
action,
disabled,
onClick,
}: HaapiStepperWebAuthnRegistrationAttachmentCardProps) => {
const { kind, title, description } = registrationAttachment;
const { currentStep } = useHaapiStepper();
Comment thread
aleixsuau marked this conversation as resolved.
const { kind, title, description } = getAnyDeviceWebAuthnRegistrationAttachment(
action,
currentStep?.metadata?.viewData?.messages
Comment thread
aleixsuau marked this conversation as resolved.
);

return (
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,10 @@ export function isAnyDeviceWebAuthnRegistrationAction(
/**
* Any-device `webauthn-registration` client operation — the only registration case that offers a
* platform vs cross-platform attachment choice (passkeys mode collapses them into a single option).
* This is exactly the case the built-in attachment-selection card is rendered for.
*/
export function isAnyDeviceWebAuthnRegistrationClientOperation(
action: HaapiAction
): action is HaapiWebAuthnAnyDeviceRegistrationAction {
export function isAnyDeviceWebAuthnRegistrationClientOperation<T extends HaapiAction>(
action: T
): action is T & HaapiWebAuthnAnyDeviceRegistrationAction {
return isWebAuthnRegistrationClientOperation(action) && isAnyDeviceWebAuthnRegistrationAction(action);
}

Expand Down
Loading
Loading