@open-mercato/onboarding 0.6.7-develop.6860.1.f796b22e80 → 0.6.7
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/AGENTS.md +5 -8
- package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +2 -25
- package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +2 -2
- package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +2 -3
- package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +2 -2
- package/dist/modules/onboarding/api/post/onboarding.js +1 -9
- package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
- package/dist/modules/onboarding/data/entities.js +0 -4
- package/dist/modules/onboarding/data/entities.js.map +2 -2
- package/dist/modules/onboarding/encryption.js +2 -4
- package/dist/modules/onboarding/encryption.js.map +2 -2
- package/dist/modules/onboarding/lib/service.js +6 -18
- package/dist/modules/onboarding/lib/service.js.map +2 -2
- package/generated/entities/onboarding_request/index.ts +0 -1
- package/generated/entity-fields-registry.ts +0 -1
- package/package.json +5 -6
- package/src/__tests__/service.test.ts +0 -16
- package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +2 -37
- package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +2 -3
- package/src/modules/onboarding/api/post/onboarding.ts +1 -9
- package/src/modules/onboarding/data/entities.ts +0 -4
- package/src/modules/onboarding/encryption.ts +1 -3
- package/src/modules/onboarding/lib/service.ts +5 -17
- package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +0 -19
- package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js +0 -15
- package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js.map +0 -7
- package/src/__tests__/backfill-legacy-request.test.ts +0 -213
- package/src/__tests__/encryption.test.ts +0 -19
- package/src/modules/onboarding/i18n/ko.json +0 -100
- package/src/modules/onboarding/migrations/Migration20260710133207_onboarding.ts +0 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/onboarding",
|
|
3
|
-
"version": "0.6.7
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -70,13 +70,13 @@
|
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@open-mercato/shared": "0.6.7
|
|
73
|
+
"@open-mercato/shared": "0.6.7"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.7
|
|
76
|
+
"@open-mercato/shared": "0.6.7",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
78
78
|
"jest": "^30.4.2",
|
|
79
|
-
"ts-jest": "^29.4.
|
|
79
|
+
"ts-jest": "^29.4.11",
|
|
80
80
|
"typescript": "7.0.2"
|
|
81
81
|
},
|
|
82
82
|
"publishConfig": {
|
|
@@ -86,6 +86,5 @@
|
|
|
86
86
|
"type": "git",
|
|
87
87
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
88
88
|
"directory": "packages/onboarding"
|
|
89
|
-
}
|
|
90
|
-
"stableVersion": "0.6.6"
|
|
89
|
+
}
|
|
91
90
|
}
|
|
@@ -2,7 +2,6 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
3
|
import { OnboardingRequest } from '../modules/onboarding/data/entities'
|
|
4
4
|
import { OnboardingService } from '../modules/onboarding/lib/service'
|
|
5
|
-
import { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
|
|
6
5
|
|
|
7
6
|
jest.mock('bcryptjs', () => ({
|
|
8
7
|
hash: jest.fn().mockResolvedValue('hashed_password'),
|
|
@@ -128,21 +127,6 @@ describe('OnboardingService', () => {
|
|
|
128
127
|
const result = await service.createOrUpdateRequest(makeStartInput())
|
|
129
128
|
const createArgs = em.create.mock.calls[0][1]
|
|
130
129
|
expect(createArgs.status).toBe('pending')
|
|
131
|
-
expect(createArgs.emailHash).toBe(hashForLookup('user@example.com'))
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
it('looks up encrypted and legacy plaintext emails without querying ciphertext as plaintext', async () => {
|
|
135
|
-
const em = createMockEm()
|
|
136
|
-
const service = new OnboardingService(em)
|
|
137
|
-
|
|
138
|
-
await service.createOrUpdateRequest(makeStartInput())
|
|
139
|
-
|
|
140
|
-
expect(em.findOne).toHaveBeenCalledWith(OnboardingRequest, {
|
|
141
|
-
$or: [
|
|
142
|
-
{ emailHash: { $in: lookupHashCandidates('user@example.com') } },
|
|
143
|
-
{ email: 'user@example.com', emailHash: null },
|
|
144
|
-
],
|
|
145
|
-
}, undefined)
|
|
146
130
|
})
|
|
147
131
|
|
|
148
132
|
it('throws PENDING_REQUEST when within cooldown', async () => {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { expect, test } from '@playwright/test';
|
|
3
3
|
import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
|
|
4
|
-
import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes';
|
|
5
4
|
|
|
6
5
|
export const integrationMeta = {
|
|
7
6
|
dependsOnModules: ['onboarding'],
|
|
@@ -27,15 +26,6 @@ type UserConsentRow = {
|
|
|
27
26
|
granted_at: Date | null;
|
|
28
27
|
};
|
|
29
28
|
|
|
30
|
-
type PendingRequestAtRest = {
|
|
31
|
-
email: string;
|
|
32
|
-
email_hash: string | null;
|
|
33
|
-
first_name: string;
|
|
34
|
-
last_name: string;
|
|
35
|
-
organization_name: string;
|
|
36
|
-
password_hash: string;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
29
|
const ONBOARDING_PASSWORD = 'IntegrationPass123!';
|
|
40
30
|
const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
|
|
41
31
|
|
|
@@ -50,32 +40,15 @@ async function replaceVerificationToken(email: string, token: string): Promise<s
|
|
|
50
40
|
set token_hash = $2,
|
|
51
41
|
expires_at = now() + interval '24 hours',
|
|
52
42
|
updated_at = now()
|
|
53
|
-
where
|
|
43
|
+
where email = $1
|
|
54
44
|
returning id`,
|
|
55
|
-
[
|
|
45
|
+
[email, hashToken(token)],
|
|
56
46
|
);
|
|
57
47
|
expect(result.rowCount, 'onboarding request should exist after submitting the form').toBe(1);
|
|
58
48
|
return result.rows[0].id;
|
|
59
49
|
});
|
|
60
50
|
}
|
|
61
51
|
|
|
62
|
-
async function readPendingRequestAtRest(email: string): Promise<PendingRequestAtRest> {
|
|
63
|
-
return withClient(async (client) => {
|
|
64
|
-
const result = await client.query<PendingRequestAtRest>(
|
|
65
|
-
`select email, email_hash, first_name, last_name, organization_name, password_hash
|
|
66
|
-
from onboarding_requests
|
|
67
|
-
where email_hash = any($1::text[])`,
|
|
68
|
-
[lookupHashCandidates(email)],
|
|
69
|
-
);
|
|
70
|
-
expect(result.rowCount, 'onboarding request should be queryable by its lookup hash').toBe(1);
|
|
71
|
-
return result.rows[0];
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function expectEncryptedPayload(value: string): void {
|
|
76
|
-
expect(value).toMatch(/^[^:]+:[^:]+:[^:]+:v1$/);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
52
|
async function readCompletedRequest(requestId: string): Promise<OnboardingRequestRow> {
|
|
80
53
|
return withClient(async (client) => {
|
|
81
54
|
const result = await client.query<OnboardingRequestRow>(
|
|
@@ -169,14 +142,6 @@ test.describe('TC-ONB-001: self-service onboarding with marketing consent', () =
|
|
|
169
142
|
await expect(page.getByRole('status')).toContainText('Check your inbox');
|
|
170
143
|
await expect(page.getByRole('status')).toContainText(email);
|
|
171
144
|
|
|
172
|
-
const pendingAtRest = await readPendingRequestAtRest(email);
|
|
173
|
-
expect(lookupHashCandidates(email)).toContain(pendingAtRest.email_hash);
|
|
174
|
-
expectEncryptedPayload(pendingAtRest.email);
|
|
175
|
-
expectEncryptedPayload(pendingAtRest.first_name);
|
|
176
|
-
expectEncryptedPayload(pendingAtRest.last_name);
|
|
177
|
-
expectEncryptedPayload(pendingAtRest.organization_name);
|
|
178
|
-
expectEncryptedPayload(pendingAtRest.password_hash);
|
|
179
|
-
|
|
180
145
|
const requestId = await replaceVerificationToken(email, token);
|
|
181
146
|
const verifyUrl = `${BASE_URL}/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`;
|
|
182
147
|
|
package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';
|
|
6
6
|
import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
|
|
7
|
-
import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes';
|
|
8
7
|
|
|
9
8
|
export const integrationMeta = {
|
|
10
9
|
dependsOnModules: ['onboarding'],
|
|
@@ -40,9 +39,9 @@ async function replaceVerificationToken(email: string, token: string): Promise<s
|
|
|
40
39
|
set token_hash = $2,
|
|
41
40
|
expires_at = now() + interval '24 hours',
|
|
42
41
|
updated_at = now()
|
|
43
|
-
where
|
|
42
|
+
where email = $1
|
|
44
43
|
returning id`,
|
|
45
|
-
[
|
|
44
|
+
[email, hashToken(token)],
|
|
46
45
|
);
|
|
47
46
|
expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);
|
|
48
47
|
return result.rows[0].id;
|
|
@@ -3,8 +3,6 @@ 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
5
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
|
-
import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
|
|
7
|
-
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
8
6
|
import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
|
|
9
7
|
import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
|
|
10
8
|
import { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
@@ -109,13 +107,7 @@ export async function POST(req: Request) {
|
|
|
109
107
|
const container = await createRequestContainer()
|
|
110
108
|
const em = (container.resolve('em') as EntityManager)
|
|
111
109
|
|
|
112
|
-
const existingUser = await
|
|
113
|
-
deletedAt: null,
|
|
114
|
-
$or: [
|
|
115
|
-
{ email: parsed.data.email },
|
|
116
|
-
{ emailHash: { $in: lookupHashCandidates(parsed.data.email) } },
|
|
117
|
-
],
|
|
118
|
-
})
|
|
110
|
+
const existingUser = await em.findOne(User, { email: parsed.data.email })
|
|
119
111
|
if (existingUser) {
|
|
120
112
|
const message = translate('onboarding.errors.emailExists', 'We already have an account with this email. Try signing in or resetting your password.')
|
|
121
113
|
return NextResponse.json({
|
|
@@ -4,7 +4,6 @@ type OnboardingStatus = 'pending' | 'processing' | 'completed' | 'expired'
|
|
|
4
4
|
|
|
5
5
|
@Entity({ tableName: 'onboarding_requests' })
|
|
6
6
|
@Unique({ properties: ['email'] })
|
|
7
|
-
@Unique({ properties: ['emailHash'] })
|
|
8
7
|
@Unique({ properties: ['tokenHash'] })
|
|
9
8
|
export class OnboardingRequest {
|
|
10
9
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
@@ -13,9 +12,6 @@ export class OnboardingRequest {
|
|
|
13
12
|
@Property({ type: 'text' })
|
|
14
13
|
email!: string
|
|
15
14
|
|
|
16
|
-
@Property({ name: 'email_hash', type: 'text', nullable: true })
|
|
17
|
-
emailHash?: string | null
|
|
18
|
-
|
|
19
15
|
@Property({ name: 'token_hash', type: 'text' })
|
|
20
16
|
tokenHash!: string
|
|
21
17
|
|
|
@@ -3,13 +3,11 @@ import type { ModuleEncryptionMap } from '@open-mercato/shared/modules/encryptio
|
|
|
3
3
|
export const defaultEncryptionMaps: ModuleEncryptionMap[] = [
|
|
4
4
|
{
|
|
5
5
|
entityId: 'onboarding:onboarding_request',
|
|
6
|
-
keyScope: 'system',
|
|
7
6
|
fields: [
|
|
8
|
-
{ field: 'email'
|
|
7
|
+
{ field: 'email' },
|
|
9
8
|
{ field: 'first_name' },
|
|
10
9
|
{ field: 'last_name' },
|
|
11
10
|
{ field: 'organization_name' },
|
|
12
|
-
{ field: 'password_hash' },
|
|
13
11
|
],
|
|
14
12
|
},
|
|
15
13
|
]
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { randomBytes, createHash } from 'node:crypto'
|
|
2
2
|
import { hash } from 'bcryptjs'
|
|
3
3
|
import { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
-
import { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
|
|
5
|
-
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
6
4
|
import { OnboardingRequest } from '../data/entities'
|
|
7
5
|
import type { OnboardingStartInput } from '../data/validators'
|
|
8
6
|
|
|
@@ -20,14 +18,8 @@ export class OnboardingService {
|
|
|
20
18
|
const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000)
|
|
21
19
|
const now = new Date()
|
|
22
20
|
const passwordHash = await hash(input.password, 10)
|
|
23
|
-
const emailHash = hashForLookup(input.email)
|
|
24
21
|
|
|
25
|
-
const existing = await
|
|
26
|
-
$or: [
|
|
27
|
-
{ emailHash: { $in: lookupHashCandidates(input.email) } },
|
|
28
|
-
{ email: input.email, emailHash: null },
|
|
29
|
-
],
|
|
30
|
-
})
|
|
22
|
+
const existing = await this.em.findOne(OnboardingRequest, { email: input.email })
|
|
31
23
|
if (existing) {
|
|
32
24
|
const lastSentAt = existing.lastEmailSentAt ?? existing.updatedAt ?? existing.createdAt
|
|
33
25
|
if (['pending', 'processing'].includes(existing.status) && lastSentAt && lastSentAt.getTime() > Date.now() - 10 * 60 * 1000) {
|
|
@@ -44,7 +36,6 @@ export class OnboardingService {
|
|
|
44
36
|
existing.termsAccepted = true
|
|
45
37
|
existing.marketingConsent = input.marketingConsent ?? false
|
|
46
38
|
existing.passwordHash = passwordHash
|
|
47
|
-
existing.emailHash = emailHash
|
|
48
39
|
existing.expiresAt = expiresAt
|
|
49
40
|
existing.completedAt = null
|
|
50
41
|
existing.processingStartedAt = null
|
|
@@ -61,7 +52,6 @@ export class OnboardingService {
|
|
|
61
52
|
|
|
62
53
|
const request = this.em.create(OnboardingRequest, {
|
|
63
54
|
email: input.email,
|
|
64
|
-
emailHash,
|
|
65
55
|
tokenHash,
|
|
66
56
|
status: 'pending',
|
|
67
57
|
firstName: input.firstName,
|
|
@@ -84,7 +74,7 @@ export class OnboardingService {
|
|
|
84
74
|
async findPendingByToken(token: string) {
|
|
85
75
|
const tokenHash = hashToken(token)
|
|
86
76
|
const now = new Date()
|
|
87
|
-
return
|
|
77
|
+
return this.em.findOne(OnboardingRequest, {
|
|
88
78
|
tokenHash,
|
|
89
79
|
status: 'pending',
|
|
90
80
|
expiresAt: { $gt: now } as any,
|
|
@@ -93,20 +83,18 @@ export class OnboardingService {
|
|
|
93
83
|
|
|
94
84
|
async findByToken(token: string) {
|
|
95
85
|
const tokenHash = hashToken(token)
|
|
96
|
-
return
|
|
86
|
+
return this.em.findOne(OnboardingRequest, { tokenHash })
|
|
97
87
|
}
|
|
98
88
|
|
|
99
89
|
async findById(id: string) {
|
|
100
|
-
return
|
|
90
|
+
return this.em.findOne(OnboardingRequest, { id })
|
|
101
91
|
}
|
|
102
92
|
|
|
103
93
|
async findLatestByTenantId(tenantId: string) {
|
|
104
|
-
return
|
|
105
|
-
this.em,
|
|
94
|
+
return this.em.findOne(
|
|
106
95
|
OnboardingRequest,
|
|
107
96
|
{ tenantId, deletedAt: null },
|
|
108
97
|
{ orderBy: { updatedAt: 'DESC', createdAt: 'DESC' } },
|
|
109
|
-
{ tenantId, organizationId: null },
|
|
110
98
|
)
|
|
111
99
|
}
|
|
112
100
|
|
|
@@ -25,15 +25,6 @@
|
|
|
25
25
|
"nullable": false,
|
|
26
26
|
"mappedType": "text"
|
|
27
27
|
},
|
|
28
|
-
"email_hash": {
|
|
29
|
-
"name": "email_hash",
|
|
30
|
-
"type": "text",
|
|
31
|
-
"unsigned": false,
|
|
32
|
-
"autoincrement": false,
|
|
33
|
-
"primary": false,
|
|
34
|
-
"nullable": true,
|
|
35
|
-
"mappedType": "text"
|
|
36
|
-
},
|
|
37
28
|
"token_hash": {
|
|
38
29
|
"name": "token_hash",
|
|
39
30
|
"type": "text",
|
|
@@ -269,16 +260,6 @@
|
|
|
269
260
|
"primary": false,
|
|
270
261
|
"unique": true
|
|
271
262
|
},
|
|
272
|
-
{
|
|
273
|
-
"keyName": "onboarding_requests_email_hash_unique",
|
|
274
|
-
"columnNames": [
|
|
275
|
-
"email_hash"
|
|
276
|
-
],
|
|
277
|
-
"composite": false,
|
|
278
|
-
"constraint": true,
|
|
279
|
-
"primary": false,
|
|
280
|
-
"unique": true
|
|
281
|
-
},
|
|
282
263
|
{
|
|
283
264
|
"keyName": "onboarding_requests_pkey",
|
|
284
265
|
"columnNames": [
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { Migration } from "@mikro-orm/migrations";
|
|
2
|
-
class Migration20260710133207_onboarding extends Migration {
|
|
3
|
-
up() {
|
|
4
|
-
this.addSql(`alter table "onboarding_requests" add "email_hash" text null;`);
|
|
5
|
-
this.addSql(`alter table "onboarding_requests" add constraint "onboarding_requests_email_hash_unique" unique ("email_hash");`);
|
|
6
|
-
}
|
|
7
|
-
down() {
|
|
8
|
-
this.addSql(`alter table "onboarding_requests" drop constraint if exists "onboarding_requests_email_hash_unique";`);
|
|
9
|
-
this.addSql(`alter table "onboarding_requests" drop column "email_hash";`);
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
export {
|
|
13
|
-
Migration20260710133207_onboarding
|
|
14
|
-
};
|
|
15
|
-
//# sourceMappingURL=Migration20260710133207_onboarding.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../../src/modules/onboarding/migrations/Migration20260710133207_onboarding.ts"],
|
|
4
|
-
"sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260710133207_onboarding extends Migration {\n\n override up(): void | Promise<void> {\n this.addSql(`alter table \"onboarding_requests\" add \"email_hash\" text null;`);\n this.addSql(`alter table \"onboarding_requests\" add constraint \"onboarding_requests_email_hash_unique\" unique (\"email_hash\");`);\n }\n\n override down(): void | Promise<void> {\n this.addSql(`alter table \"onboarding_requests\" drop constraint if exists \"onboarding_requests_email_hash_unique\";`);\n this.addSql(`alter table \"onboarding_requests\" drop column \"email_hash\";`);\n }\n\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,2CAA2C,UAAU;AAAA,EAEvD,KAA2B;AAClC,SAAK,OAAO,+DAA+D;AAC3E,SAAK,OAAO,iHAAiH;AAAA,EAC/H;AAAA,EAES,OAA6B;AACpC,SAAK,OAAO,sGAAsG;AAClH,SAAK,OAAO,6DAA6D;AAAA,EAC3E;AAEF;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
-
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
-
import { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
|
|
4
|
-
import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
|
|
5
|
-
import type { TenantDek } from '@open-mercato/shared/lib/encryption/kms'
|
|
6
|
-
import { OnboardingRequest } from '../modules/onboarding/data/entities'
|
|
7
|
-
import { OnboardingService } from '../modules/onboarding/lib/service'
|
|
8
|
-
import { defaultEncryptionMaps } from '../modules/onboarding/encryption'
|
|
9
|
-
|
|
10
|
-
jest.mock('bcryptjs', () => ({
|
|
11
|
-
hash: jest.fn().mockResolvedValue('freshly_hashed_password'),
|
|
12
|
-
}))
|
|
13
|
-
|
|
14
|
-
const LEGACY_EMAIL = 'legacy.signup@example.com'
|
|
15
|
-
const SYSTEM_KEY = randomBytes(32).toString('base64')
|
|
16
|
-
|
|
17
|
-
function hashToken(token: string) {
|
|
18
|
-
return createHash('sha256').update(token).digest('hex')
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function makeLegacyPlaintextRow(): OnboardingRequest {
|
|
22
|
-
return Object.assign(new OnboardingRequest(), {
|
|
23
|
-
id: 'legacy-row',
|
|
24
|
-
email: LEGACY_EMAIL,
|
|
25
|
-
emailHash: null,
|
|
26
|
-
tokenHash: hashToken('legacy-token'),
|
|
27
|
-
status: 'pending',
|
|
28
|
-
firstName: 'Ada',
|
|
29
|
-
lastName: 'Lovelace',
|
|
30
|
-
organizationName: 'Analytical Engines',
|
|
31
|
-
locale: 'en',
|
|
32
|
-
termsAccepted: true,
|
|
33
|
-
marketingConsent: false,
|
|
34
|
-
passwordHash: 'legacy_bcrypt_hash',
|
|
35
|
-
expiresAt: new Date(Date.now() + 86_400_000),
|
|
36
|
-
completedAt: null,
|
|
37
|
-
processingStartedAt: null,
|
|
38
|
-
tenantId: null,
|
|
39
|
-
organizationId: null,
|
|
40
|
-
userId: null,
|
|
41
|
-
lastEmailSentAt: new Date(Date.now() - 60 * 60 * 1000),
|
|
42
|
-
preparationStartedAt: null,
|
|
43
|
-
preparationCompletedAt: null,
|
|
44
|
-
readyEmailSentAt: null,
|
|
45
|
-
createdAt: new Date(Date.now() - 7 * 86_400_000),
|
|
46
|
-
updatedAt: new Date(Date.now() - 7 * 86_400_000),
|
|
47
|
-
deletedAt: null,
|
|
48
|
-
})
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function matchesCondition(actual: unknown, condition: unknown): boolean {
|
|
52
|
-
if (condition !== null && typeof condition === 'object' && !(condition instanceof Date)) {
|
|
53
|
-
const operators = condition as Record<string, unknown>
|
|
54
|
-
if ('$in' in operators) return (operators.$in as unknown[]).includes(actual)
|
|
55
|
-
if ('$gt' in operators) return (actual as Date) > (operators.$gt as Date)
|
|
56
|
-
if ('$ne' in operators) return actual !== operators.$ne
|
|
57
|
-
return false
|
|
58
|
-
}
|
|
59
|
-
return actual === condition
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function matchesFilter(row: Record<string, unknown>, filter: Record<string, unknown>): boolean {
|
|
63
|
-
return Object.entries(filter).every(([key, condition]) => {
|
|
64
|
-
if (key === '$or') return (condition as Record<string, unknown>[]).some((branch) => matchesFilter(row, branch))
|
|
65
|
-
return matchesCondition(row[key], condition)
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Minimal filter-evaluating EntityManager stand-in. The point of this suite is that a
|
|
71
|
-
* backfilled row is still *found* by the service's real lookup, so the fake has to
|
|
72
|
-
* actually evaluate the `$or` the service builds rather than record the call.
|
|
73
|
-
*/
|
|
74
|
-
function makeEm(rows: OnboardingRequest[]) {
|
|
75
|
-
const created: OnboardingRequest[] = []
|
|
76
|
-
return {
|
|
77
|
-
findOne: jest.fn(async (_entity: unknown, where: Record<string, unknown>) =>
|
|
78
|
-
rows.find((row) => matchesFilter(row as unknown as Record<string, unknown>, where)) ?? null),
|
|
79
|
-
create: jest.fn((_entity: unknown, data: Record<string, unknown>) => {
|
|
80
|
-
const row = Object.assign(new OnboardingRequest(), data)
|
|
81
|
-
created.push(row)
|
|
82
|
-
return row
|
|
83
|
-
}),
|
|
84
|
-
persist: jest.fn(() => ({ flush: jest.fn(async () => {}) })),
|
|
85
|
-
flush: jest.fn(async () => {}),
|
|
86
|
-
created,
|
|
87
|
-
} as unknown as EntityManager & { created: OnboardingRequest[]; findOne: jest.Mock; create: jest.Mock }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function makeEncryptionService(em: EntityManager) {
|
|
91
|
-
const kms = {
|
|
92
|
-
isHealthy: () => true,
|
|
93
|
-
async getTenantDek(tenantId: string): Promise<TenantDek | null> {
|
|
94
|
-
return { tenantId, key: SYSTEM_KEY, fetchedAt: Date.now() }
|
|
95
|
-
},
|
|
96
|
-
async createTenantDek(tenantId: string): Promise<TenantDek | null> {
|
|
97
|
-
return this.getTenantDek(tenantId)
|
|
98
|
-
},
|
|
99
|
-
}
|
|
100
|
-
return new TenantDataEncryptionService(em, { kms, defaultEncryptionMaps })
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Applies exactly what `mercato entities backfill-system-encryption` applies to one row:
|
|
105
|
-
* the mapped fields go through `encryptEntityPayload` under the system key, and the
|
|
106
|
-
* resulting ciphertext plus lookup hash are written back to the record.
|
|
107
|
-
*/
|
|
108
|
-
async function backfillRow(service: TenantDataEncryptionService, row: OnboardingRequest): Promise<void> {
|
|
109
|
-
const encrypted = await service.encryptEntityPayload('onboarding:onboarding_request', {
|
|
110
|
-
email: row.email,
|
|
111
|
-
email_hash: row.emailHash ?? null,
|
|
112
|
-
first_name: row.firstName,
|
|
113
|
-
last_name: row.lastName,
|
|
114
|
-
organization_name: row.organizationName,
|
|
115
|
-
password_hash: row.passwordHash ?? null,
|
|
116
|
-
}, null, null)
|
|
117
|
-
row.email = encrypted.email as string
|
|
118
|
-
row.emailHash = encrypted.email_hash as string
|
|
119
|
-
row.firstName = encrypted.first_name as string
|
|
120
|
-
row.lastName = encrypted.last_name as string
|
|
121
|
-
row.organizationName = encrypted.organization_name as string
|
|
122
|
-
row.passwordHash = encrypted.password_hash as string
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
describe('backfilled legacy onboarding request (#3876)', () => {
|
|
126
|
-
const originalToggle = process.env.TENANT_DATA_ENCRYPTION
|
|
127
|
-
|
|
128
|
-
beforeEach(() => {
|
|
129
|
-
process.env.TENANT_DATA_ENCRYPTION = 'yes'
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
afterEach(() => {
|
|
133
|
-
if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
|
|
134
|
-
else process.env.TENANT_DATA_ENCRYPTION = originalToggle
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('encrypts every mapped field and populates the lookup hash', async () => {
|
|
138
|
-
const row = makeLegacyPlaintextRow()
|
|
139
|
-
const em = makeEm([row])
|
|
140
|
-
|
|
141
|
-
await backfillRow(makeEncryptionService(em), row)
|
|
142
|
-
|
|
143
|
-
for (const value of [row.email, row.firstName, row.lastName, row.organizationName, row.passwordHash]) {
|
|
144
|
-
expect(value).toMatch(/^[^:]+:[^:]+:[^:]+:v1$/)
|
|
145
|
-
}
|
|
146
|
-
expect(lookupHashCandidates(LEGACY_EMAIL)).toContain(row.emailHash)
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
it('still resolves the row on resubmit instead of creating a duplicate', async () => {
|
|
150
|
-
const row = makeLegacyPlaintextRow()
|
|
151
|
-
const em = makeEm([row])
|
|
152
|
-
await backfillRow(makeEncryptionService(em), row)
|
|
153
|
-
|
|
154
|
-
const service = new OnboardingService(em)
|
|
155
|
-
const { request } = await service.createOrUpdateRequest({
|
|
156
|
-
email: LEGACY_EMAIL,
|
|
157
|
-
firstName: 'Ada',
|
|
158
|
-
lastName: 'Lovelace',
|
|
159
|
-
organizationName: 'Analytical Engines',
|
|
160
|
-
password: 'Secret1!',
|
|
161
|
-
confirmPassword: 'Secret1!',
|
|
162
|
-
termsAccepted: true,
|
|
163
|
-
marketingConsent: false,
|
|
164
|
-
} as never)
|
|
165
|
-
|
|
166
|
-
expect(request.id).toBe('legacy-row')
|
|
167
|
-
expect(em.created).toHaveLength(0)
|
|
168
|
-
expect(em.create).not.toHaveBeenCalled()
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
it('still resolves the row by verification token after the backfill', async () => {
|
|
172
|
-
const row = makeLegacyPlaintextRow()
|
|
173
|
-
const em = makeEm([row])
|
|
174
|
-
await backfillRow(makeEncryptionService(em), row)
|
|
175
|
-
|
|
176
|
-
const found = await new OnboardingService(em).findPendingByToken('legacy-token')
|
|
177
|
-
|
|
178
|
-
expect(found?.id).toBe('legacy-row')
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('leaves a row the backfill already processed unchanged on a second pass', async () => {
|
|
182
|
-
const row = makeLegacyPlaintextRow()
|
|
183
|
-
const em = makeEm([row])
|
|
184
|
-
const service = makeEncryptionService(em)
|
|
185
|
-
|
|
186
|
-
await backfillRow(service, row)
|
|
187
|
-
const afterFirstPass = { email: row.email, emailHash: row.emailHash, passwordHash: row.passwordHash }
|
|
188
|
-
await backfillRow(service, row)
|
|
189
|
-
|
|
190
|
-
expect(row.email).toBe(afterFirstPass.email)
|
|
191
|
-
expect(row.emailHash).toBe(afterFirstPass.emailHash)
|
|
192
|
-
expect(row.passwordHash).toBe(afterFirstPass.passwordHash)
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
it('still finds a row that was never backfilled, through the legacy plaintext branch', async () => {
|
|
196
|
-
const row = makeLegacyPlaintextRow()
|
|
197
|
-
const em = makeEm([row])
|
|
198
|
-
|
|
199
|
-
const found = await new OnboardingService(em).createOrUpdateRequest({
|
|
200
|
-
email: LEGACY_EMAIL,
|
|
201
|
-
firstName: 'Ada',
|
|
202
|
-
lastName: 'Lovelace',
|
|
203
|
-
organizationName: 'Analytical Engines',
|
|
204
|
-
password: 'Secret1!',
|
|
205
|
-
confirmPassword: 'Secret1!',
|
|
206
|
-
termsAccepted: true,
|
|
207
|
-
marketingConsent: false,
|
|
208
|
-
} as never)
|
|
209
|
-
|
|
210
|
-
expect(found.request.id).toBe('legacy-row')
|
|
211
|
-
expect(em.create).not.toHaveBeenCalled()
|
|
212
|
-
})
|
|
213
|
-
})
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { defaultEncryptionMaps } from '../modules/onboarding/encryption'
|
|
2
|
-
|
|
3
|
-
describe('onboarding encryption map', () => {
|
|
4
|
-
it('protects pre-tenant PII and the transient password hash with a system key', () => {
|
|
5
|
-
expect(defaultEncryptionMaps).toEqual([
|
|
6
|
-
{
|
|
7
|
-
entityId: 'onboarding:onboarding_request',
|
|
8
|
-
keyScope: 'system',
|
|
9
|
-
fields: [
|
|
10
|
-
{ field: 'email', hashField: 'email_hash' },
|
|
11
|
-
{ field: 'first_name' },
|
|
12
|
-
{ field: 'last_name' },
|
|
13
|
-
{ field: 'organization_name' },
|
|
14
|
-
{ field: 'password_hash' },
|
|
15
|
-
],
|
|
16
|
-
},
|
|
17
|
-
])
|
|
18
|
-
})
|
|
19
|
-
})
|