@mettlecast/domain-cli 0.2.85 → 0.2.87
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/builder/build-registry.d.ts +8 -2
- package/dist/builder/build-registry.js +80 -88
- package/dist/commands/build-catalog.js +2 -3
- package/dist/commands/update-all.d.ts +1 -0
- package/dist/commands/update-all.js +174 -0
- package/dist/commands/validate.js +26 -59
- package/dist/utils/toolchain-manifest.d.ts +90 -0
- package/dist/utils/toolchain-manifest.js +144 -0
- package/package.json +3 -3
- package/scripts/generate-toolchain-manifest.mjs +163 -0
- package/src/__tests__/build-registry.test.ts +125 -3
- package/src/__tests__/commands/build-catalog.test.ts +89 -0
- package/src/__tests__/commands/update-all.test.ts +341 -3
- package/src/__tests__/package-freshness.test.ts +8 -4
- package/src/__tests__/toolchain-manifest.test.ts +185 -0
- package/src/__tests__/validate.test.ts +183 -108
- package/src/builder/build-registry.ts +116 -92
- package/src/commands/build-catalog.ts +2 -3
- package/src/commands/update-all.ts +216 -0
- package/src/commands/validate.ts +29 -70
- package/src/utils/toolchain-manifest.ts +236 -0
- package/toolchain-manifest.json +10 -0
|
@@ -31,22 +31,24 @@ describe('runValidate error shape', () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Action-first migration (#4619,
|
|
34
|
+
* Action-first migration (#4619, #5090):
|
|
35
35
|
*
|
|
36
36
|
* `runValidate` ultimately delegates to `buildRegistry` for the
|
|
37
37
|
* registry assembly. These tests exercise the action mapping directly
|
|
38
38
|
* through `buildRegistry` (mocked file walker + module loader) so we
|
|
39
|
-
* catch regressions in how
|
|
40
|
-
*
|
|
39
|
+
* catch regressions in how exposure is validated.
|
|
40
|
+
*
|
|
41
|
+
* Issue #5090 — strict registry contract: every action must declare
|
|
42
|
+
* exposure, backendAccess, and idempotent explicitly. No legacy defaults.
|
|
41
43
|
*/
|
|
42
|
-
describe('buildRegistry action mapping (
|
|
44
|
+
describe('buildRegistry action mapping (strict contract #5090)', () => {
|
|
43
45
|
const DOMAIN_ROOT = '/fake/domains/billing';
|
|
44
46
|
|
|
45
47
|
beforeEach(() => {
|
|
46
48
|
vi.clearAllMocks();
|
|
47
49
|
});
|
|
48
50
|
|
|
49
|
-
it('reads backendAccess directly when
|
|
51
|
+
it('reads backendAccess and exposure directly when action declares both', async () => {
|
|
50
52
|
mockWalkDomainDir.mockResolvedValue({
|
|
51
53
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
52
54
|
webhooks: [],
|
|
@@ -93,15 +95,12 @@ describe('buildRegistry action mapping (action-first migration)', () => {
|
|
|
93
95
|
}
|
|
94
96
|
});
|
|
95
97
|
|
|
96
|
-
it('
|
|
98
|
+
it('preserves inputSchema and outputSchema extraction', async () => {
|
|
97
99
|
mockWalkDomainDir.mockResolvedValue({
|
|
98
100
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
99
101
|
webhooks: [],
|
|
100
102
|
subscribers: [],
|
|
101
|
-
actions: [
|
|
102
|
-
'/fake/domains/billing/actions/verify-token.ts',
|
|
103
|
-
'/fake/domains/billing/actions/provision-tenant.ts',
|
|
104
|
-
],
|
|
103
|
+
actions: ['/fake/domains/billing/actions/charge-card.ts'],
|
|
105
104
|
schedules: [],
|
|
106
105
|
jobs: [],
|
|
107
106
|
integrations: [],
|
|
@@ -111,24 +110,26 @@ describe('buildRegistry action mapping (action-first migration)', () => {
|
|
|
111
110
|
.mockResolvedValueOnce([
|
|
112
111
|
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
113
112
|
])
|
|
114
|
-
// First call resolves the actions array (Promise.all maps in order).
|
|
115
113
|
.mockResolvedValueOnce([
|
|
116
|
-
{
|
|
117
|
-
|
|
114
|
+
{
|
|
115
|
+
_kind: 'action',
|
|
116
|
+
_exportName: 'chargeCard',
|
|
117
|
+
id: 'charge-card',
|
|
118
|
+
backendAccess: 'domain',
|
|
119
|
+
exposure: { type: 'internal' },
|
|
120
|
+
idempotent: true,
|
|
121
|
+
input: { type: 'object', properties: { amount: { type: 'number' } } },
|
|
122
|
+
output: { type: 'object', properties: { id: { type: 'string' } } },
|
|
123
|
+
},
|
|
118
124
|
]);
|
|
119
125
|
|
|
120
126
|
const { registry } = await buildRegistry(DOMAIN_ROOT);
|
|
121
127
|
|
|
122
|
-
expect(registry.actions).
|
|
123
|
-
|
|
124
|
-
const provision = registry.actions.find(a => a.id === 'provision-tenant');
|
|
125
|
-
expect(verify?.backendAccess).toBe('domain');
|
|
126
|
-
expect(verify?.exposure).toEqual({ type: 'internal' });
|
|
127
|
-
expect(provision?.backendAccess).toBe('domain');
|
|
128
|
-
expect(provision?.exposure).toEqual({ type: 'internal' });
|
|
128
|
+
expect(registry.actions[0]?.inputSchema).toEqual({ type: 'object', properties: { amount: { type: 'number' } } });
|
|
129
|
+
expect(registry.actions[0]?.outputSchema).toEqual({ type: 'object', properties: { id: { type: 'string' } } });
|
|
129
130
|
});
|
|
130
131
|
|
|
131
|
-
it('
|
|
132
|
+
it('throws on missing exposure (no legacy default) (#5090)', async () => {
|
|
132
133
|
mockWalkDomainDir.mockResolvedValue({
|
|
133
134
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
134
135
|
webhooks: [],
|
|
@@ -144,21 +145,45 @@ describe('buildRegistry action mapping (action-first migration)', () => {
|
|
|
144
145
|
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
145
146
|
])
|
|
146
147
|
.mockResolvedValueOnce([
|
|
147
|
-
{ _kind: 'action', _exportName: 'legacy', id: 'legacy',
|
|
148
|
+
{ _kind: 'action', _exportName: 'legacy', id: 'legacy', backendAccess: 'private', idempotent: false },
|
|
148
149
|
]);
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
await expect(buildRegistry(DOMAIN_ROOT)).rejects.toThrow(/exposure/i);
|
|
152
|
+
});
|
|
151
153
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
it('throws on missing backendAccess (no legacy visibility fallback) (#5090)', async () => {
|
|
155
|
+
mockWalkDomainDir.mockResolvedValue({
|
|
156
|
+
domain: '/fake/domains/billing/domain.config.ts',
|
|
157
|
+
webhooks: [],
|
|
158
|
+
subscribers: [],
|
|
159
|
+
actions: ['/fake/domains/billing/actions/no-backend.ts'],
|
|
160
|
+
schedules: [],
|
|
161
|
+
jobs: [],
|
|
162
|
+
integrations: [],
|
|
163
|
+
publishes: undefined,
|
|
164
|
+
});
|
|
165
|
+
mockLoadModuleExports
|
|
166
|
+
.mockResolvedValueOnce([
|
|
167
|
+
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
168
|
+
])
|
|
169
|
+
.mockResolvedValueOnce([
|
|
170
|
+
{
|
|
171
|
+
_kind: 'action', _exportName: 'noBackend',
|
|
172
|
+
id: 'no-backend',
|
|
173
|
+
exposure: { type: 'internal' },
|
|
174
|
+
idempotent: false,
|
|
175
|
+
},
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
await expect(buildRegistry(DOMAIN_ROOT)).rejects.toThrow(/backendAccess/i);
|
|
154
179
|
});
|
|
155
180
|
|
|
156
|
-
it('
|
|
181
|
+
it('rejects internal exposure with extra keys (#5090)', async () => {
|
|
157
182
|
mockWalkDomainDir.mockResolvedValue({
|
|
158
183
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
159
184
|
webhooks: [],
|
|
160
185
|
subscribers: [],
|
|
161
|
-
actions: ['/fake/domains/billing/actions/
|
|
186
|
+
actions: ['/fake/domains/billing/actions/internal-extra.ts'],
|
|
162
187
|
schedules: [],
|
|
163
188
|
jobs: [],
|
|
164
189
|
integrations: [],
|
|
@@ -170,34 +195,59 @@ describe('buildRegistry action mapping (action-first migration)', () => {
|
|
|
170
195
|
])
|
|
171
196
|
.mockResolvedValueOnce([
|
|
172
197
|
{
|
|
173
|
-
_kind: 'action',
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
198
|
+
_kind: 'action', _exportName: 'internalExtra',
|
|
199
|
+
id: 'internal-extra', backendAccess: 'private',
|
|
200
|
+
exposure: { type: 'internal', path: '/v1/secret' },
|
|
201
|
+
idempotent: false,
|
|
202
|
+
},
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
await expect(buildRegistry(DOMAIN_ROOT)).rejects.toThrow(/unexpected keys/i);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('accepts a clean internal action with all required fields (#5090)', async () => {
|
|
209
|
+
mockWalkDomainDir.mockResolvedValue({
|
|
210
|
+
domain: '/fake/domains/billing/domain.config.ts',
|
|
211
|
+
webhooks: [],
|
|
212
|
+
subscribers: [],
|
|
213
|
+
actions: ['/fake/domains/billing/actions/clean.ts'],
|
|
214
|
+
schedules: [],
|
|
215
|
+
jobs: [],
|
|
216
|
+
integrations: [],
|
|
217
|
+
publishes: undefined,
|
|
218
|
+
});
|
|
219
|
+
mockLoadModuleExports
|
|
220
|
+
.mockResolvedValueOnce([
|
|
221
|
+
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
222
|
+
])
|
|
223
|
+
.mockResolvedValueOnce([
|
|
224
|
+
{
|
|
225
|
+
_kind: 'action', _exportName: 'clean',
|
|
226
|
+
id: 'clean', backendAccess: 'private',
|
|
177
227
|
exposure: { type: 'internal' },
|
|
178
|
-
idempotent:
|
|
179
|
-
input: { type: 'object', properties: { amount: { type: 'number' } } },
|
|
180
|
-
output: { type: 'object', properties: { id: { type: 'string' } } },
|
|
228
|
+
idempotent: false,
|
|
181
229
|
},
|
|
182
230
|
]);
|
|
183
231
|
|
|
184
232
|
const { registry } = await buildRegistry(DOMAIN_ROOT);
|
|
185
|
-
|
|
186
|
-
expect(registry.actions[0]
|
|
187
|
-
expect(registry.actions[0]?.outputSchema).toEqual({ type: 'object', properties: { id: { type: 'string' } } });
|
|
233
|
+
expect(registry.actions).toHaveLength(1);
|
|
234
|
+
expect(registry.actions[0]!.exposure).toEqual({ type: 'internal' });
|
|
188
235
|
});
|
|
189
236
|
});
|
|
190
237
|
|
|
191
238
|
/**
|
|
192
|
-
* Action-first security validation rules (#4619, Wave 6 Task 6.1).
|
|
239
|
+
* Action-first security validation rules (#4619, Wave 6 Task 6.1, #5090).
|
|
193
240
|
*
|
|
194
|
-
* These tests exercise the
|
|
241
|
+
* These tests exercise the validation gates added to `runValidate`.
|
|
195
242
|
* Each test mocks `buildRegistry` consumers so the assertions target the
|
|
196
243
|
* rule codes (`ACTION_EXPOSURE_REQUIRED`, `TENANT_API_PATH_REQUIRED`,
|
|
197
244
|
* `AUTH_NONE_REQUIRES_EXCEPTION`, etc.) without needing a real
|
|
198
245
|
* domain on disk.
|
|
246
|
+
*
|
|
247
|
+
* Issue #5090 — strict contract: missing exposure now fails build,
|
|
248
|
+
* and API-exposed actions require input/output schemas.
|
|
199
249
|
*/
|
|
200
|
-
describe('runValidate action-first security rules (#4619 /
|
|
250
|
+
describe('runValidate action-first security rules (#4619 / #5090)', () => {
|
|
201
251
|
const DOMAIN_ROOT = '/fake/domains/billing';
|
|
202
252
|
|
|
203
253
|
beforeEach(() => {
|
|
@@ -225,14 +275,6 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
225
275
|
integrations: [],
|
|
226
276
|
publishes: undefined,
|
|
227
277
|
});
|
|
228
|
-
// Mock consumption order inside buildRegistry:
|
|
229
|
-
// 1. domain (paths.domain) -> mock #1
|
|
230
|
-
// 2. webhooks/subscribers/schedules/jobs/integrations -> empty arrays, no calls
|
|
231
|
-
// 3. actions files (paths.actions)-> mock #N (if any)
|
|
232
|
-
// 4. publishes (paths.publishes) -> undefined, no call
|
|
233
|
-
// Each non-empty slot consumes exactly one mock per file (we
|
|
234
|
-
// generate one synthetic file path per item so the slot has length
|
|
235
|
-
// equal to the supplied arrays).
|
|
236
278
|
mockLoadModuleExports.mockResolvedValueOnce([
|
|
237
279
|
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
238
280
|
]);
|
|
@@ -244,20 +286,31 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
244
286
|
return { errors: result.errors, warnings: result.warnings, valid: result.valid };
|
|
245
287
|
}
|
|
246
288
|
|
|
247
|
-
it('ACTION_EXPOSURE_REQUIRED —
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
],
|
|
289
|
+
it('ACTION_EXPOSURE_REQUIRED — buildRegistry throws when an action has no exposure (#5090)', async () => {
|
|
290
|
+
// buildRegistry now throws on missing exposure — no more legacy default.
|
|
291
|
+
// This test verifies that the build fails fast instead of silently
|
|
292
|
+
// defaulting to internal exposure.
|
|
293
|
+
mockWalkDomainDir.mockResolvedValue({
|
|
294
|
+
domain: '/fake/domains/billing/domain.config.ts',
|
|
295
|
+
webhooks: [],
|
|
296
|
+
subscribers: [],
|
|
297
|
+
actions: ['/fake/domains/billing/actions/x.ts'],
|
|
298
|
+
schedules: [],
|
|
299
|
+
jobs: [],
|
|
300
|
+
integrations: [],
|
|
301
|
+
publishes: undefined,
|
|
257
302
|
});
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
303
|
+
mockLoadModuleExports.mockResolvedValueOnce([
|
|
304
|
+
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
305
|
+
]);
|
|
306
|
+
mockLoadModuleExports.mockResolvedValueOnce([
|
|
307
|
+
{
|
|
308
|
+
_kind: 'action', _exportName: 'sysReset',
|
|
309
|
+
id: 'sys-reset', backendAccess: 'platform', idempotent: false,
|
|
310
|
+
// exposure omitted — the strict contract rejects this
|
|
311
|
+
},
|
|
312
|
+
]);
|
|
313
|
+
await expect(runValidate(DOMAIN_ROOT, false)).rejects.toThrow(/exposure/i);
|
|
261
314
|
});
|
|
262
315
|
|
|
263
316
|
it('ACTION_EXPOSURE_REQUIRED — does NOT fire for explicit internal exposure', async () => {
|
|
@@ -274,21 +327,6 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
274
327
|
expect(errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED')).toBeUndefined();
|
|
275
328
|
});
|
|
276
329
|
|
|
277
|
-
it('ACTION_EXPOSURE_REQUIRED — silent on legacy private action (migration compatibility)', async () => {
|
|
278
|
-
// Legacy `visibility: 'private'` actions map to `backendAccess: 'private'`
|
|
279
|
-
// and default `exposure` to `{ type: 'internal' }`. The rule must not
|
|
280
|
-
// flag these so the migration window stays unblocked.
|
|
281
|
-
const { errors } = await runValidateWith({
|
|
282
|
-
actions: [
|
|
283
|
-
{
|
|
284
|
-
_kind: 'action', _exportName: 'legacyPrivate',
|
|
285
|
-
id: 'legacy-private', visibility: 'private', idempotent: false,
|
|
286
|
-
},
|
|
287
|
-
],
|
|
288
|
-
});
|
|
289
|
-
expect(errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED')).toBeUndefined();
|
|
290
|
-
});
|
|
291
|
-
|
|
292
330
|
it('TENANT_API_PATH_REQUIRED — fires when tenancy is required but path lacks tenant placeholder', async () => {
|
|
293
331
|
const { errors } = await runValidateWith({
|
|
294
332
|
actions: [
|
|
@@ -326,26 +364,33 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
326
364
|
expect(errors.find(e => e.code === 'TENANT_API_PATH_REQUIRED')).toBeUndefined();
|
|
327
365
|
});
|
|
328
366
|
|
|
329
|
-
it('API_EXPOSURE_AUTH_REQUIRED —
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
tenancy: 'required',
|
|
340
|
-
// auth omitted on purpose
|
|
341
|
-
},
|
|
342
|
-
idempotent: false,
|
|
343
|
-
},
|
|
344
|
-
],
|
|
367
|
+
it('API_EXPOSURE_AUTH_REQUIRED — buildRegistry throws when API action has no auth (#5090)', async () => {
|
|
368
|
+
mockWalkDomainDir.mockResolvedValue({
|
|
369
|
+
domain: '/fake/domains/billing/domain.config.ts',
|
|
370
|
+
webhooks: [],
|
|
371
|
+
subscribers: [],
|
|
372
|
+
actions: ['/fake/domains/billing/actions/x.ts'],
|
|
373
|
+
schedules: [],
|
|
374
|
+
jobs: [],
|
|
375
|
+
integrations: [],
|
|
376
|
+
publishes: undefined,
|
|
345
377
|
});
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
378
|
+
mockLoadModuleExports.mockResolvedValueOnce([
|
|
379
|
+
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
380
|
+
]);
|
|
381
|
+
mockLoadModuleExports.mockResolvedValueOnce([
|
|
382
|
+
{
|
|
383
|
+
_kind: 'action', _exportName: 'listUsers',
|
|
384
|
+
id: 'list-users', backendAccess: 'domain',
|
|
385
|
+
exposure: {
|
|
386
|
+
type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
|
|
387
|
+
tenancy: 'required',
|
|
388
|
+
// auth omitted — buildRegistry now throws
|
|
389
|
+
},
|
|
390
|
+
idempotent: false,
|
|
391
|
+
},
|
|
392
|
+
]);
|
|
393
|
+
await expect(runValidate(DOMAIN_ROOT, false)).rejects.toThrow(/auth/i);
|
|
349
394
|
});
|
|
350
395
|
|
|
351
396
|
it('API_EXPOSURE_AUTH_REQUIRED — silent when auth is explicitly declared', async () => {
|
|
@@ -393,7 +438,7 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
393
438
|
exposure: {
|
|
394
439
|
type: 'api', path: '/v1/health', method: 'GET',
|
|
395
440
|
auth: 'none', tenancy: 'none',
|
|
396
|
-
securityException: { reason: 'public liveness probe;
|
|
441
|
+
securityException: { reason: 'public liveness probe; ticket OPS-123' },
|
|
397
442
|
},
|
|
398
443
|
idempotent: false,
|
|
399
444
|
},
|
|
@@ -493,7 +538,44 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
493
538
|
expect(errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBeUndefined();
|
|
494
539
|
});
|
|
495
540
|
|
|
496
|
-
it('
|
|
541
|
+
it('API_INPUT_SCHEMA_REQUIRED — fires when API action has no inputSchema', async () => {
|
|
542
|
+
const { errors } = await runValidateWith({
|
|
543
|
+
actions: [
|
|
544
|
+
{
|
|
545
|
+
_kind: 'action', _exportName: 'listUsers',
|
|
546
|
+
id: 'list-users', backendAccess: 'domain',
|
|
547
|
+
exposure: {
|
|
548
|
+
type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
|
|
549
|
+
auth: 'required', tenancy: 'required',
|
|
550
|
+
},
|
|
551
|
+
idempotent: false,
|
|
552
|
+
// no input/output schema
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
});
|
|
556
|
+
expect(errors.some(e => e.code === 'API_INPUT_SCHEMA_REQUIRED')).toBe(true);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('API_OUTPUT_SCHEMA_REQUIRED — fires when API action has no outputSchema', async () => {
|
|
560
|
+
const { errors } = await runValidateWith({
|
|
561
|
+
actions: [
|
|
562
|
+
{
|
|
563
|
+
_kind: 'action', _exportName: 'listUsers',
|
|
564
|
+
id: 'list-users', backendAccess: 'domain',
|
|
565
|
+
exposure: {
|
|
566
|
+
type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
|
|
567
|
+
auth: 'required', tenancy: 'required',
|
|
568
|
+
},
|
|
569
|
+
idempotent: false,
|
|
570
|
+
input: { type: 'object', properties: {} },
|
|
571
|
+
// no output schema
|
|
572
|
+
},
|
|
573
|
+
],
|
|
574
|
+
});
|
|
575
|
+
expect(errors.some(e => e.code === 'API_OUTPUT_SCHEMA_REQUIRED')).toBe(true);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('combined: well-formed api action with input/output schemas passes every rule', async () => {
|
|
497
579
|
const { errors } = await runValidateWith({
|
|
498
580
|
actions: [
|
|
499
581
|
{
|
|
@@ -505,6 +587,8 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
505
587
|
roles: ['billing-admin'],
|
|
506
588
|
},
|
|
507
589
|
idempotent: true,
|
|
590
|
+
input: { type: 'object', properties: { amount: { type: 'number' } } },
|
|
591
|
+
output: { type: 'object', properties: { id: { type: 'string' } } },
|
|
508
592
|
},
|
|
509
593
|
],
|
|
510
594
|
});
|
|
@@ -512,28 +596,19 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
512
596
|
'ACTION_EXPOSURE_REQUIRED', 'API_EXPOSURE_AUTH_REQUIRED',
|
|
513
597
|
'TENANT_API_PATH_REQUIRED', 'AUTH_NONE_REQUIRES_EXCEPTION',
|
|
514
598
|
'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC', 'SYSTEM_API_REQUIRES_ROLE',
|
|
599
|
+
'API_INPUT_SCHEMA_REQUIRED', 'API_OUTPUT_SCHEMA_REQUIRED',
|
|
515
600
|
];
|
|
516
601
|
const firedSecurityRules = errors.filter(e => securityCodes.includes(e.code));
|
|
517
602
|
expect(firedSecurityRules).toHaveLength(0);
|
|
518
|
-
// Non-security rules (e.g. MISSING_HANDLER_FILE on the synthetic
|
|
519
|
-
// mock file path) may legitimately fire; we only assert that the
|
|
520
|
-
// Wave 6 security gates are clean.
|
|
521
603
|
});
|
|
522
604
|
});
|
|
523
605
|
|
|
524
606
|
/**
|
|
525
607
|
* Deployment-time security rules (#4662 Task D, #4689).
|
|
526
608
|
*
|
|
527
|
-
* These mirror the CDK aspect's synth-time checks so CI can gate the
|
|
528
|
-
* build BEFORE synth is attempted. Each rule emits a structured
|
|
529
|
-
* `ValidationError` whose `code` matches the aspect annotation code,
|
|
530
|
-
* making the two layers correlatable.
|
|
531
|
-
*
|
|
532
609
|
* Issue #4689: defineApi and registry.apis were removed. The action-first
|
|
533
610
|
* `TENANT_API_PATH_REQUIRED` rule in the `#4619 / Wave 6` suite now owns
|
|
534
|
-
* the placeholder invariant.
|
|
535
|
-
* no duplicate `SECURITY_MISSING_TENANT_PATH` codes are emitted for the
|
|
536
|
-
* action surface.
|
|
611
|
+
* the placeholder invariant.
|
|
537
612
|
*/
|
|
538
613
|
describe('runValidate deployment-time security rules (#4662 Task D)', () => {
|
|
539
614
|
const DOMAIN_ROOT = '/fake/domains/billing';
|