serverless-event-orchestrator 2.2.0 → 2.4.0

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 (45) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +489 -489
  3. package/dist/dispatcher.d.ts +6 -1
  4. package/dist/dispatcher.d.ts.map +1 -1
  5. package/dist/dispatcher.js +31 -7
  6. package/dist/dispatcher.js.map +1 -1
  7. package/dist/tenant/helpers.js +1 -1
  8. package/dist/tenant/helpers.js.map +1 -1
  9. package/jest.config.js +32 -32
  10. package/package.json +82 -81
  11. package/src/dispatcher.ts +586 -558
  12. package/src/http/body-parser.ts +60 -60
  13. package/src/http/cors.ts +76 -76
  14. package/src/http/index.ts +3 -3
  15. package/src/http/response.ts +209 -209
  16. package/src/identity/extractor.ts +207 -207
  17. package/src/identity/index.ts +2 -2
  18. package/src/identity/jwt-verifier.ts +41 -41
  19. package/src/index.ts +128 -128
  20. package/src/middleware/crm-guard.ts +51 -51
  21. package/src/middleware/index.ts +3 -3
  22. package/src/middleware/init-tenant-context.ts +59 -59
  23. package/src/middleware/tenant-guard.ts +54 -54
  24. package/src/tenant/TenantContext.ts +115 -115
  25. package/src/tenant/helpers.ts +112 -112
  26. package/src/tenant/index.ts +21 -21
  27. package/src/tenant/types.ts +101 -101
  28. package/src/types/event-type.enum.ts +21 -21
  29. package/src/types/index.ts +2 -2
  30. package/src/types/routes.ts +218 -218
  31. package/src/utils/headers.ts +72 -72
  32. package/src/utils/index.ts +2 -2
  33. package/src/utils/path-matcher.ts +84 -84
  34. package/tests/cors.test.ts +133 -133
  35. package/tests/dispatcher.test.ts +795 -715
  36. package/tests/headers.test.ts +99 -99
  37. package/tests/identity.test.ts +301 -301
  38. package/tests/middleware/crm-guard.test.ts +69 -69
  39. package/tests/middleware/init-tenant-context.test.ts +147 -147
  40. package/tests/middleware/tenant-guard.test.ts +100 -100
  41. package/tests/path-matcher.test.ts +102 -102
  42. package/tests/response.test.ts +155 -155
  43. package/tests/tenant/TenantContext.test.ts +134 -134
  44. package/tests/tenant/helpers.test.ts +187 -187
  45. package/tsconfig.json +24 -24
@@ -1,134 +1,134 @@
1
- import { TenantContext } from '../../src/tenant/TenantContext';
2
- import type { TenantInfo } from '../../src/tenant/types';
3
-
4
- const mockTenantORG: TenantInfo = {
5
- tenantId: 'org_century21',
6
- tenantType: 'ORG',
7
- userId: 'user_001',
8
- personProfileId: 'person_001',
9
- orgProfileId: 'org_century21',
10
- countryCode: 'CO',
11
- plan: 'PRO',
12
- hasCRM: true,
13
- };
14
-
15
- const mockTenantPERSON: TenantInfo = {
16
- tenantId: 'person_agent_x',
17
- tenantType: 'PERSON',
18
- userId: 'user_002',
19
- personProfileId: 'person_agent_x',
20
- countryCode: 'MX',
21
- plan: 'FREE',
22
- hasCRM: false,
23
- };
24
-
25
- describe('TenantContext', () => {
26
- afterEach(() => {
27
- TenantContext._reset();
28
- });
29
-
30
- describe('run() + current()', () => {
31
- it('stores and retrieves TenantInfo correctly inside callback', async () => {
32
- await TenantContext.run(mockTenantORG, async () => {
33
- const tenant = TenantContext.current();
34
- expect(tenant.tenantId).toBe('org_century21');
35
- expect(tenant.tenantType).toBe('ORG');
36
- expect(tenant.userId).toBe('user_001');
37
- expect(tenant.countryCode).toBe('CO');
38
- expect(tenant.plan).toBe('PRO');
39
- expect(tenant.hasCRM).toBe(true);
40
- });
41
- });
42
-
43
- it('supports PERSON tenants', async () => {
44
- await TenantContext.run(mockTenantPERSON, async () => {
45
- const tenant = TenantContext.current();
46
- expect(tenant.tenantId).toBe('person_agent_x');
47
- expect(tenant.tenantType).toBe('PERSON');
48
- expect(tenant.orgProfileId).toBeUndefined();
49
- });
50
- });
51
-
52
- it('isolates context between concurrent calls', async () => {
53
- const results: string[] = [];
54
-
55
- await Promise.all([
56
- TenantContext.run(mockTenantORG, async () => {
57
- await new Promise((r) => setTimeout(r, 10));
58
- results.push(TenantContext.current().tenantId);
59
- }),
60
- TenantContext.run(mockTenantPERSON, async () => {
61
- await new Promise((r) => setTimeout(r, 5));
62
- results.push(TenantContext.current().tenantId);
63
- }),
64
- ]);
65
-
66
- expect(results).toContain('org_century21');
67
- expect(results).toContain('person_agent_x');
68
- });
69
-
70
- it('propagates context to nested async functions', async () => {
71
- async function nestedFunction(): Promise<string> {
72
- return TenantContext.current().tenantId;
73
- }
74
-
75
- await TenantContext.run(mockTenantORG, async () => {
76
- const result = await nestedFunction();
77
- expect(result).toBe('org_century21');
78
- });
79
- });
80
- });
81
-
82
- describe('current() — fail-closed', () => {
83
- it('throws error if not initialized', () => {
84
- expect(() => TenantContext.current()).toThrow('TenantContext not initialized');
85
- });
86
-
87
- it('throws error after exiting run() scope', async () => {
88
- await TenantContext.run(mockTenantORG, async () => {
89
- expect(TenantContext.current().tenantId).toBe('org_century21');
90
- });
91
-
92
- // Outside scope → no more context
93
- expect(() => TenantContext.current()).toThrow('TenantContext not initialized');
94
- });
95
- });
96
-
97
- describe('currentOptional()', () => {
98
- it('returns undefined if no context', () => {
99
- expect(TenantContext.currentOptional()).toBeUndefined();
100
- });
101
-
102
- it('returns TenantInfo if context exists', async () => {
103
- await TenantContext.run(mockTenantORG, async () => {
104
- const tenant = TenantContext.currentOptional();
105
- expect(tenant).toBeDefined();
106
- expect(tenant?.tenantId).toBe('org_century21');
107
- });
108
- });
109
- });
110
-
111
- describe('set()', () => {
112
- it('sets context in current AsyncLocalStorage scope', async () => {
113
- await TenantContext.run(mockTenantORG, async () => {
114
- expect(TenantContext.current().tenantId).toBe('org_century21');
115
-
116
- // Overwrite with set()
117
- TenantContext.set(mockTenantPERSON);
118
- expect(TenantContext.current().tenantId).toBe('person_agent_x');
119
- });
120
- });
121
- });
122
-
123
- describe('isActive()', () => {
124
- it('returns false outside a context', () => {
125
- expect(TenantContext.isActive()).toBe(false);
126
- });
127
-
128
- it('returns true inside a context', async () => {
129
- await TenantContext.run(mockTenantORG, async () => {
130
- expect(TenantContext.isActive()).toBe(true);
131
- });
132
- });
133
- });
134
- });
1
+ import { TenantContext } from '../../src/tenant/TenantContext';
2
+ import type { TenantInfo } from '../../src/tenant/types';
3
+
4
+ const mockTenantORG: TenantInfo = {
5
+ tenantId: 'org_century21',
6
+ tenantType: 'ORG',
7
+ userId: 'user_001',
8
+ personProfileId: 'person_001',
9
+ orgProfileId: 'org_century21',
10
+ countryCode: 'CO',
11
+ plan: 'PRO',
12
+ hasCRM: true,
13
+ };
14
+
15
+ const mockTenantPERSON: TenantInfo = {
16
+ tenantId: 'person_agent_x',
17
+ tenantType: 'PERSON',
18
+ userId: 'user_002',
19
+ personProfileId: 'person_agent_x',
20
+ countryCode: 'MX',
21
+ plan: 'FREE',
22
+ hasCRM: false,
23
+ };
24
+
25
+ describe('TenantContext', () => {
26
+ afterEach(() => {
27
+ TenantContext._reset();
28
+ });
29
+
30
+ describe('run() + current()', () => {
31
+ it('stores and retrieves TenantInfo correctly inside callback', async () => {
32
+ await TenantContext.run(mockTenantORG, async () => {
33
+ const tenant = TenantContext.current();
34
+ expect(tenant.tenantId).toBe('org_century21');
35
+ expect(tenant.tenantType).toBe('ORG');
36
+ expect(tenant.userId).toBe('user_001');
37
+ expect(tenant.countryCode).toBe('CO');
38
+ expect(tenant.plan).toBe('PRO');
39
+ expect(tenant.hasCRM).toBe(true);
40
+ });
41
+ });
42
+
43
+ it('supports PERSON tenants', async () => {
44
+ await TenantContext.run(mockTenantPERSON, async () => {
45
+ const tenant = TenantContext.current();
46
+ expect(tenant.tenantId).toBe('person_agent_x');
47
+ expect(tenant.tenantType).toBe('PERSON');
48
+ expect(tenant.orgProfileId).toBeUndefined();
49
+ });
50
+ });
51
+
52
+ it('isolates context between concurrent calls', async () => {
53
+ const results: string[] = [];
54
+
55
+ await Promise.all([
56
+ TenantContext.run(mockTenantORG, async () => {
57
+ await new Promise((r) => setTimeout(r, 10));
58
+ results.push(TenantContext.current().tenantId);
59
+ }),
60
+ TenantContext.run(mockTenantPERSON, async () => {
61
+ await new Promise((r) => setTimeout(r, 5));
62
+ results.push(TenantContext.current().tenantId);
63
+ }),
64
+ ]);
65
+
66
+ expect(results).toContain('org_century21');
67
+ expect(results).toContain('person_agent_x');
68
+ });
69
+
70
+ it('propagates context to nested async functions', async () => {
71
+ async function nestedFunction(): Promise<string> {
72
+ return TenantContext.current().tenantId;
73
+ }
74
+
75
+ await TenantContext.run(mockTenantORG, async () => {
76
+ const result = await nestedFunction();
77
+ expect(result).toBe('org_century21');
78
+ });
79
+ });
80
+ });
81
+
82
+ describe('current() — fail-closed', () => {
83
+ it('throws error if not initialized', () => {
84
+ expect(() => TenantContext.current()).toThrow('TenantContext not initialized');
85
+ });
86
+
87
+ it('throws error after exiting run() scope', async () => {
88
+ await TenantContext.run(mockTenantORG, async () => {
89
+ expect(TenantContext.current().tenantId).toBe('org_century21');
90
+ });
91
+
92
+ // Outside scope → no more context
93
+ expect(() => TenantContext.current()).toThrow('TenantContext not initialized');
94
+ });
95
+ });
96
+
97
+ describe('currentOptional()', () => {
98
+ it('returns undefined if no context', () => {
99
+ expect(TenantContext.currentOptional()).toBeUndefined();
100
+ });
101
+
102
+ it('returns TenantInfo if context exists', async () => {
103
+ await TenantContext.run(mockTenantORG, async () => {
104
+ const tenant = TenantContext.currentOptional();
105
+ expect(tenant).toBeDefined();
106
+ expect(tenant?.tenantId).toBe('org_century21');
107
+ });
108
+ });
109
+ });
110
+
111
+ describe('set()', () => {
112
+ it('sets context in current AsyncLocalStorage scope', async () => {
113
+ await TenantContext.run(mockTenantORG, async () => {
114
+ expect(TenantContext.current().tenantId).toBe('org_century21');
115
+
116
+ // Overwrite with set()
117
+ TenantContext.set(mockTenantPERSON);
118
+ expect(TenantContext.current().tenantId).toBe('person_agent_x');
119
+ });
120
+ });
121
+ });
122
+
123
+ describe('isActive()', () => {
124
+ it('returns false outside a context', () => {
125
+ expect(TenantContext.isActive()).toBe(false);
126
+ });
127
+
128
+ it('returns true inside a context', async () => {
129
+ await TenantContext.run(mockTenantORG, async () => {
130
+ expect(TenantContext.isActive()).toBe(true);
131
+ });
132
+ });
133
+ });
134
+ });
@@ -1,187 +1,187 @@
1
- import {
2
- isTenantType,
3
- isPlan,
4
- tenantInfoFromClaims,
5
- tenantInfoFromHeaders,
6
- tenantInfoToHeaders,
7
- TENANT_HEADERS,
8
- } from '../../src/tenant';
9
- import type { TenantInfo } from '../../src/tenant';
10
-
11
- describe('Type Guards', () => {
12
- describe('isTenantType', () => {
13
- it('accepts ORG', () => expect(isTenantType('ORG')).toBe(true));
14
- it('accepts PERSON', () => expect(isTenantType('PERSON')).toBe(true));
15
- it('rejects invalid string', () => expect(isTenantType('INVALID')).toBe(false));
16
- it('rejects undefined', () => expect(isTenantType(undefined)).toBe(false));
17
- it('rejects null', () => expect(isTenantType(null)).toBe(false));
18
- });
19
-
20
- describe('isPlan', () => {
21
- it.each(['FREE', 'BASIC', 'PRO', 'ENTERPRISE'])('accepts %s', (plan) => {
22
- expect(isPlan(plan)).toBe(true);
23
- });
24
- it('rejects invalid string', () => expect(isPlan('PREMIUM')).toBe(false));
25
- });
26
- });
27
-
28
- describe('tenantInfoFromClaims', () => {
29
- const validClaims = {
30
- 'custom:tenantId': 'org_abc',
31
- 'custom:tenantType': 'ORG',
32
- 'custom:userId': 'user_001',
33
- 'custom:countryCode': 'CO',
34
- 'custom:personProfileId': 'person_001',
35
- 'custom:orgProfileId': 'org_abc',
36
- 'custom:plan': 'PRO',
37
- 'custom:hasCRM': 'true',
38
- };
39
-
40
- it('builds complete TenantInfo from valid claims', () => {
41
- const result = tenantInfoFromClaims(validClaims);
42
- expect(result).toEqual({
43
- tenantId: 'org_abc',
44
- tenantType: 'ORG',
45
- userId: 'user_001',
46
- countryCode: 'CO',
47
- personProfileId: 'person_001',
48
- orgProfileId: 'org_abc',
49
- plan: 'PRO',
50
- hasCRM: 'true',
51
- });
52
- });
53
-
54
- it('returns undefined if tenantId is missing', () => {
55
- const { 'custom:tenantId': _, ...claims } = validClaims;
56
- expect(tenantInfoFromClaims(claims)).toBeUndefined();
57
- });
58
-
59
- it('returns undefined if tenantType is missing', () => {
60
- const { 'custom:tenantType': _, ...claims } = validClaims;
61
- expect(tenantInfoFromClaims(claims)).toBeUndefined();
62
- });
63
-
64
- it('returns undefined if tenantType is invalid', () => {
65
- expect(tenantInfoFromClaims({ ...validClaims, 'custom:tenantType': 'INVALID' })).toBeUndefined();
66
- });
67
-
68
- it('hasCRM is false if claim is not "true"', () => {
69
- const result = tenantInfoFromClaims({ ...validClaims, 'custom:hasCRM': 'false' });
70
- expect(result?.hasCRM).toBe('false');
71
- });
72
-
73
- it('plan is undefined if claim is not a valid Plan', () => {
74
- const result = tenantInfoFromClaims({ ...validClaims, 'custom:plan': 'PREMIUM' });
75
- expect(result?.plan).toBeUndefined();
76
- });
77
-
78
- it('uses sub as fallback for userId', () => {
79
- const claims = {
80
- 'custom:tenantId': 'org_abc',
81
- 'custom:tenantType': 'ORG',
82
- 'sub': 'cognito-sub-123',
83
- 'custom:countryCode': 'CO',
84
- };
85
- const result = tenantInfoFromClaims(claims);
86
- expect(result?.userId).toBe('cognito-sub-123');
87
- });
88
-
89
- describe('without custom: prefix (Cognito Pre Token Generation V2)', () => {
90
- const v2Claims = {
91
- tenantId: 'org_abc',
92
- tenantType: 'ORG',
93
- userId: 'user_001',
94
- countryCode: 'CO',
95
- personProfileId: 'person_001',
96
- orgProfileId: 'org_abc',
97
- plan: 'PRO',
98
- hasCRM: 'true',
99
- };
100
-
101
- it('builds complete TenantInfo from claims without custom: prefix', () => {
102
- const result = tenantInfoFromClaims(v2Claims);
103
- expect(result).toEqual({
104
- tenantId: 'org_abc',
105
- tenantType: 'ORG',
106
- userId: 'user_001',
107
- countryCode: 'CO',
108
- personProfileId: 'person_001',
109
- orgProfileId: 'org_abc',
110
- plan: 'PRO',
111
- hasCRM: 'true',
112
- });
113
- });
114
-
115
- it('returns undefined if tenantId is missing (no prefix)', () => {
116
- const { tenantId: _, ...claims } = v2Claims;
117
- expect(tenantInfoFromClaims(claims)).toBeUndefined();
118
- });
119
-
120
- it('returns undefined if tenantType is invalid (no prefix)', () => {
121
- expect(tenantInfoFromClaims({ ...v2Claims, tenantType: 'INVALID' })).toBeUndefined();
122
- });
123
-
124
- it('prefers custom: prefix over unprefixed when both exist', () => {
125
- const mixedClaims = {
126
- 'custom:tenantId': 'from_custom',
127
- tenantId: 'from_plain',
128
- 'custom:tenantType': 'ORG',
129
- tenantType: 'PERSON',
130
- 'custom:userId': 'user_custom',
131
- userId: 'user_plain',
132
- 'custom:countryCode': 'CO',
133
- countryCode: 'US',
134
- };
135
- const result = tenantInfoFromClaims(mixedClaims);
136
- expect(result?.tenantId).toBe('from_custom');
137
- expect(result?.tenantType).toBe('ORG');
138
- expect(result?.userId).toBe('user_custom');
139
- expect(result?.countryCode).toBe('CO');
140
- });
141
-
142
- it('uses sub as fallback for userId without prefix', () => {
143
- const claims = {
144
- tenantId: 'org_abc',
145
- tenantType: 'ORG',
146
- sub: 'cognito-sub-123',
147
- countryCode: 'CO',
148
- };
149
- const result = tenantInfoFromClaims(claims);
150
- expect(result?.userId).toBe('cognito-sub-123');
151
- });
152
- });
153
- });
154
-
155
- describe('tenantInfoFromHeaders / tenantInfoToHeaders', () => {
156
- const tenant: TenantInfo = {
157
- tenantId: 'org_abc',
158
- tenantType: 'ORG',
159
- userId: 'user_001',
160
- countryCode: 'CO',
161
- personProfileId: 'person_001',
162
- orgProfileId: 'org_abc',
163
- };
164
-
165
- it('roundtrip: toHeaders → fromHeaders returns same TenantInfo', () => {
166
- const headers = tenantInfoToHeaders(tenant);
167
- const result = tenantInfoFromHeaders(headers);
168
- expect(result).toEqual(tenant);
169
- });
170
-
171
- it('fromHeaders returns undefined if x-tenant-id is missing', () => {
172
- const headers = tenantInfoToHeaders(tenant);
173
- delete headers[TENANT_HEADERS.TENANT_ID];
174
- expect(tenantInfoFromHeaders(headers)).toBeUndefined();
175
- });
176
-
177
- it('fromHeaders supports lowercase headers (API Gateway normalization)', () => {
178
- const headers = {
179
- 'x-tenant-id': 'org_abc',
180
- 'x-tenant-type': 'ORG',
181
- 'x-user-id': 'user_001',
182
- 'x-country-code': 'CO',
183
- };
184
- const result = tenantInfoFromHeaders(headers);
185
- expect(result?.tenantId).toBe('org_abc');
186
- });
187
- });
1
+ import {
2
+ isTenantType,
3
+ isPlan,
4
+ tenantInfoFromClaims,
5
+ tenantInfoFromHeaders,
6
+ tenantInfoToHeaders,
7
+ TENANT_HEADERS,
8
+ } from '../../src/tenant';
9
+ import type { TenantInfo } from '../../src/tenant';
10
+
11
+ describe('Type Guards', () => {
12
+ describe('isTenantType', () => {
13
+ it('accepts ORG', () => expect(isTenantType('ORG')).toBe(true));
14
+ it('accepts PERSON', () => expect(isTenantType('PERSON')).toBe(true));
15
+ it('rejects invalid string', () => expect(isTenantType('INVALID')).toBe(false));
16
+ it('rejects undefined', () => expect(isTenantType(undefined)).toBe(false));
17
+ it('rejects null', () => expect(isTenantType(null)).toBe(false));
18
+ });
19
+
20
+ describe('isPlan', () => {
21
+ it.each(['FREE', 'BASIC', 'PRO', 'ENTERPRISE'])('accepts %s', (plan) => {
22
+ expect(isPlan(plan)).toBe(true);
23
+ });
24
+ it('rejects invalid string', () => expect(isPlan('PREMIUM')).toBe(false));
25
+ });
26
+ });
27
+
28
+ describe('tenantInfoFromClaims', () => {
29
+ const validClaims = {
30
+ 'custom:tenantId': 'org_abc',
31
+ 'custom:tenantType': 'ORG',
32
+ 'custom:userId': 'user_001',
33
+ 'custom:countryCode': 'CO',
34
+ 'custom:personProfileId': 'person_001',
35
+ 'custom:orgProfileId': 'org_abc',
36
+ 'custom:plan': 'PRO',
37
+ 'custom:hasCRM': 'true',
38
+ };
39
+
40
+ it('builds complete TenantInfo from valid claims', () => {
41
+ const result = tenantInfoFromClaims(validClaims);
42
+ expect(result).toEqual({
43
+ tenantId: 'org_abc',
44
+ tenantType: 'ORG',
45
+ userId: 'user_001',
46
+ countryCode: 'CO',
47
+ personProfileId: 'person_001',
48
+ orgProfileId: 'org_abc',
49
+ plan: 'PRO',
50
+ hasCRM: true,
51
+ });
52
+ });
53
+
54
+ it('returns undefined if tenantId is missing', () => {
55
+ const { 'custom:tenantId': _, ...claims } = validClaims;
56
+ expect(tenantInfoFromClaims(claims)).toBeUndefined();
57
+ });
58
+
59
+ it('returns undefined if tenantType is missing', () => {
60
+ const { 'custom:tenantType': _, ...claims } = validClaims;
61
+ expect(tenantInfoFromClaims(claims)).toBeUndefined();
62
+ });
63
+
64
+ it('returns undefined if tenantType is invalid', () => {
65
+ expect(tenantInfoFromClaims({ ...validClaims, 'custom:tenantType': 'INVALID' })).toBeUndefined();
66
+ });
67
+
68
+ it('hasCRM is false if claim is not "true"', () => {
69
+ const result = tenantInfoFromClaims({ ...validClaims, 'custom:hasCRM': 'false' });
70
+ expect(result?.hasCRM).toBe(false);
71
+ });
72
+
73
+ it('plan is undefined if claim is not a valid Plan', () => {
74
+ const result = tenantInfoFromClaims({ ...validClaims, 'custom:plan': 'PREMIUM' });
75
+ expect(result?.plan).toBeUndefined();
76
+ });
77
+
78
+ it('uses sub as fallback for userId', () => {
79
+ const claims = {
80
+ 'custom:tenantId': 'org_abc',
81
+ 'custom:tenantType': 'ORG',
82
+ 'sub': 'cognito-sub-123',
83
+ 'custom:countryCode': 'CO',
84
+ };
85
+ const result = tenantInfoFromClaims(claims);
86
+ expect(result?.userId).toBe('cognito-sub-123');
87
+ });
88
+
89
+ describe('without custom: prefix (Cognito Pre Token Generation V2)', () => {
90
+ const v2Claims = {
91
+ tenantId: 'org_abc',
92
+ tenantType: 'ORG',
93
+ userId: 'user_001',
94
+ countryCode: 'CO',
95
+ personProfileId: 'person_001',
96
+ orgProfileId: 'org_abc',
97
+ plan: 'PRO',
98
+ hasCRM: 'true',
99
+ };
100
+
101
+ it('builds complete TenantInfo from claims without custom: prefix', () => {
102
+ const result = tenantInfoFromClaims(v2Claims);
103
+ expect(result).toEqual({
104
+ tenantId: 'org_abc',
105
+ tenantType: 'ORG',
106
+ userId: 'user_001',
107
+ countryCode: 'CO',
108
+ personProfileId: 'person_001',
109
+ orgProfileId: 'org_abc',
110
+ plan: 'PRO',
111
+ hasCRM: true,
112
+ });
113
+ });
114
+
115
+ it('returns undefined if tenantId is missing (no prefix)', () => {
116
+ const { tenantId: _, ...claims } = v2Claims;
117
+ expect(tenantInfoFromClaims(claims)).toBeUndefined();
118
+ });
119
+
120
+ it('returns undefined if tenantType is invalid (no prefix)', () => {
121
+ expect(tenantInfoFromClaims({ ...v2Claims, tenantType: 'INVALID' })).toBeUndefined();
122
+ });
123
+
124
+ it('prefers custom: prefix over unprefixed when both exist', () => {
125
+ const mixedClaims = {
126
+ 'custom:tenantId': 'from_custom',
127
+ tenantId: 'from_plain',
128
+ 'custom:tenantType': 'ORG',
129
+ tenantType: 'PERSON',
130
+ 'custom:userId': 'user_custom',
131
+ userId: 'user_plain',
132
+ 'custom:countryCode': 'CO',
133
+ countryCode: 'US',
134
+ };
135
+ const result = tenantInfoFromClaims(mixedClaims);
136
+ expect(result?.tenantId).toBe('from_custom');
137
+ expect(result?.tenantType).toBe('ORG');
138
+ expect(result?.userId).toBe('user_custom');
139
+ expect(result?.countryCode).toBe('CO');
140
+ });
141
+
142
+ it('uses sub as fallback for userId without prefix', () => {
143
+ const claims = {
144
+ tenantId: 'org_abc',
145
+ tenantType: 'ORG',
146
+ sub: 'cognito-sub-123',
147
+ countryCode: 'CO',
148
+ };
149
+ const result = tenantInfoFromClaims(claims);
150
+ expect(result?.userId).toBe('cognito-sub-123');
151
+ });
152
+ });
153
+ });
154
+
155
+ describe('tenantInfoFromHeaders / tenantInfoToHeaders', () => {
156
+ const tenant: TenantInfo = {
157
+ tenantId: 'org_abc',
158
+ tenantType: 'ORG',
159
+ userId: 'user_001',
160
+ countryCode: 'CO',
161
+ personProfileId: 'person_001',
162
+ orgProfileId: 'org_abc',
163
+ };
164
+
165
+ it('roundtrip: toHeaders → fromHeaders returns same TenantInfo', () => {
166
+ const headers = tenantInfoToHeaders(tenant);
167
+ const result = tenantInfoFromHeaders(headers);
168
+ expect(result).toEqual(tenant);
169
+ });
170
+
171
+ it('fromHeaders returns undefined if x-tenant-id is missing', () => {
172
+ const headers = tenantInfoToHeaders(tenant);
173
+ delete headers[TENANT_HEADERS.TENANT_ID];
174
+ expect(tenantInfoFromHeaders(headers)).toBeUndefined();
175
+ });
176
+
177
+ it('fromHeaders supports lowercase headers (API Gateway normalization)', () => {
178
+ const headers = {
179
+ 'x-tenant-id': 'org_abc',
180
+ 'x-tenant-type': 'ORG',
181
+ 'x-user-id': 'user_001',
182
+ 'x-country-code': 'CO',
183
+ };
184
+ const result = tenantInfoFromHeaders(headers);
185
+ expect(result?.tenantId).toBe('org_abc');
186
+ });
187
+ });