serverless-event-orchestrator 2.2.0 → 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 -489
- package/dist/dispatcher.d.ts +6 -1
- package/dist/dispatcher.d.ts.map +1 -1
- package/dist/dispatcher.js +31 -7
- package/dist/dispatcher.js.map +1 -1
- package/jest.config.js +32 -32
- package/package.json +82 -81
- package/src/dispatcher.ts +586 -558
- 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 -128
- 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 -21
- package/src/types/index.ts +2 -2
- package/src/types/routes.ts +218 -218
- 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,69 +1,69 @@
|
|
|
1
|
-
import { crmGuard } from '../../src/middleware/crm-guard';
|
|
2
|
-
import { RouteSegment } from '../../src/types/event-type.enum';
|
|
3
|
-
import type { NormalizedEvent } from '../../src/types/routes';
|
|
4
|
-
import type { TenantInfo } from '../../src/tenant';
|
|
5
|
-
|
|
6
|
-
function createEvent(options: {
|
|
7
|
-
hasCRM?: boolean;
|
|
8
|
-
groups?: string[];
|
|
9
|
-
}): NormalizedEvent {
|
|
10
|
-
const tenantInfo: TenantInfo = {
|
|
11
|
-
tenantId: 'org_abc',
|
|
12
|
-
tenantType: 'ORG',
|
|
13
|
-
userId: 'user-1',
|
|
14
|
-
countryCode: 'CO',
|
|
15
|
-
hasCRM: options.hasCRM,
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
eventRaw: {},
|
|
20
|
-
eventType: 'apigateway',
|
|
21
|
-
payload: { headers: {} },
|
|
22
|
-
params: {},
|
|
23
|
-
context: {
|
|
24
|
-
segment: RouteSegment.Private,
|
|
25
|
-
identity: {
|
|
26
|
-
userId: 'user-1',
|
|
27
|
-
groups: options.groups ?? [],
|
|
28
|
-
claims: {},
|
|
29
|
-
},
|
|
30
|
-
requestId: 'req-test',
|
|
31
|
-
tenantInfo,
|
|
32
|
-
},
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
describe('crmGuard', () => {
|
|
37
|
-
it('allows access when hasCRM is true', async () => {
|
|
38
|
-
const event = createEvent({ hasCRM: true });
|
|
39
|
-
const result = await crmGuard(event);
|
|
40
|
-
expect(result).toBeDefined();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('throws 403 when hasCRM is false', async () => {
|
|
44
|
-
const event = createEvent({ hasCRM: false });
|
|
45
|
-
await expect(crmGuard(event)).rejects.toMatchObject({ statusCode: 403 });
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('throws 403 when hasCRM is undefined', async () => {
|
|
49
|
-
const event = createEvent({});
|
|
50
|
-
await expect(crmGuard(event)).rejects.toMatchObject({ statusCode: 403 });
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('PLATFORM_ADMIN bypasses CRM check', async () => {
|
|
54
|
-
const event = createEvent({ hasCRM: false, groups: ['PLATFORM_ADMIN'] });
|
|
55
|
-
const result = await crmGuard(event);
|
|
56
|
-
expect(result).toBeDefined();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('error includes CRM_ACCESS_DENIED code', async () => {
|
|
60
|
-
const event = createEvent({ hasCRM: false, groups: ['ORG_ADMIN'] });
|
|
61
|
-
try {
|
|
62
|
-
await crmGuard(event);
|
|
63
|
-
fail('Should have thrown');
|
|
64
|
-
} catch (err: any) {
|
|
65
|
-
const body = JSON.parse(err.body);
|
|
66
|
-
expect(body.code).toBe('CRM_ACCESS_DENIED');
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
});
|
|
1
|
+
import { crmGuard } from '../../src/middleware/crm-guard';
|
|
2
|
+
import { RouteSegment } from '../../src/types/event-type.enum';
|
|
3
|
+
import type { NormalizedEvent } from '../../src/types/routes';
|
|
4
|
+
import type { TenantInfo } from '../../src/tenant';
|
|
5
|
+
|
|
6
|
+
function createEvent(options: {
|
|
7
|
+
hasCRM?: boolean;
|
|
8
|
+
groups?: string[];
|
|
9
|
+
}): NormalizedEvent {
|
|
10
|
+
const tenantInfo: TenantInfo = {
|
|
11
|
+
tenantId: 'org_abc',
|
|
12
|
+
tenantType: 'ORG',
|
|
13
|
+
userId: 'user-1',
|
|
14
|
+
countryCode: 'CO',
|
|
15
|
+
hasCRM: options.hasCRM,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
eventRaw: {},
|
|
20
|
+
eventType: 'apigateway',
|
|
21
|
+
payload: { headers: {} },
|
|
22
|
+
params: {},
|
|
23
|
+
context: {
|
|
24
|
+
segment: RouteSegment.Private,
|
|
25
|
+
identity: {
|
|
26
|
+
userId: 'user-1',
|
|
27
|
+
groups: options.groups ?? [],
|
|
28
|
+
claims: {},
|
|
29
|
+
},
|
|
30
|
+
requestId: 'req-test',
|
|
31
|
+
tenantInfo,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('crmGuard', () => {
|
|
37
|
+
it('allows access when hasCRM is true', async () => {
|
|
38
|
+
const event = createEvent({ hasCRM: true });
|
|
39
|
+
const result = await crmGuard(event);
|
|
40
|
+
expect(result).toBeDefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('throws 403 when hasCRM is false', async () => {
|
|
44
|
+
const event = createEvent({ hasCRM: false });
|
|
45
|
+
await expect(crmGuard(event)).rejects.toMatchObject({ statusCode: 403 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('throws 403 when hasCRM is undefined', async () => {
|
|
49
|
+
const event = createEvent({});
|
|
50
|
+
await expect(crmGuard(event)).rejects.toMatchObject({ statusCode: 403 });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('PLATFORM_ADMIN bypasses CRM check', async () => {
|
|
54
|
+
const event = createEvent({ hasCRM: false, groups: ['PLATFORM_ADMIN'] });
|
|
55
|
+
const result = await crmGuard(event);
|
|
56
|
+
expect(result).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('error includes CRM_ACCESS_DENIED code', async () => {
|
|
60
|
+
const event = createEvent({ hasCRM: false, groups: ['ORG_ADMIN'] });
|
|
61
|
+
try {
|
|
62
|
+
await crmGuard(event);
|
|
63
|
+
fail('Should have thrown');
|
|
64
|
+
} catch (err: any) {
|
|
65
|
+
const body = JSON.parse(err.body);
|
|
66
|
+
expect(body.code).toBe('CRM_ACCESS_DENIED');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -1,147 +1,147 @@
|
|
|
1
|
-
import { initTenantContext } from '../../src/middleware/init-tenant-context';
|
|
2
|
-
import { TenantContext } from '../../src/tenant';
|
|
3
|
-
import { RouteSegment } from '../../src/types/event-type.enum';
|
|
4
|
-
import type { NormalizedEvent } from '../../src/types/routes';
|
|
5
|
-
|
|
6
|
-
function createMockEvent(overrides: Partial<{
|
|
7
|
-
segment: RouteSegment;
|
|
8
|
-
claims: Record<string, any>;
|
|
9
|
-
headers: Record<string, string>;
|
|
10
|
-
userId: string;
|
|
11
|
-
groups: string[];
|
|
12
|
-
}>): NormalizedEvent {
|
|
13
|
-
return {
|
|
14
|
-
eventRaw: {},
|
|
15
|
-
eventType: 'apigateway',
|
|
16
|
-
payload: {
|
|
17
|
-
body: {},
|
|
18
|
-
pathParameters: {},
|
|
19
|
-
queryStringParameters: {},
|
|
20
|
-
headers: overrides.headers ?? {},
|
|
21
|
-
},
|
|
22
|
-
params: {},
|
|
23
|
-
context: {
|
|
24
|
-
segment: overrides.segment ?? RouteSegment.Private,
|
|
25
|
-
identity: overrides.claims ? {
|
|
26
|
-
userId: overrides.userId ?? 'user-1',
|
|
27
|
-
groups: overrides.groups ?? [],
|
|
28
|
-
claims: overrides.claims,
|
|
29
|
-
} : undefined,
|
|
30
|
-
requestId: 'req-test',
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
describe('initTenantContext', () => {
|
|
36
|
-
afterEach(() => {
|
|
37
|
-
TenantContext._reset();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe('from JWT claims (private/backoffice)', () => {
|
|
41
|
-
it('extracts tenantInfo from identity.claims and adds to context.tenantInfo', async () => {
|
|
42
|
-
const event = createMockEvent({
|
|
43
|
-
segment: RouteSegment.Private,
|
|
44
|
-
claims: {
|
|
45
|
-
'custom:tenantId': 'org_abc',
|
|
46
|
-
'custom:tenantType': 'ORG',
|
|
47
|
-
'custom:userId': 'user-1',
|
|
48
|
-
'custom:countryCode': 'CO',
|
|
49
|
-
'custom:personProfileId': 'person_1',
|
|
50
|
-
'custom:orgProfileId': 'org_abc',
|
|
51
|
-
'custom:plan': 'PRO',
|
|
52
|
-
'custom:hasCRM': 'true',
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const result = await initTenantContext(event) as NormalizedEvent;
|
|
57
|
-
|
|
58
|
-
expect(result.context.tenantInfo).toBeDefined();
|
|
59
|
-
expect(result.context.tenantInfo?.tenantId).toBe('org_abc');
|
|
60
|
-
expect(result.context.tenantInfo?.tenantType).toBe('ORG');
|
|
61
|
-
expect(result.context.tenantInfo?.countryCode).toBe('CO');
|
|
62
|
-
expect(result.context.tenantInfo?.plan).toBe('PRO');
|
|
63
|
-
expect(result.context.tenantInfo?.hasCRM).toBe('true');
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('initializes TenantContext (AsyncLocalStorage)', async () => {
|
|
67
|
-
const event = createMockEvent({
|
|
68
|
-
claims: {
|
|
69
|
-
'custom:tenantId': 'org_abc',
|
|
70
|
-
'custom:tenantType': 'ORG',
|
|
71
|
-
'custom:userId': 'user-1',
|
|
72
|
-
'custom:countryCode': 'CO',
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
await initTenantContext(event);
|
|
77
|
-
|
|
78
|
-
expect(TenantContext.isActive()).toBe(true);
|
|
79
|
-
expect(TenantContext.current().tenantId).toBe('org_abc');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('handles incomplete claims without error (returns event without tenantInfo)', async () => {
|
|
83
|
-
const event = createMockEvent({
|
|
84
|
-
claims: { 'custom:tenantId': 'org_abc' }, // missing tenantType, userId, countryCode
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const result = await initTenantContext(event) as NormalizedEvent;
|
|
88
|
-
|
|
89
|
-
expect(result.context.tenantInfo).toBeUndefined();
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe('from headers (internal/Lambda-to-Lambda)', () => {
|
|
94
|
-
it('extracts tenantInfo from x-tenant-* headers', async () => {
|
|
95
|
-
const event = createMockEvent({
|
|
96
|
-
segment: RouteSegment.Internal,
|
|
97
|
-
headers: {
|
|
98
|
-
'x-tenant-id': 'org_xyz',
|
|
99
|
-
'x-tenant-type': 'ORG',
|
|
100
|
-
'x-user-id': 'user-2',
|
|
101
|
-
'x-country-code': 'MX',
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const result = await initTenantContext(event) as NormalizedEvent;
|
|
106
|
-
|
|
107
|
-
expect(result.context.tenantInfo?.tenantId).toBe('org_xyz');
|
|
108
|
-
expect(result.context.tenantInfo?.tenantType).toBe('ORG');
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
describe('public routes (no authentication)', () => {
|
|
113
|
-
it('returns event without tenantInfo when no claims or headers', async () => {
|
|
114
|
-
const event = createMockEvent({
|
|
115
|
-
segment: RouteSegment.Public,
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const result = await initTenantContext(event) as NormalizedEvent;
|
|
119
|
-
|
|
120
|
-
expect(result.context.tenantInfo).toBeUndefined();
|
|
121
|
-
expect(TenantContext.isActive()).toBe(false);
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
describe('priority: claims > headers', () => {
|
|
126
|
-
it('uses claims if both are present', async () => {
|
|
127
|
-
const event = createMockEvent({
|
|
128
|
-
claims: {
|
|
129
|
-
'custom:tenantId': 'org_from_claims',
|
|
130
|
-
'custom:tenantType': 'ORG',
|
|
131
|
-
'custom:userId': 'user-1',
|
|
132
|
-
'custom:countryCode': 'CO',
|
|
133
|
-
},
|
|
134
|
-
headers: {
|
|
135
|
-
'x-tenant-id': 'org_from_headers',
|
|
136
|
-
'x-tenant-type': 'PERSON',
|
|
137
|
-
'x-user-id': 'user-2',
|
|
138
|
-
'x-country-code': 'MX',
|
|
139
|
-
},
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
const result = await initTenantContext(event) as NormalizedEvent;
|
|
143
|
-
|
|
144
|
-
expect(result.context.tenantInfo?.tenantId).toBe('org_from_claims');
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
});
|
|
1
|
+
import { initTenantContext } from '../../src/middleware/init-tenant-context';
|
|
2
|
+
import { TenantContext } from '../../src/tenant';
|
|
3
|
+
import { RouteSegment } from '../../src/types/event-type.enum';
|
|
4
|
+
import type { NormalizedEvent } from '../../src/types/routes';
|
|
5
|
+
|
|
6
|
+
function createMockEvent(overrides: Partial<{
|
|
7
|
+
segment: RouteSegment;
|
|
8
|
+
claims: Record<string, any>;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
userId: string;
|
|
11
|
+
groups: string[];
|
|
12
|
+
}>): NormalizedEvent {
|
|
13
|
+
return {
|
|
14
|
+
eventRaw: {},
|
|
15
|
+
eventType: 'apigateway',
|
|
16
|
+
payload: {
|
|
17
|
+
body: {},
|
|
18
|
+
pathParameters: {},
|
|
19
|
+
queryStringParameters: {},
|
|
20
|
+
headers: overrides.headers ?? {},
|
|
21
|
+
},
|
|
22
|
+
params: {},
|
|
23
|
+
context: {
|
|
24
|
+
segment: overrides.segment ?? RouteSegment.Private,
|
|
25
|
+
identity: overrides.claims ? {
|
|
26
|
+
userId: overrides.userId ?? 'user-1',
|
|
27
|
+
groups: overrides.groups ?? [],
|
|
28
|
+
claims: overrides.claims,
|
|
29
|
+
} : undefined,
|
|
30
|
+
requestId: 'req-test',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('initTenantContext', () => {
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
TenantContext._reset();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('from JWT claims (private/backoffice)', () => {
|
|
41
|
+
it('extracts tenantInfo from identity.claims and adds to context.tenantInfo', async () => {
|
|
42
|
+
const event = createMockEvent({
|
|
43
|
+
segment: RouteSegment.Private,
|
|
44
|
+
claims: {
|
|
45
|
+
'custom:tenantId': 'org_abc',
|
|
46
|
+
'custom:tenantType': 'ORG',
|
|
47
|
+
'custom:userId': 'user-1',
|
|
48
|
+
'custom:countryCode': 'CO',
|
|
49
|
+
'custom:personProfileId': 'person_1',
|
|
50
|
+
'custom:orgProfileId': 'org_abc',
|
|
51
|
+
'custom:plan': 'PRO',
|
|
52
|
+
'custom:hasCRM': 'true',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = await initTenantContext(event) as NormalizedEvent;
|
|
57
|
+
|
|
58
|
+
expect(result.context.tenantInfo).toBeDefined();
|
|
59
|
+
expect(result.context.tenantInfo?.tenantId).toBe('org_abc');
|
|
60
|
+
expect(result.context.tenantInfo?.tenantType).toBe('ORG');
|
|
61
|
+
expect(result.context.tenantInfo?.countryCode).toBe('CO');
|
|
62
|
+
expect(result.context.tenantInfo?.plan).toBe('PRO');
|
|
63
|
+
expect(result.context.tenantInfo?.hasCRM).toBe('true');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('initializes TenantContext (AsyncLocalStorage)', async () => {
|
|
67
|
+
const event = createMockEvent({
|
|
68
|
+
claims: {
|
|
69
|
+
'custom:tenantId': 'org_abc',
|
|
70
|
+
'custom:tenantType': 'ORG',
|
|
71
|
+
'custom:userId': 'user-1',
|
|
72
|
+
'custom:countryCode': 'CO',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await initTenantContext(event);
|
|
77
|
+
|
|
78
|
+
expect(TenantContext.isActive()).toBe(true);
|
|
79
|
+
expect(TenantContext.current().tenantId).toBe('org_abc');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('handles incomplete claims without error (returns event without tenantInfo)', async () => {
|
|
83
|
+
const event = createMockEvent({
|
|
84
|
+
claims: { 'custom:tenantId': 'org_abc' }, // missing tenantType, userId, countryCode
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const result = await initTenantContext(event) as NormalizedEvent;
|
|
88
|
+
|
|
89
|
+
expect(result.context.tenantInfo).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('from headers (internal/Lambda-to-Lambda)', () => {
|
|
94
|
+
it('extracts tenantInfo from x-tenant-* headers', async () => {
|
|
95
|
+
const event = createMockEvent({
|
|
96
|
+
segment: RouteSegment.Internal,
|
|
97
|
+
headers: {
|
|
98
|
+
'x-tenant-id': 'org_xyz',
|
|
99
|
+
'x-tenant-type': 'ORG',
|
|
100
|
+
'x-user-id': 'user-2',
|
|
101
|
+
'x-country-code': 'MX',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const result = await initTenantContext(event) as NormalizedEvent;
|
|
106
|
+
|
|
107
|
+
expect(result.context.tenantInfo?.tenantId).toBe('org_xyz');
|
|
108
|
+
expect(result.context.tenantInfo?.tenantType).toBe('ORG');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('public routes (no authentication)', () => {
|
|
113
|
+
it('returns event without tenantInfo when no claims or headers', async () => {
|
|
114
|
+
const event = createMockEvent({
|
|
115
|
+
segment: RouteSegment.Public,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = await initTenantContext(event) as NormalizedEvent;
|
|
119
|
+
|
|
120
|
+
expect(result.context.tenantInfo).toBeUndefined();
|
|
121
|
+
expect(TenantContext.isActive()).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('priority: claims > headers', () => {
|
|
126
|
+
it('uses claims if both are present', async () => {
|
|
127
|
+
const event = createMockEvent({
|
|
128
|
+
claims: {
|
|
129
|
+
'custom:tenantId': 'org_from_claims',
|
|
130
|
+
'custom:tenantType': 'ORG',
|
|
131
|
+
'custom:userId': 'user-1',
|
|
132
|
+
'custom:countryCode': 'CO',
|
|
133
|
+
},
|
|
134
|
+
headers: {
|
|
135
|
+
'x-tenant-id': 'org_from_headers',
|
|
136
|
+
'x-tenant-type': 'PERSON',
|
|
137
|
+
'x-user-id': 'user-2',
|
|
138
|
+
'x-country-code': 'MX',
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const result = await initTenantContext(event) as NormalizedEvent;
|
|
143
|
+
|
|
144
|
+
expect(result.context.tenantInfo?.tenantId).toBe('org_from_claims');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -1,100 +1,100 @@
|
|
|
1
|
-
import { tenantGuard } from '../../src/middleware/tenant-guard';
|
|
2
|
-
import { RouteSegment } from '../../src/types/event-type.enum';
|
|
3
|
-
import type { NormalizedEvent } from '../../src/types/routes';
|
|
4
|
-
import type { TenantInfo } from '../../src/tenant';
|
|
5
|
-
|
|
6
|
-
function createEvent(options: {
|
|
7
|
-
tenantInfo?: TenantInfo;
|
|
8
|
-
groups?: string[];
|
|
9
|
-
}): NormalizedEvent {
|
|
10
|
-
return {
|
|
11
|
-
eventRaw: {},
|
|
12
|
-
eventType: 'apigateway',
|
|
13
|
-
payload: { headers: {} },
|
|
14
|
-
params: {},
|
|
15
|
-
context: {
|
|
16
|
-
segment: RouteSegment.Private,
|
|
17
|
-
identity: {
|
|
18
|
-
userId: 'user-1',
|
|
19
|
-
groups: options.groups ?? [],
|
|
20
|
-
claims: {},
|
|
21
|
-
},
|
|
22
|
-
requestId: 'req-test',
|
|
23
|
-
tenantInfo: options.tenantInfo,
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const mockTenant: TenantInfo = {
|
|
29
|
-
tenantId: 'org_abc',
|
|
30
|
-
tenantType: 'ORG',
|
|
31
|
-
userId: 'user-1',
|
|
32
|
-
countryCode: 'CO',
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
describe('tenantGuard', () => {
|
|
36
|
-
it('allows access when tenantInfo exists', async () => {
|
|
37
|
-
const event = createEvent({ tenantInfo: mockTenant });
|
|
38
|
-
const result = await tenantGuard(event) as NormalizedEvent;
|
|
39
|
-
expect(result).toBeDefined();
|
|
40
|
-
expect(result.context.tenantInfo?.tenantId).toBe('org_abc');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('throws 403 when no tenantInfo and not PLATFORM_ADMIN', async () => {
|
|
44
|
-
const event = createEvent({ groups: ['AGENT'] });
|
|
45
|
-
await expect(tenantGuard(event)).rejects.toMatchObject({
|
|
46
|
-
statusCode: 403,
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('throws 403 when no tenantInfo and no groups', async () => {
|
|
51
|
-
const event = createEvent({});
|
|
52
|
-
await expect(tenantGuard(event)).rejects.toMatchObject({
|
|
53
|
-
statusCode: 403,
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('throws 403 when tenantId is empty string', async () => {
|
|
58
|
-
const event = createEvent({
|
|
59
|
-
tenantInfo: { ...mockTenant, tenantId: '' }
|
|
60
|
-
});
|
|
61
|
-
await expect(tenantGuard(event)).rejects.toMatchObject({
|
|
62
|
-
statusCode: 403,
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('throws 403 when tenantId is whitespace only', async () => {
|
|
67
|
-
const event = createEvent({
|
|
68
|
-
tenantInfo: { ...mockTenant, tenantId: ' ' }
|
|
69
|
-
});
|
|
70
|
-
await expect(tenantGuard(event)).rejects.toMatchObject({
|
|
71
|
-
statusCode: 403,
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('error includes TENANT_CONTEXT_MISSING code', async () => {
|
|
76
|
-
const event = createEvent({ groups: ['ORG_ADMIN'] });
|
|
77
|
-
try {
|
|
78
|
-
await tenantGuard(event);
|
|
79
|
-
fail('Should have thrown');
|
|
80
|
-
} catch (err: any) {
|
|
81
|
-
const body = JSON.parse(err.body);
|
|
82
|
-
expect(body.code).toBe('TENANT_CONTEXT_MISSING');
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('allows PLATFORM_ADMIN without tenantInfo (cross-tenant)', async () => {
|
|
87
|
-
const event = createEvent({ groups: ['PLATFORM_ADMIN'] });
|
|
88
|
-
const result = await tenantGuard(event);
|
|
89
|
-
expect(result).toBeDefined();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('allows PLATFORM_ADMIN with tenantInfo', async () => {
|
|
93
|
-
const event = createEvent({
|
|
94
|
-
tenantInfo: mockTenant,
|
|
95
|
-
groups: ['PLATFORM_ADMIN'],
|
|
96
|
-
});
|
|
97
|
-
const result = await tenantGuard(event) as NormalizedEvent;
|
|
98
|
-
expect(result.context.tenantInfo?.tenantId).toBe('org_abc');
|
|
99
|
-
});
|
|
100
|
-
});
|
|
1
|
+
import { tenantGuard } from '../../src/middleware/tenant-guard';
|
|
2
|
+
import { RouteSegment } from '../../src/types/event-type.enum';
|
|
3
|
+
import type { NormalizedEvent } from '../../src/types/routes';
|
|
4
|
+
import type { TenantInfo } from '../../src/tenant';
|
|
5
|
+
|
|
6
|
+
function createEvent(options: {
|
|
7
|
+
tenantInfo?: TenantInfo;
|
|
8
|
+
groups?: string[];
|
|
9
|
+
}): NormalizedEvent {
|
|
10
|
+
return {
|
|
11
|
+
eventRaw: {},
|
|
12
|
+
eventType: 'apigateway',
|
|
13
|
+
payload: { headers: {} },
|
|
14
|
+
params: {},
|
|
15
|
+
context: {
|
|
16
|
+
segment: RouteSegment.Private,
|
|
17
|
+
identity: {
|
|
18
|
+
userId: 'user-1',
|
|
19
|
+
groups: options.groups ?? [],
|
|
20
|
+
claims: {},
|
|
21
|
+
},
|
|
22
|
+
requestId: 'req-test',
|
|
23
|
+
tenantInfo: options.tenantInfo,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const mockTenant: TenantInfo = {
|
|
29
|
+
tenantId: 'org_abc',
|
|
30
|
+
tenantType: 'ORG',
|
|
31
|
+
userId: 'user-1',
|
|
32
|
+
countryCode: 'CO',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe('tenantGuard', () => {
|
|
36
|
+
it('allows access when tenantInfo exists', async () => {
|
|
37
|
+
const event = createEvent({ tenantInfo: mockTenant });
|
|
38
|
+
const result = await tenantGuard(event) as NormalizedEvent;
|
|
39
|
+
expect(result).toBeDefined();
|
|
40
|
+
expect(result.context.tenantInfo?.tenantId).toBe('org_abc');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('throws 403 when no tenantInfo and not PLATFORM_ADMIN', async () => {
|
|
44
|
+
const event = createEvent({ groups: ['AGENT'] });
|
|
45
|
+
await expect(tenantGuard(event)).rejects.toMatchObject({
|
|
46
|
+
statusCode: 403,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('throws 403 when no tenantInfo and no groups', async () => {
|
|
51
|
+
const event = createEvent({});
|
|
52
|
+
await expect(tenantGuard(event)).rejects.toMatchObject({
|
|
53
|
+
statusCode: 403,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('throws 403 when tenantId is empty string', async () => {
|
|
58
|
+
const event = createEvent({
|
|
59
|
+
tenantInfo: { ...mockTenant, tenantId: '' }
|
|
60
|
+
});
|
|
61
|
+
await expect(tenantGuard(event)).rejects.toMatchObject({
|
|
62
|
+
statusCode: 403,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('throws 403 when tenantId is whitespace only', async () => {
|
|
67
|
+
const event = createEvent({
|
|
68
|
+
tenantInfo: { ...mockTenant, tenantId: ' ' }
|
|
69
|
+
});
|
|
70
|
+
await expect(tenantGuard(event)).rejects.toMatchObject({
|
|
71
|
+
statusCode: 403,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('error includes TENANT_CONTEXT_MISSING code', async () => {
|
|
76
|
+
const event = createEvent({ groups: ['ORG_ADMIN'] });
|
|
77
|
+
try {
|
|
78
|
+
await tenantGuard(event);
|
|
79
|
+
fail('Should have thrown');
|
|
80
|
+
} catch (err: any) {
|
|
81
|
+
const body = JSON.parse(err.body);
|
|
82
|
+
expect(body.code).toBe('TENANT_CONTEXT_MISSING');
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('allows PLATFORM_ADMIN without tenantInfo (cross-tenant)', async () => {
|
|
87
|
+
const event = createEvent({ groups: ['PLATFORM_ADMIN'] });
|
|
88
|
+
const result = await tenantGuard(event);
|
|
89
|
+
expect(result).toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('allows PLATFORM_ADMIN with tenantInfo', async () => {
|
|
93
|
+
const event = createEvent({
|
|
94
|
+
tenantInfo: mockTenant,
|
|
95
|
+
groups: ['PLATFORM_ADMIN'],
|
|
96
|
+
});
|
|
97
|
+
const result = await tenantGuard(event) as NormalizedEvent;
|
|
98
|
+
expect(result.context.tenantInfo?.tenantId).toBe('org_abc');
|
|
99
|
+
});
|
|
100
|
+
});
|