serverless-event-orchestrator 2.0.1 → 2.3.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.
- package/LICENSE +21 -21
- package/README.md +489 -434
- package/dist/dispatcher.d.ts +6 -1
- package/dist/dispatcher.d.ts.map +1 -1
- package/dist/dispatcher.js +66 -7
- package/dist/dispatcher.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types/event-type.enum.d.ts +1 -0
- package/dist/types/event-type.enum.d.ts.map +1 -1
- package/dist/types/event-type.enum.js +1 -0
- package/dist/types/event-type.enum.js.map +1 -1
- package/dist/types/routes.d.ts +6 -0
- package/dist/types/routes.d.ts.map +1 -1
- package/jest.config.js +32 -32
- package/package.json +82 -81
- package/src/dispatcher.ts +586 -519
- package/src/http/body-parser.ts +60 -60
- package/src/http/cors.ts +76 -76
- package/src/http/index.ts +3 -3
- package/src/http/response.ts +209 -209
- package/src/identity/extractor.ts +207 -207
- package/src/identity/index.ts +2 -2
- package/src/identity/jwt-verifier.ts +41 -41
- package/src/index.ts +128 -127
- package/src/middleware/crm-guard.ts +51 -51
- package/src/middleware/index.ts +3 -3
- package/src/middleware/init-tenant-context.ts +59 -59
- package/src/middleware/tenant-guard.ts +54 -54
- package/src/tenant/TenantContext.ts +115 -115
- package/src/tenant/helpers.ts +112 -112
- package/src/tenant/index.ts +21 -21
- package/src/tenant/types.ts +101 -101
- package/src/types/event-type.enum.ts +21 -20
- package/src/types/index.ts +2 -2
- package/src/types/routes.ts +218 -211
- package/src/utils/headers.ts +72 -72
- package/src/utils/index.ts +2 -2
- package/src/utils/path-matcher.ts +84 -84
- package/tests/cors.test.ts +133 -133
- package/tests/dispatcher.test.ts +795 -715
- package/tests/headers.test.ts +99 -99
- package/tests/identity.test.ts +301 -301
- package/tests/middleware/crm-guard.test.ts +69 -69
- package/tests/middleware/init-tenant-context.test.ts +147 -147
- package/tests/middleware/tenant-guard.test.ts +100 -100
- package/tests/path-matcher.test.ts +102 -102
- package/tests/response.test.ts +155 -155
- package/tests/tenant/TenantContext.test.ts +134 -134
- package/tests/tenant/helpers.test.ts +187 -187
- 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
|
+
});
|