@mettlecast/domain-cli 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/builder/build-registry.d.ts +1 -1
- package/dist/builder/build-registry.js +129 -49
- package/dist/builder/build-types.d.ts +1 -1
- package/dist/builder/load-module.d.ts +1 -1
- package/dist/builder/load-module.js +3 -3
- package/dist/cli.js +3 -3
- package/dist/commands/add-api.js +2 -2
- package/dist/commands/add-domain.js +4 -4
- package/dist/commands/add-fixture-factory.js +5 -6
- package/dist/commands/build-catalog.d.ts +8 -24
- package/dist/commands/build-catalog.js +12 -23
- package/dist/commands/build-flows.js +1 -1
- package/dist/commands/build.js +7 -6
- package/dist/commands/check-hashes.js +2 -2
- package/dist/commands/create-project.js +1 -1
- package/dist/commands/dev.js +1 -1
- package/dist/commands/doctor.d.ts +5 -7
- package/dist/commands/doctor.js +110 -202
- package/dist/commands/explain.js +13 -13
- package/dist/commands/generate-openapi.d.ts +10 -1
- package/dist/commands/generate-openapi.js +19 -33
- package/dist/commands/power-tune.js +2 -2
- package/dist/commands/show-dns.d.ts +1 -1
- package/dist/commands/show-dns.js +5 -5
- package/dist/commands/show.d.ts +2 -3
- package/dist/commands/show.js +0 -2
- package/dist/commands/test.js +0 -1
- package/dist/commands/upgrade-backend.js +3 -3
- package/dist/commands/upgrade.js +17 -11
- package/dist/commands/validate.js +144 -39
- package/dist/server/api-server.d.ts +1 -1
- package/dist/server/mount-routes.d.ts +11 -2
- package/dist/server/mount-routes.js +20 -8
- package/dist/templates/api-skeleton.d.ts +5 -0
- package/dist/templates/api-skeleton.js +28 -27
- package/dist/templates/claude-md.js +1 -1
- package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
- package/dist/templates/patterns/api/create-with-event.js +38 -32
- package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
- package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
- package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
- package/dist/templates/patterns/api/paginated-list.js +30 -24
- package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
- package/dist/templates/patterns/api/simple-crud.js +46 -35
- package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
- package/dist/templates/patterns/api/streaming-list.js +46 -41
- package/dist/templates/patterns/api/system-admin.d.ts +4 -0
- package/dist/templates/patterns/api/system-admin.js +59 -52
- package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
- package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
- package/dist/types.d.ts +100 -0
- package/dist/types.js +1 -0
- package/dist/utils/file-helpers.d.ts +0 -2
- package/dist/utils/file-helpers.js +2 -3
- package/dist/utils/header-inject.js +2 -2
- package/dist/utils/install-file.d.ts +1 -1
- package/dist/utils/install-file.js +1 -1
- package/dist/utils/manifest.js +1 -2
- package/dist/utils/scaffold-config.d.ts +8 -2
- package/dist/utils/scaffold-config.js +3 -1
- package/package.json +1 -1
- package/src/__tests__/build-registry.test.ts +43 -20
- package/src/__tests__/build-types.test.ts +4 -7
- package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
- package/src/__tests__/commands/add-api.test.ts +12 -10
- package/src/__tests__/commands/add-domain.test.ts +8 -5
- package/src/__tests__/commands/check-hashes.test.ts +9 -9
- package/src/__tests__/commands/create-project.test.ts +5 -5
- package/src/__tests__/commands/dev.test.ts +0 -1
- package/src/__tests__/commands/upgrade.test.ts +7 -7
- package/src/__tests__/doctor.test.ts +60 -67
- package/src/__tests__/mount-routes.test.ts +64 -23
- package/src/__tests__/package-freshness.test.ts +94 -0
- package/src/__tests__/scaffold-src/part-a-layout.test.ts +10 -10
- package/src/__tests__/scripts/package-scaffold.test.ts +5 -5
- package/src/__tests__/smoke/scaffold.test.ts +13 -15
- package/src/__tests__/utils/install-file.test.ts +2 -2
- package/src/__tests__/utils/manifest.test.ts +2 -2
- package/src/__tests__/validate.test.ts +570 -1
- package/src/builder/build-registry.ts +154 -59
- package/src/builder/build-types.ts +1 -1
- package/src/builder/load-module.ts +3 -3
- package/src/cli.ts +4 -4
- package/src/commands/add-api.ts +2 -2
- package/src/commands/add-domain.ts +4 -4
- package/src/commands/add-fixture-factory.ts +5 -6
- package/src/commands/build-catalog.ts +18 -40
- package/src/commands/build-flows.ts +1 -1
- package/src/commands/build.ts +8 -7
- package/src/commands/check-hashes.ts +2 -2
- package/src/commands/create-project.ts +1 -1
- package/src/commands/dev.ts +1 -1
- package/src/commands/doctor.ts +120 -218
- package/src/commands/explain.ts +13 -13
- package/src/commands/generate-openapi.ts +30 -52
- package/src/commands/power-tune.ts +2 -2
- package/src/commands/show-dns.ts +5 -5
- package/src/commands/show.ts +2 -5
- package/src/commands/test.ts +0 -1
- package/src/commands/upgrade-backend.ts +3 -3
- package/src/commands/upgrade.ts +16 -10
- package/src/commands/validate.ts +180 -40
- package/src/server/api-server.ts +1 -1
- package/src/server/mount-routes.ts +21 -10
- package/src/templates/api-skeleton.ts +29 -28
- package/src/templates/claude-md.ts +1 -1
- package/src/templates/patterns/api/create-with-event.ts +39 -33
- package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
- package/src/templates/patterns/api/paginated-list.ts +31 -25
- package/src/templates/patterns/api/simple-crud.ts +47 -36
- package/src/templates/patterns/api/streaming-list.ts +47 -42
- package/src/templates/patterns/api/system-admin.ts +60 -53
- package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
- package/src/types.ts +128 -0
- package/src/utils/file-helpers.ts +2 -5
- package/src/utils/header-inject.ts +2 -2
- package/src/utils/install-file.ts +1 -1
- package/src/utils/manifest.ts +1 -2
- package/src/utils/scaffold-config.ts +12 -3
|
@@ -448,11 +448,10 @@ describe('scaffold smoke test (issue #3831)', () => {
|
|
|
448
448
|
expect(tsconfig.compilerOptions.strict).toBe(true);
|
|
449
449
|
});
|
|
450
450
|
|
|
451
|
-
it('seeds api handlers from auth that import
|
|
451
|
+
it('seeds api handlers from auth that import defineAction + exposure from @mettlecast/domain-runtime', async () => {
|
|
452
452
|
// The auth module ships a hand-written seed api/me.ts. We
|
|
453
453
|
// confirm it parses (no obvious syntax errors) and uses the runtime
|
|
454
|
-
// primitive. This is a structural check
|
|
455
|
-
// initOtel()" expectation is documented below as a separate soft gate.
|
|
454
|
+
// primitive. This is a structural check.
|
|
456
455
|
const apiFile = join(projectDir, 'domains', 'auth', 'api', 'me.ts');
|
|
457
456
|
const s = await stat(apiFile).catch(() => null);
|
|
458
457
|
if (!s) {
|
|
@@ -462,7 +461,8 @@ describe('scaffold smoke test (issue #3831)', () => {
|
|
|
462
461
|
}
|
|
463
462
|
const content = await readFile(apiFile, 'utf-8');
|
|
464
463
|
expect(content).toMatch(/from\s+['"]@mettlecast\/domain-runtime['"]/);
|
|
465
|
-
expect(content).toMatch(/
|
|
464
|
+
expect(content).toMatch(/defineAction\s*\(/);
|
|
465
|
+
expect(content).toMatch(/type:\s*['"]api['"]/);
|
|
466
466
|
});
|
|
467
467
|
|
|
468
468
|
it('seeds initOtel() references in api handlers (wave-2 acceptance gate)', async () => {
|
|
@@ -499,20 +499,18 @@ describe('scaffold smoke test (issue #3831)', () => {
|
|
|
499
499
|
}
|
|
500
500
|
});
|
|
501
501
|
|
|
502
|
-
it('api-skeleton template (add-api) emits
|
|
503
|
-
// The add-api template now
|
|
504
|
-
//
|
|
505
|
-
// not a direct `ky` npm import. Seed handlers may also import `ky`
|
|
506
|
-
// directly if they need a browser-friendly HTTP client.
|
|
502
|
+
it('api-skeleton template (add-api) emits defineAction + exposure.type=api references', async () => {
|
|
503
|
+
// The add-api template now emits defineAction + exposure.type='api' as
|
|
504
|
+
// the canonical action-first HTTP endpoint primitive (#4689).
|
|
507
505
|
const out = apiSkeletonTemplateForTest('payments', 'charge-card', 'required');
|
|
508
|
-
const
|
|
509
|
-
const
|
|
510
|
-
if (!
|
|
506
|
+
const hasDefineAction = /defineAction\s*\(/.test(out);
|
|
507
|
+
const hasApiExposure = /type:\s*['"]api['"]/.test(out);
|
|
508
|
+
if (!hasDefineAction || !hasApiExposure) {
|
|
511
509
|
throw new Error(
|
|
512
510
|
'api-skeleton template (packages/domain-cli/src/templates/api-skeleton.ts) ' +
|
|
513
|
-
'does not emit
|
|
514
|
-
`
|
|
515
|
-
' expected: import {
|
|
511
|
+
'does not emit defineAction + exposure.type=api\n' +
|
|
512
|
+
` hasDefineAction=${hasDefineAction} hasApiExposure=${hasApiExposure}\n` +
|
|
513
|
+
' expected: import { defineAction } from "@mettlecast/domain-runtime" AND exposure: { type: "api", ... }',
|
|
516
514
|
);
|
|
517
515
|
}
|
|
518
516
|
});
|
|
@@ -157,8 +157,8 @@ describe('installScaffoldFile', () => {
|
|
|
157
157
|
|
|
158
158
|
const result = await installScaffoldFile(filePath, newContent, 'editable', currentEntry);
|
|
159
159
|
expect(result.status).toBe('update-available');
|
|
160
|
-
// Verify NO .
|
|
161
|
-
await expect(access(filePath + '.
|
|
160
|
+
// Verify NO .mc-upgrade file written
|
|
161
|
+
await expect(access(filePath + '.mc-upgrade')).rejects.toThrow();
|
|
162
162
|
// Verify original file NOT overwritten
|
|
163
163
|
const diskContent = await readFile(filePath, 'utf-8');
|
|
164
164
|
expect(diskContent).toBe(existingContent);
|
|
@@ -57,8 +57,8 @@ describe('manifest utilities', () => {
|
|
|
57
57
|
expect(inferPolicyFromPath('infra/main.tf')).toBe('managed');
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it('returns editable for .mc
|
|
61
|
-
expect(inferPolicyFromPath('.mc/
|
|
60
|
+
it('returns editable for unrecognised .mc paths', () => {
|
|
61
|
+
expect(inferPolicyFromPath('.mc/custom/stack.ts')).toBe('editable');
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
it('returns managed for mc-deploy.yml', () => {
|
|
@@ -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,579 @@ 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
|
+
webhooks: [],
|
|
53
|
+
subscribers: [],
|
|
54
|
+
actions: ['/fake/domains/billing/actions/charge-card.ts'],
|
|
55
|
+
schedules: [],
|
|
56
|
+
jobs: [],
|
|
57
|
+
integrations: [],
|
|
58
|
+
publishes: undefined,
|
|
59
|
+
});
|
|
60
|
+
mockLoadModuleExports
|
|
61
|
+
.mockResolvedValueOnce([
|
|
62
|
+
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
63
|
+
])
|
|
64
|
+
.mockResolvedValueOnce([
|
|
65
|
+
{
|
|
66
|
+
_kind: 'action',
|
|
67
|
+
_exportName: 'chargeCard',
|
|
68
|
+
id: 'charge-card',
|
|
69
|
+
backendAccess: 'domain',
|
|
70
|
+
exposure: {
|
|
71
|
+
type: 'api',
|
|
72
|
+
path: '/v1/tenants/{tenantId}/billing/charges',
|
|
73
|
+
method: 'POST',
|
|
74
|
+
auth: 'required',
|
|
75
|
+
tenancy: 'required',
|
|
76
|
+
},
|
|
77
|
+
idempotent: true,
|
|
78
|
+
},
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const { registry } = await buildRegistry(DOMAIN_ROOT);
|
|
82
|
+
|
|
83
|
+
expect(registry.actions).toHaveLength(1);
|
|
84
|
+
const action = registry.actions[0]!;
|
|
85
|
+
expect(action.id).toBe('charge-card');
|
|
86
|
+
expect(action.backendAccess).toBe('domain');
|
|
87
|
+
expect(action.exposure.type).toBe('api');
|
|
88
|
+
if (action.exposure.type === 'api') {
|
|
89
|
+
expect(action.exposure.path).toBe('/v1/tenants/{tenantId}/billing/charges');
|
|
90
|
+
expect(action.exposure.method).toBe('POST');
|
|
91
|
+
expect(action.exposure.auth).toBe('required');
|
|
92
|
+
expect(action.exposure.tenancy).toBe('required');
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('falls back from legacy visibility to backendAccess and collapses workspace -> domain', async () => {
|
|
97
|
+
mockWalkDomainDir.mockResolvedValue({
|
|
98
|
+
domain: '/fake/domains/billing/domain.config.ts',
|
|
99
|
+
webhooks: [],
|
|
100
|
+
subscribers: [],
|
|
101
|
+
actions: [
|
|
102
|
+
'/fake/domains/billing/actions/verify-token.ts',
|
|
103
|
+
'/fake/domains/billing/actions/provision-tenant.ts',
|
|
104
|
+
],
|
|
105
|
+
schedules: [],
|
|
106
|
+
jobs: [],
|
|
107
|
+
integrations: [],
|
|
108
|
+
publishes: undefined,
|
|
109
|
+
});
|
|
110
|
+
mockLoadModuleExports
|
|
111
|
+
.mockResolvedValueOnce([
|
|
112
|
+
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
113
|
+
])
|
|
114
|
+
// First call resolves the actions array (Promise.all maps in order).
|
|
115
|
+
.mockResolvedValueOnce([
|
|
116
|
+
{ _kind: 'action', _exportName: 'verifyToken', id: 'verify-token', visibility: 'workspace', idempotent: false },
|
|
117
|
+
{ _kind: 'action', _exportName: 'provisionTenant', id: 'provision-tenant', visibility: 'domain', idempotent: true },
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const { registry } = await buildRegistry(DOMAIN_ROOT);
|
|
121
|
+
|
|
122
|
+
expect(registry.actions).toHaveLength(2);
|
|
123
|
+
const verify = registry.actions.find(a => a.id === 'verify-token');
|
|
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' });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('defaults missing exposure to { type: internal } for legacy actions', async () => {
|
|
132
|
+
mockWalkDomainDir.mockResolvedValue({
|
|
133
|
+
domain: '/fake/domains/billing/domain.config.ts',
|
|
134
|
+
webhooks: [],
|
|
135
|
+
subscribers: [],
|
|
136
|
+
actions: ['/fake/domains/billing/actions/legacy.ts'],
|
|
137
|
+
schedules: [],
|
|
138
|
+
jobs: [],
|
|
139
|
+
integrations: [],
|
|
140
|
+
publishes: undefined,
|
|
141
|
+
});
|
|
142
|
+
mockLoadModuleExports
|
|
143
|
+
.mockResolvedValueOnce([
|
|
144
|
+
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
145
|
+
])
|
|
146
|
+
.mockResolvedValueOnce([
|
|
147
|
+
{ _kind: 'action', _exportName: 'legacy', id: 'legacy', visibility: 'private', idempotent: false },
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const { registry } = await buildRegistry(DOMAIN_ROOT);
|
|
151
|
+
|
|
152
|
+
expect(registry.actions[0]?.exposure).toEqual({ type: 'internal' });
|
|
153
|
+
expect(registry.actions[0]?.backendAccess).toBe('private');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('preserves inputSchema and outputSchema extraction', async () => {
|
|
157
|
+
mockWalkDomainDir.mockResolvedValue({
|
|
158
|
+
domain: '/fake/domains/billing/domain.config.ts',
|
|
159
|
+
webhooks: [],
|
|
160
|
+
subscribers: [],
|
|
161
|
+
actions: ['/fake/domains/billing/actions/charge-card.ts'],
|
|
162
|
+
schedules: [],
|
|
163
|
+
jobs: [],
|
|
164
|
+
integrations: [],
|
|
165
|
+
publishes: undefined,
|
|
166
|
+
});
|
|
167
|
+
mockLoadModuleExports
|
|
168
|
+
.mockResolvedValueOnce([
|
|
169
|
+
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
170
|
+
])
|
|
171
|
+
.mockResolvedValueOnce([
|
|
172
|
+
{
|
|
173
|
+
_kind: 'action',
|
|
174
|
+
_exportName: 'chargeCard',
|
|
175
|
+
id: 'charge-card',
|
|
176
|
+
backendAccess: 'domain',
|
|
177
|
+
exposure: { type: 'internal' },
|
|
178
|
+
idempotent: true,
|
|
179
|
+
input: { type: 'object', properties: { amount: { type: 'number' } } },
|
|
180
|
+
output: { type: 'object', properties: { id: { type: 'string' } } },
|
|
181
|
+
},
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
const { registry } = await buildRegistry(DOMAIN_ROOT);
|
|
185
|
+
|
|
186
|
+
expect(registry.actions[0]?.inputSchema).toEqual({ type: 'object', properties: { amount: { type: 'number' } } });
|
|
187
|
+
expect(registry.actions[0]?.outputSchema).toEqual({ type: 'object', properties: { id: { type: 'string' } } });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Action-first security validation rules (#4619, Wave 6 Task 6.1).
|
|
193
|
+
*
|
|
194
|
+
* These tests exercise the new validation gates added to `runValidate`.
|
|
195
|
+
* Each test mocks `buildRegistry` consumers so the assertions target the
|
|
196
|
+
* rule codes (`ACTION_EXPOSURE_REQUIRED`, `TENANT_API_PATH_REQUIRED`,
|
|
197
|
+
* `AUTH_NONE_REQUIRES_EXCEPTION`, etc.) without needing a real
|
|
198
|
+
* domain on disk.
|
|
199
|
+
*/
|
|
200
|
+
describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
201
|
+
const DOMAIN_ROOT = '/fake/domains/billing';
|
|
202
|
+
|
|
203
|
+
beforeEach(() => {
|
|
204
|
+
vi.clearAllMocks();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Helper that wires the two file-walker / module-loader mocks so
|
|
209
|
+
* `buildRegistry` returns the supplied actions plus the canonical
|
|
210
|
+
* domain row. Returns the ValidationResult
|
|
211
|
+
* produced by `runValidate`.
|
|
212
|
+
*/
|
|
213
|
+
async function runValidateWith({
|
|
214
|
+
actions = [],
|
|
215
|
+
}: {
|
|
216
|
+
actions?: Record<string, unknown>[];
|
|
217
|
+
}): Promise<{ errors: { code: string; message: string }[]; warnings: string[]; valid: boolean; }> {
|
|
218
|
+
mockWalkDomainDir.mockResolvedValue({
|
|
219
|
+
domain: '/fake/domains/billing/domain.config.ts',
|
|
220
|
+
webhooks: [],
|
|
221
|
+
subscribers: [],
|
|
222
|
+
actions: actions.map(() => '/fake/domains/billing/actions/x.ts'),
|
|
223
|
+
schedules: [],
|
|
224
|
+
jobs: [],
|
|
225
|
+
integrations: [],
|
|
226
|
+
publishes: undefined,
|
|
227
|
+
});
|
|
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
|
+
mockLoadModuleExports.mockResolvedValueOnce([
|
|
237
|
+
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
238
|
+
]);
|
|
239
|
+
if (actions.length > 0) {
|
|
240
|
+
mockLoadModuleExports.mockResolvedValueOnce(actions as never);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const result = await runValidate(DOMAIN_ROOT, false);
|
|
244
|
+
return { errors: result.errors, warnings: result.warnings, valid: result.valid };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
it('ACTION_EXPOSURE_REQUIRED — fires when a non-private action omits exposure', async () => {
|
|
248
|
+
const { errors } = await runValidateWith({
|
|
249
|
+
actions: [
|
|
250
|
+
// `backendAccess: 'platform'` is the strongest "new-style" signal;
|
|
251
|
+
// omitting `exposure` should now fail the build.
|
|
252
|
+
{
|
|
253
|
+
_kind: 'action', _exportName: 'sysReset',
|
|
254
|
+
id: 'sys-reset', backendAccess: 'platform', idempotent: false,
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
});
|
|
258
|
+
const rule = errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED');
|
|
259
|
+
expect(rule).toBeDefined();
|
|
260
|
+
expect(rule?.message).toContain("'sys-reset'");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('ACTION_EXPOSURE_REQUIRED — does NOT fire for explicit internal exposure', async () => {
|
|
264
|
+
const { errors } = await runValidateWith({
|
|
265
|
+
actions: [
|
|
266
|
+
{
|
|
267
|
+
_kind: 'action', _exportName: 'sysReset',
|
|
268
|
+
id: 'sys-reset', backendAccess: 'platform',
|
|
269
|
+
exposure: { type: 'internal' },
|
|
270
|
+
idempotent: false,
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
});
|
|
274
|
+
expect(errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED')).toBeUndefined();
|
|
275
|
+
});
|
|
276
|
+
|
|
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
|
+
it('TENANT_API_PATH_REQUIRED — fires when tenancy is required but path lacks tenant placeholder', async () => {
|
|
293
|
+
const { errors } = await runValidateWith({
|
|
294
|
+
actions: [
|
|
295
|
+
{
|
|
296
|
+
_kind: 'action', _exportName: 'listAll',
|
|
297
|
+
id: 'list-all', backendAccess: 'platform',
|
|
298
|
+
exposure: {
|
|
299
|
+
type: 'api', path: '/v1/admin/users', method: 'GET',
|
|
300
|
+
auth: 'required', tenancy: 'required',
|
|
301
|
+
},
|
|
302
|
+
idempotent: false,
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
});
|
|
306
|
+
const rule = errors.find(e => e.code === 'TENANT_API_PATH_REQUIRED');
|
|
307
|
+
expect(rule).toBeDefined();
|
|
308
|
+
expect(rule?.message).toContain("'list-all'");
|
|
309
|
+
expect(rule?.message).toContain('/v1/tenants/{tenantId}/');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('TENANT_API_PATH_REQUIRED — passes when tenancy is required AND path includes placeholder', async () => {
|
|
313
|
+
const { errors } = await runValidateWith({
|
|
314
|
+
actions: [
|
|
315
|
+
{
|
|
316
|
+
_kind: 'action', _exportName: 'listUsers',
|
|
317
|
+
id: 'list-users', backendAccess: 'domain',
|
|
318
|
+
exposure: {
|
|
319
|
+
type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
|
|
320
|
+
auth: 'required', tenancy: 'required',
|
|
321
|
+
},
|
|
322
|
+
idempotent: false,
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
});
|
|
326
|
+
expect(errors.find(e => e.code === 'TENANT_API_PATH_REQUIRED')).toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('API_EXPOSURE_AUTH_REQUIRED — fires when auth was defaulted (not declared in source)', async () => {
|
|
330
|
+
// Source did NOT include `auth` — the builder will default to
|
|
331
|
+
// 'required' and the validator must surface the omission.
|
|
332
|
+
const { errors } = await runValidateWith({
|
|
333
|
+
actions: [
|
|
334
|
+
{
|
|
335
|
+
_kind: 'action', _exportName: 'listUsers',
|
|
336
|
+
id: 'list-users', backendAccess: 'domain',
|
|
337
|
+
exposure: {
|
|
338
|
+
type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
|
|
339
|
+
tenancy: 'required',
|
|
340
|
+
// auth omitted on purpose
|
|
341
|
+
},
|
|
342
|
+
idempotent: false,
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
});
|
|
346
|
+
const rule = errors.find(e => e.code === 'API_EXPOSURE_AUTH_REQUIRED');
|
|
347
|
+
expect(rule).toBeDefined();
|
|
348
|
+
expect(rule?.message).toContain("'list-users'");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('API_EXPOSURE_AUTH_REQUIRED — silent when auth is explicitly declared', async () => {
|
|
352
|
+
const { errors } = await runValidateWith({
|
|
353
|
+
actions: [
|
|
354
|
+
{
|
|
355
|
+
_kind: 'action', _exportName: 'listUsers',
|
|
356
|
+
id: 'list-users', backendAccess: 'domain',
|
|
357
|
+
exposure: {
|
|
358
|
+
type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
|
|
359
|
+
auth: 'required', tenancy: 'required',
|
|
360
|
+
},
|
|
361
|
+
idempotent: false,
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
});
|
|
365
|
+
expect(errors.find(e => e.code === 'API_EXPOSURE_AUTH_REQUIRED')).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('AUTH_NONE_REQUIRES_EXCEPTION — fires when auth is none without securityException', async () => {
|
|
369
|
+
const { errors } = await runValidateWith({
|
|
370
|
+
actions: [
|
|
371
|
+
{
|
|
372
|
+
_kind: 'action', _exportName: 'health',
|
|
373
|
+
id: 'health', backendAccess: 'platform',
|
|
374
|
+
exposure: {
|
|
375
|
+
type: 'api', path: '/v1/health', method: 'GET',
|
|
376
|
+
auth: 'none', tenancy: 'none',
|
|
377
|
+
},
|
|
378
|
+
idempotent: false,
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
const rule = errors.find(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION');
|
|
383
|
+
expect(rule).toBeDefined();
|
|
384
|
+
expect(rule?.message).toContain("'health'");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('AUTH_NONE_REQUIRES_EXCEPTION — passes when auth is none WITH securityException.reason', async () => {
|
|
388
|
+
const { errors } = await runValidateWith({
|
|
389
|
+
actions: [
|
|
390
|
+
{
|
|
391
|
+
_kind: 'action', _exportName: 'health',
|
|
392
|
+
id: 'health', backendAccess: 'platform',
|
|
393
|
+
exposure: {
|
|
394
|
+
type: 'api', path: '/v1/health', method: 'GET',
|
|
395
|
+
auth: 'none', tenancy: 'none',
|
|
396
|
+
securityException: { reason: 'public liveness probe; no PII; ticket OPS-123' },
|
|
397
|
+
},
|
|
398
|
+
idempotent: false,
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
});
|
|
402
|
+
expect(errors.find(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION')).toBeUndefined();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('AUTH_NONE_REQUIRES_EXCEPTION — fires when securityException.reason is whitespace-only', async () => {
|
|
406
|
+
const { errors } = await runValidateWith({
|
|
407
|
+
actions: [
|
|
408
|
+
{
|
|
409
|
+
_kind: 'action', _exportName: 'health',
|
|
410
|
+
id: 'health', backendAccess: 'platform',
|
|
411
|
+
exposure: {
|
|
412
|
+
type: 'api', path: '/v1/health', method: 'GET',
|
|
413
|
+
auth: 'none', tenancy: 'none',
|
|
414
|
+
securityException: { reason: ' ' },
|
|
415
|
+
},
|
|
416
|
+
idempotent: false,
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
});
|
|
420
|
+
expect(errors.find(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION')).toBeDefined();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC — fires when tenancy is none without securityException', async () => {
|
|
424
|
+
const { errors } = await runValidateWith({
|
|
425
|
+
actions: [
|
|
426
|
+
{
|
|
427
|
+
_kind: 'action', _exportName: 'publicLookup',
|
|
428
|
+
id: 'public-lookup', backendAccess: 'platform',
|
|
429
|
+
exposure: {
|
|
430
|
+
type: 'api', path: '/v1/public/lookup', method: 'GET',
|
|
431
|
+
auth: 'required', tenancy: 'none',
|
|
432
|
+
},
|
|
433
|
+
idempotent: false,
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
});
|
|
437
|
+
const rule = errors.find(e => e.code === 'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC');
|
|
438
|
+
expect(rule).toBeDefined();
|
|
439
|
+
expect(rule?.message).toContain("'public-lookup'");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('SYSTEM_API_REQUIRES_ROLE — fires when system tenancy + required auth without roles', async () => {
|
|
443
|
+
const { errors } = await runValidateWith({
|
|
444
|
+
actions: [
|
|
445
|
+
{
|
|
446
|
+
_kind: 'action', _exportName: 'adminPing',
|
|
447
|
+
id: 'admin-ping', backendAccess: 'platform',
|
|
448
|
+
exposure: {
|
|
449
|
+
type: 'api', path: '/v1/admin/ping', method: 'GET',
|
|
450
|
+
auth: 'required', tenancy: 'system',
|
|
451
|
+
},
|
|
452
|
+
idempotent: false,
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
});
|
|
456
|
+
const rule = errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE');
|
|
457
|
+
expect(rule).toBeDefined();
|
|
458
|
+
expect(rule?.message).toContain("'admin-ping'");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('SYSTEM_API_REQUIRES_ROLE — passes when system tenancy has non-empty roles', async () => {
|
|
462
|
+
const { errors } = await runValidateWith({
|
|
463
|
+
actions: [
|
|
464
|
+
{
|
|
465
|
+
_kind: 'action', _exportName: 'adminPing',
|
|
466
|
+
id: 'admin-ping', backendAccess: 'platform',
|
|
467
|
+
exposure: {
|
|
468
|
+
type: 'api', path: '/v1/admin/ping', method: 'GET',
|
|
469
|
+
auth: 'required', tenancy: 'system',
|
|
470
|
+
roles: ['sys-admin'],
|
|
471
|
+
},
|
|
472
|
+
idempotent: false,
|
|
473
|
+
},
|
|
474
|
+
],
|
|
475
|
+
});
|
|
476
|
+
expect(errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBeUndefined();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('SYSTEM_API_REQUIRES_ROLE — silent when auth is service (service-only route, no role narrowing required)', async () => {
|
|
480
|
+
const { errors } = await runValidateWith({
|
|
481
|
+
actions: [
|
|
482
|
+
{
|
|
483
|
+
_kind: 'action', _exportName: 'replicationHook',
|
|
484
|
+
id: 'replication-hook', backendAccess: 'platform',
|
|
485
|
+
exposure: {
|
|
486
|
+
type: 'api', path: '/v1/admin/replicate', method: 'POST',
|
|
487
|
+
auth: 'service', tenancy: 'system',
|
|
488
|
+
},
|
|
489
|
+
idempotent: false,
|
|
490
|
+
},
|
|
491
|
+
],
|
|
492
|
+
});
|
|
493
|
+
expect(errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBeUndefined();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('combined: well-formed api action passes every rule', async () => {
|
|
497
|
+
const { errors } = await runValidateWith({
|
|
498
|
+
actions: [
|
|
499
|
+
{
|
|
500
|
+
_kind: 'action', _exportName: 'chargeCard',
|
|
501
|
+
id: 'charge-card', backendAccess: 'domain',
|
|
502
|
+
exposure: {
|
|
503
|
+
type: 'api', path: '/v1/tenants/{tenantId}/billing/charges', method: 'POST',
|
|
504
|
+
auth: 'required', tenancy: 'required',
|
|
505
|
+
roles: ['billing-admin'],
|
|
506
|
+
},
|
|
507
|
+
idempotent: true,
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
});
|
|
511
|
+
const securityCodes = [
|
|
512
|
+
'ACTION_EXPOSURE_REQUIRED', 'API_EXPOSURE_AUTH_REQUIRED',
|
|
513
|
+
'TENANT_API_PATH_REQUIRED', 'AUTH_NONE_REQUIRES_EXCEPTION',
|
|
514
|
+
'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC', 'SYSTEM_API_REQUIRES_ROLE',
|
|
515
|
+
];
|
|
516
|
+
const firedSecurityRules = errors.filter(e => securityCodes.includes(e.code));
|
|
517
|
+
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
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Deployment-time security rules (#4662 Task D, #4689).
|
|
526
|
+
*
|
|
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
|
+
* Issue #4689: defineApi and registry.apis were removed. The action-first
|
|
533
|
+
* `TENANT_API_PATH_REQUIRED` rule in the `#4619 / Wave 6` suite now owns
|
|
534
|
+
* the placeholder invariant. The deployment-time rules below assert that
|
|
535
|
+
* no duplicate `SECURITY_MISSING_TENANT_PATH` codes are emitted for the
|
|
536
|
+
* action surface.
|
|
537
|
+
*/
|
|
538
|
+
describe('runValidate deployment-time security rules (#4662 Task D)', () => {
|
|
539
|
+
const DOMAIN_ROOT = '/fake/domains/billing';
|
|
540
|
+
|
|
541
|
+
beforeEach(() => {
|
|
542
|
+
vi.clearAllMocks();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
async function runValidateWith({
|
|
546
|
+
actions = [],
|
|
547
|
+
}: {
|
|
548
|
+
actions?: Record<string, unknown>[];
|
|
549
|
+
}): Promise<{ errors: { code: string; message: string }[]; warnings: string[]; valid: boolean; }> {
|
|
550
|
+
mockWalkDomainDir.mockResolvedValue({
|
|
551
|
+
domain: '/fake/domains/billing/domain.config.ts',
|
|
552
|
+
webhooks: [],
|
|
553
|
+
subscribers: [],
|
|
554
|
+
actions: actions.map(() => '/fake/domains/billing/actions/x.ts'),
|
|
555
|
+
schedules: [],
|
|
556
|
+
jobs: [],
|
|
557
|
+
integrations: [],
|
|
558
|
+
publishes: undefined,
|
|
559
|
+
});
|
|
560
|
+
mockLoadModuleExports.mockResolvedValueOnce([
|
|
561
|
+
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
562
|
+
]);
|
|
563
|
+
if (actions.length > 0) {
|
|
564
|
+
mockLoadModuleExports.mockResolvedValueOnce(actions as never);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const result = await runValidate(DOMAIN_ROOT, false);
|
|
568
|
+
return { errors: result.errors, warnings: result.warnings, valid: result.valid };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
it('does NOT emit SECURITY_MISSING_TENANT_PATH for an action that already includes the tenant placeholder', async () => {
|
|
572
|
+
const { errors } = await runValidateWith({
|
|
573
|
+
actions: [
|
|
574
|
+
{
|
|
575
|
+
_kind: 'action', _exportName: 'listUsers',
|
|
576
|
+
id: 'list-users', backendAccess: 'domain',
|
|
577
|
+
exposure: {
|
|
578
|
+
type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
|
|
579
|
+
auth: 'required', tenancy: 'required',
|
|
580
|
+
},
|
|
581
|
+
idempotent: true,
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
});
|
|
585
|
+
expect(errors.find(e => e.code === 'SECURITY_MISSING_TENANT_PATH')).toBeUndefined();
|
|
586
|
+
});
|
|
587
|
+
});
|