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,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
+ });