@mettlecast/domain-cli 0.2.58 → 0.2.60

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 (49) hide show
  1. package/dist/builder/build-registry.js +128 -13
  2. package/dist/builder/load-module.js +3 -3
  3. package/dist/cli.js +2 -2
  4. package/dist/commands/build-catalog.d.ts +2 -2
  5. package/dist/commands/build-catalog.js +5 -5
  6. package/dist/commands/build-flows.js +1 -1
  7. package/dist/commands/build.js +5 -5
  8. package/dist/commands/check-hashes.js +2 -2
  9. package/dist/commands/create-project.js +1 -1
  10. package/dist/commands/doctor.d.ts +5 -7
  11. package/dist/commands/doctor.js +44 -165
  12. package/dist/commands/power-tune.js +2 -2
  13. package/dist/commands/show-dns.d.ts +1 -1
  14. package/dist/commands/show-dns.js +5 -5
  15. package/dist/commands/upgrade.js +17 -11
  16. package/dist/commands/validate.js +183 -0
  17. package/dist/utils/header-inject.js +2 -2
  18. package/dist/utils/install-file.d.ts +1 -1
  19. package/dist/utils/install-file.js +1 -1
  20. package/dist/utils/manifest.js +1 -2
  21. package/dist/utils/scaffold-config.d.ts +2 -2
  22. package/dist/utils/scaffold-config.js +1 -1
  23. package/package.json +1 -1
  24. package/src/__tests__/commands/check-hashes.test.ts +9 -9
  25. package/src/__tests__/commands/upgrade.test.ts +7 -7
  26. package/src/__tests__/doctor.test.ts +60 -67
  27. package/src/__tests__/package-freshness.test.ts +114 -0
  28. package/src/__tests__/scaffold-src/part-a-layout.test.ts +10 -10
  29. package/src/__tests__/scripts/package-scaffold.test.ts +5 -5
  30. package/src/__tests__/utils/install-file.test.ts +2 -2
  31. package/src/__tests__/utils/manifest.test.ts +2 -2
  32. package/src/__tests__/validate.test.ts +652 -1
  33. package/src/builder/build-registry.ts +147 -15
  34. package/src/builder/load-module.ts +3 -3
  35. package/src/cli.ts +3 -3
  36. package/src/commands/build-catalog.ts +5 -5
  37. package/src/commands/build-flows.ts +1 -1
  38. package/src/commands/build.ts +5 -5
  39. package/src/commands/check-hashes.ts +2 -2
  40. package/src/commands/create-project.ts +1 -1
  41. package/src/commands/doctor.ts +52 -181
  42. package/src/commands/power-tune.ts +2 -2
  43. package/src/commands/show-dns.ts +5 -5
  44. package/src/commands/upgrade.ts +16 -10
  45. package/src/commands/validate.ts +226 -1
  46. package/src/utils/header-inject.ts +2 -2
  47. package/src/utils/install-file.ts +1 -1
  48. package/src/utils/manifest.ts +1 -2
  49. package/src/utils/scaffold-config.ts +3 -3
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
3
  vi.mock('../utils/logger.js', () => ({
4
4
  cliLogger: {
@@ -9,10 +9,661 @@ vi.mock('../utils/logger.js', () => ({
9
9
  },
10
10
  }));
11
11
 
12
+ vi.mock('../builder/load-module.js', () => ({
13
+ loadModuleExports: vi.fn(),
14
+ }));
15
+ vi.mock('../utils/file-helpers.js', () => ({
16
+ walkDomainDir: vi.fn(),
17
+ }));
18
+
12
19
  import { runValidate } from '../commands/validate.js';
20
+ import { buildRegistry } from '../builder/build-registry.js';
21
+ import { loadModuleExports } from '../builder/load-module.js';
22
+ import { walkDomainDir } from '../utils/file-helpers.js';
23
+
24
+ const mockLoadModuleExports = vi.mocked(loadModuleExports);
25
+ const mockWalkDomainDir = vi.mocked(walkDomainDir);
13
26
 
14
27
  describe('runValidate error shape', () => {
15
28
  it('throws when domain root has no domain.config.ts', async () => {
16
29
  await expect(runValidate('/nonexistent-path', false)).rejects.toThrow();
17
30
  });
18
31
  });
32
+
33
+ /**
34
+ * Action-first migration (#4619, Wave 1 Task 1.2):
35
+ *
36
+ * `runValidate` ultimately delegates to `buildRegistry` for the
37
+ * registry assembly. These tests exercise the action mapping directly
38
+ * through `buildRegistry` (mocked file walker + module loader) so we
39
+ * catch regressions in how legacy `visibility` maps to `backendAccess`
40
+ * and how `exposure` is serialized.
41
+ */
42
+ describe('buildRegistry action mapping (action-first migration)', () => {
43
+ const DOMAIN_ROOT = '/fake/domains/billing';
44
+
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ });
48
+
49
+ it('reads backendAccess directly when raw action exposes it', async () => {
50
+ mockWalkDomainDir.mockResolvedValue({
51
+ domain: '/fake/domains/billing/domain.config.ts',
52
+ apis: [],
53
+ webhooks: [],
54
+ subscribers: [],
55
+ actions: ['/fake/domains/billing/actions/charge-card.ts'],
56
+ schedules: [],
57
+ jobs: [],
58
+ integrations: [],
59
+ publishes: undefined,
60
+ });
61
+ mockLoadModuleExports
62
+ .mockResolvedValueOnce([
63
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
64
+ ])
65
+ .mockResolvedValueOnce([
66
+ {
67
+ _kind: 'action',
68
+ _exportName: 'chargeCard',
69
+ id: 'charge-card',
70
+ backendAccess: 'domain',
71
+ exposure: {
72
+ type: 'api',
73
+ path: '/v1/tenants/{tenantId}/billing/charges',
74
+ method: 'POST',
75
+ auth: 'required',
76
+ tenancy: 'required',
77
+ },
78
+ idempotent: true,
79
+ },
80
+ ]);
81
+
82
+ const { registry } = await buildRegistry(DOMAIN_ROOT);
83
+
84
+ expect(registry.actions).toHaveLength(1);
85
+ const action = registry.actions[0]!;
86
+ expect(action.id).toBe('charge-card');
87
+ expect(action.backendAccess).toBe('domain');
88
+ expect(action.exposure.type).toBe('api');
89
+ if (action.exposure.type === 'api') {
90
+ expect(action.exposure.path).toBe('/v1/tenants/{tenantId}/billing/charges');
91
+ expect(action.exposure.method).toBe('POST');
92
+ expect(action.exposure.auth).toBe('required');
93
+ expect(action.exposure.tenancy).toBe('required');
94
+ }
95
+ });
96
+
97
+ it('falls back from legacy visibility to backendAccess and collapses workspace -> domain', async () => {
98
+ mockWalkDomainDir.mockResolvedValue({
99
+ domain: '/fake/domains/billing/domain.config.ts',
100
+ apis: [],
101
+ webhooks: [],
102
+ subscribers: [],
103
+ actions: [
104
+ '/fake/domains/billing/actions/verify-token.ts',
105
+ '/fake/domains/billing/actions/provision-tenant.ts',
106
+ ],
107
+ schedules: [],
108
+ jobs: [],
109
+ integrations: [],
110
+ publishes: undefined,
111
+ });
112
+ mockLoadModuleExports
113
+ .mockResolvedValueOnce([
114
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
115
+ ])
116
+ // First call resolves the actions array (Promise.all maps in order).
117
+ .mockResolvedValueOnce([
118
+ { _kind: 'action', _exportName: 'verifyToken', id: 'verify-token', visibility: 'workspace', idempotent: false },
119
+ { _kind: 'action', _exportName: 'provisionTenant', id: 'provision-tenant', visibility: 'domain', idempotent: true },
120
+ ]);
121
+
122
+ const { registry } = await buildRegistry(DOMAIN_ROOT);
123
+
124
+ expect(registry.actions).toHaveLength(2);
125
+ const verify = registry.actions.find(a => a.id === 'verify-token');
126
+ const provision = registry.actions.find(a => a.id === 'provision-tenant');
127
+ expect(verify?.backendAccess).toBe('domain');
128
+ expect(verify?.exposure).toEqual({ type: 'internal' });
129
+ expect(provision?.backendAccess).toBe('domain');
130
+ expect(provision?.exposure).toEqual({ type: 'internal' });
131
+ });
132
+
133
+ it('defaults missing exposure to { type: internal } for legacy actions', async () => {
134
+ mockWalkDomainDir.mockResolvedValue({
135
+ domain: '/fake/domains/billing/domain.config.ts',
136
+ apis: [],
137
+ webhooks: [],
138
+ subscribers: [],
139
+ actions: ['/fake/domains/billing/actions/legacy.ts'],
140
+ schedules: [],
141
+ jobs: [],
142
+ integrations: [],
143
+ publishes: undefined,
144
+ });
145
+ mockLoadModuleExports
146
+ .mockResolvedValueOnce([
147
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
148
+ ])
149
+ .mockResolvedValueOnce([
150
+ { _kind: 'action', _exportName: 'legacy', id: 'legacy', visibility: 'private', idempotent: false },
151
+ ]);
152
+
153
+ const { registry } = await buildRegistry(DOMAIN_ROOT);
154
+
155
+ expect(registry.actions[0]?.exposure).toEqual({ type: 'internal' });
156
+ expect(registry.actions[0]?.backendAccess).toBe('private');
157
+ });
158
+
159
+ it('preserves inputSchema and outputSchema extraction', async () => {
160
+ mockWalkDomainDir.mockResolvedValue({
161
+ domain: '/fake/domains/billing/domain.config.ts',
162
+ apis: [],
163
+ webhooks: [],
164
+ subscribers: [],
165
+ actions: ['/fake/domains/billing/actions/charge-card.ts'],
166
+ schedules: [],
167
+ jobs: [],
168
+ integrations: [],
169
+ publishes: undefined,
170
+ });
171
+ mockLoadModuleExports
172
+ .mockResolvedValueOnce([
173
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
174
+ ])
175
+ .mockResolvedValueOnce([
176
+ {
177
+ _kind: 'action',
178
+ _exportName: 'chargeCard',
179
+ id: 'charge-card',
180
+ backendAccess: 'domain',
181
+ exposure: { type: 'internal' },
182
+ idempotent: true,
183
+ input: { type: 'object', properties: { amount: { type: 'number' } } },
184
+ output: { type: 'object', properties: { id: { type: 'string' } } },
185
+ },
186
+ ]);
187
+
188
+ const { registry } = await buildRegistry(DOMAIN_ROOT);
189
+
190
+ expect(registry.actions[0]?.inputSchema).toEqual({ type: 'object', properties: { amount: { type: 'number' } } });
191
+ expect(registry.actions[0]?.outputSchema).toEqual({ type: 'object', properties: { id: { type: 'string' } } });
192
+ });
193
+ });
194
+
195
+ /**
196
+ * Action-first security validation rules (#4619, Wave 6 Task 6.1).
197
+ *
198
+ * These tests exercise the new validation gates added to `runValidate`.
199
+ * Each test mocks `buildRegistry` consumers so the assertions target the
200
+ * rule codes (`ACTION_EXPOSURE_REQUIRED`, `TENANT_API_PATH_REQUIRED`,
201
+ * `AUTH_NONE_REQUIRES_EXCEPTION`, etc.) without needing a real
202
+ * domain on disk.
203
+ */
204
+ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
205
+ const DOMAIN_ROOT = '/fake/domains/billing';
206
+
207
+ beforeEach(() => {
208
+ vi.clearAllMocks();
209
+ });
210
+
211
+ /**
212
+ * Helper that wires the two file-walker / module-loader mocks so
213
+ * `buildRegistry` returns the supplied actions (plus the canonical
214
+ * domain row) and the supplied apis list. Returns the ValidationResult
215
+ * produced by `runValidate`.
216
+ */
217
+ async function runValidateWith({
218
+ actions = [],
219
+ apis = [],
220
+ }: {
221
+ actions?: Record<string, unknown>[];
222
+ apis?: Record<string, unknown>[];
223
+ }): Promise<{ errors: { code: string; message: string }[]; warnings: string[]; valid: boolean; }> {
224
+ mockWalkDomainDir.mockResolvedValue({
225
+ domain: '/fake/domains/billing/domain.config.ts',
226
+ apis: apis.map(() => '/fake/domains/billing/api/x.ts'),
227
+ webhooks: [],
228
+ subscribers: [],
229
+ actions: actions.map(() => '/fake/domains/billing/actions/x.ts'),
230
+ schedules: [],
231
+ jobs: [],
232
+ integrations: [],
233
+ publishes: undefined,
234
+ });
235
+ // Mock consumption order inside buildRegistry:
236
+ // 1. domain (paths.domain) -> mock #1
237
+ // 2. apis files (paths.apis) -> mock #2 (if any)
238
+ // 3. webhooks/subscribers/schedules/jobs/integrations -> empty arrays, no calls
239
+ // 4. actions files (paths.actions)-> mock #N (if any)
240
+ // 5. publishes (paths.publishes) -> undefined, no call
241
+ // Each non-empty slot consumes exactly one mock per file (we
242
+ // generate one synthetic file path per item so the slot has length
243
+ // equal to the supplied arrays).
244
+ mockLoadModuleExports.mockResolvedValueOnce([
245
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
246
+ ]);
247
+ if (apis.length > 0) {
248
+ mockLoadModuleExports.mockResolvedValueOnce(apis as never);
249
+ }
250
+ if (actions.length > 0) {
251
+ mockLoadModuleExports.mockResolvedValueOnce(actions as never);
252
+ }
253
+
254
+ const result = await runValidate(DOMAIN_ROOT, false);
255
+ return { errors: result.errors, warnings: result.warnings, valid: result.valid };
256
+ }
257
+
258
+ it('ACTION_EXPOSURE_REQUIRED — fires when a non-private action omits exposure', async () => {
259
+ const { errors } = await runValidateWith({
260
+ actions: [
261
+ // `backendAccess: 'platform'` is the strongest "new-style" signal;
262
+ // omitting `exposure` should now fail the build.
263
+ {
264
+ _kind: 'action', _exportName: 'sysReset',
265
+ id: 'sys-reset', backendAccess: 'platform', idempotent: false,
266
+ },
267
+ ],
268
+ });
269
+ const rule = errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED');
270
+ expect(rule).toBeDefined();
271
+ expect(rule?.message).toContain("'sys-reset'");
272
+ });
273
+
274
+ it('ACTION_EXPOSURE_REQUIRED — does NOT fire for explicit internal exposure', async () => {
275
+ const { errors } = await runValidateWith({
276
+ actions: [
277
+ {
278
+ _kind: 'action', _exportName: 'sysReset',
279
+ id: 'sys-reset', backendAccess: 'platform',
280
+ exposure: { type: 'internal' },
281
+ idempotent: false,
282
+ },
283
+ ],
284
+ });
285
+ expect(errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED')).toBeUndefined();
286
+ });
287
+
288
+ it('ACTION_EXPOSURE_REQUIRED — silent on legacy private action (migration compatibility)', async () => {
289
+ // Legacy `visibility: 'private'` actions map to `backendAccess: 'private'`
290
+ // and default `exposure` to `{ type: 'internal' }`. The rule must not
291
+ // flag these so the migration window stays unblocked.
292
+ const { errors } = await runValidateWith({
293
+ actions: [
294
+ {
295
+ _kind: 'action', _exportName: 'legacyPrivate',
296
+ id: 'legacy-private', visibility: 'private', idempotent: false,
297
+ },
298
+ ],
299
+ });
300
+ expect(errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED')).toBeUndefined();
301
+ });
302
+
303
+ it('TENANT_API_PATH_REQUIRED — fires when tenancy is required but path lacks tenant placeholder', async () => {
304
+ const { errors } = await runValidateWith({
305
+ actions: [
306
+ {
307
+ _kind: 'action', _exportName: 'listAll',
308
+ id: 'list-all', backendAccess: 'platform',
309
+ exposure: {
310
+ type: 'api', path: '/v1/admin/users', method: 'GET',
311
+ auth: 'required', tenancy: 'required',
312
+ },
313
+ idempotent: false,
314
+ },
315
+ ],
316
+ });
317
+ const rule = errors.find(e => e.code === 'TENANT_API_PATH_REQUIRED');
318
+ expect(rule).toBeDefined();
319
+ expect(rule?.message).toContain("'list-all'");
320
+ expect(rule?.message).toContain('/v1/tenants/{tenantId}/');
321
+ });
322
+
323
+ it('TENANT_API_PATH_REQUIRED — passes when tenancy is required AND path includes placeholder', async () => {
324
+ const { errors } = await runValidateWith({
325
+ actions: [
326
+ {
327
+ _kind: 'action', _exportName: 'listUsers',
328
+ id: 'list-users', backendAccess: 'domain',
329
+ exposure: {
330
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
331
+ auth: 'required', tenancy: 'required',
332
+ },
333
+ idempotent: false,
334
+ },
335
+ ],
336
+ });
337
+ expect(errors.find(e => e.code === 'TENANT_API_PATH_REQUIRED')).toBeUndefined();
338
+ });
339
+
340
+ it('API_EXPOSURE_AUTH_REQUIRED — fires when auth was defaulted (not declared in source)', async () => {
341
+ // Source did NOT include `auth` — the builder will default to
342
+ // 'required' and the validator must surface the omission.
343
+ const { errors } = await runValidateWith({
344
+ actions: [
345
+ {
346
+ _kind: 'action', _exportName: 'listUsers',
347
+ id: 'list-users', backendAccess: 'domain',
348
+ exposure: {
349
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
350
+ tenancy: 'required',
351
+ // auth omitted on purpose
352
+ },
353
+ idempotent: false,
354
+ },
355
+ ],
356
+ });
357
+ const rule = errors.find(e => e.code === 'API_EXPOSURE_AUTH_REQUIRED');
358
+ expect(rule).toBeDefined();
359
+ expect(rule?.message).toContain("'list-users'");
360
+ });
361
+
362
+ it('API_EXPOSURE_AUTH_REQUIRED — silent when auth is explicitly declared', async () => {
363
+ const { errors } = await runValidateWith({
364
+ actions: [
365
+ {
366
+ _kind: 'action', _exportName: 'listUsers',
367
+ id: 'list-users', backendAccess: 'domain',
368
+ exposure: {
369
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
370
+ auth: 'required', tenancy: 'required',
371
+ },
372
+ idempotent: false,
373
+ },
374
+ ],
375
+ });
376
+ expect(errors.find(e => e.code === 'API_EXPOSURE_AUTH_REQUIRED')).toBeUndefined();
377
+ });
378
+
379
+ it('AUTH_NONE_REQUIRES_EXCEPTION — fires when auth is none without securityException', async () => {
380
+ const { errors } = await runValidateWith({
381
+ actions: [
382
+ {
383
+ _kind: 'action', _exportName: 'health',
384
+ id: 'health', backendAccess: 'platform',
385
+ exposure: {
386
+ type: 'api', path: '/v1/health', method: 'GET',
387
+ auth: 'none', tenancy: 'none',
388
+ },
389
+ idempotent: false,
390
+ },
391
+ ],
392
+ });
393
+ const rule = errors.find(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION');
394
+ expect(rule).toBeDefined();
395
+ expect(rule?.message).toContain("'health'");
396
+ });
397
+
398
+ it('AUTH_NONE_REQUIRES_EXCEPTION — passes when auth is none WITH securityException.reason', async () => {
399
+ const { errors } = await runValidateWith({
400
+ actions: [
401
+ {
402
+ _kind: 'action', _exportName: 'health',
403
+ id: 'health', backendAccess: 'platform',
404
+ exposure: {
405
+ type: 'api', path: '/v1/health', method: 'GET',
406
+ auth: 'none', tenancy: 'none',
407
+ securityException: { reason: 'public liveness probe; no PII; ticket OPS-123' },
408
+ },
409
+ idempotent: false,
410
+ },
411
+ ],
412
+ });
413
+ expect(errors.find(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION')).toBeUndefined();
414
+ });
415
+
416
+ it('AUTH_NONE_REQUIRES_EXCEPTION — fires when securityException.reason is whitespace-only', async () => {
417
+ const { errors } = await runValidateWith({
418
+ actions: [
419
+ {
420
+ _kind: 'action', _exportName: 'health',
421
+ id: 'health', backendAccess: 'platform',
422
+ exposure: {
423
+ type: 'api', path: '/v1/health', method: 'GET',
424
+ auth: 'none', tenancy: 'none',
425
+ securityException: { reason: ' ' },
426
+ },
427
+ idempotent: false,
428
+ },
429
+ ],
430
+ });
431
+ expect(errors.find(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION')).toBeDefined();
432
+ });
433
+
434
+ it('TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC — fires when tenancy is none without securityException', async () => {
435
+ const { errors } = await runValidateWith({
436
+ actions: [
437
+ {
438
+ _kind: 'action', _exportName: 'publicLookup',
439
+ id: 'public-lookup', backendAccess: 'platform',
440
+ exposure: {
441
+ type: 'api', path: '/v1/public/lookup', method: 'GET',
442
+ auth: 'required', tenancy: 'none',
443
+ },
444
+ idempotent: false,
445
+ },
446
+ ],
447
+ });
448
+ const rule = errors.find(e => e.code === 'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC');
449
+ expect(rule).toBeDefined();
450
+ expect(rule?.message).toContain("'public-lookup'");
451
+ });
452
+
453
+ it('SYSTEM_API_REQUIRES_ROLE — fires when system tenancy + required auth without roles', async () => {
454
+ const { errors } = await runValidateWith({
455
+ actions: [
456
+ {
457
+ _kind: 'action', _exportName: 'adminPing',
458
+ id: 'admin-ping', backendAccess: 'platform',
459
+ exposure: {
460
+ type: 'api', path: '/v1/admin/ping', method: 'GET',
461
+ auth: 'required', tenancy: 'system',
462
+ },
463
+ idempotent: false,
464
+ },
465
+ ],
466
+ });
467
+ const rule = errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE');
468
+ expect(rule).toBeDefined();
469
+ expect(rule?.message).toContain("'admin-ping'");
470
+ });
471
+
472
+ it('SYSTEM_API_REQUIRES_ROLE — passes when system tenancy has non-empty roles', async () => {
473
+ const { errors } = await runValidateWith({
474
+ actions: [
475
+ {
476
+ _kind: 'action', _exportName: 'adminPing',
477
+ id: 'admin-ping', backendAccess: 'platform',
478
+ exposure: {
479
+ type: 'api', path: '/v1/admin/ping', method: 'GET',
480
+ auth: 'required', tenancy: 'system',
481
+ roles: ['sys-admin'],
482
+ },
483
+ idempotent: false,
484
+ },
485
+ ],
486
+ });
487
+ expect(errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBeUndefined();
488
+ });
489
+
490
+ it('SYSTEM_API_REQUIRES_ROLE — silent when auth is service (service-only route, no role narrowing required)', async () => {
491
+ const { errors } = await runValidateWith({
492
+ actions: [
493
+ {
494
+ _kind: 'action', _exportName: 'replicationHook',
495
+ id: 'replication-hook', backendAccess: 'platform',
496
+ exposure: {
497
+ type: 'api', path: '/v1/admin/replicate', method: 'POST',
498
+ auth: 'service', tenancy: 'system',
499
+ },
500
+ idempotent: false,
501
+ },
502
+ ],
503
+ });
504
+ expect(errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBeUndefined();
505
+ });
506
+
507
+ it('DEFINE_API_LEGACY_USAGE — fires once per legacy defineApi entry', async () => {
508
+ // Stub out the api builder checks (requestSchema, examples, etc.)
509
+ // because the test fixture intentionally provides a minimal api
510
+ // shape. We only assert on the new DEFINE_API_LEGACY_USAGE rule
511
+ // here; other rules are covered by build-registry.test.ts and the
512
+ // existing validate.test.ts suite.
513
+ const { errors } = await runValidateWith({
514
+ actions: [],
515
+ apis: [
516
+ {
517
+ _kind: 'api', _exportName: 'legacyList',
518
+ id: 'legacy-list', path: '/v1/list', method: 'GET',
519
+ auth: { type: 'jwt' },
520
+ versions: {
521
+ v1: {
522
+ status: 'stable',
523
+ input: { type: 'object', properties: {}, default: {} },
524
+ output: { type: 'object', properties: {}, default: {} },
525
+ handler: () => undefined,
526
+ },
527
+ },
528
+ examples: { request: {}, response: {} },
529
+ },
530
+ ],
531
+ });
532
+ const rules = errors.filter(e => e.code === 'DEFINE_API_LEGACY_USAGE');
533
+ expect(rules).toHaveLength(1);
534
+ expect(rules[0]?.message).toContain("'legacy-list'");
535
+ });
536
+
537
+ it('DEFINE_API_LEGACY_USAGE — silent when registry has no defineApi rows', async () => {
538
+ const { errors } = await runValidateWith({
539
+ actions: [
540
+ {
541
+ _kind: 'action', _exportName: 'internalOnly',
542
+ id: 'internal-only', backendAccess: 'domain',
543
+ exposure: { type: 'internal' },
544
+ idempotent: false,
545
+ },
546
+ ],
547
+ apis: [],
548
+ });
549
+ expect(errors.find(e => e.code === 'DEFINE_API_LEGACY_USAGE')).toBeUndefined();
550
+ });
551
+
552
+ it('combined: well-formed api action passes every rule', async () => {
553
+ const { errors } = await runValidateWith({
554
+ actions: [
555
+ {
556
+ _kind: 'action', _exportName: 'chargeCard',
557
+ id: 'charge-card', backendAccess: 'domain',
558
+ exposure: {
559
+ type: 'api', path: '/v1/tenants/{tenantId}/billing/charges', method: 'POST',
560
+ auth: 'required', tenancy: 'required',
561
+ roles: ['billing-admin'],
562
+ },
563
+ idempotent: true,
564
+ },
565
+ ],
566
+ });
567
+ const securityCodes = [
568
+ 'ACTION_EXPOSURE_REQUIRED', 'API_EXPOSURE_AUTH_REQUIRED',
569
+ 'TENANT_API_PATH_REQUIRED', 'AUTH_NONE_REQUIRES_EXCEPTION',
570
+ 'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC', 'SYSTEM_API_REQUIRES_ROLE',
571
+ 'DEFINE_API_LEGACY_USAGE',
572
+ ];
573
+ const firedSecurityRules = errors.filter(e => securityCodes.includes(e.code));
574
+ expect(firedSecurityRules).toHaveLength(0);
575
+ // Non-security rules (e.g. MISSING_HANDLER_FILE on the synthetic
576
+ // mock file path) may legitimately fire; we only assert that the
577
+ // Wave 6 security gates are clean.
578
+ });
579
+ });
580
+
581
+ /**
582
+ * Deployment-time security rules (#4662 Task D).
583
+ *
584
+ * These mirror the CDK aspect's synth-time checks so CI can gate the
585
+ * build BEFORE synth is attempted. Each rule emits a structured
586
+ * `ValidationError` whose `code` matches the aspect annotation code,
587
+ * making the two layers correlatable.
588
+ */
589
+ describe('runValidate deployment-time security rules (#4662 Task D)', () => {
590
+ const DOMAIN_ROOT = '/fake/domains/billing';
591
+
592
+ beforeEach(() => {
593
+ vi.clearAllMocks();
594
+ });
595
+
596
+ async function runValidateWith({
597
+ actions = [],
598
+ apis = [],
599
+ }: {
600
+ actions?: Record<string, unknown>[];
601
+ apis?: Record<string, unknown>[];
602
+ }): Promise<{ errors: { code: string; message: string }[]; warnings: string[]; valid: boolean; }> {
603
+ mockWalkDomainDir.mockResolvedValue({
604
+ domain: '/fake/domains/billing/domain.config.ts',
605
+ apis: apis.map(() => '/fake/domains/billing/api/x.ts'),
606
+ webhooks: [],
607
+ subscribers: [],
608
+ actions: actions.map(() => '/fake/domains/billing/actions/x.ts'),
609
+ schedules: [],
610
+ jobs: [],
611
+ integrations: [],
612
+ publishes: undefined,
613
+ });
614
+ mockLoadModuleExports.mockResolvedValueOnce([
615
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
616
+ ]);
617
+ if (apis.length > 0) {
618
+ mockLoadModuleExports.mockResolvedValueOnce(apis as never);
619
+ }
620
+ if (actions.length > 0) {
621
+ mockLoadModuleExports.mockResolvedValueOnce(actions as never);
622
+ }
623
+
624
+ const result = await runValidate(DOMAIN_ROOT, false);
625
+ return { errors: result.errors, warnings: result.warnings, valid: result.valid };
626
+ }
627
+
628
+ it('SECURITY_MISSING_TENANT_PATH — fires for an API whose path lacks the tenant placeholder', async () => {
629
+ const { errors } = await runValidateWith({
630
+ apis: [
631
+ {
632
+ _kind: 'api', _exportName: 'listUsers',
633
+ id: 'list-users', path: '/v1/users', method: 'GET',
634
+ authType: 'jwt',
635
+ },
636
+ ],
637
+ });
638
+ const rule = errors.find(e => e.code === 'SECURITY_MISSING_TENANT_PATH');
639
+ expect(rule).toBeDefined();
640
+ expect(rule?.message).toContain("'list-users'");
641
+ expect(rule?.message).toContain('/v1/tenants/{tenantId}/');
642
+ });
643
+
644
+ it('SECURITY_MISSING_TENANT_PATH — silent when the API path includes the placeholder', async () => {
645
+ const { errors } = await runValidateWith({
646
+ apis: [
647
+ {
648
+ _kind: 'api', _exportName: 'listUsers',
649
+ id: 'list-users', path: '/v1/tenants/{tenantId}/users', method: 'GET',
650
+ authType: 'jwt',
651
+ },
652
+ ],
653
+ });
654
+ expect(errors.find(e => e.code === 'SECURITY_MISSING_TENANT_PATH')).toBeUndefined();
655
+ });
656
+
657
+ it('SECURITY_MISSING_TENANT_PATH — silent for anonymous (authType=none) APIs', async () => {
658
+ const { errors } = await runValidateWith({
659
+ apis: [
660
+ {
661
+ _kind: 'api', _exportName: 'health',
662
+ id: 'health', path: '/v1/health', method: 'GET',
663
+ auth: { type: 'none' },
664
+ },
665
+ ],
666
+ });
667
+ expect(errors.find(e => e.code === 'SECURITY_MISSING_TENANT_PATH')).toBeUndefined();
668
+ });
669
+ });