@open-mercato/onboarding 0.6.5-develop.5337.1.534b781eac → 0.6.5
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-build.log +1 -1
- package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +141 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +7 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +158 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +7 -0
- package/dist/modules/onboarding/api/get/onboarding/status.js +35 -17
- package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
- package/dist/modules/onboarding/api/get/onboarding/verify.js +77 -195
- package/dist/modules/onboarding/api/get/onboarding/verify.js.map +2 -2
- package/dist/modules/onboarding/api/post/onboarding.js +10 -1
- package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
- package/dist/modules/onboarding/data/entities.js +3 -0
- package/dist/modules/onboarding/data/entities.js.map +2 -2
- package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js +31 -1
- package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js.map +2 -2
- package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js +36 -7
- package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js.map +2 -2
- package/dist/modules/onboarding/lib/deferred-provisioning.js +235 -0
- package/dist/modules/onboarding/lib/deferred-provisioning.js.map +7 -0
- package/dist/modules/onboarding/lib/preparation-claim.js +10 -0
- package/dist/modules/onboarding/lib/preparation-claim.js.map +7 -0
- package/dist/modules/onboarding/lib/provisioning.js +18 -0
- package/dist/modules/onboarding/lib/provisioning.js.map +7 -0
- package/dist/modules/onboarding/lib/service.js +31 -0
- package/dist/modules/onboarding/lib/service.js.map +2 -2
- package/dist/modules/onboarding/lib/verify-base-url.js +86 -0
- package/dist/modules/onboarding/lib/verify-base-url.js.map +7 -0
- package/dist/modules/onboarding/migrations/Migration20260611120000.js +13 -0
- package/dist/modules/onboarding/migrations/Migration20260611120000.js.map +7 -0
- package/generated/entities/onboarding_request/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +4 -5
- package/src/__tests__/deferred-provisioning.test.ts +210 -0
- package/src/__tests__/onboarding-submit-rate-limit.test.ts +22 -0
- package/src/__tests__/provisioning.test.ts +89 -0
- package/src/__tests__/status-endpoint-auth.test.ts +130 -3
- package/src/__tests__/verify-base-url.test.ts +65 -0
- package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +176 -0
- package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +198 -0
- package/src/modules/onboarding/api/get/onboarding/status.ts +46 -17
- package/src/modules/onboarding/api/get/onboarding/verify.ts +96 -227
- package/src/modules/onboarding/api/post/onboarding.ts +9 -0
- package/src/modules/onboarding/data/entities.ts +3 -0
- package/src/modules/onboarding/frontend/onboarding/OnboardingPageClient.tsx +32 -2
- package/src/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.tsx +38 -6
- package/src/modules/onboarding/i18n/de.json +10 -1
- package/src/modules/onboarding/i18n/en.json +10 -1
- package/src/modules/onboarding/i18n/es.json +10 -1
- package/src/modules/onboarding/i18n/pl.json +10 -1
- package/src/modules/onboarding/lib/deferred-provisioning.ts +295 -0
- package/src/modules/onboarding/lib/preparation-claim.ts +6 -0
- package/src/modules/onboarding/lib/provisioning.ts +35 -0
- package/src/modules/onboarding/lib/service.ts +33 -0
- package/src/modules/onboarding/lib/verify-base-url.ts +107 -0
- package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +10 -0
- package/src/modules/onboarding/migrations/Migration20260611120000.ts +13 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { runBestEffortProvisioningStep } from '@open-mercato/onboarding/modules/onboarding/lib/provisioning'
|
|
2
|
+
|
|
3
|
+
function makeLogger() {
|
|
4
|
+
return { error: jest.fn(), info: jest.fn() }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function uniqueViolation(message: string): Error {
|
|
8
|
+
return Object.assign(new Error(message), {
|
|
9
|
+
code: '23505',
|
|
10
|
+
constraint: 'catalog_products_handle_scope_unique',
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('runBestEffortProvisioningStep', () => {
|
|
15
|
+
it('returns ok and does not log when the step succeeds', async () => {
|
|
16
|
+
const logger = makeLogger()
|
|
17
|
+
const run = jest.fn().mockResolvedValue(undefined)
|
|
18
|
+
|
|
19
|
+
const result = await runBestEffortProvisioningStep('marketing-consent', run, logger)
|
|
20
|
+
|
|
21
|
+
expect(result).toEqual({ ok: true })
|
|
22
|
+
expect(run).toHaveBeenCalledTimes(1)
|
|
23
|
+
expect(logger.error).not.toHaveBeenCalled()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('swallows a thrown error, logs it, and reports failure instead of re-throwing', async () => {
|
|
27
|
+
const logger = makeLogger()
|
|
28
|
+
const failure = new Error('boom')
|
|
29
|
+
|
|
30
|
+
const result = await runBestEffortProvisioningStep(
|
|
31
|
+
'seedDefaults:catalog',
|
|
32
|
+
() => Promise.reject(failure),
|
|
33
|
+
logger,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
expect(result).toEqual({ ok: false, error: failure })
|
|
37
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
38
|
+
'[onboarding.verify] non-fatal provisioning step failed',
|
|
39
|
+
{ step: 'seedDefaults:catalog', error: failure },
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('logs an expected unique-constraint collision at info, not error', async () => {
|
|
44
|
+
// A concurrent verify / re-verify re-applies a seed against rows that
|
|
45
|
+
// already exist. seed hooks are not fully idempotent, so the duplicate-key
|
|
46
|
+
// collision is expected and harmless — it must not surface as an error and
|
|
47
|
+
// bury genuine failures (maintainer feedback on PR #2954).
|
|
48
|
+
const logger = makeLogger()
|
|
49
|
+
const failure = uniqueViolation('duplicate key value violates unique constraint')
|
|
50
|
+
|
|
51
|
+
const result = await runBestEffortProvisioningStep(
|
|
52
|
+
'seedExamples:catalog',
|
|
53
|
+
() => Promise.reject(failure),
|
|
54
|
+
logger,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(result).toEqual({ ok: false, error: failure })
|
|
58
|
+
expect(logger.error).not.toHaveBeenCalled()
|
|
59
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
60
|
+
'[onboarding.verify] non-fatal provisioning step skipped (already applied)',
|
|
61
|
+
{ step: 'seedExamples:catalog' },
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('keeps provisioning alive when one module hook throws or times out (issue #2951)', async () => {
|
|
66
|
+
// Regression for #2951: a single seedDefaults hook that throws (or exceeds
|
|
67
|
+
// its timeout) must not abort the loop or strand the freshly provisioned
|
|
68
|
+
// workspace. Every module still runs and the sequence resolves so the
|
|
69
|
+
// verify handler can reach markCompleted.
|
|
70
|
+
const logger = makeLogger()
|
|
71
|
+
const ran: string[] = []
|
|
72
|
+
const hooks: Record<string, () => Promise<void>> = {
|
|
73
|
+
auth: async () => { ran.push('auth') },
|
|
74
|
+
catalog: async () => { ran.push('catalog'); throw new Error('seed failed') },
|
|
75
|
+
sales: async () => { ran.push('sales') },
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const results = []
|
|
79
|
+
for (const moduleId of Object.keys(hooks)) {
|
|
80
|
+
results.push(
|
|
81
|
+
await runBestEffortProvisioningStep(`seedDefaults:${moduleId}`, hooks[moduleId], logger),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
expect(ran).toEqual(['auth', 'catalog', 'sales'])
|
|
86
|
+
expect(results.map((r) => r.ok)).toEqual([true, false, true])
|
|
87
|
+
expect(logger.error).toHaveBeenCalledTimes(1)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -2,6 +2,19 @@ import { OnboardingRequest } from '../modules/onboarding/data/entities'
|
|
|
2
2
|
|
|
3
3
|
const findLatestByTenantId = jest.fn()
|
|
4
4
|
const sendWorkspaceReadyEmail = jest.fn()
|
|
5
|
+
const assertAllowedAppOrigin = jest.fn()
|
|
6
|
+
const markCompleted = jest.fn()
|
|
7
|
+
const runDeferredProvisioning = jest.fn()
|
|
8
|
+
|
|
9
|
+
jest.mock('next/server', () => {
|
|
10
|
+
const actual = jest.requireActual('next/server')
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
after: (callback: () => unknown) => {
|
|
14
|
+
void callback()
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
})
|
|
5
18
|
|
|
6
19
|
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
7
20
|
createRequestContainer: jest.fn(async () => ({
|
|
@@ -13,13 +26,19 @@ jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
|
13
26
|
}))
|
|
14
27
|
|
|
15
28
|
jest.mock('@open-mercato/shared/lib/url', () => ({
|
|
16
|
-
|
|
17
|
-
mapSecurityEmailUrlError: () =>
|
|
29
|
+
assertAllowedAppOrigin: (...args: unknown[]) => assertAllowedAppOrigin(...args),
|
|
30
|
+
mapSecurityEmailUrlError: (error: unknown) => {
|
|
31
|
+
if (error instanceof Error && error.message === 'origin rejected') {
|
|
32
|
+
return { status: 400, body: { error: 'Invalid request origin' } }
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
},
|
|
18
36
|
}))
|
|
19
37
|
|
|
20
38
|
jest.mock('@open-mercato/onboarding/modules/onboarding/lib/service', () => ({
|
|
21
39
|
OnboardingService: jest.fn().mockImplementation(() => ({
|
|
22
40
|
findLatestByTenantId,
|
|
41
|
+
markCompleted,
|
|
23
42
|
})),
|
|
24
43
|
}))
|
|
25
44
|
|
|
@@ -27,6 +46,18 @@ jest.mock('@open-mercato/onboarding/modules/onboarding/lib/ready-email', () => (
|
|
|
27
46
|
sendWorkspaceReadyEmail,
|
|
28
47
|
}))
|
|
29
48
|
|
|
49
|
+
jest.mock('@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning', () => ({
|
|
50
|
+
resolveProvisioningIds: (request: OnboardingRequest) => {
|
|
51
|
+
if (!request.tenantId || !request.organizationId || !request.userId) return null
|
|
52
|
+
return {
|
|
53
|
+
tenantId: request.tenantId,
|
|
54
|
+
organizationId: request.organizationId,
|
|
55
|
+
userId: request.userId,
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
runDeferredProvisioning: (...args: unknown[]) => runDeferredProvisioning(...args),
|
|
59
|
+
}))
|
|
60
|
+
|
|
30
61
|
import { GET } from '../modules/onboarding/api/get/onboarding/status'
|
|
31
62
|
|
|
32
63
|
const TENANT_ID = '11111111-1111-4111-8111-111111111111'
|
|
@@ -56,6 +87,21 @@ describe('onboarding status endpoint authorization', () => {
|
|
|
56
87
|
beforeEach(() => {
|
|
57
88
|
findLatestByTenantId.mockReset()
|
|
58
89
|
sendWorkspaceReadyEmail.mockReset()
|
|
90
|
+
assertAllowedAppOrigin.mockReset()
|
|
91
|
+
markCompleted.mockReset()
|
|
92
|
+
runDeferredProvisioning.mockReset()
|
|
93
|
+
markCompleted.mockImplementation(async (request: OnboardingRequest, data: {
|
|
94
|
+
tenantId: string
|
|
95
|
+
organizationId: string
|
|
96
|
+
userId: string
|
|
97
|
+
}) => {
|
|
98
|
+
request.status = 'completed'
|
|
99
|
+
request.tenantId = data.tenantId
|
|
100
|
+
request.organizationId = data.organizationId
|
|
101
|
+
request.userId = data.userId
|
|
102
|
+
request.completedAt = new Date()
|
|
103
|
+
request.processingStartedAt = null
|
|
104
|
+
})
|
|
59
105
|
findLatestByTenantId.mockResolvedValue(makeRequest())
|
|
60
106
|
})
|
|
61
107
|
|
|
@@ -97,8 +143,23 @@ describe('onboarding status endpoint authorization', () => {
|
|
|
97
143
|
expect(body.ok).toBe(true)
|
|
98
144
|
expect(body.tenantId).toBe(TENANT_ID)
|
|
99
145
|
expect(body.ready).toBe(true)
|
|
100
|
-
expect(body.loginUrl).toBe(
|
|
146
|
+
expect(body.loginUrl).toBe(`/login?tenant=${TENANT_ID}`)
|
|
101
147
|
expect(findLatestByTenantId).toHaveBeenCalledWith(TENANT_ID)
|
|
148
|
+
expect(assertAllowedAppOrigin).toHaveBeenCalledTimes(1)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('rejects a matched cookie when the request origin is not allowed', async () => {
|
|
152
|
+
assertAllowedAppOrigin.mockImplementation(() => {
|
|
153
|
+
throw new Error('origin rejected')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const res = await GET(
|
|
157
|
+
buildRequest({ tenantId: TENANT_ID, cookie: `om_login_tenant=${TENANT_ID}` }),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
expect(res.status).toBe(400)
|
|
161
|
+
expect(await res.json()).toEqual({ ok: false, error: 'Invalid request origin' })
|
|
162
|
+
expect(findLatestByTenantId).not.toHaveBeenCalled()
|
|
102
163
|
})
|
|
103
164
|
|
|
104
165
|
it('returns 404 for an authorized caller when no onboarding record exists', async () => {
|
|
@@ -108,4 +169,70 @@ describe('onboarding status endpoint authorization', () => {
|
|
|
108
169
|
)
|
|
109
170
|
expect(res.status).toBe(404)
|
|
110
171
|
})
|
|
172
|
+
|
|
173
|
+
it('does not schedule deferred provisioning while a fresh preparation claim is active', async () => {
|
|
174
|
+
// Regression for the 2026-06-11 demo outage: every preparing-page poll
|
|
175
|
+
// scheduled another full deferred-provisioning chain until the completion
|
|
176
|
+
// flag landed, exhausting the PG connection pool. While a fresh claim is
|
|
177
|
+
// recorded the poll must not schedule another run.
|
|
178
|
+
findLatestByTenantId.mockResolvedValue(
|
|
179
|
+
makeRequest({
|
|
180
|
+
userId: '55555555-5555-4555-8555-555555555555',
|
|
181
|
+
preparationCompletedAt: null,
|
|
182
|
+
preparationStartedAt: new Date(),
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const res = await GET(
|
|
187
|
+
buildRequest({ tenantId: TENANT_ID, cookie: `om_login_tenant=${TENANT_ID}` }),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
expect(res.status).toBe(200)
|
|
191
|
+
expect(runDeferredProvisioning).not.toHaveBeenCalled()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('schedules deferred provisioning again once the preparation claim is stale', async () => {
|
|
195
|
+
findLatestByTenantId.mockResolvedValue(
|
|
196
|
+
makeRequest({
|
|
197
|
+
userId: '55555555-5555-4555-8555-555555555555',
|
|
198
|
+
preparationCompletedAt: null,
|
|
199
|
+
preparationStartedAt: new Date(Date.now() - 20 * 60 * 1000),
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
const res = await GET(
|
|
204
|
+
buildRequest({ tenantId: TENANT_ID, cookie: `om_login_tenant=${TENANT_ID}` }),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
expect(res.status).toBe(200)
|
|
208
|
+
expect(runDeferredProvisioning).toHaveBeenCalledTimes(1)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('recovers an interrupted processing request that already has provisioned ids', async () => {
|
|
212
|
+
const request = makeRequest({
|
|
213
|
+
status: 'processing',
|
|
214
|
+
tenantId: TENANT_ID,
|
|
215
|
+
organizationId: '44444444-4444-4444-8444-444444444444',
|
|
216
|
+
userId: '55555555-5555-4555-8555-555555555555',
|
|
217
|
+
completedAt: null,
|
|
218
|
+
preparationCompletedAt: null,
|
|
219
|
+
processingStartedAt: new Date(),
|
|
220
|
+
})
|
|
221
|
+
findLatestByTenantId.mockResolvedValue(request)
|
|
222
|
+
|
|
223
|
+
const res = await GET(
|
|
224
|
+
buildRequest({ tenantId: TENANT_ID, cookie: `om_login_tenant=${TENANT_ID}` }),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
expect(res.status).toBe(200)
|
|
228
|
+
const body = await res.json()
|
|
229
|
+
expect(body.ok).toBe(true)
|
|
230
|
+
expect(body.status).toBe('completed')
|
|
231
|
+
expect(body.ready).toBe(false)
|
|
232
|
+
expect(markCompleted).toHaveBeenCalledWith(request, {
|
|
233
|
+
tenantId: TENANT_ID,
|
|
234
|
+
organizationId: '44444444-4444-4444-8444-444444444444',
|
|
235
|
+
userId: '55555555-5555-4555-8555-555555555555',
|
|
236
|
+
})
|
|
237
|
+
})
|
|
111
238
|
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { resolveVerifyRedirectBaseUrl } from '../modules/onboarding/lib/verify-base-url'
|
|
2
|
+
|
|
3
|
+
function buildRequest(url: string, headers: Record<string, string> = {}) {
|
|
4
|
+
return new Request(url, { headers })
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
describe('onboarding verify redirect base URL', () => {
|
|
8
|
+
it('uses APP_URL when it matches the verification request origin', () => {
|
|
9
|
+
const result = resolveVerifyRedirectBaseUrl(
|
|
10
|
+
buildRequest('https://demo.openmercato.com/api/onboarding/onboarding/verify?token=t'),
|
|
11
|
+
{
|
|
12
|
+
APP_URL: 'https://demo.openmercato.com',
|
|
13
|
+
NODE_ENV: 'production',
|
|
14
|
+
},
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
expect(result).toEqual({ ok: true, baseUrl: 'https://demo.openmercato.com' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('rejects a verify request when APP_URL points to a different local port', () => {
|
|
21
|
+
const result = resolveVerifyRedirectBaseUrl(
|
|
22
|
+
buildRequest('http://localhost:3001/api/onboarding/onboarding/verify?token=t'),
|
|
23
|
+
{
|
|
24
|
+
APP_URL: 'http://localhost:3000',
|
|
25
|
+
APP_ALLOWED_ORIGINS: 'http://localhost:3001',
|
|
26
|
+
NODE_ENV: 'development',
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
expect(result.ok).toBe(false)
|
|
31
|
+
if (!result.ok) {
|
|
32
|
+
expect(result.status).toBe('redirect_misconfigured')
|
|
33
|
+
expect(result.redirectOrigin).toBe('http://localhost:3001')
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('accepts equivalent loopback hostnames on the same port', () => {
|
|
38
|
+
const result = resolveVerifyRedirectBaseUrl(
|
|
39
|
+
buildRequest('http://127.0.0.1:3001/api/onboarding/onboarding/verify?token=t'),
|
|
40
|
+
{
|
|
41
|
+
APP_URL: 'http://localhost:3001',
|
|
42
|
+
NODE_ENV: 'development',
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(result).toEqual({ ok: true, baseUrl: 'http://localhost:3001' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns an explicit error for non-allowlisted origins', () => {
|
|
50
|
+
const result = resolveVerifyRedirectBaseUrl(
|
|
51
|
+
buildRequest('https://evil.example/api/onboarding/onboarding/verify?token=t'),
|
|
52
|
+
{
|
|
53
|
+
APP_URL: 'https://demo.openmercato.com',
|
|
54
|
+
NODE_ENV: 'production',
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
expect(result.ok).toBe(false)
|
|
59
|
+
if (!result.ok) {
|
|
60
|
+
expect(result.status).toBe('origin_not_allowed')
|
|
61
|
+
expect(result.redirectOrigin).toBeNull()
|
|
62
|
+
expect(result.httpStatus).toBe(400)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { expect, test } from '@playwright/test';
|
|
3
|
+
import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
|
|
4
|
+
|
|
5
|
+
export const integrationMeta = {
|
|
6
|
+
dependsOnModules: ['onboarding'],
|
|
7
|
+
requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],
|
|
8
|
+
requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type OnboardingRequestRow = {
|
|
12
|
+
id: string;
|
|
13
|
+
status: string;
|
|
14
|
+
tenant_id: string | null;
|
|
15
|
+
organization_id: string | null;
|
|
16
|
+
user_id: string | null;
|
|
17
|
+
marketing_consent: boolean | null;
|
|
18
|
+
completed_at: Date | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type UserConsentRow = {
|
|
22
|
+
consent_type: string;
|
|
23
|
+
is_granted: boolean;
|
|
24
|
+
source: string | null;
|
|
25
|
+
integrity_hash: string | null;
|
|
26
|
+
granted_at: Date | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ONBOARDING_PASSWORD = 'IntegrationPass123!';
|
|
30
|
+
const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
|
|
31
|
+
|
|
32
|
+
function hashToken(token: string): string {
|
|
33
|
+
return createHash('sha256').update(token).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function replaceVerificationToken(email: string, token: string): Promise<string> {
|
|
37
|
+
return withClient(async (client) => {
|
|
38
|
+
const result = await client.query<{ id: string }>(
|
|
39
|
+
`update onboarding_requests
|
|
40
|
+
set token_hash = $2,
|
|
41
|
+
expires_at = now() + interval '24 hours',
|
|
42
|
+
updated_at = now()
|
|
43
|
+
where email = $1
|
|
44
|
+
returning id`,
|
|
45
|
+
[email, hashToken(token)],
|
|
46
|
+
);
|
|
47
|
+
expect(result.rowCount, 'onboarding request should exist after submitting the form').toBe(1);
|
|
48
|
+
return result.rows[0].id;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readCompletedRequest(requestId: string): Promise<OnboardingRequestRow> {
|
|
53
|
+
return withClient(async (client) => {
|
|
54
|
+
const result = await client.query<OnboardingRequestRow>(
|
|
55
|
+
`select id, status, tenant_id, organization_id, user_id, marketing_consent, completed_at
|
|
56
|
+
from onboarding_requests
|
|
57
|
+
where id = $1`,
|
|
58
|
+
[requestId],
|
|
59
|
+
);
|
|
60
|
+
expect(result.rowCount, 'completed onboarding request should remain queryable').toBe(1);
|
|
61
|
+
return result.rows[0];
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readMarketingConsent(userId: string): Promise<UserConsentRow> {
|
|
66
|
+
return withClient(async (client) => {
|
|
67
|
+
const result = await client.query<UserConsentRow>(
|
|
68
|
+
`select consent_type, is_granted, source, integrity_hash, granted_at
|
|
69
|
+
from user_consents
|
|
70
|
+
where user_id = $1 and consent_type = 'marketing_email'
|
|
71
|
+
order by created_at desc
|
|
72
|
+
limit 1`,
|
|
73
|
+
[userId],
|
|
74
|
+
);
|
|
75
|
+
expect(result.rowCount, 'marketing consent should be persisted for the onboarded user').toBe(1);
|
|
76
|
+
return result.rows[0];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test.describe('TC-ONB-001: self-service onboarding with marketing consent', () => {
|
|
81
|
+
test('does not offer tenant login while workspace preparation is still running', async ({ page }) => {
|
|
82
|
+
const tenantId = '33333333-3333-4333-8333-333333333333';
|
|
83
|
+
await page.route('**/api/onboarding/onboarding/status?**', async (route) => {
|
|
84
|
+
await route.fulfill({
|
|
85
|
+
status: 200,
|
|
86
|
+
contentType: 'application/json',
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
ok: true,
|
|
89
|
+
status: 'processing',
|
|
90
|
+
ready: false,
|
|
91
|
+
emailSent: false,
|
|
92
|
+
tenantId,
|
|
93
|
+
loginUrl: null,
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await page.goto(`/onboarding/preparing?tenant=${tenantId}`);
|
|
99
|
+
|
|
100
|
+
await expect(page.getByText('We are preparing your workspace')).toBeVisible();
|
|
101
|
+
await expect(page.getByRole('link', { name: 'Open tenant login' })).toHaveCount(0);
|
|
102
|
+
await expect(page.getByRole('link', { name: 'Go to home page' })).toBeVisible();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('shows a retryable error when workspace status polling fails', async ({ page }) => {
|
|
106
|
+
const tenantId = '33333333-3333-4333-8333-333333333333';
|
|
107
|
+
await page.route('**/api/onboarding/onboarding/status?**', async (route) => {
|
|
108
|
+
await route.fulfill({
|
|
109
|
+
status: 400,
|
|
110
|
+
contentType: 'application/json',
|
|
111
|
+
body: JSON.stringify({ ok: false, error: 'Invalid request origin' }),
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await page.goto(`/onboarding/preparing?tenant=${tenantId}`);
|
|
116
|
+
|
|
117
|
+
await expect(page.getByText('We are preparing your workspace')).toBeVisible();
|
|
118
|
+
const statusAlert = page.getByText('Workspace status check failed').locator('..');
|
|
119
|
+
await expect(statusAlert).toContainText('Invalid request origin');
|
|
120
|
+
await expect(page.getByRole('link', { name: 'Open tenant login' })).toHaveCount(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('creates the workspace, verifies from the email link, and logs in to the new tenant', async ({ page }) => {
|
|
124
|
+
const unique = randomUUID().slice(0, 8);
|
|
125
|
+
const email = `qa-onboarding-${unique}@example.test`;
|
|
126
|
+
const organizationName = `QA Onboarding ${unique}`;
|
|
127
|
+
const token = `integration-onboarding-${randomUUID().replace(/-/g, '')}`;
|
|
128
|
+
|
|
129
|
+
await page.goto('/onboarding');
|
|
130
|
+
await expect(page.getByText('Create your Open Mercato workspace')).toBeVisible();
|
|
131
|
+
|
|
132
|
+
await page.getByLabel('Work email').fill(email);
|
|
133
|
+
await page.getByLabel('First name').fill('QA');
|
|
134
|
+
await page.getByLabel('Last name').fill('Onboarding');
|
|
135
|
+
await page.getByLabel('Organization name').fill(organizationName);
|
|
136
|
+
await page.locator('input[name="password"]').fill(ONBOARDING_PASSWORD);
|
|
137
|
+
await page.locator('input[name="confirmPassword"]').fill(ONBOARDING_PASSWORD);
|
|
138
|
+
await page.locator('#terms').click();
|
|
139
|
+
await page.locator('#marketingConsent').click();
|
|
140
|
+
await page.getByRole('button', { name: 'Send verification email' }).click();
|
|
141
|
+
|
|
142
|
+
await expect(page.getByRole('status')).toContainText('Check your inbox');
|
|
143
|
+
await expect(page.getByRole('status')).toContainText(email);
|
|
144
|
+
|
|
145
|
+
const requestId = await replaceVerificationToken(email, token);
|
|
146
|
+
const verifyUrl = `${BASE_URL}/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`;
|
|
147
|
+
|
|
148
|
+
await page.setContent(`<a href="${verifyUrl}">Verify workspace</a>`);
|
|
149
|
+
await page.getByRole('link', { name: 'Verify workspace' }).click();
|
|
150
|
+
await expect(page).toHaveURL(/\/onboarding\/preparing\?tenant=[0-9a-f-]+/);
|
|
151
|
+
await expect(page.getByText('We are preparing your workspace')).toBeVisible();
|
|
152
|
+
|
|
153
|
+
const completed = await readCompletedRequest(requestId);
|
|
154
|
+
expect(completed.status).toBe('completed');
|
|
155
|
+
expect(completed.marketing_consent).toBe(true);
|
|
156
|
+
expect(completed.completed_at).toBeTruthy();
|
|
157
|
+
expect(completed.tenant_id, 'tenant id should be recorded').toBeTruthy();
|
|
158
|
+
expect(completed.organization_id, 'organization id should be recorded').toBeTruthy();
|
|
159
|
+
expect(completed.user_id, 'user id should be recorded').toBeTruthy();
|
|
160
|
+
|
|
161
|
+
const consent = await readMarketingConsent(completed.user_id!);
|
|
162
|
+
expect(consent.consent_type).toBe('marketing_email');
|
|
163
|
+
expect(consent.is_granted).toBe(true);
|
|
164
|
+
expect(consent.source).toBeTruthy();
|
|
165
|
+
expect(consent.granted_at).toBeTruthy();
|
|
166
|
+
expect(consent.integrity_hash, 'consent integrity hash should be computed instead of redirecting to status=error').toBeTruthy();
|
|
167
|
+
|
|
168
|
+
await page.goto(`/login?tenant=${encodeURIComponent(completed.tenant_id!)}`);
|
|
169
|
+
await expect(page.locator('form[data-auth-ready="1"]')).toBeVisible();
|
|
170
|
+
await expect(page.getByText(/You're logging in to/i)).toBeVisible();
|
|
171
|
+
await page.getByLabel('Email').fill(email);
|
|
172
|
+
await page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);
|
|
173
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
174
|
+
await expect(page).toHaveURL(/\/backend(?:\/.*)?$/);
|
|
175
|
+
});
|
|
176
|
+
});
|