@mettlecast/domain-cdk-packer 0.2.59 → 0.2.61
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/dist/DomainStack.js +114 -33
- package/dist/__tests__/action-construct.test.d.ts +1 -0
- package/dist/__tests__/action-construct.test.js +159 -0
- package/dist/__tests__/domain-stack.test.js +234 -1
- package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.d.ts +11 -0
- package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.js +47 -0
- package/dist/__tests__/grouped-lambda-factory.test.d.ts +1 -0
- package/dist/__tests__/grouped-lambda-factory.test.js +147 -0
- package/dist/__tests__/registry.test.js +108 -0
- package/dist/__tests__/security-assertion-aspect.test.d.ts +1 -0
- package/dist/__tests__/security-assertion-aspect.test.js +200 -0
- package/dist/aspects/index.d.ts +4 -0
- package/dist/aspects/index.js +2 -0
- package/dist/aspects/security-assertion-aspect.d.ts +191 -0
- package/dist/aspects/security-assertion-aspect.js +297 -0
- package/dist/constructs/action-construct.d.ts +16 -6
- package/dist/constructs/action-construct.js +10 -11
- package/dist/grouped-lambda-factory.d.ts +69 -0
- package/dist/grouped-lambda-factory.js +99 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/registry.d.ts +110 -2
- package/package.json +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave 7 Task 7.1 (#4619) — generated dedicated Lambda wrapper tests.
|
|
3
|
+
*
|
|
4
|
+
* The dedicated-mode bundler writes an entry module that wires a domain action
|
|
5
|
+
* export into a runtime adapter. For action adapters the wiring is non-trivial:
|
|
6
|
+
* `createActionLambdaHandler` takes `(registry, options)` and
|
|
7
|
+
* `createExposedActionApiHandler` takes `(action, options)`. These assertions
|
|
8
|
+
* use the pure entry-content helper so they do not race with other tests that
|
|
9
|
+
* also write `.tib-domain-entries` artifacts.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { buildDedicatedEntryContent } from '../grouped-lambda-factory.js';
|
|
13
|
+
const DOMAIN_ID = 'billing';
|
|
14
|
+
function render(entry, primitiveType = 'action') {
|
|
15
|
+
return buildDedicatedEntryContent('./handler.js', entry, primitiveType, DOMAIN_ID);
|
|
16
|
+
}
|
|
17
|
+
describe('grouped-lambda-factory — generated action wrapper (Wave 7 Task 7.1)', () => {
|
|
18
|
+
it('emits a createActionLambdaHandler(registry, options) wrapper for internal actions', () => {
|
|
19
|
+
const content = render({ id: 'internal-helper', handlerFile: 'src/actions/internal-helper.ts' });
|
|
20
|
+
expect(content).toMatch(/import\s*\{\s*createActionLambdaHandler\s*\}\s*from\s*['"]@mettlecast\/domain-runtime['"]/);
|
|
21
|
+
expect(content).toMatch(/const\s+registry\s*=\s*\{\s*"billing"\s*:\s*\{\s*"internal-helper"\s*:\s*internalHelper\s*\}\s*\}/);
|
|
22
|
+
expect(content).toMatch(/export\s+const\s+handler\s*=\s*createActionLambdaHandler\(\s*registry\s*,\s*\{/);
|
|
23
|
+
expect(content).toMatch(/domainId:\s*"billing"/);
|
|
24
|
+
expect(content).toMatch(/databaseUrl:\s*process\.env\.DATABASE_URL/);
|
|
25
|
+
expect(content).toMatch(/eventBusName:\s*process\.env\.EVENT_BUS_NAME/);
|
|
26
|
+
expect(content).toMatch(/idempotencyTableName:\s*process\.env\.IDEMPOTENCY_TABLE/);
|
|
27
|
+
});
|
|
28
|
+
it('emits a createExposedActionApiHandler(action, options) wrapper for API-exposed actions', () => {
|
|
29
|
+
const content = render({
|
|
30
|
+
id: 'public-charge',
|
|
31
|
+
handlerFile: 'src/actions/public-charge.ts',
|
|
32
|
+
adapter: 'createExposedActionApiHandler',
|
|
33
|
+
});
|
|
34
|
+
expect(content).toMatch(/import\s*\{\s*createExposedActionApiHandler\s*\}\s*from\s*['"]@mettlecast\/domain-runtime['"]/);
|
|
35
|
+
expect(content).toMatch(/const\s+actionRegistry\s*=\s*\{\s*"billing"\s*:\s*\{\s*"public-charge"\s*:\s*publicCharge\s*\}\s*\}/);
|
|
36
|
+
expect(content).toMatch(/export\s+const\s+handler\s*=\s*createExposedActionApiHandler\(\s*publicCharge\s*,\s*\{/);
|
|
37
|
+
expect(content).toMatch(/definingDomain:\s*"billing"/);
|
|
38
|
+
expect(content).toMatch(/domainId:\s*"billing"/);
|
|
39
|
+
expect(content).toMatch(/actionRegistry,/);
|
|
40
|
+
expect(content).toMatch(/callerDomainId:\s*"billing"/);
|
|
41
|
+
});
|
|
42
|
+
it('does NOT emit a bare `${adapter}(${exportName})` shape for any action adapter', () => {
|
|
43
|
+
const content = render({ id: 'another-internal', handlerFile: 'src/actions/another-internal.ts' });
|
|
44
|
+
expect(content).not.toMatch(/createActionLambdaHandler\(\s*anotherInternal\s*\)/);
|
|
45
|
+
expect(content).not.toMatch(/createExposedActionApiHandler\(\s*anotherInternal\s*\)/);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildDedicatedEntryContent, ADAPTER_BY_PRIMITIVE, EXPOSED_ACTION_ADAPTER, } from '../grouped-lambda-factory.js';
|
|
3
|
+
/**
|
|
4
|
+
* Wave 7 Task 7.3 (#4619) — narrow, pure tests for the dedicated-mode
|
|
5
|
+
* Lambda entry-content generator.
|
|
6
|
+
*
|
|
7
|
+
* These tests pin the adapter-selection logic added in Wave 4 Task 4.1
|
|
8
|
+
* (API-exposed actions wrap with `createExposedActionApiHandler` while
|
|
9
|
+
* internal actions keep `createActionLambdaHandler`) WITHOUT spinning up
|
|
10
|
+
* CDK or esbuild. The previous version of `domain-stack.test.ts` skipped
|
|
11
|
+
* these assertions via a runtime-export probe because the installed
|
|
12
|
+
* `@mettlecast/domain-runtime` package sometimes lags behind the
|
|
13
|
+
* integration branch. Testing the source code we generate — not the
|
|
14
|
+
* downstream package state — removes that skip and the regression-masking
|
|
15
|
+
* risk that came with it.
|
|
16
|
+
*
|
|
17
|
+
* End-to-end route-creation / authorizer-attachment behaviour is still
|
|
18
|
+
* covered by `domain-stack.test.ts` (the action-exposure block at the
|
|
19
|
+
* bottom of that file), but the heavy assertions about WHICH adapter is
|
|
20
|
+
* wired into WHICH entry now live here where they run in milliseconds.
|
|
21
|
+
*
|
|
22
|
+
* Compatibility note (Task 7.1 overlap, #4619): the test surface here
|
|
23
|
+
* deliberately targets only the parts of `buildDedicatedEntryContent`
|
|
24
|
+
* that are stable across the Wave 7.1 wrapper fixes (which change the
|
|
25
|
+
* action-adapter call shape, not the adapter selection logic). When
|
|
26
|
+
* Task 7.1 lands, this file can grow assertions for the new wrappers
|
|
27
|
+
* without rewriting the existing ones.
|
|
28
|
+
*/
|
|
29
|
+
describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
|
|
30
|
+
const DOMAIN_ID = 'billing';
|
|
31
|
+
const entry = (id, adapter) => ({
|
|
32
|
+
id,
|
|
33
|
+
handlerFile: `src/handlers/${id}.ts`,
|
|
34
|
+
...(adapter !== undefined ? { adapter } : {}),
|
|
35
|
+
});
|
|
36
|
+
describe('adapter selection by primitive type (defaults)', () => {
|
|
37
|
+
const cases = [
|
|
38
|
+
{ primitive: 'api', expectedAdapter: 'createApiLambdaHandler' },
|
|
39
|
+
{ primitive: 'subscriber', expectedAdapter: 'createSubscriberLambdaHandler' },
|
|
40
|
+
{ primitive: 'job', expectedAdapter: 'createJobLambdaHandler' },
|
|
41
|
+
{ primitive: 'webhook', expectedAdapter: 'createWebhookLambdaHandler' },
|
|
42
|
+
{ primitive: 'action', expectedAdapter: 'createActionLambdaHandler' },
|
|
43
|
+
];
|
|
44
|
+
for (const { primitive, expectedAdapter } of cases) {
|
|
45
|
+
it(`imports the default adapter for primitive type "${primitive}"`, () => {
|
|
46
|
+
const content = buildDedicatedEntryContent('./handler.js', entry('list-things'), primitive, DOMAIN_ID);
|
|
47
|
+
expect(content).toContain(`import { ${expectedAdapter} } from '@mettlecast/domain-runtime';`);
|
|
48
|
+
if (primitive === 'action') {
|
|
49
|
+
expect(content).toContain(`const registry = { "${DOMAIN_ID}": { "list-things": listThings } };`);
|
|
50
|
+
expect(content).toContain('export const handler = createActionLambdaHandler(registry, {');
|
|
51
|
+
expect(content).toContain(`domainId: "${DOMAIN_ID}"`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
expect(content).toContain(`export const handler = ${expectedAdapter}(listThings);`);
|
|
55
|
+
}
|
|
56
|
+
// Adapter map should agree with what is generated
|
|
57
|
+
expect(ADAPTER_BY_PRIMITIVE[primitive]).toBe(expectedAdapter);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
it('schedule has no entry in ADAPTER_BY_PRIMITIVE (uses hydrateCtx envelope instead)', () => {
|
|
61
|
+
// Schedules intentionally fall through to the inline `hydrateCtx`
|
|
62
|
+
// wrapper path (see buildDedicatedEntryContent's schedule branch).
|
|
63
|
+
// Pinning that here guards against future refactors silently
|
|
64
|
+
// promoting schedules into the adapter-map path.
|
|
65
|
+
const keys = Object.keys(ADAPTER_BY_PRIMITIVE);
|
|
66
|
+
expect(keys).not.toContain('schedule');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('action adapter override (Wave 4 Task 4.1, #4619)', () => {
|
|
70
|
+
it('uses createExposedActionApiHandler when entry.adapter is the exposed-action adapter', () => {
|
|
71
|
+
const apiExposed = entry('list-invoices', EXPOSED_ACTION_ADAPTER);
|
|
72
|
+
const content = buildDedicatedEntryContent('./handler.js', apiExposed, 'action', DOMAIN_ID);
|
|
73
|
+
expect(content).toContain(`import { ${EXPOSED_ACTION_ADAPTER} } from '@mettlecast/domain-runtime';`);
|
|
74
|
+
expect(content).toContain(`const actionRegistry = { "${DOMAIN_ID}": { "list-invoices": listInvoices } };`);
|
|
75
|
+
expect(content).toContain(`export const handler = ${EXPOSED_ACTION_ADAPTER}(listInvoices, {`);
|
|
76
|
+
expect(content).toContain(`definingDomain: "${DOMAIN_ID}"`);
|
|
77
|
+
expect(content).toContain('actionRegistry,');
|
|
78
|
+
// Must NOT also import the envelope-based adapter — that would be
|
|
79
|
+
// dead code and risks accidental misuse if the adapter selection ever
|
|
80
|
+
// regresses.
|
|
81
|
+
expect(content).not.toContain('createActionLambdaHandler');
|
|
82
|
+
});
|
|
83
|
+
it('uses createActionLambdaHandler when no adapter override is supplied (internal action)', () => {
|
|
84
|
+
const internal = entry('internal-helper');
|
|
85
|
+
const content = buildDedicatedEntryContent('./handler.js', internal, 'action', DOMAIN_ID);
|
|
86
|
+
expect(content).toContain(`import { createActionLambdaHandler } from '@mettlecast/domain-runtime';`);
|
|
87
|
+
expect(content).toContain(`const registry = { "${DOMAIN_ID}": { "internal-helper": internalHelper } };`);
|
|
88
|
+
expect(content).toContain('export const handler = createActionLambdaHandler(registry, {');
|
|
89
|
+
expect(content).toContain(`domainId: "${DOMAIN_ID}"`);
|
|
90
|
+
// Must NOT use the exposed adapter for internal actions.
|
|
91
|
+
expect(content).not.toContain(EXPOSED_ACTION_ADAPTER);
|
|
92
|
+
});
|
|
93
|
+
it('per-entry adapter override takes precedence over the primitive-type default', () => {
|
|
94
|
+
// Even if primitiveType were 'api', an explicit adapter override wins.
|
|
95
|
+
const overridden = entry('charge-card', EXPOSED_ACTION_ADAPTER);
|
|
96
|
+
const content = buildDedicatedEntryContent('./handler.js', overridden, 'api', DOMAIN_ID);
|
|
97
|
+
expect(content).toContain(`import { ${EXPOSED_ACTION_ADAPTER} } from '@mettlecast/domain-runtime';`);
|
|
98
|
+
expect(content).not.toContain('createApiLambdaHandler');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe('schedule envelope', () => {
|
|
102
|
+
it('uses hydrateCtx (no per-primitive adapter) for schedules', () => {
|
|
103
|
+
const schedule = entry('nightly-rollup');
|
|
104
|
+
const content = buildDedicatedEntryContent('./handler.js', schedule, 'schedule', DOMAIN_ID);
|
|
105
|
+
expect(content).toContain(`import { hydrateCtx } from '@mettlecast/domain-runtime';`);
|
|
106
|
+
expect(content).toContain('hydrateCtx(event, {');
|
|
107
|
+
expect(content).toContain('databaseUrl: process.env.DATABASE_URL');
|
|
108
|
+
expect(content).toContain('eventBusName: process.env.EVENT_BUS_NAME');
|
|
109
|
+
expect(content).toContain('try { await ctx.db.release(); } catch {}');
|
|
110
|
+
// Schedules should NOT use any of the Lambda-handler adapter factories.
|
|
111
|
+
expect(content).not.toMatch(/import \{ \w+LambdaHandler \}/);
|
|
112
|
+
});
|
|
113
|
+
it('does NOT honour entry.adapter for schedules — hydrateCtx is the only path', () => {
|
|
114
|
+
const schedule = entry('nightly-rollup', EXPOSED_ACTION_ADAPTER);
|
|
115
|
+
const content = buildDedicatedEntryContent('./handler.js', schedule, 'schedule', DOMAIN_ID);
|
|
116
|
+
expect(content).toContain(`import { hydrateCtx } from '@mettlecast/domain-runtime';`);
|
|
117
|
+
expect(content).not.toContain(EXPOSED_ACTION_ADAPTER);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe('handler import path wiring', () => {
|
|
121
|
+
it('uses the caller-supplied relative import path verbatim', () => {
|
|
122
|
+
const e = entry('list-things');
|
|
123
|
+
const content = buildDedicatedEntryContent('../../../../src/handlers/list-things.ts', e, 'api', DOMAIN_ID);
|
|
124
|
+
expect(content).toContain(`import { listThings } from '../../../../src/handlers/list-things.ts';`);
|
|
125
|
+
});
|
|
126
|
+
it('converts kebab-case handler IDs to camelCase export names', () => {
|
|
127
|
+
const e = entry('list-invoices-for-tenant');
|
|
128
|
+
const content = buildDedicatedEntryContent('./handler.js', e, 'action', DOMAIN_ID);
|
|
129
|
+
expect(content).toMatch(/import \{ listInvoicesForTenant \} from/);
|
|
130
|
+
expect(content).toContain(`"list-invoices-for-tenant": listInvoicesForTenant`);
|
|
131
|
+
expect(content).toContain('createActionLambdaHandler(registry, {');
|
|
132
|
+
});
|
|
133
|
+
it('emits a single export const handler per entry', () => {
|
|
134
|
+
const e = entry('list-things');
|
|
135
|
+
const content = buildDedicatedEntryContent('./handler.js', e, 'api', DOMAIN_ID);
|
|
136
|
+
const matches = content.match(/^export const handler\b/gm) ?? [];
|
|
137
|
+
expect(matches.length).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe('EXPOSED_ACTION_ADAPTER constant', () => {
|
|
141
|
+
it('matches the Wave 3 / Wave 4 exported name', () => {
|
|
142
|
+
// Pinning the literal value guards against silent rename during future
|
|
143
|
+
// refactors of the runtime barrel.
|
|
144
|
+
expect(EXPOSED_ACTION_ADAPTER).toBe('createExposedActionApiHandler');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -32,3 +32,111 @@ describe('DomainRegistry type', () => {
|
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
|
+
/**
|
|
36
|
+
* Action-first migration (#4619, Wave 1 Task 1.2):
|
|
37
|
+
*
|
|
38
|
+
* ActionRegistryEntry must surface `backendAccess` and `exposure` so the
|
|
39
|
+
* registry builder can carry them through to CDK/contract generation.
|
|
40
|
+
* The legacy `visibility` field stays optional during the migration window.
|
|
41
|
+
*/
|
|
42
|
+
describe('ActionRegistryEntry type (action-first migration)', () => {
|
|
43
|
+
it('requires backendAccess and exposure on every action entry', () => {
|
|
44
|
+
const entry = {
|
|
45
|
+
id: 'charge-card',
|
|
46
|
+
kind: 'action',
|
|
47
|
+
handlerFile: 'src/actions/charge-card.ts',
|
|
48
|
+
backendAccess: 'domain',
|
|
49
|
+
exposure: { type: 'internal' },
|
|
50
|
+
idempotent: true,
|
|
51
|
+
};
|
|
52
|
+
expect(entry.backendAccess).toBe('domain');
|
|
53
|
+
expect(entry.exposure).toEqual({ type: 'internal' });
|
|
54
|
+
});
|
|
55
|
+
it('accepts the full backendAccess scope union', () => {
|
|
56
|
+
const scopes = ['private', 'domain', 'platform'];
|
|
57
|
+
for (const backendAccess of scopes) {
|
|
58
|
+
const entry = {
|
|
59
|
+
id: `a-${backendAccess}`,
|
|
60
|
+
kind: 'action',
|
|
61
|
+
handlerFile: 'a.ts',
|
|
62
|
+
backendAccess,
|
|
63
|
+
exposure: { type: 'internal' },
|
|
64
|
+
idempotent: false,
|
|
65
|
+
};
|
|
66
|
+
expect(entry.backendAccess).toBe(backendAccess);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
it('accepts an api exposure with all optional metadata', () => {
|
|
70
|
+
const exposure = {
|
|
71
|
+
type: 'api',
|
|
72
|
+
path: '/v1/tenants/{tenantId}/billing/invoices',
|
|
73
|
+
method: 'POST',
|
|
74
|
+
auth: 'required',
|
|
75
|
+
tenancy: 'required',
|
|
76
|
+
roles: ['billing-admin'],
|
|
77
|
+
securityException: { reason: 'bootstrap path before tenant exists' },
|
|
78
|
+
};
|
|
79
|
+
const entry = {
|
|
80
|
+
id: 'create-invoice',
|
|
81
|
+
kind: 'action',
|
|
82
|
+
handlerFile: 'src/actions/create-invoice.ts',
|
|
83
|
+
backendAccess: 'domain',
|
|
84
|
+
exposure,
|
|
85
|
+
idempotent: true,
|
|
86
|
+
};
|
|
87
|
+
expect(entry.exposure.type).toBe('api');
|
|
88
|
+
if (entry.exposure.type === 'api') {
|
|
89
|
+
expect(entry.exposure.path).toContain('{tenantId}');
|
|
90
|
+
expect(entry.exposure.method).toBe('POST');
|
|
91
|
+
expect(entry.exposure.auth).toBe('required');
|
|
92
|
+
expect(entry.exposure.tenancy).toBe('required');
|
|
93
|
+
expect(entry.exposure.roles).toEqual(['billing-admin']);
|
|
94
|
+
expect(entry.exposure.securityException?.reason).toMatch(/bootstrap/);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
it('discriminates the ActionExposure union by type', () => {
|
|
98
|
+
const samples = [
|
|
99
|
+
{ type: 'internal' },
|
|
100
|
+
{ type: 'api', path: '/v1/x', method: 'GET', auth: 'none', tenancy: 'none' },
|
|
101
|
+
];
|
|
102
|
+
for (const exposure of samples) {
|
|
103
|
+
if (exposure.type === 'api') {
|
|
104
|
+
expect(exposure.path).toBeDefined();
|
|
105
|
+
expect(exposure.method).toBe('GET');
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
expect(exposure.type).toBe('internal');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
it('keeps legacy visibility as an optional field during the migration', () => {
|
|
113
|
+
// The legacy field must remain present-but-optional on the type so
|
|
114
|
+
// existing CDK constructs (e.g. action-construct.ts) keep compiling
|
|
115
|
+
// until they are updated to consume backendAccess directly.
|
|
116
|
+
const entry = {
|
|
117
|
+
id: 'legacy',
|
|
118
|
+
kind: 'action',
|
|
119
|
+
handlerFile: 'src/actions/legacy.ts',
|
|
120
|
+
backendAccess: 'private',
|
|
121
|
+
exposure: { type: 'internal' },
|
|
122
|
+
visibility: 'workspace',
|
|
123
|
+
idempotent: false,
|
|
124
|
+
};
|
|
125
|
+
expect(entry.visibility).toBe('workspace');
|
|
126
|
+
expect(entry.backendAccess).toBe('private');
|
|
127
|
+
});
|
|
128
|
+
it('preserves input/output schema snapshot fields', () => {
|
|
129
|
+
const entry = {
|
|
130
|
+
id: 'with-schemas',
|
|
131
|
+
kind: 'action',
|
|
132
|
+
handlerFile: 'src/actions/with-schemas.ts',
|
|
133
|
+
backendAccess: 'domain',
|
|
134
|
+
exposure: { type: 'internal' },
|
|
135
|
+
idempotent: false,
|
|
136
|
+
inputSchema: { type: 'object', properties: { amount: { type: 'number' } } },
|
|
137
|
+
outputSchema: { type: 'object', properties: { id: { type: 'string' } } },
|
|
138
|
+
};
|
|
139
|
+
expect(entry.inputSchema).toBeDefined();
|
|
140
|
+
expect(entry.outputSchema).toBeDefined();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import * as cdk from 'aws-cdk-lib';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { DomainStack } from '../DomainStack.js';
|
|
6
|
+
const DOMAIN_ROOT = path.join(process.cwd(), '.test-security-aspect');
|
|
7
|
+
const baseRegistry = {
|
|
8
|
+
schemaVersion: '1',
|
|
9
|
+
domainRoot: DOMAIN_ROOT,
|
|
10
|
+
domain: { id: 'sec-domain', kind: 'domain', name: 'Sec Domain', tenancy: 'none' },
|
|
11
|
+
apis: [],
|
|
12
|
+
webhooks: [],
|
|
13
|
+
subscribers: [],
|
|
14
|
+
schedules: [],
|
|
15
|
+
jobs: [],
|
|
16
|
+
actions: [],
|
|
17
|
+
integrations: [],
|
|
18
|
+
events: [],
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Build a stack, force synth, and return its error annotations so each
|
|
22
|
+
* test can assert specifically on the codes emitted by
|
|
23
|
+
* `SecurityAssertionAspect`. CDK aspects only run during synth, so the
|
|
24
|
+
* helper must call `app.synth()`. Annotations attached by aspects to
|
|
25
|
+
* child constructs (e.g. a `CfnRoute`) live on the child's metadata,
|
|
26
|
+
* not the stack's — we walk the construct tree to aggregate them.
|
|
27
|
+
*/
|
|
28
|
+
function buildStackAndGetErrors(registry) {
|
|
29
|
+
const app = new cdk.App();
|
|
30
|
+
const stack = new DomainStack(app, 'SecAspectStack', {
|
|
31
|
+
registry,
|
|
32
|
+
eventBusArn: 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus',
|
|
33
|
+
});
|
|
34
|
+
// Force synthesis so the aspect's visit() fires.
|
|
35
|
+
try {
|
|
36
|
+
app.synth();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Synth may throw when the aspect emits a hard error; we still want
|
|
40
|
+
// to read the metadata so the assertion can find the error code.
|
|
41
|
+
}
|
|
42
|
+
const errorMessages = [];
|
|
43
|
+
const collect = (construct) => {
|
|
44
|
+
for (const m of construct.node.metadata) {
|
|
45
|
+
if (m.type === 'aws:cdk:error') {
|
|
46
|
+
errorMessages.push(typeof m.data === 'string' ? m.data : JSON.stringify(m.data));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const child of construct.node.children) {
|
|
50
|
+
collect(child);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
collect(stack);
|
|
54
|
+
return { stack, errorMessages };
|
|
55
|
+
}
|
|
56
|
+
beforeAll(() => {
|
|
57
|
+
const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
|
|
58
|
+
fs.mkdirSync(handlerDir, { recursive: true });
|
|
59
|
+
// The CDK grouped-lambda-factory derives the named export from the
|
|
60
|
+
// entry id (`list-users` -> `listUsers`). We provide every named export
|
|
61
|
+
// the security-aspect test uses so each test can pick the same handler
|
|
62
|
+
// file regardless of which entry id it instantiates.
|
|
63
|
+
fs.writeFileSync(path.join(handlerDir, 'noop.ts'), [
|
|
64
|
+
'export const listUsers = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };',
|
|
65
|
+
'export const listAll = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };',
|
|
66
|
+
'export const noopAction = { id: "noop-action", backendAccess: "private", exposure: { type: "internal" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };',
|
|
67
|
+
].join('\n') + '\n');
|
|
68
|
+
});
|
|
69
|
+
afterAll(() => {
|
|
70
|
+
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* Issue #4662 Task D — deployment-time assertions. The CDK aspect is the
|
|
74
|
+
* last line of defence: it walks the synthesised tree and verifies the
|
|
75
|
+
* invariants the CLI validator already enforces. These tests pin each
|
|
76
|
+
* invariant independently so a regression in any single check is caught.
|
|
77
|
+
*/
|
|
78
|
+
describe('SecurityAssertionAspect (#4662 Task D)', () => {
|
|
79
|
+
it('does not emit errors for an empty registry', () => {
|
|
80
|
+
const { errorMessages } = buildStackAndGetErrors(baseRegistry);
|
|
81
|
+
// The base stack emits zero routes / zero Function URLs, so the aspect
|
|
82
|
+
// should have nothing to complain about.
|
|
83
|
+
const aspectCodes = ['SECURITY_MISSING_JWT_AUTHORIZER', 'SECURITY_MISSING_TENANT_PATH', 'SECURITY_ACTION_FUNCTION_URL', 'SECURITY_MISSING_SECURITY_EXCEPTION'];
|
|
84
|
+
expect(errorMessages.filter(m => aspectCodes.some(c => m.includes(c)))).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
it('emits SECURITY_MISSING_TENANT_PATH when an API path lacks the tenant placeholder', () => {
|
|
87
|
+
const registry = {
|
|
88
|
+
...baseRegistry,
|
|
89
|
+
apis: [
|
|
90
|
+
{ id: 'list-users', kind: 'api', handlerFile: 'src/handlers/noop.ts', path: '/users', method: 'GET', authType: 'jwt' },
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
94
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_TENANT_PATH') && m.includes('list-users'))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
it('does NOT emit SECURITY_MISSING_TENANT_PATH when the path includes the placeholder', () => {
|
|
97
|
+
const registry = {
|
|
98
|
+
...baseRegistry,
|
|
99
|
+
apis: [
|
|
100
|
+
{ id: 'list-users', kind: 'api', handlerFile: 'src/handlers/noop.ts', path: '/v1/tenants/{tenantId}/users', method: 'GET', authType: 'jwt' },
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
// We expect the JWT authorizer error (because no Cognito is provided),
|
|
104
|
+
// but NOT the tenant path error.
|
|
105
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
106
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_TENANT_PATH'))).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
it('emits SECURITY_MISSING_JWT_AUTHORIZER when JWT API is declared without Cognito config', () => {
|
|
109
|
+
const registry = {
|
|
110
|
+
...baseRegistry,
|
|
111
|
+
apis: [
|
|
112
|
+
{ id: 'list-users', kind: 'api', handlerFile: 'src/handlers/noop.ts', path: '/v1/tenants/{tenantId}/users', method: 'GET', authType: 'jwt' },
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
116
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_JWT_AUTHORIZER') || m.includes('JWT-protected routes'))).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
it('does NOT emit SECURITY_MISSING_SECURITY_EXCEPTION when authType is none but the route is the health probe', () => {
|
|
119
|
+
// The DomainStack installs the health/ready probes as `authType: 'none'` routes.
|
|
120
|
+
// Because they are special-cased in the aspect (and CLI validator
|
|
121
|
+
// ignores them), the aspect must NOT fire for them.
|
|
122
|
+
const registry = {
|
|
123
|
+
...baseRegistry,
|
|
124
|
+
apis: [],
|
|
125
|
+
};
|
|
126
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
127
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_SECURITY_EXCEPTION'))).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
it('does NOT emit Function URL error for the existing health/ready probes', () => {
|
|
130
|
+
// HealthConstruct attaches `Lambda::Url` to its functions. The aspect
|
|
131
|
+
// exempts them via fn-name regex; we must not regress that exemption.
|
|
132
|
+
const registry = { ...baseRegistry };
|
|
133
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
134
|
+
// HealthConstruct uses NodejsFunction which DOES NOT create Lambda::Url
|
|
135
|
+
// (URLs are only created when fn.addFunctionUrl is called). So there
|
|
136
|
+
// should be no Function URL errors at all on the base stack.
|
|
137
|
+
expect(errorMessages.some(m => m.includes('SECURITY_ACTION_FUNCTION_URL'))).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
it('emits SECURITY_MISSING_TENANT_PATH for an action exposure with tenancy=required but bad path', () => {
|
|
140
|
+
const registry = {
|
|
141
|
+
...baseRegistry,
|
|
142
|
+
actions: [
|
|
143
|
+
{
|
|
144
|
+
id: 'list-all',
|
|
145
|
+
kind: 'action',
|
|
146
|
+
handlerFile: 'src/handlers/noop.ts',
|
|
147
|
+
backendAccess: 'domain',
|
|
148
|
+
exposure: { type: 'api', path: '/v1/admin/users', method: 'GET', auth: 'required', tenancy: 'required' },
|
|
149
|
+
idempotent: false,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
154
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_TENANT_PATH') && m.includes('list-all'))).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
it('emits SECURITY_MISSING_JWT_AUTHORIZER for an action exposure with auth=required but no Cognito', () => {
|
|
157
|
+
const registry = {
|
|
158
|
+
...baseRegistry,
|
|
159
|
+
actions: [
|
|
160
|
+
{
|
|
161
|
+
id: 'list-users',
|
|
162
|
+
kind: 'action',
|
|
163
|
+
handlerFile: 'src/handlers/noop.ts',
|
|
164
|
+
backendAccess: 'domain',
|
|
165
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
|
|
166
|
+
idempotent: false,
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
171
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_JWT_AUTHORIZER') && m.includes('list-users'))).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
it('does NOT emit SECURITY_MISSING_SECURITY_EXCEPTION for an action with auth=none AND securityException.reason', () => {
|
|
174
|
+
const registry = {
|
|
175
|
+
...baseRegistry,
|
|
176
|
+
actions: [
|
|
177
|
+
{
|
|
178
|
+
id: 'noop-action',
|
|
179
|
+
kind: 'action',
|
|
180
|
+
handlerFile: 'src/handlers/noop.ts',
|
|
181
|
+
backendAccess: 'platform',
|
|
182
|
+
exposure: {
|
|
183
|
+
type: 'api',
|
|
184
|
+
path: '/v1/health',
|
|
185
|
+
method: 'GET',
|
|
186
|
+
auth: 'none',
|
|
187
|
+
tenancy: 'none',
|
|
188
|
+
securityException: { reason: 'public liveness probe; ticket OPS-123' },
|
|
189
|
+
},
|
|
190
|
+
idempotent: false,
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
const { errorMessages } = buildStackAndGetErrors(registry);
|
|
195
|
+
// Note: the CLI validator's AUTH_NONE_REQUIRES_EXCEPTION rule mirrors
|
|
196
|
+
// this; we only check that the *aspect* does not block a well-formed
|
|
197
|
+
// action.
|
|
198
|
+
expect(errorMessages.some(m => m.includes('SECURITY_MISSING_SECURITY_EXCEPTION'))).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
package/dist/aspects/index.d.ts
CHANGED
|
@@ -3,3 +3,7 @@ export type { TibTaggingAspectProps } from './tagging-aspect.js';
|
|
|
3
3
|
export { LogRetentionAspect } from './log-retention-aspect.js';
|
|
4
4
|
export { IamBoundariesAspect } from './iam-boundaries-aspect.js';
|
|
5
5
|
export type { IamBoundariesAspectProps } from './iam-boundaries-aspect.js';
|
|
6
|
+
export { SecurityAssertionAspect } from './security-assertion-aspect.js';
|
|
7
|
+
export type { SecurityAssertionAspectProps } from './security-assertion-aspect.js';
|
|
8
|
+
export { SECURITY_ASSERTION_CODES } from './security-assertion-aspect.js';
|
|
9
|
+
export type { SecurityAssertionCode } from './security-assertion-aspect.js';
|
package/dist/aspects/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { TibTaggingAspect } from './tagging-aspect.js';
|
|
2
2
|
export { LogRetentionAspect } from './log-retention-aspect.js';
|
|
3
3
|
export { IamBoundariesAspect } from './iam-boundaries-aspect.js';
|
|
4
|
+
export { SecurityAssertionAspect } from './security-assertion-aspect.js';
|
|
5
|
+
export { SECURITY_ASSERTION_CODES } from './security-assertion-aspect.js';
|