@open-mercato/onboarding 0.6.5-develop.5382.1.f542de69af → 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,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
|
+
});
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server'
|
|
1
|
+
import { after, NextResponse } from 'next/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
-
import {
|
|
5
|
+
import { assertAllowedAppOrigin, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
|
|
6
6
|
import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
|
|
7
7
|
import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
|
|
8
|
+
import {
|
|
9
|
+
resolveProvisioningIds,
|
|
10
|
+
runDeferredProvisioning,
|
|
11
|
+
} from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'
|
|
12
|
+
import { isPreparationClaimActive } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
|
|
8
13
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
9
14
|
|
|
10
15
|
export const metadata = {
|
|
@@ -51,9 +56,8 @@ export async function GET(req: Request) {
|
|
|
51
56
|
return NextResponse.json({ ok: false, error: 'Not authorized for this tenant.' }, { status: 403 })
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
let baseUrl: string
|
|
55
59
|
try {
|
|
56
|
-
|
|
60
|
+
assertAllowedAppOrigin(req)
|
|
57
61
|
} catch (error) {
|
|
58
62
|
const mapped = mapSecurityEmailUrlError(error, {
|
|
59
63
|
scope: 'onboarding.status',
|
|
@@ -62,6 +66,7 @@ export async function GET(req: Request) {
|
|
|
62
66
|
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })
|
|
63
67
|
throw error
|
|
64
68
|
}
|
|
69
|
+
|
|
65
70
|
const container = await createRequestContainer()
|
|
66
71
|
const em = container.resolve('em') as EntityManager
|
|
67
72
|
const service = new OnboardingService(em)
|
|
@@ -70,24 +75,48 @@ export async function GET(req: Request) {
|
|
|
70
75
|
return NextResponse.json({ ok: false, error: 'Onboarding request not found.' }, { status: 404 })
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
|
|
78
|
+
const provisioningIds = resolveProvisioningIds(request)
|
|
79
|
+
if (provisioningIds && request.status === 'processing') {
|
|
80
|
+
await service.markCompleted(request, provisioningIds)
|
|
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
|
+
) {
|
|
92
|
+
after(async () => {
|
|
93
|
+
await runDeferredProvisioning({
|
|
94
|
+
requestId: request.id,
|
|
95
|
+
tenantId: provisioningIds.tenantId,
|
|
96
|
+
organizationId: provisioningIds.organizationId,
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const emailSent = Boolean(request.readyEmailSentAt)
|
|
74
102
|
const ready = request.status === 'completed' && Boolean(request.preparationCompletedAt)
|
|
75
|
-
const loginUrl = ready && request.tenantId ?
|
|
103
|
+
const loginUrl = ready && request.tenantId ? `/login?tenant=${encodeURIComponent(request.tenantId)}` : null
|
|
76
104
|
|
|
77
105
|
if (ready && request.tenantId && !request.readyEmailSentAt) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
tenantId: request.tenantId,
|
|
82
|
-
})
|
|
83
|
-
} catch (error) {
|
|
84
|
-
console.error('[onboarding.status] ready email retry failed', {
|
|
106
|
+
const readyTenantId = request.tenantId
|
|
107
|
+
after(async () => {
|
|
108
|
+
await sendWorkspaceReadyEmail({
|
|
85
109
|
requestId: request.id,
|
|
86
|
-
tenantId:
|
|
87
|
-
|
|
88
|
-
error,
|
|
110
|
+
tenantId: readyTenantId,
|
|
111
|
+
}).catch((error) => {
|
|
112
|
+
console.error('[onboarding.status] ready email retry failed', {
|
|
113
|
+
requestId: request.id,
|
|
114
|
+
tenantId: readyTenantId,
|
|
115
|
+
organizationId: request.organizationId,
|
|
116
|
+
error,
|
|
117
|
+
})
|
|
89
118
|
})
|
|
90
|
-
}
|
|
119
|
+
})
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
return NextResponse.json({
|