@open-mercato/onboarding 0.6.6-develop.5412.1.e2a52b14f0 → 0.6.6-develop.5431.1.384a97c7a2

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.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +141 -0
  3. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +7 -0
  4. package/dist/modules/onboarding/api/get/onboarding/status.js +34 -17
  5. package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
  6. package/dist/modules/onboarding/api/get/onboarding/verify.js +77 -195
  7. package/dist/modules/onboarding/api/get/onboarding/verify.js.map +2 -2
  8. package/dist/modules/onboarding/api/post/onboarding.js +10 -1
  9. package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
  10. package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js +31 -1
  11. package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js.map +2 -2
  12. package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js +36 -7
  13. package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js.map +2 -2
  14. package/dist/modules/onboarding/lib/deferred-provisioning.js +219 -0
  15. package/dist/modules/onboarding/lib/deferred-provisioning.js.map +7 -0
  16. package/dist/modules/onboarding/lib/provisioning.js +18 -0
  17. package/dist/modules/onboarding/lib/provisioning.js.map +7 -0
  18. package/dist/modules/onboarding/lib/verify-base-url.js +86 -0
  19. package/dist/modules/onboarding/lib/verify-base-url.js.map +7 -0
  20. package/package.json +3 -3
  21. package/src/__tests__/onboarding-submit-rate-limit.test.ts +22 -0
  22. package/src/__tests__/provisioning.test.ts +89 -0
  23. package/src/__tests__/status-endpoint-auth.test.ts +92 -3
  24. package/src/__tests__/verify-base-url.test.ts +65 -0
  25. package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +176 -0
  26. package/src/modules/onboarding/api/get/onboarding/status.ts +36 -17
  27. package/src/modules/onboarding/api/get/onboarding/verify.ts +96 -227
  28. package/src/modules/onboarding/api/post/onboarding.ts +9 -0
  29. package/src/modules/onboarding/frontend/onboarding/OnboardingPageClient.tsx +32 -2
  30. package/src/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.tsx +38 -6
  31. package/src/modules/onboarding/i18n/de.json +10 -1
  32. package/src/modules/onboarding/i18n/en.json +10 -1
  33. package/src/modules/onboarding/i18n/es.json +10 -1
  34. package/src/modules/onboarding/i18n/pl.json +10 -1
  35. package/src/modules/onboarding/lib/deferred-provisioning.ts +258 -0
  36. package/src/modules/onboarding/lib/provisioning.ts +35 -0
  37. package/src/modules/onboarding/lib/verify-base-url.ts +107 -0
@@ -0,0 +1,86 @@
1
+ import {
2
+ AppOriginConfigurationError,
3
+ AppOriginRejectedError,
4
+ getSecurityEmailBaseUrl,
5
+ resolveRequestOrigin
6
+ } from "@open-mercato/shared/lib/url";
7
+ function normalizeOrigin(raw) {
8
+ try {
9
+ return new URL(raw).origin;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ function originPort(url) {
15
+ if (url.port) return url.port;
16
+ if (url.protocol === "https:") return "443";
17
+ if (url.protocol === "http:") return "80";
18
+ return "";
19
+ }
20
+ function isLoopbackHostname(hostname) {
21
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
22
+ }
23
+ function originsMatchForRedirect(configuredOrigin, requestOrigin) {
24
+ if (configuredOrigin === requestOrigin) return true;
25
+ try {
26
+ const configured = new URL(configuredOrigin);
27
+ const request = new URL(requestOrigin);
28
+ return configured.protocol === request.protocol && isLoopbackHostname(configured.hostname) && isLoopbackHostname(request.hostname) && originPort(configured) === originPort(request);
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ function resolveVerifyRedirectBaseUrl(req, env = process.env) {
34
+ try {
35
+ const baseUrl = getSecurityEmailBaseUrl(req, env);
36
+ const configuredOrigin = normalizeOrigin(baseUrl);
37
+ const requestOrigin = normalizeOrigin(resolveRequestOrigin(req)) ?? normalizeOrigin(req.url);
38
+ if (configuredOrigin && requestOrigin && !originsMatchForRedirect(configuredOrigin, requestOrigin)) {
39
+ console.error("[onboarding.verify] APP_URL does not match verification request origin", {
40
+ requestUrl: req.url,
41
+ configuredOrigin,
42
+ requestOrigin
43
+ });
44
+ return {
45
+ ok: false,
46
+ status: "redirect_misconfigured",
47
+ message: "Onboarding verification redirect is misconfigured.",
48
+ redirectOrigin: requestOrigin,
49
+ httpStatus: 400
50
+ };
51
+ }
52
+ return { ok: true, baseUrl };
53
+ } catch (error) {
54
+ if (error instanceof AppOriginRejectedError) {
55
+ console.error("[onboarding.verify] rejected request origin for redirect base", {
56
+ requestUrl: req.url,
57
+ reason: error.message
58
+ });
59
+ return {
60
+ ok: false,
61
+ status: "origin_not_allowed",
62
+ message: "Onboarding verification request origin is not allowed.",
63
+ redirectOrigin: null,
64
+ httpStatus: 400
65
+ };
66
+ }
67
+ if (error instanceof AppOriginConfigurationError) {
68
+ console.error("[onboarding.verify] APP_URL is required for onboarding verification redirects", {
69
+ requestUrl: req.url,
70
+ reason: error.message
71
+ });
72
+ return {
73
+ ok: false,
74
+ status: "url_not_configured",
75
+ message: "Onboarding verification redirect is not configured.",
76
+ redirectOrigin: null,
77
+ httpStatus: 500
78
+ };
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+ export {
84
+ resolveVerifyRedirectBaseUrl
85
+ };
86
+ //# sourceMappingURL=verify-base-url.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/onboarding/lib/verify-base-url.ts"],
4
+ "sourcesContent": ["import {\n AppOriginConfigurationError,\n AppOriginRejectedError,\n getSecurityEmailBaseUrl,\n resolveRequestOrigin,\n} from '@open-mercato/shared/lib/url'\n\ntype EnvLike = Record<string, string | undefined>\n\nexport type VerifyRedirectBaseUrlResult =\n | { ok: true; baseUrl: string }\n | {\n ok: false\n status: 'origin_not_allowed' | 'redirect_misconfigured' | 'url_not_configured'\n message: string\n redirectOrigin: string | null\n httpStatus: number\n }\n\nfunction normalizeOrigin(raw: string): string | null {\n try {\n return new URL(raw).origin\n } catch {\n return null\n }\n}\n\nfunction originPort(url: URL): string {\n if (url.port) return url.port\n if (url.protocol === 'https:') return '443'\n if (url.protocol === 'http:') return '80'\n return ''\n}\n\nfunction isLoopbackHostname(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'\n}\n\nfunction originsMatchForRedirect(configuredOrigin: string, requestOrigin: string): boolean {\n if (configuredOrigin === requestOrigin) return true\n try {\n const configured = new URL(configuredOrigin)\n const request = new URL(requestOrigin)\n return (\n configured.protocol === request.protocol &&\n isLoopbackHostname(configured.hostname) &&\n isLoopbackHostname(request.hostname) &&\n originPort(configured) === originPort(request)\n )\n } catch {\n return false\n }\n}\n\nexport function resolveVerifyRedirectBaseUrl(\n req: Request,\n env: EnvLike = process.env,\n): VerifyRedirectBaseUrlResult {\n try {\n const baseUrl = getSecurityEmailBaseUrl(req, env)\n const configuredOrigin = normalizeOrigin(baseUrl)\n const requestOrigin = normalizeOrigin(resolveRequestOrigin(req)) ?? normalizeOrigin(req.url)\n if (configuredOrigin && requestOrigin && !originsMatchForRedirect(configuredOrigin, requestOrigin)) {\n console.error('[onboarding.verify] APP_URL does not match verification request origin', {\n requestUrl: req.url,\n configuredOrigin,\n requestOrigin,\n })\n return {\n ok: false,\n status: 'redirect_misconfigured',\n message: 'Onboarding verification redirect is misconfigured.',\n redirectOrigin: requestOrigin,\n httpStatus: 400,\n }\n }\n return { ok: true, baseUrl }\n } catch (error) {\n if (error instanceof AppOriginRejectedError) {\n console.error('[onboarding.verify] rejected request origin for redirect base', {\n requestUrl: req.url,\n reason: error.message,\n })\n return {\n ok: false,\n status: 'origin_not_allowed',\n message: 'Onboarding verification request origin is not allowed.',\n redirectOrigin: null,\n httpStatus: 400,\n }\n }\n if (error instanceof AppOriginConfigurationError) {\n console.error('[onboarding.verify] APP_URL is required for onboarding verification redirects', {\n requestUrl: req.url,\n reason: error.message,\n })\n return {\n ok: false,\n status: 'url_not_configured',\n message: 'Onboarding verification redirect is not configured.',\n redirectOrigin: null,\n httpStatus: 500,\n }\n }\n throw error\n }\n}\n"],
5
+ "mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAcP,SAAS,gBAAgB,KAA4B;AACnD,MAAI;AACF,WAAO,IAAI,IAAI,GAAG,EAAE;AAAA,EACtB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,WAAW,KAAkB;AACpC,MAAI,IAAI,KAAM,QAAO,IAAI;AACzB,MAAI,IAAI,aAAa,SAAU,QAAO;AACtC,MAAI,IAAI,aAAa,QAAS,QAAO;AACrC,SAAO;AACT;AAEA,SAAS,mBAAmB,UAA2B;AACrD,SAAO,aAAa,eAAe,aAAa,eAAe,aAAa;AAC9E;AAEA,SAAS,wBAAwB,kBAA0B,eAAgC;AACzF,MAAI,qBAAqB,cAAe,QAAO;AAC/C,MAAI;AACF,UAAM,aAAa,IAAI,IAAI,gBAAgB;AAC3C,UAAM,UAAU,IAAI,IAAI,aAAa;AACrC,WACE,WAAW,aAAa,QAAQ,YAChC,mBAAmB,WAAW,QAAQ,KACtC,mBAAmB,QAAQ,QAAQ,KACnC,WAAW,UAAU,MAAM,WAAW,OAAO;AAAA,EAEjD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,6BACd,KACA,MAAe,QAAQ,KACM;AAC7B,MAAI;AACF,UAAM,UAAU,wBAAwB,KAAK,GAAG;AAChD,UAAM,mBAAmB,gBAAgB,OAAO;AAChD,UAAM,gBAAgB,gBAAgB,qBAAqB,GAAG,CAAC,KAAK,gBAAgB,IAAI,GAAG;AAC3F,QAAI,oBAAoB,iBAAiB,CAAC,wBAAwB,kBAAkB,aAAa,GAAG;AAClG,cAAQ,MAAM,0EAA0E;AAAA,QACtF,YAAY,IAAI;AAAA,QAChB;AAAA,QACA;AAAA,MACF,CAAC;AACD,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,gBAAgB;AAAA,QAChB,YAAY;AAAA,MACd;AAAA,IACF;AACA,WAAO,EAAE,IAAI,MAAM,QAAQ;AAAA,EAC7B,SAAS,OAAO;AACd,QAAI,iBAAiB,wBAAwB;AAC3C,cAAQ,MAAM,iEAAiE;AAAA,QAC7E,YAAY,IAAI;AAAA,QAChB,QAAQ,MAAM;AAAA,MAChB,CAAC;AACD,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,gBAAgB;AAAA,QAChB,YAAY;AAAA,MACd;AAAA,IACF;AACA,QAAI,iBAAiB,6BAA6B;AAChD,cAAQ,MAAM,iFAAiF;AAAA,QAC7F,YAAY,IAAI;AAAA,QAChB,QAAQ,MAAM;AAAA,MAChB,CAAC;AACD,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,gBAAgB;AAAA,QAChB,YAAY;AAAA,MACd;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACF;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/onboarding",
3
- "version": "0.6.6-develop.5412.1.e2a52b14f0",
3
+ "version": "0.6.6-develop.5431.1.384a97c7a2",
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.5412.1.e2a52b14f0"
72
+ "@open-mercato/shared": "0.6.6-develop.5431.1.384a97c7a2"
73
73
  },
74
74
  "devDependencies": {
75
- "@open-mercato/shared": "0.6.6-develop.5412.1.e2a52b14f0",
75
+ "@open-mercato/shared": "0.6.6-develop.5431.1.384a97c7a2",
76
76
  "@types/jest": "^30.0.0",
77
77
  "jest": "^30.4.2",
78
78
  "ts-jest": "^29.4.11"
@@ -0,0 +1,22 @@
1
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
2
+ createRequestContainer: jest.fn(),
3
+ }))
4
+
5
+ import { metadata, openApi } from '../modules/onboarding/api/post/onboarding'
6
+
7
+ describe('onboarding submission rate limiting', () => {
8
+ it('declares an IP rate limit so the API dispatcher throttles unauthenticated submissions', () => {
9
+ expect(metadata.POST.requireAuth).toBe(false)
10
+ expect(metadata.POST.rateLimit).toEqual({
11
+ points: 10,
12
+ duration: 60,
13
+ blockDuration: 60,
14
+ keyPrefix: 'onboarding',
15
+ })
16
+ })
17
+
18
+ it('documents the 429 response in the OpenAPI doc', () => {
19
+ const errors = openApi.methods?.POST?.errors ?? []
20
+ expect(errors.some((entry) => entry.status === 429)).toBe(true)
21
+ })
22
+ })
@@ -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
- getSecurityEmailBaseUrl: () => 'https://app.example.com',
17
- mapSecurityEmailUrlError: () => null,
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(`https://app.example.com/login?tenant=${TENANT_ID}`)
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,32 @@ describe('onboarding status endpoint authorization', () => {
108
169
  )
109
170
  expect(res.status).toBe(404)
110
171
  })
172
+
173
+ it('recovers an interrupted processing request that already has provisioned ids', async () => {
174
+ const request = makeRequest({
175
+ status: 'processing',
176
+ tenantId: TENANT_ID,
177
+ organizationId: '44444444-4444-4444-8444-444444444444',
178
+ userId: '55555555-5555-4555-8555-555555555555',
179
+ completedAt: null,
180
+ preparationCompletedAt: null,
181
+ processingStartedAt: new Date(),
182
+ })
183
+ findLatestByTenantId.mockResolvedValue(request)
184
+
185
+ const res = await GET(
186
+ buildRequest({ tenantId: TENANT_ID, cookie: `om_login_tenant=${TENANT_ID}` }),
187
+ )
188
+
189
+ expect(res.status).toBe(200)
190
+ const body = await res.json()
191
+ expect(body.ok).toBe(true)
192
+ expect(body.status).toBe('completed')
193
+ expect(body.ready).toBe(false)
194
+ expect(markCompleted).toHaveBeenCalledWith(request, {
195
+ tenantId: TENANT_ID,
196
+ organizationId: '44444444-4444-4444-8444-444444444444',
197
+ userId: '55555555-5555-4555-8555-555555555555',
198
+ })
199
+ })
111
200
  })
@@ -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
+ });