@faststore/core 4.3.0-dev.6 → 4.3.0-dev.8
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/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +271 -30
- package/CHANGELOG.md +12 -0
- package/package.json +9 -6
- package/src/pages/api/fs/password-protection/unlock.ts +123 -0
- package/src/pages/password-protection/index.tsx +93 -0
- package/src/pages/password-protection/password-protection.module.scss +43 -0
- package/src/proxy.ts +47 -2
- package/src/sdk/account/useSetPassword.ts +82 -72
- package/src/server/password-protection/webops-api.ts +38 -0
- package/src/server/password-protection-service.ts +283 -0
- package/src/utils/unlockResponse.ts +25 -0
- package/test/pages/api/unlock.test.ts +277 -0
- package/test/pages/password-protection.browser.test.tsx +201 -0
- package/test/proxy.test.ts +99 -0
- package/test/sdk/account/useSetPassword.test.ts +192 -0
- package/test/server/password-protection-service.test.ts +624 -0
- package/test/server/webops-api.test.ts +92 -0
- package/test/utils/unlockResponse.test.ts +57 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import type { UnlockResponse } from '../../../src/utils/unlockResponse'
|
|
5
|
+
|
|
6
|
+
vi.mock('discovery.config', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: { api: { storeId: 'test-store' } },
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
vi.mock('../../../src/server/password-protection/webops-api', () => ({
|
|
12
|
+
sessionUrl: () =>
|
|
13
|
+
new URL('https://faststore.vtex.com/api/v1/password-protection/session'),
|
|
14
|
+
passwordProtectionTimeouts: { defaultMs: 10_000 },
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
function createReqRes(overrides: Partial<NextApiRequest> = {}): {
|
|
18
|
+
req: NextApiRequest
|
|
19
|
+
res: NextApiResponse<UnlockResponse> & {
|
|
20
|
+
status: Mock
|
|
21
|
+
json: Mock
|
|
22
|
+
end: Mock
|
|
23
|
+
setHeader: Mock
|
|
24
|
+
_getStatus: () => number
|
|
25
|
+
}
|
|
26
|
+
} {
|
|
27
|
+
const res: Record<string, unknown> = {
|
|
28
|
+
_status: 0,
|
|
29
|
+
status: vi.fn().mockImplementation(function (
|
|
30
|
+
this: Record<string, unknown>,
|
|
31
|
+
code: number
|
|
32
|
+
) {
|
|
33
|
+
this._status = code
|
|
34
|
+
return this
|
|
35
|
+
}),
|
|
36
|
+
json: vi.fn(),
|
|
37
|
+
end: vi.fn(),
|
|
38
|
+
setHeader: vi.fn(),
|
|
39
|
+
_getStatus() {
|
|
40
|
+
return (this as Record<string, unknown>)._status
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
req: {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
body: { password: 'secret' },
|
|
48
|
+
query: {},
|
|
49
|
+
...overrides,
|
|
50
|
+
} as unknown as NextApiRequest,
|
|
51
|
+
res: res as unknown as ReturnType<typeof createReqRes>['res'],
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function getHandler() {
|
|
56
|
+
const mod = await import(
|
|
57
|
+
'../../../src/pages/api/fs/password-protection/unlock'
|
|
58
|
+
)
|
|
59
|
+
return mod.default
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('/api/fs/password-protection/unlock', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.resetModules()
|
|
65
|
+
global.fetch = vi.fn()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns 405 for non-POST requests', async () => {
|
|
69
|
+
const handler = await getHandler()
|
|
70
|
+
const { req, res } = createReqRes({ method: 'GET' })
|
|
71
|
+
|
|
72
|
+
await handler(req, res)
|
|
73
|
+
|
|
74
|
+
expect(res.status).toHaveBeenCalledWith(405)
|
|
75
|
+
expect(res.end).toHaveBeenCalled()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('returns 400 when password is missing', async () => {
|
|
79
|
+
const handler = await getHandler()
|
|
80
|
+
const { req, res } = createReqRes({ body: {} })
|
|
81
|
+
|
|
82
|
+
await handler(req, res)
|
|
83
|
+
|
|
84
|
+
expect(res.status).toHaveBeenCalledWith(400)
|
|
85
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
86
|
+
expect.objectContaining({ success: false, error: 'Password is required' })
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('returns 400 when request body is missing', async () => {
|
|
91
|
+
const handler = await getHandler()
|
|
92
|
+
const { req, res } = createReqRes({ body: undefined })
|
|
93
|
+
|
|
94
|
+
await handler(req, res)
|
|
95
|
+
|
|
96
|
+
expect(res.status).toHaveBeenCalledWith(400)
|
|
97
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
98
|
+
expect.objectContaining({ success: false, error: 'Password is required' })
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('returns 400 when password is not a string', async () => {
|
|
103
|
+
const handler = await getHandler()
|
|
104
|
+
const { req, res } = createReqRes({ body: { password: 123 } })
|
|
105
|
+
|
|
106
|
+
await handler(req, res)
|
|
107
|
+
|
|
108
|
+
expect(res.status).toHaveBeenCalledWith(400)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns 401 when WebOps returns 401', async () => {
|
|
112
|
+
;(global.fetch as Mock).mockResolvedValue({ ok: false, status: 401 })
|
|
113
|
+
const handler = await getHandler()
|
|
114
|
+
const { req, res } = createReqRes()
|
|
115
|
+
|
|
116
|
+
await handler(req, res)
|
|
117
|
+
|
|
118
|
+
expect(res.status).toHaveBeenCalledWith(401)
|
|
119
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
120
|
+
expect.objectContaining({ success: false, error: 'Invalid password' })
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('returns 401 when WebOps returns 403', async () => {
|
|
125
|
+
;(global.fetch as Mock).mockResolvedValue({ ok: false, status: 403 })
|
|
126
|
+
const handler = await getHandler()
|
|
127
|
+
const { req, res } = createReqRes()
|
|
128
|
+
|
|
129
|
+
await handler(req, res)
|
|
130
|
+
|
|
131
|
+
expect(res.status).toHaveBeenCalledWith(401)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('returns 503 when WebOps returns a non-ok status other than 401/403', async () => {
|
|
135
|
+
;(global.fetch as Mock).mockResolvedValue({ ok: false, status: 502 })
|
|
136
|
+
const handler = await getHandler()
|
|
137
|
+
const { req, res } = createReqRes()
|
|
138
|
+
|
|
139
|
+
await handler(req, res)
|
|
140
|
+
|
|
141
|
+
expect(res.status).toHaveBeenCalledWith(503)
|
|
142
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
143
|
+
expect.objectContaining({ success: false })
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('returns 500 when WebOps response body is not a valid payload', async () => {
|
|
148
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
149
|
+
ok: true,
|
|
150
|
+
json: async () => 'not-an-object',
|
|
151
|
+
})
|
|
152
|
+
const handler = await getHandler()
|
|
153
|
+
const { req, res } = createReqRes()
|
|
154
|
+
|
|
155
|
+
await handler(req, res)
|
|
156
|
+
|
|
157
|
+
expect(res.status).toHaveBeenCalledWith(500)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('returns 401 when WebOps responds with valid=false', async () => {
|
|
161
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
162
|
+
ok: true,
|
|
163
|
+
json: async () => ({ valid: false }),
|
|
164
|
+
})
|
|
165
|
+
const handler = await getHandler()
|
|
166
|
+
const { req, res } = createReqRes()
|
|
167
|
+
|
|
168
|
+
await handler(req, res)
|
|
169
|
+
|
|
170
|
+
expect(res.status).toHaveBeenCalledWith(401)
|
|
171
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
172
|
+
expect.objectContaining({ success: false, error: 'Invalid password' })
|
|
173
|
+
)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('returns 401 when WebOps responds valid=true without a token', async () => {
|
|
177
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
178
|
+
ok: true,
|
|
179
|
+
json: async () => ({ valid: true }),
|
|
180
|
+
})
|
|
181
|
+
const handler = await getHandler()
|
|
182
|
+
const { req, res } = createReqRes()
|
|
183
|
+
|
|
184
|
+
await handler(req, res)
|
|
185
|
+
|
|
186
|
+
expect(res.status).toHaveBeenCalledWith(401)
|
|
187
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
188
|
+
expect.objectContaining({ success: false, error: 'Invalid password' })
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('sets protection cookie and returns 200 with redirectUrl on success', async () => {
|
|
193
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
194
|
+
ok: true,
|
|
195
|
+
json: async () => ({ valid: true, token: 'jwt-abc' }),
|
|
196
|
+
})
|
|
197
|
+
const handler = await getHandler()
|
|
198
|
+
const { req, res } = createReqRes({ query: { returnTo: '/checkout' } })
|
|
199
|
+
|
|
200
|
+
await handler(req, res)
|
|
201
|
+
|
|
202
|
+
expect(res.setHeader).toHaveBeenCalledWith(
|
|
203
|
+
'Set-Cookie',
|
|
204
|
+
expect.arrayContaining([
|
|
205
|
+
expect.stringContaining('__fs_password_protection=jwt-abc'),
|
|
206
|
+
])
|
|
207
|
+
)
|
|
208
|
+
expect(res.setHeader).toHaveBeenCalledWith(
|
|
209
|
+
'Set-Cookie',
|
|
210
|
+
expect.arrayContaining([expect.stringContaining('HttpOnly')])
|
|
211
|
+
)
|
|
212
|
+
expect(res.status).toHaveBeenCalledWith(200)
|
|
213
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
214
|
+
expect.objectContaining({ success: true, redirectUrl: '/checkout' })
|
|
215
|
+
)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('defaults redirectUrl to "/" when returnTo is not provided', async () => {
|
|
219
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
220
|
+
ok: true,
|
|
221
|
+
json: async () => ({ valid: true, token: 'jwt-abc' }),
|
|
222
|
+
})
|
|
223
|
+
const handler = await getHandler()
|
|
224
|
+
const { req, res } = createReqRes({ query: {} })
|
|
225
|
+
|
|
226
|
+
await handler(req, res)
|
|
227
|
+
|
|
228
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
229
|
+
expect.objectContaining({ success: true, redirectUrl: '/' })
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('sanitizes open-redirect attempts in returnTo', async () => {
|
|
234
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
235
|
+
ok: true,
|
|
236
|
+
json: async () => ({ valid: true, token: 'jwt-abc' }),
|
|
237
|
+
})
|
|
238
|
+
const handler = await getHandler()
|
|
239
|
+
const { req, res } = createReqRes({ query: { returnTo: '//evil.com' } })
|
|
240
|
+
|
|
241
|
+
await handler(req, res)
|
|
242
|
+
|
|
243
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
244
|
+
expect.objectContaining({ redirectUrl: '/' })
|
|
245
|
+
)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('sanitizes absolute URL in returnTo', async () => {
|
|
249
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
250
|
+
ok: true,
|
|
251
|
+
json: async () => ({ valid: true, token: 'jwt-abc' }),
|
|
252
|
+
})
|
|
253
|
+
const handler = await getHandler()
|
|
254
|
+
const { req, res } = createReqRes({
|
|
255
|
+
query: { returnTo: 'https://evil.com' },
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
await handler(req, res)
|
|
259
|
+
|
|
260
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
261
|
+
expect.objectContaining({ redirectUrl: '/' })
|
|
262
|
+
)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('returns 503 when fetch throws', async () => {
|
|
266
|
+
;(global.fetch as Mock).mockRejectedValue(new Error('network failure'))
|
|
267
|
+
const handler = await getHandler()
|
|
268
|
+
const { req, res } = createReqRes()
|
|
269
|
+
|
|
270
|
+
await handler(req, res)
|
|
271
|
+
|
|
272
|
+
expect(res.status).toHaveBeenCalledWith(503)
|
|
273
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
274
|
+
expect.objectContaining({ success: false })
|
|
275
|
+
)
|
|
276
|
+
})
|
|
277
|
+
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import '@testing-library/jest-dom/vitest'
|
|
6
|
+
import React from 'react'
|
|
7
|
+
import {
|
|
8
|
+
cleanup,
|
|
9
|
+
fireEvent,
|
|
10
|
+
render,
|
|
11
|
+
screen,
|
|
12
|
+
waitFor,
|
|
13
|
+
} from '@testing-library/react'
|
|
14
|
+
import userEvent from '@testing-library/user-event'
|
|
15
|
+
import {
|
|
16
|
+
afterEach,
|
|
17
|
+
beforeEach,
|
|
18
|
+
describe,
|
|
19
|
+
expect,
|
|
20
|
+
it,
|
|
21
|
+
vi,
|
|
22
|
+
type Mock,
|
|
23
|
+
} from 'vitest'
|
|
24
|
+
|
|
25
|
+
const mockUseRouter = vi.hoisted(() => vi.fn())
|
|
26
|
+
|
|
27
|
+
vi.mock('next/router', () => ({
|
|
28
|
+
useRouter: mockUseRouter,
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
vi.mock('next/head', () => ({
|
|
32
|
+
default: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
vi.mock(
|
|
36
|
+
'@faststore/ui',
|
|
37
|
+
() => ({
|
|
38
|
+
Button: ({
|
|
39
|
+
children,
|
|
40
|
+
loading,
|
|
41
|
+
...props
|
|
42
|
+
}: React.PropsWithChildren<
|
|
43
|
+
React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }
|
|
44
|
+
>) => (
|
|
45
|
+
<button {...props} aria-busy={loading}>
|
|
46
|
+
{children}
|
|
47
|
+
</button>
|
|
48
|
+
),
|
|
49
|
+
InputField: ({
|
|
50
|
+
label,
|
|
51
|
+
error,
|
|
52
|
+
...props
|
|
53
|
+
}: React.InputHTMLAttributes<HTMLInputElement> & {
|
|
54
|
+
label: string
|
|
55
|
+
error?: string
|
|
56
|
+
}) => (
|
|
57
|
+
<label htmlFor={props.id}>
|
|
58
|
+
{label}
|
|
59
|
+
<input {...props} />
|
|
60
|
+
{error && <span role="alert">{error}</span>}
|
|
61
|
+
</label>
|
|
62
|
+
),
|
|
63
|
+
}),
|
|
64
|
+
{ virtual: true }
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
import PasswordProtectionLogin from '../../src/pages/password-protection'
|
|
68
|
+
|
|
69
|
+
const originalLocation = window.location
|
|
70
|
+
|
|
71
|
+
function stubLocation(origin = 'https://store.example.com') {
|
|
72
|
+
Object.defineProperty(window, 'location', {
|
|
73
|
+
configurable: true,
|
|
74
|
+
value: { ...originalLocation, origin, href: origin },
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function fillAndSubmit(password = 'secret') {
|
|
79
|
+
fireEvent.change(screen.getByLabelText('Password'), {
|
|
80
|
+
target: { value: password },
|
|
81
|
+
})
|
|
82
|
+
await userEvent.click(screen.getByRole('button', { name: 'Unlock' }))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('PasswordProtectionLogin', () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
stubLocation()
|
|
88
|
+
mockUseRouter.mockReturnValue({ query: {} })
|
|
89
|
+
global.fetch = vi.fn()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
cleanup()
|
|
94
|
+
vi.clearAllMocks()
|
|
95
|
+
Object.defineProperty(window, 'location', {
|
|
96
|
+
configurable: true,
|
|
97
|
+
value: originalLocation,
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('renders the password protection form', () => {
|
|
102
|
+
render(<PasswordProtectionLogin />)
|
|
103
|
+
|
|
104
|
+
expect(
|
|
105
|
+
screen.queryByText('This store is password protected')
|
|
106
|
+
).toBeInTheDocument()
|
|
107
|
+
expect(
|
|
108
|
+
screen.queryByText('Enter the password to access the store')
|
|
109
|
+
).toBeInTheDocument()
|
|
110
|
+
expect(screen.queryByLabelText('Password')).toBeInTheDocument()
|
|
111
|
+
expect(screen.queryByRole('button', { name: 'Unlock' })).toBeInTheDocument()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('posts the password and redirects to the requested return path', async () => {
|
|
115
|
+
mockUseRouter.mockReturnValue({
|
|
116
|
+
query: { returnTo: '/checkout?step=cart' },
|
|
117
|
+
})
|
|
118
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
119
|
+
json: async () => ({
|
|
120
|
+
success: true,
|
|
121
|
+
redirectUrl: '/checkout?step=cart',
|
|
122
|
+
}),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
render(<PasswordProtectionLogin />)
|
|
126
|
+
await fillAndSubmit()
|
|
127
|
+
|
|
128
|
+
await waitFor(() => {
|
|
129
|
+
expect(global.fetch).toHaveBeenCalled()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const [requestUrl, requestInit] = (global.fetch as Mock).mock.calls[0]
|
|
133
|
+
const url = new URL(requestUrl)
|
|
134
|
+
|
|
135
|
+
expect(url.pathname).toBe('/api/fs/password-protection/unlock')
|
|
136
|
+
expect(url.searchParams.get('returnTo')).toBe('/checkout?step=cart')
|
|
137
|
+
expect(requestInit).toMatchObject({
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'Content-Type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ password: 'secret' }),
|
|
141
|
+
})
|
|
142
|
+
expect(window.location.href).toBe('/checkout?step=cart')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('defaults returnTo to root and shows API validation errors', async () => {
|
|
146
|
+
mockUseRouter.mockReturnValue({ query: { returnTo: ['/unsafe'] } })
|
|
147
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
148
|
+
json: async () => ({
|
|
149
|
+
success: false,
|
|
150
|
+
error: 'Invalid password',
|
|
151
|
+
}),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
render(<PasswordProtectionLogin />)
|
|
155
|
+
await fillAndSubmit('wrong')
|
|
156
|
+
|
|
157
|
+
expect((await screen.findByRole('alert')).textContent).toBe(
|
|
158
|
+
'Invalid password'
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const [requestUrl] = (global.fetch as Mock).mock.calls[0]
|
|
162
|
+
expect(new URL(requestUrl).searchParams.get('returnTo')).toBe('/')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('uses the fallback invalid-password message when API omits an error', async () => {
|
|
166
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
167
|
+
json: async () => ({ success: false }),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
render(<PasswordProtectionLogin />)
|
|
171
|
+
await fillAndSubmit()
|
|
172
|
+
|
|
173
|
+
expect((await screen.findByRole('alert')).textContent).toBe(
|
|
174
|
+
'Invalid password'
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('shows service unavailable when the response is not a login payload', async () => {
|
|
179
|
+
;(global.fetch as Mock).mockResolvedValue({
|
|
180
|
+
json: async () => null,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
render(<PasswordProtectionLogin />)
|
|
184
|
+
await fillAndSubmit()
|
|
185
|
+
|
|
186
|
+
expect((await screen.findByRole('alert')).textContent).toBe(
|
|
187
|
+
'Service temporarily unavailable'
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('shows service unavailable when the login request fails', async () => {
|
|
192
|
+
;(global.fetch as Mock).mockRejectedValue(new Error('network'))
|
|
193
|
+
|
|
194
|
+
render(<PasswordProtectionLogin />)
|
|
195
|
+
await fillAndSubmit()
|
|
196
|
+
|
|
197
|
+
expect((await screen.findByRole('alert')).textContent).toBe(
|
|
198
|
+
'Service temporarily unavailable'
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const checkStoreProtectionMock = vi.hoisted(() => vi.fn())
|
|
5
|
+
|
|
6
|
+
vi.mock('discovery.config', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: {
|
|
9
|
+
localization: {
|
|
10
|
+
enabled: false,
|
|
11
|
+
locales: {},
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
vi.mock('../src/server/password-protection-service', () => ({
|
|
17
|
+
PasswordProtectionService: class {
|
|
18
|
+
checkStoreProtection = checkStoreProtectionMock
|
|
19
|
+
},
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
vi.mock('src/utils/localization/bindingPaths', () => ({
|
|
23
|
+
getCustomPathsFromBindings: vi.fn(() => []),
|
|
24
|
+
getSubdomainBindings: vi.fn(() => []),
|
|
25
|
+
isValidLocale: vi.fn(() => false),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
function request(path = '/products'): NextRequest {
|
|
29
|
+
return new NextRequest(`https://store.example.com${path}`, {
|
|
30
|
+
headers: { host: 'store.example.com' },
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('proxy', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns an error response when access check throws', async () => {
|
|
40
|
+
checkStoreProtectionMock.mockRejectedValue(new Error('access check failed'))
|
|
41
|
+
const { proxy } = await import('../src/proxy')
|
|
42
|
+
|
|
43
|
+
const response = await proxy(request('/products'))
|
|
44
|
+
|
|
45
|
+
expect(response.type).toBe('error')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns the protection response when access is blocked', async () => {
|
|
49
|
+
const redirect = NextResponse.redirect(
|
|
50
|
+
new URL(
|
|
51
|
+
'/password-protection?returnTo=%2Fproducts',
|
|
52
|
+
'https://store.example.com'
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
checkStoreProtectionMock.mockResolvedValue({ response: redirect })
|
|
56
|
+
const { proxy } = await import('../src/proxy')
|
|
57
|
+
|
|
58
|
+
const response = await proxy(request('/products'))
|
|
59
|
+
|
|
60
|
+
expect(response).toBe(redirect)
|
|
61
|
+
expect(response.status).toBe(307)
|
|
62
|
+
expect(response.headers.get('location')).toContain('/password-protection')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('continues the request and copies protection cookies when access is allowed', async () => {
|
|
66
|
+
const protectionResponse = NextResponse.next()
|
|
67
|
+
protectionResponse.cookies.set('__fs_password_protection', 'jwt-abc')
|
|
68
|
+
checkStoreProtectionMock.mockResolvedValue({ response: protectionResponse })
|
|
69
|
+
const { proxy } = await import('../src/proxy')
|
|
70
|
+
|
|
71
|
+
const response = (await proxy(request('/products?sku=1'))) as NextResponse
|
|
72
|
+
|
|
73
|
+
expect(response.status).toBe(200)
|
|
74
|
+
expect(response.cookies.get('__fs_password_protection')?.value).toBe(
|
|
75
|
+
'jwt-abc'
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('keeps the root and data routes in the matcher configuration', async () => {
|
|
80
|
+
const { config } = await import('../src/proxy')
|
|
81
|
+
const dynamicRouteMatcher = config.matcher[1]
|
|
82
|
+
const matcherRegex = new RegExp(`^${dynamicRouteMatcher}$`)
|
|
83
|
+
|
|
84
|
+
expect(config.matcher).toContain('/')
|
|
85
|
+
expect(config.matcher).toContain('/_next/data/:path*')
|
|
86
|
+
expect(config.matcher).toEqual(
|
|
87
|
+
expect.arrayContaining([
|
|
88
|
+
expect.stringContaining('api/fs/password-protection/unlock$'),
|
|
89
|
+
expect.stringContaining('password-protection'),
|
|
90
|
+
expect.stringContaining('~partytown'),
|
|
91
|
+
])
|
|
92
|
+
)
|
|
93
|
+
expect(config.matcher).not.toEqual(
|
|
94
|
+
expect.arrayContaining([expect.stringContaining('(?!api|')])
|
|
95
|
+
)
|
|
96
|
+
expect(matcherRegex.test('/api/fs/password-protection/unlock')).toBe(false)
|
|
97
|
+
expect(matcherRegex.test('/api/products')).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
})
|