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
94 changes: 74 additions & 20 deletions src/test/unit/ui/sign-data-page-render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ jest.mock('../../../api/loader', () => ({
}));

const SignData = require('../../../ui/app/pages/signData').default;
const { ERROR } = require('../../../config/config');

const MESSAGE = 'Delegate to LUCEM pool';
const ORIGIN = 'https://magic-delegation.test';
Expand Down Expand Up @@ -104,6 +105,17 @@ const click = async (node) => {
});
};

const type = async (input, value) => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set;
await act(async () => {
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
};

describe('dApp sign message screen', () => {
let closeSpy;

Expand Down Expand Up @@ -171,38 +183,79 @@ describe('dApp sign message screen', () => {
await act(async () => root.unmount());
});

// Regression: signData called an undefined `capture(Events…)` after signing.
// confirmModal caught the ReferenceError and reported failure, so the dApp got
// an error instead of the signature the user had just approved.
test('the password is on the page, so signing takes no second dialog', async () => {
const { container, root } = await mount();

expect(byTestId(container, 'sign-data-password')).toBeTruthy();
// Nothing to confirm in a dialog: the page itself is the confirmation.
expect(
[...document.querySelectorAll('button')].some(
(b) => b.textContent.trim() === 'Confirm'
)
).toBe(false);
// No password typed yet, so there is nothing to submit.
expect(
byTestId(container, 'sign-data-primary-action').hasAttribute('disabled')
).toBe(true);
await act(async () => root.unmount());
});

// Regression: signData called an undefined `capture(Events…)` after signing,
// and the caller reported that ReferenceError as failure — so the dApp got an
// error instead of the signature the user had just approved.
test('a successful signature is returned to the dApp, not an error', async () => {
const { container, root, controller } = await mount();

await type(byTestId(container, 'sign-data-password'), 'pa$$word');
await click(byTestId(container, 'sign-data-primary-action'));

const input = document.querySelector('input[type="password"]');
expect(input).toBeTruthy();
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set;
setter.call(input, 'pa$$word');
input.dispatchEvent(new Event('input', { bubbles: true }));
});

const confirm = [...document.querySelectorAll('button')].find(
(b) => b.textContent.trim() === 'Confirm'
expect(mockSignDataCIP30).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
'pa$$word',
0
);
expect(confirm).toBeTruthy();
await click(confirm);

expect(mockSignDataCIP30).toHaveBeenCalled();
expect(controller.returnData).toHaveBeenCalledWith({
data: 'signed_cip30',
});
expect(controller.returnData).not.toHaveBeenCalledWith(
expect.objectContaining({ error: expect.anything() })
);
expect(closeSpy).toHaveBeenCalled();
await act(async () => root.unmount());
});

test('a wrong password is reported in place, without ending the request', async () => {
mockSignDataCIP30.mockRejectedValueOnce(ERROR.wrongPassword);
const { container, root, controller } = await mount();

await type(byTestId(container, 'sign-data-password'), 'nope');
await click(byTestId(container, 'sign-data-primary-action'));

expect(byTestId(container, 'sign-data-wrong-password')).toBeTruthy();
// The dApp is still waiting, and the popup stays open for another try.
expect(controller.returnData).not.toHaveBeenCalled();
expect(closeSpy).not.toHaveBeenCalled();

mockSignDataCIP30.mockResolvedValueOnce('signed_cip30');
await type(byTestId(container, 'sign-data-password'), 'pa$$word');
await click(byTestId(container, 'sign-data-primary-action'));
expect(controller.returnData).toHaveBeenCalledWith({
data: 'signed_cip30',
});
await act(async () => root.unmount());
});

test('a failure other than a wrong password is handed back to the dApp', async () => {
const boom = new Error('signing blew up');
mockSignDataCIP30.mockRejectedValueOnce(boom);
const { container, root, controller } = await mount();

await type(byTestId(container, 'sign-data-password'), 'pa$$word');
await click(byTestId(container, 'sign-data-primary-action'));

expect(controller.returnData).toHaveBeenCalledWith({ error: boom });
expect(closeSpy).toHaveBeenCalled();
await act(async () => root.unmount());
});

Expand All @@ -216,6 +269,7 @@ describe('dApp sign message screen', () => {
expect(
byTestId(container, 'sign-data-primary-action').hasAttribute('disabled')
).toBe(true);
expect(byTestId(container, 'sign-data-password')).toBeNull();
await act(async () => root.unmount());
});
});
27 changes: 23 additions & 4 deletions src/test/unit/ui/sign-tx-refresh.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const internalSrc = fs.readFileSync(
path.join(__dirname, '../../../ui/indexInternal.jsx'),
'utf8'
);
const inlineActionSrc = fs.readFileSync(
path.join(__dirname, '../../../ui/app/components/inlineSignAction.jsx'),
'utf8'
);

describe('CIP-30 sign UI refresh — structural contracts', () => {
test('uses themed page chrome instead of a leftover gray card', () => {
Expand Down Expand Up @@ -63,14 +67,29 @@ describe('CIP-30 sign UI refresh — structural contracts', () => {
test('Sign footer stays in the popup — same pattern as Send', () => {
expect(signSrc).toContain('data-testid="sign-tx-footer"');
expect(signSrc).toContain('lucem-sign-footer');
expect(signSrc).toContain('data-testid="sign-tx-primary-action"');
expect(signSrc).toContain("bg=\"yellow.400\"");
expect(signSrc).toContain('fontWeight="black"');
expect(signSrc).toContain('data-testid="sign-tx-cancel"');
expect(signSrc).toContain('safe-area-inset-bottom');
expect(stylesSrc).toMatch(
/html\[data-layout=['"]extension['"]\] \.lucem-sign-footer/
);
// The footer actions live in InlineSignAction, which derives the testids
// sign-tx-primary-action / sign-tx-cancel from this prefix.
expect(signSrc).toContain('<InlineSignAction');
expect(signSrc).toContain('testId="sign-tx"');
expect(inlineActionSrc).toContain('${testId}-primary-action');
expect(inlineActionSrc).toContain('${testId}-cancel');
expect(inlineActionSrc).toContain('bg="yellow.400"');
expect(inlineActionSrc).toContain('fontWeight="black"');
});

test('a software account signs on the page, without a password dialog', () => {
// Password entry is inline, so approving is one screen. Only hardware
// accounts, which have device prompts to run, open the dialog.
expect(signSrc).toMatch(
/isHw=\{isHW\(account\.index\)\}[\s\S]{0,800}onHwRequest=\{\(\) =>\s*ref\.current\.openModal\(account\.index\)\}/
);
expect(inlineActionSrc).toContain('${testId}-password');
expect(inlineActionSrc).toContain('ERROR.wrongPassword');
expect(inlineActionSrc).toContain('autoComplete="current-password"');
});

test('keeps origin, Details, and dApp decline/sign wiring', () => {
Expand Down
156 changes: 156 additions & 0 deletions src/ui/app/components/inlineSignAction.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import React from 'react';
import {
Button,
Input,
InputGroup,
InputRightElement,
Stack,
Text,
} from '@chakra-ui/react';
import { ERROR } from '../../../config/config';
import useSurfaceColors from '../hooks/useSurfaceColors';

/**
* Password entry and the confirm button, in the footer of a dApp approval
* screen. Approving a request is then a single screen rather than a review
* screen plus a password dialog stacked on top of it.
*
* Hardware accounts keep their own dialog: those flows are genuinely
* multi-step (device prompts, QR exchange) and have no password to collect.
*/
const InlineSignAction = ({
testId,
label,
cancelLabel = 'Cancel',
isHw,
isDisabled,
sign,
onSigned,
onFailed,
onHwRequest,
onCancel,
}) => {
const { pageFg, mutedFg, inputBg, inputBorder, placeholder } =
useSurfaceColors();
const [password, setPassword] = React.useState('');
const [show, setShow] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [wrongPassword, setWrongPassword] = React.useState(false);

const canSubmit = !isDisabled && !busy && (isHw || password.length > 0);

const submit = async () => {
if (!canSubmit) return;
if (isHw) {
onHwRequest();
return;
}
setBusy(true);
setWrongPassword(false);
try {
const result = await sign(password);
setPassword('');
await onSigned(result);
} catch (e) {
if (e === ERROR.wrongPassword) setWrongPassword(true);
else await onFailed(e);
setBusy(false);
}
};

return (
<Stack spacing={3} w="full" align="center">
{!isHw && !isDisabled ? (
<Stack spacing={1.5} w="full">
<InputGroup size="md">
<Input
data-testid={`${testId}-password`}
aria-label="Password"
placeholder="Password"
_placeholder={{ color: placeholder }}
type={show ? 'text' : 'password'}
autoCapitalize="off"
autoCorrect="off"
autoComplete="current-password"
height="48px"
rounded="2xl"
bg={inputBg}
borderColor={inputBorder}
focusBorderColor="yellow.600"
isInvalid={wrongPassword}
value={password}
pr="4.5rem"
onChange={(e) => {
setWrongPassword(false);
setPassword(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') submit();
}}
/>
<InputRightElement width="4.5rem" height="48px">
<Button
size="sm"
variant="ghost"
rounded="lg"
color={mutedFg}
onClick={() => setShow((s) => !s)}
>
{show ? 'Hide' : 'Show'}
</Button>
</InputRightElement>
</InputGroup>
{wrongPassword ? (
<Text
data-testid={`${testId}-wrong-password`}
fontSize="xs"
color="red.300"
px={1}
>
Wrong password.
</Text>
) : null}
</Stack>
) : null}
<Button
data-testid={`${testId}-primary-action`}
width="full"
height="52px"
rounded="2xl"
colorScheme="yellow"
bg="yellow.400"
color="gray.900"
fontWeight="black"
isDisabled={!canSubmit}
isLoading={busy}
_hover={{ bg: 'yellow.300', transform: 'translateY(-1px)' }}
_active={{ bg: 'yellow.500' }}
_disabled={{
bg: 'whiteAlpha.200',
color: 'whiteAlpha.500',
cursor: 'not-allowed',
transform: 'none',
opacity: 1,
}}
onClick={submit}
>
{isHw ? `${label} with device` : label}
</Button>
<Button
data-testid={`${testId}-cancel`}
variant="ghost"
width="full"
height="44px"
rounded="2xl"
color={pageFg}
isDisabled={busy}
_hover={{ bg: 'whiteAlpha.100' }}
onClick={onCancel}
>
{cancelLabel}
</Button>
</Stack>
);
};

export default InlineSignAction;
Loading