@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.
@@ -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
+ })