@akinon/pz-theme 2.0.95-rc.0 → 2.0.95-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/package.json +4 -4
- package/src/__tests__/auth-captcha.test.tsx +261 -0
- package/src/__tests__/auth-commerce-events.test.tsx +5 -1
- package/src/__tests__/auth-destination.test.tsx +348 -0
- package/src/__tests__/auth-error-reading.test.ts +232 -0
- package/src/__tests__/auth-flow-scope.test.tsx +162 -0
- package/src/__tests__/auth-form-error-block.test.tsx +2 -0
- package/src/__tests__/auth-form-success-block.test.tsx +429 -0
- package/src/__tests__/auth-form-validation.test.tsx +2 -0
- package/src/__tests__/auth-slot-coverage.test.ts +9 -1
- package/src/__tests__/auth-step-title.test.tsx +2 -0
- package/src/__tests__/block-action-audience.test.ts +154 -0
- package/src/__tests__/detached-submit-state.test.tsx +80 -2
- package/src/__tests__/forgot-password-composed.test.tsx +2 -0
- package/src/__tests__/form-state-composition.test.tsx +484 -0
- package/src/__tests__/modal-login-close.test.tsx +366 -0
- package/src/__tests__/otp-form-composed.test.tsx +2 -0
- package/src/__tests__/password-reset-step.test.tsx +2 -0
- package/src/__tests__/register-form-composed.test.tsx +253 -4
- package/src/__tests__/registry-parity.test.ts +12 -0
- package/src/__tests__/reset-password-composed.test.tsx +385 -0
- package/src/auth/auth-captcha.tsx +125 -0
- package/src/auth/auth-destination.ts +168 -0
- package/src/auth/auth-field-errors.tsx +31 -0
- package/src/auth/form-state-context.tsx +170 -0
- package/src/auth/form-success-context.tsx +56 -0
- package/src/auth/read-auth-error.ts +332 -0
- package/src/blocks/account-button-block.tsx +25 -0
- package/src/blocks/add-to-cart-block.tsx +15 -0
- package/src/blocks/auth-field-block.tsx +13 -3
- package/src/blocks/auth-flow-block.tsx +45 -3
- package/src/blocks/auth-form-block.tsx +337 -54
- package/src/blocks/auth-success-block.tsx +85 -0
- package/src/blocks/block-renderer-registry.tsx +8 -0
- package/src/blocks/button-block.tsx +43 -13
- package/src/blocks/captcha-block.tsx +61 -0
- package/src/blocks/change-password-form-block.tsx +15 -0
- package/src/blocks/forgot-password-block.tsx +132 -9
- package/src/blocks/form-state-block.tsx +84 -0
- package/src/blocks/newsletter-form-block.tsx +16 -0
- package/src/blocks/otp-form-block.tsx +161 -67
- package/src/blocks/profile-form-block.tsx +13 -0
- package/src/blocks/register-form-block.tsx +362 -110
- package/src/blocks/reset-password-block.tsx +717 -0
- package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
- package/src/chrome/placeholder-slugs.ts +3 -0
- package/src/chrome/route-slug.ts +11 -0
- package/src/index.ts +10 -0
- package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
- package/src/overlays/__tests__/modal-registry.test.ts +5 -1
- package/src/overlays/modal-chrome-css.ts +8 -1
- package/src/overlays/modal-host-context.tsx +66 -0
- package/src/overlays/modal-preview-host.tsx +7 -2
- package/src/overlays/modal-registry.ts +38 -1
- package/src/overlays/overlay-loading.tsx +144 -0
- package/src/overlays/overlay-surface.tsx +23 -0
- package/src/overlays/overlay-tokens.ts +12 -0
- package/src/overlays/theme-modal-mount.tsx +85 -6
- package/src/utils/visibility-rules.ts +114 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
+
## 2.0.95-rc.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [86187b1c]
|
|
8
|
+
- Updated dependencies [0cf9ea239]
|
|
9
|
+
- Updated dependencies [324f97d55]
|
|
10
|
+
- Updated dependencies [51ea06888]
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- Updated dependencies [2fea41430]
|
|
13
|
+
- Updated dependencies [b55acb768]
|
|
14
|
+
- Updated dependencies [760258c1c]
|
|
15
|
+
- Updated dependencies [143be2b9d]
|
|
16
|
+
- Updated dependencies [7889b08fe]
|
|
17
|
+
- Updated dependencies [9f8cd3bc5]
|
|
18
|
+
- Updated dependencies [bfafa3f49]
|
|
19
|
+
- Updated dependencies [57d7eb305]
|
|
20
|
+
- Updated dependencies [d99a6a7d5]
|
|
21
|
+
- Updated dependencies [9db81a714]
|
|
22
|
+
- Updated dependencies [591e345e1]
|
|
23
|
+
- Updated dependencies [4de5303c5]
|
|
24
|
+
- Updated dependencies [95b139dc1]
|
|
25
|
+
- Updated dependencies [1d00f2d06]
|
|
26
|
+
- Updated dependencies [4ac7b2a1e]
|
|
27
|
+
- Updated dependencies [e9598c71d]
|
|
28
|
+
- Updated dependencies [4998a9631]
|
|
29
|
+
- Updated dependencies [804d2bd6c]
|
|
30
|
+
- Updated dependencies [3909d3224]
|
|
31
|
+
- Updated dependencies [6a3d8a631]
|
|
32
|
+
- Updated dependencies [e18836b20]
|
|
33
|
+
- Updated dependencies [b1111d7b]
|
|
34
|
+
- @akinon/next@2.0.95-rc.1
|
|
35
|
+
- @akinon/theme-contract@2.0.95-rc.1
|
|
36
|
+
|
|
3
37
|
## 2.0.95-rc.0
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.95-rc.
|
|
4
|
+
"version": "2.0.95-rc.1",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.95-rc.
|
|
11
|
+
"@akinon/next": "2.0.95-rc.1",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.95-rc.
|
|
16
|
+
"@akinon/theme-contract": "2.0.95-rc.1",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.95-rc.
|
|
24
|
+
"@akinon/next": "2.0.95-rc.1",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import AuthFormBlock from '../blocks/auth-form-block';
|
|
5
|
+
import CaptchaBlock from '../blocks/captcha-block';
|
|
6
|
+
import { Block } from '../theme-block';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The security check commerce escalates to after too many attempts.
|
|
10
|
+
*
|
|
11
|
+
* Before this, the composable auth set could not answer it at all: the forms
|
|
12
|
+
* sent no `captchaValidated`, nothing rendered a widget, and the challenge
|
|
13
|
+
* reached the shopper as the literal `[{"type":"captcha"}]` in the error slot.
|
|
14
|
+
* A brand whose commerce is abuse-protected therefore had to keep its
|
|
15
|
+
* hand-written login.
|
|
16
|
+
*
|
|
17
|
+
* The widget itself stays in the core `useCaptcha` hook — the same one the
|
|
18
|
+
* brands' own views use — so what is pinned here is the SEAM: the flag on the
|
|
19
|
+
* body, where the widget is drawn, and the one retry a solved challenge earns.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const signIn = jest.fn();
|
|
23
|
+
|
|
24
|
+
jest.mock('next-auth/react', () => ({
|
|
25
|
+
signIn: (...args: unknown[]) => signIn(...args),
|
|
26
|
+
useSession: () => ({ data: null, status: 'unauthenticated' })
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock('next/navigation', () => ({
|
|
30
|
+
usePathname: () => '/',
|
|
31
|
+
useSearchParams: () => new URLSearchParams(),
|
|
32
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn() })
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
jest.mock('@akinon/next/hooks/use-localization', () => ({
|
|
36
|
+
useLocalization: () => ({ locale: 'en', currency: 'USD' })
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const validate = jest.fn();
|
|
40
|
+
let captchaValidated = false;
|
|
41
|
+
|
|
42
|
+
jest.mock('@akinon/next/hooks', () => ({
|
|
43
|
+
useLocalization: () => ({ locale: 'en', currency: 'USD' }),
|
|
44
|
+
useCaptcha: () => ({
|
|
45
|
+
// The real hook renders reCAPTCHA; a marker is enough to prove the form
|
|
46
|
+
// put the widget where the composition asked for it.
|
|
47
|
+
CaptchaView: () => <div data-testid="captcha-widget" />,
|
|
48
|
+
validated: captchaValidated,
|
|
49
|
+
isVisible: true,
|
|
50
|
+
validate
|
|
51
|
+
})
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const block = (overrides: Partial<Block>): Block =>
|
|
55
|
+
({
|
|
56
|
+
id: 'blk',
|
|
57
|
+
type: 'text',
|
|
58
|
+
label: 'Block',
|
|
59
|
+
value: '',
|
|
60
|
+
order: 0,
|
|
61
|
+
hidden: false,
|
|
62
|
+
properties: {},
|
|
63
|
+
styles: {},
|
|
64
|
+
...overrides
|
|
65
|
+
}) as Block;
|
|
66
|
+
|
|
67
|
+
const field = (fieldType: string, order: number): Block =>
|
|
68
|
+
block({
|
|
69
|
+
id: `field-${fieldType}`,
|
|
70
|
+
type: 'auth-field',
|
|
71
|
+
label: fieldType,
|
|
72
|
+
properties: { fieldType },
|
|
73
|
+
order
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const renderForm = (properties: Record<string, unknown>, children?: Block[]) =>
|
|
77
|
+
render(
|
|
78
|
+
<AuthFormBlock
|
|
79
|
+
block={
|
|
80
|
+
block({
|
|
81
|
+
id: 'login-1',
|
|
82
|
+
type: 'auth-form',
|
|
83
|
+
label: 'Auth Form',
|
|
84
|
+
properties,
|
|
85
|
+
blocks: children
|
|
86
|
+
}) as Block
|
|
87
|
+
}
|
|
88
|
+
placeholderId="placeholder-1"
|
|
89
|
+
sectionId="section-1"
|
|
90
|
+
currentBreakpoint="desktop"
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const fill = (container: HTMLElement) => {
|
|
95
|
+
['email', 'password'].forEach((name) => {
|
|
96
|
+
const input = container.querySelector(
|
|
97
|
+
`input[name="${name}"]`
|
|
98
|
+
) as HTMLInputElement;
|
|
99
|
+
fireEvent.change(input, {
|
|
100
|
+
target: { value: name === 'email' ? 'ada@example.com' : 'hunter2' }
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const captchaRefusal = {
|
|
106
|
+
error: 'CredentialsSignin',
|
|
107
|
+
code: JSON.stringify([{ type: 'captcha' }])
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const bodyOf = (call: number) =>
|
|
111
|
+
signIn.mock.calls[call][1] as Record<string, unknown>;
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
signIn.mockReset();
|
|
115
|
+
validate.mockReset();
|
|
116
|
+
captchaValidated = false;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('a form that does not offer the check', () => {
|
|
120
|
+
it('sends no captcha flag and draws no widget', async () => {
|
|
121
|
+
signIn.mockResolvedValue({ error: null, url: '/account' });
|
|
122
|
+
|
|
123
|
+
const { container } = renderForm({});
|
|
124
|
+
fill(container);
|
|
125
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
126
|
+
|
|
127
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
128
|
+
|
|
129
|
+
// Every published composition posts exactly the body it posts today.
|
|
130
|
+
expect(bodyOf(0)).not.toHaveProperty('captchaValidated');
|
|
131
|
+
expect(screen.queryByTestId('captcha-widget')).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('still names the escalation instead of printing its JSON', async () => {
|
|
135
|
+
signIn.mockResolvedValue(captchaRefusal);
|
|
136
|
+
|
|
137
|
+
const { container } = renderForm({
|
|
138
|
+
captchaLabel: 'Security check needed.'
|
|
139
|
+
});
|
|
140
|
+
fill(container);
|
|
141
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
142
|
+
|
|
143
|
+
await waitFor(() =>
|
|
144
|
+
expect(screen.getByText('Security check needed.')).toBeInTheDocument()
|
|
145
|
+
);
|
|
146
|
+
// Nothing to answer it with, so no retry is attempted.
|
|
147
|
+
expect(signIn).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(validate).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('a form that offers the check', () => {
|
|
153
|
+
it('sends the flag and draws the widget above its submit', async () => {
|
|
154
|
+
signIn.mockResolvedValue({ error: null, url: '/account' });
|
|
155
|
+
|
|
156
|
+
const { container } = renderForm({ showCaptcha: true });
|
|
157
|
+
fill(container);
|
|
158
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
159
|
+
|
|
160
|
+
await waitFor(() => expect(signIn).toHaveBeenCalled());
|
|
161
|
+
|
|
162
|
+
expect(bodyOf(0).captchaValidated).toBe(false);
|
|
163
|
+
expect(screen.getByTestId('captcha-widget')).toBeInTheDocument();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('retries once when the shopper solves the challenge', async () => {
|
|
167
|
+
signIn
|
|
168
|
+
.mockResolvedValueOnce(captchaRefusal)
|
|
169
|
+
.mockResolvedValueOnce({ error: null, url: '/account' });
|
|
170
|
+
// What `validate` resolves is the whole decision: true means commerce
|
|
171
|
+
// accepted the answer, and only then is there any point in retrying.
|
|
172
|
+
validate.mockImplementation(async () => {
|
|
173
|
+
captchaValidated = true;
|
|
174
|
+
|
|
175
|
+
return true;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const { container } = renderForm({ showCaptcha: true });
|
|
179
|
+
fill(container);
|
|
180
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
181
|
+
|
|
182
|
+
await waitFor(() => expect(signIn).toHaveBeenCalledTimes(2));
|
|
183
|
+
expect(validate).toHaveBeenCalledTimes(1);
|
|
184
|
+
// The same body, now vouched for — the shopper does not retype anything.
|
|
185
|
+
expect(bodyOf(1).email).toBe('ada@example.com');
|
|
186
|
+
expect(bodyOf(1).captchaValidated).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('does not retry while the challenge is unsolved', async () => {
|
|
190
|
+
signIn.mockResolvedValue(captchaRefusal);
|
|
191
|
+
// The first `validate` only REVEALS the widget, and answers false.
|
|
192
|
+
validate.mockResolvedValue(false);
|
|
193
|
+
|
|
194
|
+
const { container } = renderForm({
|
|
195
|
+
showCaptcha: true,
|
|
196
|
+
captchaLabel: 'Security check needed.'
|
|
197
|
+
});
|
|
198
|
+
fill(container);
|
|
199
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
200
|
+
|
|
201
|
+
await waitFor(() => expect(validate).toHaveBeenCalled());
|
|
202
|
+
|
|
203
|
+
expect(signIn).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect(screen.getByText('Security check needed.')).toBeInTheDocument();
|
|
205
|
+
// And the widget is on screen, so the next submit can succeed.
|
|
206
|
+
expect(screen.getByTestId('captcha-widget')).toBeInTheDocument();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('draws the widget where the composition placed it, and only there', async () => {
|
|
210
|
+
signIn.mockResolvedValue({ error: null, url: '/account' });
|
|
211
|
+
|
|
212
|
+
const { container } = renderForm({ showCaptcha: true }, [
|
|
213
|
+
field('email', 0),
|
|
214
|
+
field('password', 1),
|
|
215
|
+
block({ id: 'captcha-1', type: 'captcha', label: 'Security Check', order: 2 })
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
// One widget, not two: the form's own fallback stands down as soon as the
|
|
219
|
+
// composition owns the placement.
|
|
220
|
+
expect(screen.getAllByTestId('captcha-widget')).toHaveLength(1);
|
|
221
|
+
expect(
|
|
222
|
+
container.querySelector('[data-auth-captcha]')?.parentElement
|
|
223
|
+
).not.toBeNull();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('the block on its own', () => {
|
|
228
|
+
it('renders nothing outside a form that offers a challenge', () => {
|
|
229
|
+
const { container } = render(
|
|
230
|
+
<CaptchaBlock
|
|
231
|
+
block={block({ id: 'captcha-1', type: 'captcha', label: 'Security Check' })}
|
|
232
|
+
placeholderId="p"
|
|
233
|
+
sectionId="s"
|
|
234
|
+
currentBreakpoint="desktop"
|
|
235
|
+
/>
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(container.innerHTML).toBe('');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('is a visible marker in the canvas, where the real widget cannot load', () => {
|
|
242
|
+
render(
|
|
243
|
+
<CaptchaBlock
|
|
244
|
+
block={
|
|
245
|
+
block({
|
|
246
|
+
id: 'captcha-1',
|
|
247
|
+
type: 'captcha',
|
|
248
|
+
label: 'Security Check',
|
|
249
|
+
properties: { label: 'Güvenlik doğrulaması' }
|
|
250
|
+
}) as Block
|
|
251
|
+
}
|
|
252
|
+
placeholderId="p"
|
|
253
|
+
sectionId="s"
|
|
254
|
+
currentBreakpoint="desktop"
|
|
255
|
+
isDesigner
|
|
256
|
+
/>
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(screen.getByText('Güvenlik doğrulaması')).toBeInTheDocument();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -32,7 +32,11 @@ jest.mock('next/navigation', () => ({
|
|
|
32
32
|
refresh: jest.fn(),
|
|
33
33
|
replace: jest.fn()
|
|
34
34
|
}),
|
|
35
|
-
usePathname: () => '/account/orders'
|
|
35
|
+
usePathname: () => '/account/orders',
|
|
36
|
+
// The login form reads the query too, since where a sign-in leaves the
|
|
37
|
+
// shopper can come from `?callbackUrl`/`?next` — a double without this
|
|
38
|
+
// throws before a single event can be measured.
|
|
39
|
+
useSearchParams: () => new URLSearchParams()
|
|
36
40
|
}));
|
|
37
41
|
|
|
38
42
|
jest.mock('@akinon/next/data/client/user', () => ({
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import AuthFormBlock from '../blocks/auth-form-block';
|
|
5
|
+
import AuthFlowBlock from '../blocks/auth-flow-block';
|
|
6
|
+
import {
|
|
7
|
+
isSafeInternalPath,
|
|
8
|
+
readReturnPath,
|
|
9
|
+
resolveAuthDestination,
|
|
10
|
+
resolveSignedInDestination,
|
|
11
|
+
withReturnPath
|
|
12
|
+
} from '../auth/auth-destination';
|
|
13
|
+
import { __resetAuthFlowBusForTests } from '../auth/auth-flow-bus';
|
|
14
|
+
import { Block } from '../theme-block';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a shopper goes after signing in, and what one who is ALREADY signed in
|
|
18
|
+
* sees on a login screen.
|
|
19
|
+
*
|
|
20
|
+
* Both used to have one compiled-in answer. The destination was `/`: a shopper
|
|
21
|
+
* sent to sign in from the basket, or one who opened the header's drawer two
|
|
22
|
+
* clicks into a product page, was taken to the homepage — and the storefront's
|
|
23
|
+
* own `?callbackUrl` / `?next` were ignored, which is what the brands' own
|
|
24
|
+
* views read. The signed-in case had no answer at all: a back button after
|
|
25
|
+
* login, a bookmarked /auth or a second tab showed a member a login form, and
|
|
26
|
+
* submitting it re-authenticated a live session.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const signIn = jest.fn(() => Promise.resolve({ error: null, url: '/account' }));
|
|
30
|
+
let status = 'unauthenticated';
|
|
31
|
+
|
|
32
|
+
jest.mock('next-auth/react', () => ({
|
|
33
|
+
signIn: (...args: unknown[]) => signIn(...args),
|
|
34
|
+
useSession: () => ({ data: null, status })
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const push = jest.fn();
|
|
38
|
+
const replace = jest.fn();
|
|
39
|
+
const refresh = jest.fn();
|
|
40
|
+
let search = new URLSearchParams();
|
|
41
|
+
|
|
42
|
+
jest.mock('next/navigation', () => ({
|
|
43
|
+
useRouter: () => ({ push, replace, refresh }),
|
|
44
|
+
useSearchParams: () => search,
|
|
45
|
+
usePathname: () => '/orders/checkout'
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
jest.mock('@akinon/next/hooks/use-localization', () => ({
|
|
49
|
+
useLocalization: () => ({ locale: 'en', currency: 'USD' })
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const block = (overrides: Partial<Block>): Block =>
|
|
53
|
+
({
|
|
54
|
+
id: 'blk',
|
|
55
|
+
type: 'text',
|
|
56
|
+
label: 'Block',
|
|
57
|
+
value: '',
|
|
58
|
+
order: 0,
|
|
59
|
+
hidden: false,
|
|
60
|
+
properties: {},
|
|
61
|
+
styles: {},
|
|
62
|
+
...overrides
|
|
63
|
+
}) as Block;
|
|
64
|
+
|
|
65
|
+
const renderLogin = (properties: Record<string, unknown> = {}) =>
|
|
66
|
+
render(
|
|
67
|
+
<AuthFormBlock
|
|
68
|
+
block={
|
|
69
|
+
block({
|
|
70
|
+
id: 'login-1',
|
|
71
|
+
type: 'auth-form',
|
|
72
|
+
label: 'Auth Form',
|
|
73
|
+
properties
|
|
74
|
+
}) as Block
|
|
75
|
+
}
|
|
76
|
+
placeholderId="placeholder-1"
|
|
77
|
+
sectionId="section-1"
|
|
78
|
+
currentBreakpoint="desktop"
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const submitLogin = (container: HTMLElement) => {
|
|
83
|
+
fireEvent.change(container.querySelector('input[name="email"]')!, {
|
|
84
|
+
target: { value: 'ada@example.com' }
|
|
85
|
+
});
|
|
86
|
+
fireEvent.change(container.querySelector('input[name="password"]')!, {
|
|
87
|
+
target: { value: 'hunter2' }
|
|
88
|
+
});
|
|
89
|
+
fireEvent.submit(container.querySelector('form')!);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
signIn.mockClear();
|
|
94
|
+
push.mockClear();
|
|
95
|
+
replace.mockClear();
|
|
96
|
+
refresh.mockClear();
|
|
97
|
+
status = 'unauthenticated';
|
|
98
|
+
search = new URLSearchParams();
|
|
99
|
+
__resetAuthFlowBusForTests();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('a destination read out of the url', () => {
|
|
103
|
+
it('refuses anything that is not a path on this origin', () => {
|
|
104
|
+
expect(isSafeInternalPath('/basket')).toBe(true);
|
|
105
|
+
expect(isSafeInternalPath('/orders/checkout?step=2')).toBe(true);
|
|
106
|
+
|
|
107
|
+
// The whole point of reading a destination out of the url is that anyone
|
|
108
|
+
// can write one — a mail, an ad, a link on another site.
|
|
109
|
+
expect(isSafeInternalPath('https://evil.example/login')).toBe(false);
|
|
110
|
+
// Protocol-relative: starts with a slash, means a different host.
|
|
111
|
+
expect(isSafeInternalPath('//evil.example')).toBe(false);
|
|
112
|
+
expect(isSafeInternalPath('/\\evil.example')).toBe(false);
|
|
113
|
+
expect(isSafeInternalPath('javascript:alert(1)')).toBe(false);
|
|
114
|
+
expect(isSafeInternalPath('')).toBe(false);
|
|
115
|
+
expect(isSafeInternalPath(null)).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reads both keys a pz storefront uses, first one winning', () => {
|
|
119
|
+
expect(
|
|
120
|
+
readReturnPath(new URLSearchParams('callbackUrl=/basket&next=/account'))
|
|
121
|
+
).toBe('/basket');
|
|
122
|
+
// `next` is what the OAuth middleware writes when it hands a shopper back.
|
|
123
|
+
expect(readReturnPath(new URLSearchParams('next=/account'))).toBe(
|
|
124
|
+
'/account'
|
|
125
|
+
);
|
|
126
|
+
expect(
|
|
127
|
+
readReturnPath(new URLSearchParams('callbackUrl=https://evil.example'))
|
|
128
|
+
).toBeNull();
|
|
129
|
+
expect(readReturnPath(null)).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('answers each mode the way its placement needs', () => {
|
|
133
|
+
const search = new URLSearchParams('callbackUrl=/basket');
|
|
134
|
+
|
|
135
|
+
expect(
|
|
136
|
+
resolveAuthDestination({ mode: 'return', authored: '/', search })
|
|
137
|
+
).toBe('/basket');
|
|
138
|
+
// Nothing to navigate to: the shopper is already where they want to be.
|
|
139
|
+
expect(
|
|
140
|
+
resolveAuthDestination({ mode: 'stay', authored: '/x', search })
|
|
141
|
+
).toBeNull();
|
|
142
|
+
// The historical answer, and still the default.
|
|
143
|
+
expect(
|
|
144
|
+
resolveAuthDestination({ mode: 'fixed', authored: '/account', search })
|
|
145
|
+
).toBe('/account');
|
|
146
|
+
// A junk authored value cannot become a navigation either.
|
|
147
|
+
expect(
|
|
148
|
+
resolveAuthDestination({ mode: 'fixed', authored: 'https://evil', search })
|
|
149
|
+
).toBe('/');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('sends a signed-in shopper where they were going, else to the account', () => {
|
|
153
|
+
expect(
|
|
154
|
+
resolveSignedInDestination('/account', new URLSearchParams('next=/basket'))
|
|
155
|
+
).toBe('/basket');
|
|
156
|
+
expect(resolveSignedInDestination('/account', null)).toBe('/account');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('records the origin on a login link, once and never over an author', () => {
|
|
160
|
+
expect(withReturnPath('/auth', '/orders/checkout')).toBe(
|
|
161
|
+
'/auth?callbackUrl=%2Forders%2Fcheckout'
|
|
162
|
+
);
|
|
163
|
+
// Not twice, and not over an answer the author wrote themselves.
|
|
164
|
+
expect(withReturnPath('/auth?callbackUrl=/basket', '/x')).toBe(
|
|
165
|
+
'/auth?callbackUrl=/basket'
|
|
166
|
+
);
|
|
167
|
+
// Nothing to record, or nowhere safe to record it on.
|
|
168
|
+
expect(withReturnPath('/auth', undefined)).toBe('/auth');
|
|
169
|
+
expect(withReturnPath('https://brand.example/auth', '/x')).toBe(
|
|
170
|
+
'https://brand.example/auth'
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('what a finished sign-in does', () => {
|
|
176
|
+
it('goes where the url asked, in return mode', async () => {
|
|
177
|
+
search = new URLSearchParams('callbackUrl=/basket');
|
|
178
|
+
|
|
179
|
+
const { container } = renderLogin({ afterSignIn: 'return' });
|
|
180
|
+
submitLogin(container);
|
|
181
|
+
|
|
182
|
+
await waitFor(() => expect(push).toHaveBeenCalledWith('/basket'));
|
|
183
|
+
expect(refresh).toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('stays put in a drawer, and still refreshes the page', async () => {
|
|
187
|
+
const { container } = renderLogin({ afterSignIn: 'stay' });
|
|
188
|
+
submitLogin(container);
|
|
189
|
+
|
|
190
|
+
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
|
191
|
+
// The refresh IS the navigation: the session has to reach the page the
|
|
192
|
+
// shopper is standing on, and nothing else should move.
|
|
193
|
+
expect(push).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('keeps the historical destination when nothing was authored', async () => {
|
|
197
|
+
const { container } = renderLogin({});
|
|
198
|
+
submitLogin(container);
|
|
199
|
+
|
|
200
|
+
await waitFor(() => expect(push).toHaveBeenCalledWith('/'));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('never navigates to a destination the url smuggled in', async () => {
|
|
204
|
+
search = new URLSearchParams('callbackUrl=https://evil.example');
|
|
205
|
+
|
|
206
|
+
const { container } = renderLogin({
|
|
207
|
+
afterSignIn: 'return',
|
|
208
|
+
callbackUrl: '/account'
|
|
209
|
+
});
|
|
210
|
+
submitLogin(container);
|
|
211
|
+
|
|
212
|
+
await waitFor(() => expect(push).toHaveBeenCalledWith('/account'));
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('a shopper who is already signed in', () => {
|
|
217
|
+
it('is told, instead of being shown a form they cannot use', () => {
|
|
218
|
+
status = 'authenticated';
|
|
219
|
+
|
|
220
|
+
renderLogin({ whenSignedIn: 'message', signedInLabel: 'Zaten girişliniz.' });
|
|
221
|
+
|
|
222
|
+
expect(screen.getByText('Zaten girişliniz.')).toBeInTheDocument();
|
|
223
|
+
expect(document.querySelector('form')).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('is taken where they were going, with replace so Back does not loop', () => {
|
|
227
|
+
status = 'authenticated';
|
|
228
|
+
search = new URLSearchParams('callbackUrl=/basket');
|
|
229
|
+
|
|
230
|
+
renderLogin({ whenSignedIn: 'redirect' });
|
|
231
|
+
|
|
232
|
+
expect(replace).toHaveBeenCalledWith('/basket');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('falls back to the authored account page', () => {
|
|
236
|
+
status = 'authenticated';
|
|
237
|
+
|
|
238
|
+
renderLogin({ whenSignedIn: 'redirect', signedInHref: '/account/orders' });
|
|
239
|
+
|
|
240
|
+
expect(replace).toHaveBeenCalledWith('/account/orders');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('cannot re-authenticate a live session, whatever the policy says', async () => {
|
|
244
|
+
status = 'authenticated';
|
|
245
|
+
|
|
246
|
+
// Left on the historical `form` policy: the form is still on screen, so
|
|
247
|
+
// the guard is what stops the password going back on the wire.
|
|
248
|
+
const { container } = renderLogin({
|
|
249
|
+
signedInLabel: 'Zaten girişliniz.'
|
|
250
|
+
});
|
|
251
|
+
submitLogin(container);
|
|
252
|
+
|
|
253
|
+
await waitFor(() =>
|
|
254
|
+
expect(screen.getByText('Zaten girişliniz.')).toBeInTheDocument()
|
|
255
|
+
);
|
|
256
|
+
expect(signIn).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('still sees the form in the canvas, so it can be styled', () => {
|
|
260
|
+
status = 'authenticated';
|
|
261
|
+
|
|
262
|
+
const { container } = render(
|
|
263
|
+
<AuthFormBlock
|
|
264
|
+
block={
|
|
265
|
+
block({
|
|
266
|
+
id: 'login-1',
|
|
267
|
+
type: 'auth-form',
|
|
268
|
+
label: 'Auth Form',
|
|
269
|
+
properties: { whenSignedIn: 'message' }
|
|
270
|
+
}) as Block
|
|
271
|
+
}
|
|
272
|
+
placeholderId="p"
|
|
273
|
+
sectionId="s"
|
|
274
|
+
currentBreakpoint="desktop"
|
|
275
|
+
isDesigner
|
|
276
|
+
/>
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(container.querySelector('form')).not.toBeNull();
|
|
280
|
+
expect(replace).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('a journey that knows the shopper', () => {
|
|
285
|
+
const step = (stepKey: string, text: string): Block =>
|
|
286
|
+
block({
|
|
287
|
+
id: `step-${stepKey}`,
|
|
288
|
+
type: 'auth-step',
|
|
289
|
+
label: stepKey,
|
|
290
|
+
properties: { stepKey },
|
|
291
|
+
blocks: [
|
|
292
|
+
block({
|
|
293
|
+
id: `text-${stepKey}`,
|
|
294
|
+
type: 'text',
|
|
295
|
+
label: 'Copy',
|
|
296
|
+
value: text
|
|
297
|
+
})
|
|
298
|
+
]
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const renderFlow = (properties: Record<string, unknown>) =>
|
|
302
|
+
render(
|
|
303
|
+
<AuthFlowBlock
|
|
304
|
+
block={
|
|
305
|
+
block({
|
|
306
|
+
id: 'flow-1',
|
|
307
|
+
type: 'auth-flow',
|
|
308
|
+
label: 'Auth Flow',
|
|
309
|
+
properties: { flowId: 'auth', ...properties },
|
|
310
|
+
blocks: [step('login', 'Log in'), step('member', 'Welcome back')]
|
|
311
|
+
}) as Block
|
|
312
|
+
}
|
|
313
|
+
placeholderId="placeholder-1"
|
|
314
|
+
sectionId="section-1"
|
|
315
|
+
currentBreakpoint="desktop"
|
|
316
|
+
/>
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
it('opens on the member step for a signed-in shopper', () => {
|
|
320
|
+
status = 'authenticated';
|
|
321
|
+
|
|
322
|
+
renderFlow({ initialStep: 'login', signedInStep: 'member' });
|
|
323
|
+
|
|
324
|
+
expect(screen.getByText('Welcome back')).toBeInTheDocument();
|
|
325
|
+
expect(screen.queryByText('Log in')).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('opens on the initial step for a guest, and while the session loads', () => {
|
|
329
|
+
renderFlow({ initialStep: 'login', signedInStep: 'member' });
|
|
330
|
+
|
|
331
|
+
expect(screen.getByText('Log in')).toBeInTheDocument();
|
|
332
|
+
|
|
333
|
+
// `loading` is not treated as signed in: the flow would open on the member
|
|
334
|
+
// step for a guest and then swap under them.
|
|
335
|
+
status = 'loading';
|
|
336
|
+
renderFlow({ initialStep: 'login', signedInStep: 'member' });
|
|
337
|
+
|
|
338
|
+
expect(screen.getAllByText('Log in').length).toBeGreaterThan(0);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('leaves a flow that named no member step exactly as it was', () => {
|
|
342
|
+
status = 'authenticated';
|
|
343
|
+
|
|
344
|
+
renderFlow({ initialStep: 'login' });
|
|
345
|
+
|
|
346
|
+
expect(screen.getByText('Log in')).toBeInTheDocument();
|
|
347
|
+
});
|
|
348
|
+
});
|