@mettlecast/domain-cdk-packer 0.2.60 → 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.
@@ -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
+ });
@@ -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';
@@ -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';