@open-mercato/onboarding 0.6.6-develop.5431.1.384a97c7a2 → 0.6.6-develop.5483.1.a1129165ea
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-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 +2 -1
- package/dist/modules/onboarding/api/get/onboarding/status.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/lib/deferred-provisioning.js +29 -13
- package/dist/modules/onboarding/lib/deferred-provisioning.js.map +2 -2
- 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/service.js +31 -0
- package/dist/modules/onboarding/lib/service.js.map +2 -2
- 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 +3 -3
- package/src/__tests__/deferred-provisioning.test.ts +210 -0
- package/src/__tests__/status-endpoint-auth.test.ts +38 -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 +11 -1
- package/src/modules/onboarding/data/entities.ts +3 -0
- package/src/modules/onboarding/lib/deferred-provisioning.ts +50 -13
- package/src/modules/onboarding/lib/preparation-claim.ts +6 -0
- package/src/modules/onboarding/lib/service.ts +33 -0
- package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +10 -0
- package/src/modules/onboarding/migrations/Migration20260611120000.ts +13 -0
|
@@ -16,6 +16,7 @@ export const tenant_id = "tenant_id";
|
|
|
16
16
|
export const organization_id = "organization_id";
|
|
17
17
|
export const user_id = "user_id";
|
|
18
18
|
export const last_email_sent_at = "last_email_sent_at";
|
|
19
|
+
export const preparation_started_at = "preparation_started_at";
|
|
19
20
|
export const preparation_completed_at = "preparation_completed_at";
|
|
20
21
|
export const ready_email_sent_at = "ready_email_sent_at";
|
|
21
22
|
export const created_at = "created_at";
|
|
@@ -20,6 +20,7 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
|
|
|
20
20
|
"organization_id": "organization_id",
|
|
21
21
|
"user_id": "user_id",
|
|
22
22
|
"last_email_sent_at": "last_email_sent_at",
|
|
23
|
+
"preparation_started_at": "preparation_started_at",
|
|
23
24
|
"preparation_completed_at": "preparation_completed_at",
|
|
24
25
|
"ready_email_sent_at": "ready_email_sent_at",
|
|
25
26
|
"created_at": "created_at",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/onboarding",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5483.1.a1129165ea",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -69,10 +69,10 @@
|
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
72
|
+
"@open-mercato/shared": "0.6.6-develop.5483.1.a1129165ea"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
75
|
+
"@open-mercato/shared": "0.6.6-develop.5483.1.a1129165ea",
|
|
76
76
|
"@types/jest": "^30.0.0",
|
|
77
77
|
"jest": "^30.4.2",
|
|
78
78
|
"ts-jest": "^29.4.11"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
const seedExamples = jest.fn(async () => {
|
|
2
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
3
|
+
})
|
|
4
|
+
const purgeIndexScope = jest.fn(async () => {})
|
|
5
|
+
const reindexEntity = jest.fn(async () => {})
|
|
6
|
+
const refreshCoverageSnapshot = jest.fn(async () => {})
|
|
7
|
+
const sendWorkspaceReadyEmail = jest.fn(async () => true)
|
|
8
|
+
|
|
9
|
+
type ClaimState = {
|
|
10
|
+
status: string
|
|
11
|
+
preparationStartedAt: Date | null
|
|
12
|
+
preparationCompletedAt: Date | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const claimState: ClaimState = {
|
|
16
|
+
status: 'completed',
|
|
17
|
+
preparationStartedAt: null,
|
|
18
|
+
preparationCompletedAt: null,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const claimPreparation = jest.fn(async (_requestId: string, claimedAt: Date, staleBefore: Date) => {
|
|
22
|
+
if (claimState.status !== 'completed') return false
|
|
23
|
+
if (claimState.preparationCompletedAt) return false
|
|
24
|
+
if (claimState.preparationStartedAt && claimState.preparationStartedAt.getTime() >= staleBefore.getTime()) {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
claimState.preparationStartedAt = claimedAt
|
|
28
|
+
return true
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const renewPreparation = jest.fn(async (_requestId: string, renewedAt: Date) => {
|
|
32
|
+
if (claimState.status !== 'completed') return false
|
|
33
|
+
if (claimState.preparationCompletedAt) return false
|
|
34
|
+
if (!claimState.preparationStartedAt) return false
|
|
35
|
+
claimState.preparationStartedAt = renewedAt
|
|
36
|
+
return true
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const findById = jest.fn(async (id: string) => ({
|
|
40
|
+
id,
|
|
41
|
+
status: claimState.status,
|
|
42
|
+
preparationCompletedAt: claimState.preparationCompletedAt,
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
const markPreparationCompleted = jest.fn(async (_request: unknown, completedAt: Date) => {
|
|
46
|
+
claimState.preparationCompletedAt = completedAt
|
|
47
|
+
claimState.preparationStartedAt = null
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
51
|
+
createRequestContainer: jest.fn(async () => ({
|
|
52
|
+
resolve: (name: string) => {
|
|
53
|
+
if (name === 'em') return {}
|
|
54
|
+
throw new Error(`unexpected resolve(${name})`)
|
|
55
|
+
},
|
|
56
|
+
})),
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
jest.mock('@open-mercato/shared/lib/modules/registry', () => ({
|
|
60
|
+
getModules: () => [{ id: 'catalog', setup: { seedExamples } }],
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
jest.mock('@open-mercato/shared/lib/entities/system-entities', () => ({
|
|
64
|
+
flattenSystemEntityIds: () => ['catalog:product'],
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
jest.mock('@open-mercato/shared/lib/encryption/entityIds', () => ({
|
|
68
|
+
getEntityIds: () => ({}),
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
jest.mock('@open-mercato/core/modules/query_index/lib/reindexer', () => ({
|
|
72
|
+
reindexEntity: (...args: unknown[]) => reindexEntity(...(args as [])),
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
jest.mock('@open-mercato/core/modules/query_index/lib/purge', () => ({
|
|
76
|
+
purgeIndexScope: (...args: unknown[]) => purgeIndexScope(...(args as [])),
|
|
77
|
+
}))
|
|
78
|
+
|
|
79
|
+
jest.mock('@open-mercato/core/modules/query_index/lib/coverage', () => ({
|
|
80
|
+
refreshCoverageSnapshot: (...args: unknown[]) => refreshCoverageSnapshot(...(args as [])),
|
|
81
|
+
}))
|
|
82
|
+
|
|
83
|
+
jest.mock('@open-mercato/onboarding/modules/onboarding/lib/ready-email', () => ({
|
|
84
|
+
sendWorkspaceReadyEmail: (...args: unknown[]) => sendWorkspaceReadyEmail(...(args as [])),
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
jest.mock('@open-mercato/onboarding/modules/onboarding/lib/service', () => ({
|
|
88
|
+
OnboardingService: jest.fn().mockImplementation(() => ({
|
|
89
|
+
claimPreparation,
|
|
90
|
+
renewPreparation,
|
|
91
|
+
findById,
|
|
92
|
+
markPreparationCompleted,
|
|
93
|
+
})),
|
|
94
|
+
}))
|
|
95
|
+
|
|
96
|
+
import { runDeferredProvisioning } from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'
|
|
97
|
+
|
|
98
|
+
const RUN_ARGS = {
|
|
99
|
+
requestId: 'req-1',
|
|
100
|
+
tenantId: '11111111-1111-4111-8111-111111111111',
|
|
101
|
+
organizationId: '33333333-3333-4333-8333-333333333333',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('runDeferredProvisioning single-flight claim', () => {
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
jest.useFakeTimers({ doNotFake: ['setImmediate'] })
|
|
107
|
+
jest.clearAllMocks()
|
|
108
|
+
claimState.status = 'completed'
|
|
109
|
+
claimState.preparationStartedAt = null
|
|
110
|
+
claimState.preparationCompletedAt = null
|
|
111
|
+
sendWorkspaceReadyEmail.mockResolvedValue(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
jest.useRealTimers()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('runs the provisioning chain only once when triggered concurrently by status polls (demo pool-exhaustion repro)', async () => {
|
|
119
|
+
// Repro for the 2026-06-11 demo outage: the preparing page polls
|
|
120
|
+
// /onboarding/status every ~1s and each poll scheduled a FULL
|
|
121
|
+
// runDeferredProvisioning chain (seedExamples for every module + a forced
|
|
122
|
+
// purge/reindex of every system entity) until preparationCompletedAt was
|
|
123
|
+
// flushed. Dozens of concurrent chains exhausted the 20-connection PG pool,
|
|
124
|
+
// the completion flag write itself timed out, and the loop never
|
|
125
|
+
// terminated. Concurrent triggers must collapse into exactly one run.
|
|
126
|
+
await Promise.all([
|
|
127
|
+
runDeferredProvisioning(RUN_ARGS),
|
|
128
|
+
runDeferredProvisioning(RUN_ARGS),
|
|
129
|
+
runDeferredProvisioning(RUN_ARGS),
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
expect(seedExamples).toHaveBeenCalledTimes(1)
|
|
133
|
+
expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
|
|
134
|
+
expect(purgeIndexScope).toHaveBeenCalledTimes(1)
|
|
135
|
+
expect(reindexEntity).toHaveBeenCalledTimes(1)
|
|
136
|
+
expect(sendWorkspaceReadyEmail).toHaveBeenCalledTimes(1)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('does not run any heavy work when preparation already completed', async () => {
|
|
140
|
+
claimState.preparationCompletedAt = new Date()
|
|
141
|
+
|
|
142
|
+
await runDeferredProvisioning(RUN_ARGS)
|
|
143
|
+
|
|
144
|
+
expect(seedExamples).not.toHaveBeenCalled()
|
|
145
|
+
expect(purgeIndexScope).not.toHaveBeenCalled()
|
|
146
|
+
expect(reindexEntity).not.toHaveBeenCalled()
|
|
147
|
+
expect(sendWorkspaceReadyEmail).not.toHaveBeenCalled()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('re-claims and recovers when a previous claim went stale', async () => {
|
|
151
|
+
claimState.preparationStartedAt = new Date(Date.now() - 20 * 60 * 1000)
|
|
152
|
+
|
|
153
|
+
await runDeferredProvisioning(RUN_ARGS)
|
|
154
|
+
|
|
155
|
+
expect(seedExamples).toHaveBeenCalledTimes(1)
|
|
156
|
+
expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('renews the lease while working so a slow run never looks stale', async () => {
|
|
160
|
+
await runDeferredProvisioning(RUN_ARGS)
|
|
161
|
+
|
|
162
|
+
expect(renewPreparation).toHaveBeenCalled()
|
|
163
|
+
expect(renewPreparation.mock.invocationCallOrder[0]).toBeLessThan(
|
|
164
|
+
markPreparationCompleted.mock.invocationCallOrder[0],
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('does not complete a request that was re-submitted (reset to pending) mid-chain', async () => {
|
|
169
|
+
// A user can re-onboard the same email while an old chain is still running:
|
|
170
|
+
// createOrUpdateRequest resets the request to pending. The stale chain must
|
|
171
|
+
// not mark THAT request prepared — the new flow owns deferred provisioning.
|
|
172
|
+
findById.mockImplementationOnce(async (id: string) => ({
|
|
173
|
+
id,
|
|
174
|
+
status: 'pending',
|
|
175
|
+
preparationCompletedAt: null,
|
|
176
|
+
}))
|
|
177
|
+
|
|
178
|
+
await runDeferredProvisioning(RUN_ARGS)
|
|
179
|
+
|
|
180
|
+
expect(markPreparationCompleted).not.toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('marks preparation completed only after the query-index rebuild (death mid-rebuild stays recoverable)', async () => {
|
|
184
|
+
// preparationCompletedAt is the terminal gate for both the status-route
|
|
185
|
+
// scheduling and claimPreparation. If it were written before the rebuild,
|
|
186
|
+
// a runner dying mid-rebuild would leave the tenant permanently without
|
|
187
|
+
// index rows — nothing would ever reclaim the run.
|
|
188
|
+
await runDeferredProvisioning(RUN_ARGS)
|
|
189
|
+
|
|
190
|
+
expect(reindexEntity).toHaveBeenCalled()
|
|
191
|
+
expect(markPreparationCompleted).toHaveBeenCalled()
|
|
192
|
+
expect(reindexEntity.mock.invocationCallOrder[0]).toBeLessThan(
|
|
193
|
+
markPreparationCompleted.mock.invocationCallOrder[0],
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('still rebuilds query indexes when the ready email fails', async () => {
|
|
198
|
+
// #2954's contract: post-provisioning steps are non-fatal. A transient
|
|
199
|
+
// SMTP failure must not abort the chain before the query-index rebuild,
|
|
200
|
+
// otherwise the tenant is left permanently without index rows (the
|
|
201
|
+
// completion flag is already set, so nothing ever retries the rebuild).
|
|
202
|
+
sendWorkspaceReadyEmail.mockRejectedValue(new Error('smtp down'))
|
|
203
|
+
|
|
204
|
+
await expect(runDeferredProvisioning(RUN_ARGS)).resolves.toBeUndefined()
|
|
205
|
+
|
|
206
|
+
expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
|
|
207
|
+
expect(purgeIndexScope).toHaveBeenCalledTimes(1)
|
|
208
|
+
expect(reindexEntity).toHaveBeenCalledTimes(1)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -170,6 +170,44 @@ describe('onboarding status endpoint authorization', () => {
|
|
|
170
170
|
expect(res.status).toBe(404)
|
|
171
171
|
})
|
|
172
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
|
+
|
|
173
211
|
it('recovers an interrupted processing request that already has provisioned ids', async () => {
|
|
174
212
|
const request = makeRequest({
|
|
175
213
|
status: 'processing',
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Adapted from PR #3007 (pkarw) — multi-tenant parallel onboarding repro for the
|
|
2
|
+
// 2026-06-11 demo pool-exhaustion outage, ported to the preparation_started_at
|
|
3
|
+
// lease column introduced by the single-flight deferred-provisioning fix.
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';
|
|
6
|
+
import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
|
|
7
|
+
|
|
8
|
+
export const integrationMeta = {
|
|
9
|
+
dependsOnModules: ['onboarding'],
|
|
10
|
+
requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],
|
|
11
|
+
requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type OnboardingTenant = {
|
|
15
|
+
email: string;
|
|
16
|
+
organizationName: string;
|
|
17
|
+
token: string;
|
|
18
|
+
requestId?: string;
|
|
19
|
+
tenantId?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type BrowserTenantSession = {
|
|
23
|
+
context: BrowserContext;
|
|
24
|
+
page: Page;
|
|
25
|
+
refreshRequests: string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
|
|
29
|
+
const ONBOARDING_PASSWORD = 'ParallelPass123!';
|
|
30
|
+
|
|
31
|
+
function hashToken(token: string): string {
|
|
32
|
+
return createHash('sha256').update(token).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function replaceVerificationToken(email: string, token: string): Promise<string> {
|
|
36
|
+
return withClient(async (client) => {
|
|
37
|
+
const result = await client.query<{ id: string }>(
|
|
38
|
+
`update onboarding_requests
|
|
39
|
+
set token_hash = $2,
|
|
40
|
+
expires_at = now() + interval '24 hours',
|
|
41
|
+
updated_at = now()
|
|
42
|
+
where email = $1
|
|
43
|
+
returning id`,
|
|
44
|
+
[email, hashToken(token)],
|
|
45
|
+
);
|
|
46
|
+
expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);
|
|
47
|
+
return result.rows[0].id;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readPreparationState(requestId: string): Promise<{
|
|
52
|
+
preparation_completed_at: Date | null;
|
|
53
|
+
preparation_started_at: Date | null;
|
|
54
|
+
}> {
|
|
55
|
+
return withClient(async (client) => {
|
|
56
|
+
const result = await client.query<{
|
|
57
|
+
preparation_completed_at: Date | null;
|
|
58
|
+
preparation_started_at: Date | null;
|
|
59
|
+
}>(
|
|
60
|
+
`select preparation_completed_at, preparation_started_at
|
|
61
|
+
from onboarding_requests
|
|
62
|
+
where id = $1`,
|
|
63
|
+
[requestId],
|
|
64
|
+
);
|
|
65
|
+
expect(result.rowCount, `onboarding request ${requestId} should remain queryable`).toBe(1);
|
|
66
|
+
return result.rows[0];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function waitForPreparationComplete(requestId: string): Promise<void> {
|
|
71
|
+
const deadline = Date.now() + 60_000;
|
|
72
|
+
let lastState: Awaited<ReturnType<typeof readPreparationState>> | null = null;
|
|
73
|
+
while (Date.now() < deadline) {
|
|
74
|
+
lastState = await readPreparationState(requestId);
|
|
75
|
+
if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
77
|
+
}
|
|
78
|
+
expect(lastState?.preparation_started_at, 'deferred preparation lease should be cleared after completion').toBeNull();
|
|
79
|
+
expect(lastState?.preparation_completed_at, 'workspace preparation should complete').toBeTruthy();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function submitOnboarding(request: APIRequestContext, tenant: OnboardingTenant): Promise<void> {
|
|
83
|
+
const response = await request.post(`${BASE_URL}/api/onboarding/onboarding`, {
|
|
84
|
+
data: {
|
|
85
|
+
email: tenant.email,
|
|
86
|
+
firstName: 'Parallel',
|
|
87
|
+
lastName: 'Login',
|
|
88
|
+
organizationName: tenant.organizationName,
|
|
89
|
+
password: ONBOARDING_PASSWORD,
|
|
90
|
+
confirmPassword: ONBOARDING_PASSWORD,
|
|
91
|
+
termsAccepted: true,
|
|
92
|
+
marketingConsent: true,
|
|
93
|
+
locale: 'en',
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(response.status(), `onboarding start should succeed for ${tenant.email}`).toBe(200);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function verifyTenantInBrowser(browser: Browser, token: string): Promise<BrowserTenantSession & { tenantId: string }> {
|
|
100
|
+
const context = await browser.newContext({ baseURL: BASE_URL });
|
|
101
|
+
const page = await context.newPage();
|
|
102
|
+
const refreshRequests: string[] = [];
|
|
103
|
+
page.on('request', (browserRequest) => {
|
|
104
|
+
const url = browserRequest.url();
|
|
105
|
+
if (url.includes('/api/auth/session/refresh')) refreshRequests.push(url);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await page.goto(`/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`);
|
|
109
|
+
await expect(page).toHaveURL(/\/onboarding\/preparing\?tenant=[0-9a-f-]+/);
|
|
110
|
+
await expect(page.getByText('We are preparing your workspace')).toBeVisible();
|
|
111
|
+
const tenantId = new URL(page.url()).searchParams.get('tenant');
|
|
112
|
+
expect(tenantId, 'verify redirect should include tenant id').toMatch(
|
|
113
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
114
|
+
);
|
|
115
|
+
return { context, page, refreshRequests, tenantId: tenantId! };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function pollOnboardingStatus(request: APIRequestContext, tenantId: string) {
|
|
119
|
+
return request.get(`${BASE_URL}/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(tenantId)}`, {
|
|
120
|
+
headers: {
|
|
121
|
+
Cookie: `om_login_tenant=${encodeURIComponent(tenantId)}`,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function loginAndAssertBackend(session: BrowserTenantSession, tenant: OnboardingTenant): Promise<void> {
|
|
127
|
+
expect(tenant.tenantId, `tenant id should exist for ${tenant.email}`).toBeTruthy();
|
|
128
|
+
await expect(session.page).toHaveURL(new RegExp(`/login\\?tenant=${tenant.tenantId}`));
|
|
129
|
+
await expect(session.page.locator('form[data-auth-ready="1"]')).toBeVisible();
|
|
130
|
+
await session.page.getByLabel('Email').fill(tenant.email);
|
|
131
|
+
await session.page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);
|
|
132
|
+
await session.page.getByRole('button', { name: 'Sign in' }).click();
|
|
133
|
+
await expect(session.page, `browser login should land ${tenant.email} in the backend`).toHaveURL(/\/backend(?:\/.*)?$/);
|
|
134
|
+
await expect(session.page.getByText(/Session expired/i)).toHaveCount(0);
|
|
135
|
+
|
|
136
|
+
const profileResult = await session.page.evaluate(async () => {
|
|
137
|
+
const response = await fetch('/api/auth/profile', { credentials: 'include' });
|
|
138
|
+
const body = await response.json().catch(() => null);
|
|
139
|
+
return { status: response.status, body };
|
|
140
|
+
});
|
|
141
|
+
expect(profileResult.status, `browser page should keep ${tenant.email} authenticated`).toBe(200);
|
|
142
|
+
const profile = profileResult.body;
|
|
143
|
+
expect(profile.email).toBe(tenant.email);
|
|
144
|
+
expect(profile.roles).toContain('admin');
|
|
145
|
+
|
|
146
|
+
await session.page.goto('/backend');
|
|
147
|
+
await expect(session.page).toHaveURL(/\/backend(?:\/.*)?$/);
|
|
148
|
+
expect(session.refreshRequests, `backend should not bounce ${tenant.email} through session refresh`).toHaveLength(0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test.describe('TC-ONB-002: multi-tenant onboarding parallel login', () => {
|
|
152
|
+
test('creates two self-service tenants, handles repeated status polling, and keeps both logins authenticated', async ({
|
|
153
|
+
browser,
|
|
154
|
+
request,
|
|
155
|
+
}) => {
|
|
156
|
+
// Two full onboardings + parallel browser logins + DB-polled preparation
|
|
157
|
+
// far exceed the 20s suite default (house pattern: TC-AI-AGENT-SETTINGS-005).
|
|
158
|
+
test.setTimeout(120_000);
|
|
159
|
+
const unique = randomUUID().slice(0, 8);
|
|
160
|
+
const tenants: OnboardingTenant[] = [1, 2].map((index) => ({
|
|
161
|
+
email: `qa-onboarding-parallel-${unique}-${index}@example.test`,
|
|
162
|
+
organizationName: `QA Onboarding Parallel ${unique} ${index}`,
|
|
163
|
+
token: `integration-parallel-${index}-${randomUUID().replace(/-/g, '')}`,
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
await Promise.all(tenants.map((tenant) => submitOnboarding(request, tenant)));
|
|
167
|
+
|
|
168
|
+
for (const tenant of tenants) {
|
|
169
|
+
tenant.requestId = await replaceVerificationToken(tenant.email, tenant.token);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const browserSessions = await Promise.all(
|
|
173
|
+
tenants.map(async (tenant) => {
|
|
174
|
+
const session = await verifyTenantInBrowser(browser, tenant.token);
|
|
175
|
+
tenant.tenantId = session.tenantId;
|
|
176
|
+
return session;
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const statusResponses = await Promise.all(
|
|
182
|
+
tenants.flatMap((tenant) =>
|
|
183
|
+
Array.from({ length: 6 }, () => pollOnboardingStatus(request, tenant.tenantId!)),
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
for (const response of statusResponses) {
|
|
187
|
+
expect(response.status(), 'status polling should not exhaust the app DB pool').toBe(200);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await Promise.all(tenants.map((tenant) => waitForPreparationComplete(tenant.requestId!)));
|
|
191
|
+
await Promise.all(
|
|
192
|
+
tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant)),
|
|
193
|
+
);
|
|
194
|
+
} finally {
|
|
195
|
+
await Promise.all(browserSessions.map((session) => session.context.close()));
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveProvisioningIds,
|
|
10
10
|
runDeferredProvisioning,
|
|
11
11
|
} from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'
|
|
12
|
+
import { isPreparationClaimActive } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
|
|
12
13
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
13
14
|
|
|
14
15
|
export const metadata = {
|
|
@@ -78,7 +79,16 @@ export async function GET(req: Request) {
|
|
|
78
79
|
if (provisioningIds && request.status === 'processing') {
|
|
79
80
|
await service.markCompleted(request, provisioningIds)
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
+
// Schedule deferred provisioning only while no runner holds a fresh claim —
|
|
83
|
+
// otherwise every ~1s poll piles another full seed + reindex chain onto the
|
|
84
|
+
// connection pool. The atomic claim inside runDeferredProvisioning remains
|
|
85
|
+
// the authoritative gate; this check just keeps polls cheap.
|
|
86
|
+
if (
|
|
87
|
+
provisioningIds &&
|
|
88
|
+
request.status === 'completed' &&
|
|
89
|
+
!request.preparationCompletedAt &&
|
|
90
|
+
!isPreparationClaimActive(request.preparationStartedAt)
|
|
91
|
+
) {
|
|
82
92
|
after(async () => {
|
|
83
93
|
await runDeferredProvisioning({
|
|
84
94
|
requestId: request.id,
|
|
@@ -60,6 +60,9 @@ export class OnboardingRequest {
|
|
|
60
60
|
@Property({ name: 'last_email_sent_at', type: Date, nullable: true })
|
|
61
61
|
lastEmailSentAt?: Date | null
|
|
62
62
|
|
|
63
|
+
@Property({ name: 'preparation_started_at', type: Date, nullable: true })
|
|
64
|
+
preparationStartedAt?: Date | null
|
|
65
|
+
|
|
63
66
|
@Property({ name: 'preparation_completed_at', type: Date, nullable: true })
|
|
64
67
|
preparationCompletedAt?: Date | null
|
|
65
68
|
|
|
@@ -11,6 +11,7 @@ import { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reinde
|
|
|
11
11
|
import { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'
|
|
12
12
|
import { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'
|
|
13
13
|
import { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
|
|
14
|
+
import { PREPARATION_CLAIM_STALE_MS } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
|
|
14
15
|
|
|
15
16
|
const VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000
|
|
16
17
|
const SEED_EXAMPLES_TIMEOUT_MS = 15_000
|
|
@@ -80,13 +81,14 @@ async function runModuleSetupHook(args: {
|
|
|
80
81
|
|
|
81
82
|
async function markWorkspaceReady(args: {
|
|
82
83
|
requestId: string
|
|
84
|
+
service: OnboardingService
|
|
83
85
|
}) {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (!request || request.preparationCompletedAt) return
|
|
89
|
-
await service.markPreparationCompleted(request, new Date())
|
|
86
|
+
const request = await args.service.findById(args.requestId)
|
|
87
|
+
// The status guard protects a request that was re-submitted (and reset to
|
|
88
|
+
// pending) while this chain was still running — completing it would let the
|
|
89
|
+
// new flow skip its own deferred provisioning.
|
|
90
|
+
if (!request || request.preparationCompletedAt || request.status !== 'completed') return
|
|
91
|
+
await args.service.markPreparationCompleted(request, new Date())
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
async function enqueueVectorReindex(args: {
|
|
@@ -187,10 +189,36 @@ export async function runDeferredProvisioning(args: {
|
|
|
187
189
|
}) {
|
|
188
190
|
const container = await createRequestContainer()
|
|
189
191
|
const em = container.resolve('em') as EntityManager
|
|
192
|
+
const service = new OnboardingService(em)
|
|
193
|
+
|
|
194
|
+
// Single-flight guard: the preparing page polls the status endpoint every
|
|
195
|
+
// second and each poll (plus the verify handler) schedules this chain. The
|
|
196
|
+
// atomic claim collapses those triggers into one run per request — without
|
|
197
|
+
// it, dozens of concurrent seed + full-reindex chains exhaust the PG
|
|
198
|
+
// connection pool (2026-06-11 demo outage). A stale claim (crashed runner)
|
|
199
|
+
// becomes reclaimable after PREPARATION_CLAIM_STALE_MS.
|
|
200
|
+
const claimedAt = new Date()
|
|
201
|
+
const claimed = await service.claimPreparation(
|
|
202
|
+
args.requestId,
|
|
203
|
+
claimedAt,
|
|
204
|
+
new Date(claimedAt.getTime() - PREPARATION_CLAIM_STALE_MS),
|
|
205
|
+
)
|
|
206
|
+
if (!claimed) {
|
|
207
|
+
console.info('[onboarding.verify] deferred provisioning skipped (already claimed or completed)', {
|
|
208
|
+
requestId: args.requestId,
|
|
209
|
+
tenantId: args.tenantId,
|
|
210
|
+
})
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
190
214
|
const modules = getModules()
|
|
191
215
|
|
|
192
216
|
for (const mod of modules) {
|
|
193
217
|
if (!mod.setup?.seedExamples) continue
|
|
218
|
+
// Heartbeat: keep the lease fresh while legitimately working so a slow run
|
|
219
|
+
// (many modules × 15s seed timeout + rebuild) can never look stale and get
|
|
220
|
+
// double-claimed by a later poll.
|
|
221
|
+
await service.renewPreparation(args.requestId, new Date()).catch(() => {})
|
|
194
222
|
try {
|
|
195
223
|
await runModuleSetupHook({
|
|
196
224
|
moduleId: mod.id,
|
|
@@ -221,10 +249,26 @@ export async function runDeferredProvisioning(args: {
|
|
|
221
249
|
}
|
|
222
250
|
}
|
|
223
251
|
|
|
252
|
+
// The rebuild runs BEFORE the completion flag: preparationCompletedAt is the
|
|
253
|
+
// terminal gate for both the status-route scheduling and claimPreparation,
|
|
254
|
+
// so a runner that dies mid-rebuild must leave the flag unset — the stale
|
|
255
|
+
// claim then makes the whole chain reclaimable and the rebuild self-heals.
|
|
256
|
+
// rebuildTenantQueryIndexes never throws (it logs per-entity failures).
|
|
257
|
+
await service.renewPreparation(args.requestId, new Date()).catch(() => {})
|
|
258
|
+
await rebuildTenantQueryIndexes({
|
|
259
|
+
em,
|
|
260
|
+
tenantId: args.tenantId,
|
|
261
|
+
organizationId: args.organizationId,
|
|
262
|
+
})
|
|
263
|
+
|
|
224
264
|
await markWorkspaceReady({
|
|
225
265
|
requestId: args.requestId,
|
|
266
|
+
service,
|
|
226
267
|
})
|
|
227
268
|
|
|
269
|
+
// Non-fatal (#2954 contract): an email failure must not abort the chain.
|
|
270
|
+
// The status endpoint retries the ready email on later polls while
|
|
271
|
+
// readyEmailSentAt is unset.
|
|
228
272
|
await sendWorkspaceReadyEmail({
|
|
229
273
|
requestId: args.requestId,
|
|
230
274
|
tenantId: args.tenantId,
|
|
@@ -235,13 +279,6 @@ export async function runDeferredProvisioning(args: {
|
|
|
235
279
|
organizationId: args.organizationId,
|
|
236
280
|
error,
|
|
237
281
|
})
|
|
238
|
-
throw error
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
await rebuildTenantQueryIndexes({
|
|
242
|
-
em,
|
|
243
|
-
tenantId: args.tenantId,
|
|
244
|
-
organizationId: args.organizationId,
|
|
245
282
|
})
|
|
246
283
|
|
|
247
284
|
await enqueueVectorReindex({
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const PREPARATION_CLAIM_STALE_MS = 10 * 60 * 1000
|
|
2
|
+
|
|
3
|
+
export function isPreparationClaimActive(startedAt: Date | null | undefined, now: Date = new Date()): boolean {
|
|
4
|
+
if (!startedAt) return false
|
|
5
|
+
return startedAt.getTime() > now.getTime() - PREPARATION_CLAIM_STALE_MS
|
|
6
|
+
}
|