@open-mercato/onboarding 0.7.1-develop.7106.1.53fe9dd1b1 → 0.7.1-develop.7112.1.d486e6f1b2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ const sendEmail = jest.fn()
2
+
3
+ jest.mock('@open-mercato/shared/lib/email/send', () => ({
4
+ sendEmail: (...args: unknown[]) => sendEmail(...args),
5
+ }))
6
+
7
+ import { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
8
+ import { hashForLookup } from '@open-mercato/shared/lib/encryption/aes'
9
+ import type { RateLimitConfig } from '@open-mercato/shared/lib/ratelimit/types'
10
+ import { sendExistingAccountNotice } from '../modules/onboarding/lib/existing-account-notice'
11
+
12
+ const VICTIM_EMAIL = 'victim@example.com'
13
+
14
+ const copy = {
15
+ preview: 'preview',
16
+ heading: 'heading',
17
+ greeting: 'greeting',
18
+ body: 'body',
19
+ cta: 'cta',
20
+ ignore: 'ignore',
21
+ footer: 'footer',
22
+ }
23
+
24
+ function containerFor(limiter: RateLimiterService | null) {
25
+ return {
26
+ resolve: (name: string) => {
27
+ if (name !== 'rateLimiterService') throw new Error(`[internal] unexpected resolve(${name})`)
28
+ if (!limiter) throw new Error('[internal] rateLimiterService is not registered')
29
+ return limiter
30
+ },
31
+ }
32
+ }
33
+
34
+ function notice(limiter: RateLimiterService | null, email: string) {
35
+ return sendExistingAccountNotice({
36
+ container: containerFor(limiter),
37
+ email,
38
+ loginUrl: 'https://app.example.com/login',
39
+ subject: 'About your Open Mercato account',
40
+ copy,
41
+ })
42
+ }
43
+
44
+ function limiterService(enabled: boolean): RateLimiterService {
45
+ return new RateLimiterService({
46
+ enabled,
47
+ strategy: 'memory',
48
+ keyPrefix: 'test',
49
+ trustProxyDepth: 0,
50
+ })
51
+ }
52
+
53
+ beforeEach(() => {
54
+ jest.clearAllMocks()
55
+ sendEmail.mockResolvedValue(undefined)
56
+ })
57
+
58
+ /**
59
+ * The parity suite asserts the notice throttle against a mocked limiter, which cannot observe
60
+ * what the real `RateLimiterService` does with the config it is handed. These cases exercise the
61
+ * real service so the window — and the documented hole in it — are pinned by the suite rather
62
+ * than by a reviewer's manual trace.
63
+ */
64
+ describe('existing-account notice against the real RateLimiterService', () => {
65
+ it('sends once per address per window when rate limiting is enabled', async () => {
66
+ const limiter = limiterService(true)
67
+
68
+ await expect(notice(limiter, VICTIM_EMAIL)).resolves.toBe('sent')
69
+ await expect(notice(limiter, VICTIM_EMAIL)).resolves.toBe('throttled')
70
+
71
+ expect(sendEmail).toHaveBeenCalledTimes(1)
72
+ await limiter.destroy()
73
+ })
74
+
75
+ it('throttles a case-variant address through the same window', async () => {
76
+ const limiter = limiterService(true)
77
+
78
+ await expect(notice(limiter, VICTIM_EMAIL)).resolves.toBe('sent')
79
+ await expect(notice(limiter, ' VICTIM@Example.COM ')).resolves.toBe('throttled')
80
+
81
+ expect(hashForLookup(' VICTIM@Example.COM ')).toBe(hashForLookup(VICTIM_EMAIL))
82
+ expect(sendEmail).toHaveBeenCalledTimes(1)
83
+ await limiter.destroy()
84
+ })
85
+
86
+ it('releases the window on a failed send so the next attempt still mails the owner', async () => {
87
+ const limiter = limiterService(true)
88
+ sendEmail.mockRejectedValueOnce(new Error('smtp down'))
89
+
90
+ await expect(notice(limiter, VICTIM_EMAIL)).rejects.toThrow('smtp down')
91
+ await expect(notice(limiter, VICTIM_EMAIL)).resolves.toBe('sent')
92
+
93
+ expect(sendEmail).toHaveBeenCalledTimes(2)
94
+ await limiter.destroy()
95
+ })
96
+
97
+ it('does not share a window with the endpoint\'s own per-IP limiter', async () => {
98
+ const limiter = limiterService(true)
99
+ const endpointConfig: RateLimitConfig = {
100
+ points: 10,
101
+ duration: 60,
102
+ blockDuration: 60,
103
+ keyPrefix: 'onboarding',
104
+ }
105
+
106
+ await limiter.consume(hashForLookup(VICTIM_EMAIL), endpointConfig)
107
+ await expect(notice(limiter, VICTIM_EMAIL)).resolves.toBe('sent')
108
+
109
+ expect(sendEmail).toHaveBeenCalledTimes(1)
110
+ await limiter.destroy()
111
+ })
112
+
113
+ /**
114
+ * Deliberate, documented gap rather than an oversight: with rate limiting switched off the
115
+ * window disappears, because failing closed would answer differently from the no-account
116
+ * branch and re-open the enumeration oracle (#5505). See `existing-account-notice.ts`.
117
+ */
118
+ it('sends unthrottled when rate limiting is disabled', async () => {
119
+ const limiter = limiterService(false)
120
+
121
+ await expect(notice(limiter, VICTIM_EMAIL)).resolves.toBe('sent')
122
+ await expect(notice(limiter, VICTIM_EMAIL)).resolves.toBe('sent')
123
+
124
+ expect(sendEmail).toHaveBeenCalledTimes(2)
125
+ await limiter.destroy()
126
+ })
127
+
128
+ it('sends unthrottled when no limiter is registered at all', async () => {
129
+ await expect(notice(null, VICTIM_EMAIL)).resolves.toBe('sent')
130
+ await expect(notice(null, VICTIM_EMAIL)).resolves.toBe('sent')
131
+
132
+ expect(sendEmail).toHaveBeenCalledTimes(2)
133
+ })
134
+ })
@@ -0,0 +1,277 @@
1
+ const findOneWithDecryption = jest.fn()
2
+ const createOrUpdateRequest = jest.fn()
3
+ const sendEmail = jest.fn()
4
+ const getSecurityEmailBaseUrl = jest.fn()
5
+ const consume = jest.fn()
6
+ const deleteRateLimitKey = jest.fn()
7
+ const resolveRateLimiterService = jest.fn()
8
+ const loadDictionary = jest.fn(async (_locale: string) => ({} as Record<string, string>))
9
+
10
+ const originalSelfServiceOnboardingEnabled = process.env.SELF_SERVICE_ONBOARDING_ENABLED
11
+
12
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
13
+ createRequestContainer: jest.fn(async () => ({
14
+ resolve: (name: string) => {
15
+ if (name === 'em') return { flush: jest.fn() }
16
+ if (name === 'rateLimiterService') return resolveRateLimiterService()
17
+ throw new Error(`[internal] unexpected resolve(${name})`)
18
+ },
19
+ })),
20
+ }))
21
+
22
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
23
+ findOneWithDecryption: (...args: unknown[]) => findOneWithDecryption(...args),
24
+ }))
25
+
26
+ jest.mock('@open-mercato/shared/lib/encryption/aes', () => ({
27
+ lookupHashCandidates: (email: string) => [`candidate:${email}`],
28
+ hashForLookup: (email: string) => `hash:${email}`,
29
+ }))
30
+
31
+ jest.mock('@open-mercato/shared/lib/url', () => ({
32
+ getSecurityEmailBaseUrl: (...args: unknown[]) => getSecurityEmailBaseUrl(...args),
33
+ mapSecurityEmailUrlError: (error: unknown) => {
34
+ if (error instanceof Error && error.message === 'APP_URL missing') {
35
+ return { status: 500, body: { error: 'Self-service onboarding is not configured.' } }
36
+ }
37
+ return null
38
+ },
39
+ }))
40
+
41
+ jest.mock('@open-mercato/shared/lib/email/send', () => ({
42
+ sendEmail: (...args: unknown[]) => sendEmail(...args),
43
+ }))
44
+
45
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
46
+ loadDictionary: (locale: string) => loadDictionary(locale),
47
+ }))
48
+
49
+ jest.mock('@open-mercato/onboarding/modules/onboarding/lib/service', () => ({
50
+ OnboardingService: jest.fn().mockImplementation(() => ({ createOrUpdateRequest })),
51
+ }))
52
+
53
+ jest.mock('@open-mercato/core/modules/auth/data/entities', () => ({
54
+ User: class User {},
55
+ }))
56
+
57
+ import { POST } from '../modules/onboarding/api/post/onboarding'
58
+ import type { OnboardingStartInput } from '../modules/onboarding/data/validators'
59
+
60
+ type UserLookup = { $or?: Array<{ email?: string }> }
61
+
62
+ const EXISTING_EMAIL = 'has-account@example.com'
63
+ const NEW_EMAIL = 'no-account@example.com'
64
+ const BASE_URL = 'https://app.example.com'
65
+
66
+ type ProbeResult = { status: number; body: unknown }
67
+
68
+ function submission(email: string, locale?: string) {
69
+ return {
70
+ ...(locale ? { locale } : {}),
71
+ email,
72
+ firstName: 'Mallory',
73
+ lastName: 'Prober',
74
+ organizationName: 'Probe Industries',
75
+ password: 'Str0ng!Passw0rd',
76
+ confirmPassword: 'Str0ng!Passw0rd',
77
+ termsAccepted: true,
78
+ marketingConsent: false,
79
+ }
80
+ }
81
+
82
+ async function probe(email: string, locale?: string): Promise<ProbeResult> {
83
+ const response = await POST(new Request(`${BASE_URL}/api/onboarding/onboarding`, {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify(submission(email, locale)),
87
+ }))
88
+ return { status: response.status, body: await response.json() }
89
+ }
90
+
91
+ type RenderedNode = { props?: { href?: unknown; children?: unknown } }
92
+
93
+ function collectStrings(node: unknown, into: string[] = []): string[] {
94
+ if (node == null || typeof node === 'boolean') return into
95
+ if (typeof node === 'string' || typeof node === 'number') {
96
+ into.push(String(node))
97
+ return into
98
+ }
99
+ if (Array.isArray(node)) {
100
+ for (const child of node) collectStrings(child, into)
101
+ return into
102
+ }
103
+ if (typeof node === 'object') {
104
+ const props = (node as RenderedNode).props
105
+ if (!props) return into
106
+ if (typeof props.href === 'string') into.push(props.href)
107
+ collectStrings(props.children, into)
108
+ }
109
+ return into
110
+ }
111
+
112
+ function noticeEmailCall() {
113
+ return sendEmail.mock.calls.find((call) => call[0]?.subject === 'About your Open Mercato account')
114
+ }
115
+
116
+ function verificationEmailCall() {
117
+ return sendEmail.mock.calls.find((call) => call[0]?.subject === 'Confirm your email to finish onboarding')
118
+ }
119
+
120
+ beforeEach(() => {
121
+ jest.clearAllMocks()
122
+ loadDictionary.mockImplementation(async () => ({}))
123
+ process.env.SELF_SERVICE_ONBOARDING_ENABLED = 'true'
124
+ getSecurityEmailBaseUrl.mockReturnValue(BASE_URL)
125
+ sendEmail.mockResolvedValue(undefined)
126
+ consume.mockResolvedValue({ allowed: true, remainingPoints: 0, msBeforeNext: 0, consumedPoints: 1 })
127
+ deleteRateLimitKey.mockResolvedValue(undefined)
128
+ resolveRateLimiterService.mockReturnValue({ consume, delete: deleteRateLimitKey })
129
+ findOneWithDecryption.mockImplementation(async (_em: unknown, _entity: unknown, where: UserLookup) => {
130
+ const probed = where?.$or?.[0]?.email
131
+ return probed === EXISTING_EMAIL ? { id: 'user-1', email: EXISTING_EMAIL } : null
132
+ })
133
+ createOrUpdateRequest.mockImplementation(async (input: OnboardingStartInput) => ({
134
+ request: {
135
+ email: input.email,
136
+ firstName: input.firstName,
137
+ lastName: input.lastName,
138
+ organizationName: input.organizationName,
139
+ marketingConsent: false,
140
+ lastEmailSentAt: new Date(),
141
+ },
142
+ token: 'verification-token',
143
+ }))
144
+ })
145
+
146
+ afterAll(() => {
147
+ if (originalSelfServiceOnboardingEnabled === undefined) {
148
+ delete process.env.SELF_SERVICE_ONBOARDING_ENABLED
149
+ } else {
150
+ process.env.SELF_SERVICE_ONBOARDING_ENABLED = originalSelfServiceOnboardingEnabled
151
+ }
152
+ })
153
+
154
+ describe('onboarding start does not disclose whether an email already has an account', () => {
155
+ it('answers identically for an address with an account and one without', async () => {
156
+ const existing = await probe(EXISTING_EMAIL)
157
+ const fresh = await probe(NEW_EMAIL)
158
+
159
+ expect(existing.status).toBe(200)
160
+ expect(existing.body).toEqual({ ok: true, email: EXISTING_EMAIL })
161
+ expect(fresh.status).toBe(existing.status)
162
+ expect(fresh.body).toEqual({ ok: true, email: NEW_EMAIL })
163
+ })
164
+
165
+ it('answers identically inside the pending-verification window', async () => {
166
+ createOrUpdateRequest.mockRejectedValueOnce(new Error('PENDING_REQUEST:7'))
167
+
168
+ const throttled = await probe(NEW_EMAIL)
169
+
170
+ expect(throttled.status).toBe(200)
171
+ expect(throttled.body).toEqual({ ok: true, email: NEW_EMAIL })
172
+ })
173
+
174
+ it('answers identically when the outbound email fails on either branch', async () => {
175
+ sendEmail.mockRejectedValue(new Error('smtp down'))
176
+
177
+ const existing = await probe(EXISTING_EMAIL)
178
+ const fresh = await probe(NEW_EMAIL)
179
+
180
+ expect(existing.status).toBe(502)
181
+ expect(fresh.status).toBe(502)
182
+ expect(existing.body).toEqual(fresh.body)
183
+ })
184
+
185
+ it('answers identically when the base URL is misconfigured on either branch', async () => {
186
+ getSecurityEmailBaseUrl.mockImplementation(() => {
187
+ throw new Error('APP_URL missing')
188
+ })
189
+
190
+ const existing = await probe(EXISTING_EMAIL)
191
+ const fresh = await probe(NEW_EMAIL)
192
+
193
+ expect(existing.status).toBe(500)
194
+ expect(fresh.status).toBe(500)
195
+ expect(existing.body).toEqual(fresh.body)
196
+ })
197
+ })
198
+
199
+ describe('onboarding start handles the existing account out of band', () => {
200
+ it('emails the address owner a neutral notice instead of a verification link', async () => {
201
+ await probe(EXISTING_EMAIL)
202
+
203
+ expect(verificationEmailCall()).toBeUndefined()
204
+ expect(createOrUpdateRequest).not.toHaveBeenCalled()
205
+
206
+ const notice = noticeEmailCall()
207
+ expect(notice?.[0].to).toBe(EXISTING_EMAIL)
208
+
209
+ const rendered = collectStrings(notice?.[0].react)
210
+ expect(rendered).toContain(`${BASE_URL}/login`)
211
+ expect(rendered).toContain('You already have an account')
212
+ for (const submitterSupplied of ['Mallory', 'Prober', 'Probe Industries']) {
213
+ expect(rendered.some((text) => text.includes(submitterSupplied))).toBe(false)
214
+ }
215
+ })
216
+
217
+ it('sends the verification email for an address without an account', async () => {
218
+ await probe(NEW_EMAIL)
219
+
220
+ expect(noticeEmailCall()).toBeUndefined()
221
+ expect(verificationEmailCall()?.[0].to).toBe(NEW_EMAIL)
222
+ })
223
+
224
+ it('throttles the notice per address and still returns the accepted response', async () => {
225
+ consume.mockResolvedValueOnce({ allowed: false, remainingPoints: 0, msBeforeNext: 60_000, consumedPoints: 2 })
226
+
227
+ const throttled = await probe(EXISTING_EMAIL)
228
+
229
+ expect(consume).toHaveBeenCalledWith(`hash:${EXISTING_EMAIL}`, expect.objectContaining({ points: 1, duration: 600 }))
230
+ expect(noticeEmailCall()).toBeUndefined()
231
+ expect(throttled.status).toBe(200)
232
+ expect(throttled.body).toEqual({ ok: true, email: EXISTING_EMAIL })
233
+ })
234
+
235
+ it('releases the notice window when the send fails so a retry behaves like the other branch', async () => {
236
+ sendEmail.mockRejectedValueOnce(new Error('smtp down'))
237
+
238
+ await probe(EXISTING_EMAIL)
239
+
240
+ expect(deleteRateLimitKey).toHaveBeenCalledWith(`hash:${EXISTING_EMAIL}`, expect.objectContaining({ points: 1, duration: 600 }))
241
+ })
242
+
243
+ it('writes the notice in the instance default locale, not the one the submitter asked for', async () => {
244
+ loadDictionary.mockImplementation(async (requested: string) => ({
245
+ 'onboarding.existingAccountEmail.heading': requested === 'en' ? 'Default locale heading' : 'Submitter locale heading',
246
+ 'onboarding.email.heading': requested === 'en' ? 'Default locale welcome' : 'Submitter locale welcome',
247
+ }))
248
+
249
+ await probe(EXISTING_EMAIL, 'pl')
250
+
251
+ const rendered = collectStrings(noticeEmailCall()?.[0].react)
252
+ expect(rendered).toContain('Default locale heading')
253
+ expect(rendered).not.toContain('Submitter locale heading')
254
+ })
255
+
256
+ it('still writes the verification email in the locale the submitter asked for', async () => {
257
+ loadDictionary.mockImplementation(async (requested: string) => ({
258
+ 'onboarding.email.heading': requested === 'en' ? 'Default locale welcome' : 'Submitter locale welcome',
259
+ }))
260
+
261
+ await probe(NEW_EMAIL, 'pl')
262
+
263
+ const rendered = collectStrings(verificationEmailCall()?.[0].react)
264
+ expect(rendered).toContain('Submitter locale welcome')
265
+ })
266
+
267
+ it('sends the notice when no rate limiter is registered', async () => {
268
+ resolveRateLimiterService.mockImplementation(() => {
269
+ throw new Error('[internal] rateLimiterService is not registered')
270
+ })
271
+
272
+ const existing = await probe(EXISTING_EMAIL)
273
+
274
+ expect(noticeEmailCall()?.[0].to).toBe(EXISTING_EMAIL)
275
+ expect(existing.status).toBe(200)
276
+ })
277
+ })
@@ -0,0 +1,176 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { expect, test, type APIRequestContext } from '@playwright/test';
5
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/api';
6
+ import { getTokenContext } from '@open-mercato/core/helpers/integration/generalFixtures';
7
+ import { createUserFixture, deleteUserIfExists } from '@open-mercato/core/helpers/integration/authFixtures';
8
+ import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
9
+ import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes';
10
+
11
+ export const integrationMeta = {
12
+ description: 'Public signup answers identically whether or not the address already has an account',
13
+ dependsOnModules: ['onboarding', 'auth'],
14
+ requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],
15
+ };
16
+
17
+ const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
18
+ const EMAIL_CAPTURE_PATH = process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || join(process.cwd(), '.ai', 'qa', 'email-capture.jsonl');
19
+ const EXISTING_ACCOUNT_PASSWORD = 'Valid1!Pass';
20
+ const SUBMITTED_PASSWORD = 'Str0ng!Passw0rd';
21
+ const SUBMITTER_FIRST_NAME = 'Mallory';
22
+ const SUBMITTER_ORGANIZATION = 'Probe Industries';
23
+
24
+ type CapturedEmail = {
25
+ to?: string;
26
+ subject?: string;
27
+ links?: string[];
28
+ text?: string;
29
+ };
30
+
31
+ type Probe = {
32
+ status: number;
33
+ rawBody: string;
34
+ };
35
+
36
+ function submission(email: string) {
37
+ return {
38
+ email,
39
+ firstName: SUBMITTER_FIRST_NAME,
40
+ lastName: 'Prober',
41
+ organizationName: SUBMITTER_ORGANIZATION,
42
+ password: SUBMITTED_PASSWORD,
43
+ confirmPassword: SUBMITTED_PASSWORD,
44
+ termsAccepted: true,
45
+ marketingConsent: false,
46
+ };
47
+ }
48
+
49
+ async function probeSignup(request: APIRequestContext, baseUrl: string, email: string): Promise<Probe> {
50
+ const response = await request.post(`${baseUrl}/api/onboarding/onboarding`, {
51
+ headers: { 'content-type': 'application/json' },
52
+ data: submission(email),
53
+ });
54
+ return { status: response.status(), rawBody: await response.text() };
55
+ }
56
+
57
+ /**
58
+ * The submitted address is the one field the two branches may legitimately differ on, because the
59
+ * caller supplied it. Masking it turns "same shape" into a byte comparison of everything else.
60
+ */
61
+ function maskSubmittedAddress(rawBody: string, email: string): string {
62
+ return rawBody.split(email).join('<submitted-address>');
63
+ }
64
+
65
+ async function readCapturedEmails(): Promise<CapturedEmail[]> {
66
+ try {
67
+ const raw = await readFile(EMAIL_CAPTURE_PATH, 'utf8');
68
+ return raw.split('\n').filter(Boolean).map((line) => JSON.parse(line) as CapturedEmail);
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ async function waitForCapturedEmails(to: string): Promise<CapturedEmail[]> {
75
+ const deadline = Date.now() + 10_000;
76
+ let captured: CapturedEmail[] = [];
77
+ while (Date.now() < deadline) {
78
+ captured = (await readCapturedEmails()).filter((entry) => entry.to?.toLowerCase() === to.toLowerCase());
79
+ if (captured.length > 0) return captured;
80
+ await new Promise((resolve) => setTimeout(resolve, 250));
81
+ }
82
+ return captured;
83
+ }
84
+
85
+ async function deleteOnboardingRequest(email: string): Promise<void> {
86
+ await withClient(async (client) => {
87
+ await client.query('delete from onboarding_requests where email_hash = any($1::text[])', [lookupHashCandidates(email)]);
88
+ }).catch(() => undefined);
89
+ }
90
+
91
+ /**
92
+ * TC-ONB-003: public signup does not disclose whether an address already has an account
93
+ * Source: GitHub issue #5505, PR #5677
94
+ * Surface: POST /api/onboarding/onboarding (requireAuth: false)
95
+ *
96
+ * The unit suite pins the property at the handler level with every collaborator mocked. This one
97
+ * proves it against a real instance: a real user row created in setup, the real encryption/lookup
98
+ * path that finds it, the real OnboardingService pending-request window, and the real outbound
99
+ * mail (captured on disk) that carries the out-of-band notice.
100
+ */
101
+ test.describe('TC-ONB-003: signup does not reveal whether an email already has an account', () => {
102
+ test('answers identically for a registered and an unregistered address, and notifies the owner out of band', async ({ request, baseURL }) => {
103
+ const resolvedBaseUrl = baseURL ?? BASE_URL;
104
+ const unique = randomUUID().slice(0, 8);
105
+ const registeredEmail = `qa-onb-003-registered-${unique}@example.test`;
106
+ const unregisteredEmail = `qa-onb-003-fresh-${unique}@example.test`;
107
+
108
+ const adminToken = await getAuthToken(request, 'admin');
109
+ const { organizationId } = getTokenContext(adminToken);
110
+ let registeredUserId: string | null = null;
111
+
112
+ try {
113
+ registeredUserId = await createUserFixture(request, adminToken, {
114
+ email: registeredEmail,
115
+ password: EXISTING_ACCOUNT_PASSWORD,
116
+ organizationId,
117
+ roles: [],
118
+ });
119
+
120
+ // (a) First probe of each address: same status, and the same body once the caller's own
121
+ // address is masked out.
122
+ const registeredFirst = await probeSignup(request, resolvedBaseUrl, registeredEmail);
123
+ const unregisteredFirst = await probeSignup(request, resolvedBaseUrl, unregisteredEmail);
124
+
125
+ expect(registeredFirst.status, 'a submission for a registered address should be accepted').toBe(200);
126
+ expect(unregisteredFirst.status, 'both branches should answer with the same status').toBe(registeredFirst.status);
127
+ expect(maskSubmittedAddress(unregisteredFirst.rawBody, unregisteredEmail))
128
+ .toBe(maskSubmittedAddress(registeredFirst.rawBody, registeredEmail));
129
+
130
+ // (c) The registered address is told out of band: a notice, never a verification link.
131
+ const registeredMail = await waitForCapturedEmails(registeredEmail);
132
+ expect(registeredMail.length, 'the account owner should be notified out of band').toBeGreaterThan(0);
133
+ const noticeLinks = registeredMail.flatMap((entry) => entry.links ?? []);
134
+ expect(noticeLinks.some((link) => link.includes('/login')), 'the notice should link to sign-in').toBeTruthy();
135
+ expect(noticeLinks.some((link) => link.includes('/api/onboarding/onboarding/verify?token=')), 'the account owner must never receive a verification link').toBeFalsy();
136
+ const noticeText = registeredMail.map((entry) => entry.text ?? '').join(' ');
137
+ expect(noticeText, 'the notice must not carry the submitter-supplied name').not.toContain(SUBMITTER_FIRST_NAME);
138
+ expect(noticeText, 'the notice must not carry the submitter-supplied organization').not.toContain(SUBMITTER_ORGANIZATION);
139
+
140
+ // (c) The unregistered address gets the verification mail and no notice.
141
+ const unregisteredMail = await waitForCapturedEmails(unregisteredEmail);
142
+ expect(unregisteredMail.length, 'a new address should receive the verification email').toBeGreaterThan(0);
143
+ const verificationLinks = unregisteredMail.flatMap((entry) => entry.links ?? []);
144
+ expect(verificationLinks.some((link) => link.includes('/api/onboarding/onboarding/verify?token=')), 'a new address should receive a verification link').toBeTruthy();
145
+ const unregisteredSubjects = unregisteredMail.map((entry) => entry.subject ?? '').join(' ');
146
+ expect(unregisteredSubjects, 'a new address must not receive the existing-account notice').not.toContain('About your Open Mercato account');
147
+
148
+ // (b) Second probe of each, inside the ten-minute windows: the registered address is inside
149
+ // the notice throttle and the new one is inside the pending-verification window, and those
150
+ // two throttles must not become a way to tell the branches apart across probes.
151
+ const registeredSecond = await probeSignup(request, resolvedBaseUrl, registeredEmail);
152
+ const unregisteredSecond = await probeSignup(request, resolvedBaseUrl, unregisteredEmail);
153
+
154
+ expect(registeredSecond.status, 'a repeated probe should stay accepted').toBe(200);
155
+ expect(unregisteredSecond.status, 'both throttled branches should answer with the same status').toBe(registeredSecond.status);
156
+ expect(maskSubmittedAddress(unregisteredSecond.rawBody, unregisteredEmail))
157
+ .toBe(maskSubmittedAddress(registeredSecond.rawBody, registeredEmail));
158
+ expect(maskSubmittedAddress(registeredSecond.rawBody, registeredEmail), 'a repeated probe should look like a first one')
159
+ .toBe(maskSubmittedAddress(registeredFirst.rawBody, registeredEmail));
160
+
161
+ // No onboarding request may be created for an address that already has an account: the
162
+ // submitter's strings must never reach a row that belongs to the account owner's address.
163
+ const rowsForRegistered = await withClient(async (client) => {
164
+ const result = await client.query<{ id: string }>(
165
+ 'select id from onboarding_requests where email_hash = any($1::text[])',
166
+ [lookupHashCandidates(registeredEmail)],
167
+ );
168
+ return result.rowCount ?? 0;
169
+ });
170
+ expect(rowsForRegistered, 'no onboarding request should exist for a registered address').toBe(0);
171
+ } finally {
172
+ await deleteOnboardingRequest(unregisteredEmail);
173
+ await deleteUserIfExists(request, adminToken, registeredUserId);
174
+ }
175
+ });
176
+ });