@mettlecast/domain-cli 0.2.60 → 0.2.62
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 +1 -36
- package/dist/builder/build-types.d.ts +1 -1
- package/dist/builder/load-module.d.ts +1 -1
- package/dist/cli.js +21 -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 +6 -22
- package/dist/commands/build-catalog.js +7 -18
- package/dist/commands/build.js +2 -1
- package/dist/commands/check-hashes.d.ts +2 -0
- package/dist/commands/check-hashes.js +8 -0
- package/dist/commands/dev.js +1 -1
- package/dist/commands/doctor.js +70 -41
- 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/regenerate-modules-hashes.d.ts +25 -0
- package/dist/commands/regenerate-modules-hashes.js +58 -0
- 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/update-all.d.ts +21 -0
- package/dist/commands/update-all.js +62 -0
- package/dist/commands/upgrade-backend.js +3 -3
- package/dist/commands/validate.js +12 -90
- 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/manifest.d.ts +1 -0
- package/dist/utils/manifest.js +16 -1
- package/dist/utils/scaffold-config.d.ts +6 -0
- package/dist/utils/scaffold-config.js +2 -0
- 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/build-flows.test.ts +55 -0
- package/src/__tests__/commands/check-hashes.test.ts +31 -0
- package/src/__tests__/commands/create-project.test.ts +5 -5
- package/src/__tests__/commands/dev.test.ts +0 -1
- package/src/__tests__/commands/regenerate-modules-hashes.test.ts +170 -0
- package/src/__tests__/commands/update-all.test.ts +322 -0
- package/src/__tests__/doctor.test.ts +73 -0
- package/src/__tests__/mount-routes.test.ts +64 -23
- package/src/__tests__/package-freshness.test.ts +1 -21
- package/src/__tests__/smoke/scaffold.test.ts +13 -15
- package/src/__tests__/utils/manifest.test.ts +128 -0
- package/src/__tests__/validate.test.ts +21 -103
- package/src/builder/build-registry.ts +7 -44
- package/src/builder/build-types.ts +1 -1
- package/src/cli.ts +23 -3
- 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 +13 -35
- package/src/commands/build.ts +3 -2
- package/src/commands/check-hashes.ts +12 -0
- package/src/commands/dev.ts +1 -1
- package/src/commands/doctor.ts +72 -41
- package/src/commands/explain.ts +13 -13
- package/src/commands/generate-openapi.ts +30 -52
- package/src/commands/regenerate-modules-hashes.ts +89 -0
- package/src/commands/show.ts +2 -5
- package/src/commands/test.ts +0 -1
- package/src/commands/update-all.ts +79 -0
- package/src/commands/upgrade-backend.ts +3 -3
- package/src/commands/validate.ts +11 -96
- 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/manifest.ts +16 -1
- package/src/utils/scaffold-config.ts +9 -0
|
@@ -16,43 +16,55 @@ vi.mock('../runtime-stubs/hydrate-local-ctx.js', () => ({
|
|
|
16
16
|
|
|
17
17
|
import { mountRoutes } from '../server/mount-routes.js';
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Build a minimal registry whose `actions[]` contains a single
|
|
21
|
+
* API-exposed entry per call. Issue #4689: the only HTTP endpoint
|
|
22
|
+
* surface is `actions[]` with `exposure.type === 'api'`.
|
|
23
|
+
*/
|
|
24
|
+
function makeRegistry(actions: Array<Partial<DomainRegistry['actions'][0]>>): DomainRegistry {
|
|
20
25
|
return {
|
|
21
26
|
schemaVersion: '1',
|
|
22
27
|
domainRoot: '/fake/domain',
|
|
23
28
|
domain: { id: 'test', kind: 'domain', name: 'Test', tenancy: 'none' },
|
|
24
|
-
apis: apis.map((a, i) => ({
|
|
25
|
-
id: `api-${i}`,
|
|
26
|
-
kind: 'api' as const,
|
|
27
|
-
handlerFile: `api/handler-${i}.ts`,
|
|
28
|
-
path: `/route-${i}`,
|
|
29
|
-
method: 'GET',
|
|
30
|
-
authType: 'none' as const,
|
|
31
|
-
...a,
|
|
32
|
-
})),
|
|
33
29
|
webhooks: [],
|
|
34
30
|
subscribers: [],
|
|
35
31
|
schedules: [],
|
|
36
32
|
jobs: [],
|
|
37
|
-
actions:
|
|
33
|
+
actions: actions.map((a, i) => ({
|
|
34
|
+
id: `action-${i}`,
|
|
35
|
+
kind: 'action' as const,
|
|
36
|
+
handlerFile: `actions/handler-${i}.ts`,
|
|
37
|
+
backendAccess: 'domain' as const,
|
|
38
|
+
exposure: {
|
|
39
|
+
type: 'api' as const,
|
|
40
|
+
path: `/route-${i}`,
|
|
41
|
+
method: 'GET' as const,
|
|
42
|
+
auth: 'required' as const,
|
|
43
|
+
tenancy: 'required' as const,
|
|
44
|
+
},
|
|
45
|
+
idempotent: true,
|
|
46
|
+
...a,
|
|
47
|
+
})) as DomainRegistry['actions'],
|
|
38
48
|
integrations: [],
|
|
39
49
|
events: [],
|
|
40
50
|
};
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
describe('mountRoutes', () => {
|
|
44
|
-
it('registers one route per
|
|
54
|
+
it('registers one route per API-exposed action', async () => {
|
|
45
55
|
const routes: Array<{ method: unknown; url: string }> = [];
|
|
46
56
|
const server = {
|
|
47
57
|
route: vi.fn((opts: { method: unknown; url: string }) => { routes.push(opts); }),
|
|
48
58
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
49
59
|
};
|
|
50
60
|
|
|
51
|
-
vi.doMock('/fake/domain/
|
|
52
|
-
|
|
61
|
+
vi.doMock('/fake/domain/actions/handler-0.ts', () => ({
|
|
62
|
+
myAction: { id: 'action-0', _kind: 'action', handler: vi.fn().mockResolvedValue({ ok: true }) },
|
|
53
63
|
}));
|
|
54
64
|
|
|
55
|
-
const registry = makeRegistry([
|
|
65
|
+
const registry = makeRegistry([
|
|
66
|
+
{ id: 'action-0', handlerFile: 'actions/handler-0.ts', exposure: { type: 'api', path: '/route-0', method: 'GET', auth: 'required', tenancy: 'required' } },
|
|
67
|
+
]);
|
|
56
68
|
|
|
57
69
|
await mountRoutes({ server: server as never, registry, domainRoot: '/fake/domain' });
|
|
58
70
|
|
|
@@ -67,13 +79,13 @@ describe('mountRoutes', () => {
|
|
|
67
79
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
68
80
|
};
|
|
69
81
|
|
|
70
|
-
vi.doMock('/fake/domain/
|
|
71
|
-
|
|
82
|
+
vi.doMock('/fake/domain/actions/handler-1.ts', () => ({
|
|
83
|
+
myAction: { id: 'action-1', _kind: 'action', handler: vi.fn() },
|
|
72
84
|
}));
|
|
73
85
|
|
|
74
|
-
const registry = makeRegistry([
|
|
75
|
-
id: '
|
|
76
|
-
|
|
86
|
+
const registry = makeRegistry([
|
|
87
|
+
{ id: 'action-1', handlerFile: 'actions/handler-1.ts', exposure: { type: 'api', path: '/users/{id}/orders', method: 'GET', auth: 'required', tenancy: 'required' } },
|
|
88
|
+
]);
|
|
77
89
|
|
|
78
90
|
await mountRoutes({ server: server as never, registry, domainRoot: '/fake/domain' });
|
|
79
91
|
|
|
@@ -86,15 +98,44 @@ describe('mountRoutes', () => {
|
|
|
86
98
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
87
99
|
};
|
|
88
100
|
|
|
89
|
-
vi.doMock('/fake/domain/
|
|
90
|
-
noHandler: { id: '
|
|
101
|
+
vi.doMock('/fake/domain/actions/handler-2.ts', () => ({
|
|
102
|
+
noHandler: { id: 'action-2', _kind: 'action' },
|
|
91
103
|
}));
|
|
92
104
|
|
|
93
|
-
const registry = makeRegistry([
|
|
105
|
+
const registry = makeRegistry([
|
|
106
|
+
{ id: 'action-2', handlerFile: 'actions/handler-2.ts', exposure: { type: 'api', path: '/skip', method: 'GET', auth: 'required', tenancy: 'required' } },
|
|
107
|
+
]);
|
|
94
108
|
|
|
95
109
|
await mountRoutes({ server: server as never, registry, domainRoot: '/fake/domain' });
|
|
96
110
|
|
|
97
111
|
expect(server.route).not.toHaveBeenCalled();
|
|
98
112
|
expect(server.log.warn).toHaveBeenCalled();
|
|
99
113
|
});
|
|
114
|
+
|
|
115
|
+
it('skips internal-only actions (exposure.type !== "api")', async () => {
|
|
116
|
+
const server = {
|
|
117
|
+
route: vi.fn(),
|
|
118
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
vi.doMock('/fake/domain/actions/handler-internal.ts', () => ({
|
|
122
|
+
internalAction: { id: 'internal-1', _kind: 'action', handler: vi.fn() },
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
const registry: DomainRegistry = {
|
|
126
|
+
...makeRegistry([]),
|
|
127
|
+
actions: [{
|
|
128
|
+
id: 'internal-1',
|
|
129
|
+
kind: 'action',
|
|
130
|
+
handlerFile: 'actions/handler-internal.ts',
|
|
131
|
+
backendAccess: 'domain',
|
|
132
|
+
exposure: { type: 'internal' },
|
|
133
|
+
idempotent: true,
|
|
134
|
+
} as unknown as DomainRegistry['actions'][0]],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
await mountRoutes({ server: server as never, registry, domainRoot: '/fake/domain' });
|
|
138
|
+
|
|
139
|
+
expect(server.route).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
100
141
|
});
|
|
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import {
|
|
3
3
|
ActionBackendAccess,
|
|
4
4
|
type ActionApiExposure,
|
|
5
|
-
type ApiRegistryEntry,
|
|
6
5
|
type DomainRegistry,
|
|
7
6
|
type ActionRegistryEntry,
|
|
8
7
|
} from '@mettlecast/domain-cdk-packer';
|
|
@@ -45,24 +44,6 @@ describe('CLI ↔ CDK packer type freshness (#4662 Task D)', () => {
|
|
|
45
44
|
expect(exposure.securityException?.reason).toMatch(/OPS-123/);
|
|
46
45
|
});
|
|
47
46
|
|
|
48
|
-
it('exposes the ApiRegistryEntry.securityException field added in #4662', () => {
|
|
49
|
-
// The build-registry does not populate this field for legacy
|
|
50
|
-
// `defineApi` entries (they don't carry securityException in
|
|
51
|
-
// source), but the registry type must declare it so that
|
|
52
|
-
// future migrations can attach it. This assertion pins the field
|
|
53
|
-
// shape so the CLI doesn't silently fall behind if a future
|
|
54
|
-
// packer refactor removes the declaration.
|
|
55
|
-
const api: ApiRegistryEntry = {
|
|
56
|
-
kind: 'api',
|
|
57
|
-
handlerFile: 'src/api.ts',
|
|
58
|
-
path: '/v1/health',
|
|
59
|
-
method: 'GET',
|
|
60
|
-
authType: 'none',
|
|
61
|
-
securityException: { reason: 'public liveness probe' },
|
|
62
|
-
};
|
|
63
|
-
expect(api.securityException?.reason).toMatch(/liveness/);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
47
|
it('exposes the ActionRegistryEntry shape with the latest exposure metadata flags', () => {
|
|
67
48
|
const action: ActionRegistryEntry = {
|
|
68
49
|
kind: 'action',
|
|
@@ -82,7 +63,6 @@ describe('CLI ↔ CDK packer type freshness (#4662 Task D)', () => {
|
|
|
82
63
|
schemaVersion: '1',
|
|
83
64
|
domainRoot: '/tmp/fake',
|
|
84
65
|
domain: { kind: 'domain', id: 'test', name: 'Test', tenancy: 'required' },
|
|
85
|
-
apis: [],
|
|
86
66
|
webhooks: [],
|
|
87
67
|
subscribers: [],
|
|
88
68
|
schedules: [],
|
|
@@ -111,4 +91,4 @@ describe('CLI ↔ CDK packer type freshness (#4662 Task D)', () => {
|
|
|
111
91
|
?? parsed.devDependencies?.['@mettlecast/domain-cdk-packer'];
|
|
112
92
|
expect(['*', 'workspace:*']).toContain(range);
|
|
113
93
|
});
|
|
114
|
-
});
|
|
94
|
+
});
|
|
@@ -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
|
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
upsertManifestFile,
|
|
20
20
|
inferPolicyFromPath,
|
|
21
21
|
getManifestFile,
|
|
22
|
+
pruneManifest,
|
|
22
23
|
type ManifestFileEntry,
|
|
23
24
|
type TibManifest,
|
|
24
25
|
} from '../../utils/manifest.js';
|
|
@@ -271,4 +272,131 @@ describe('manifest utilities', () => {
|
|
|
271
272
|
await rm(tmpDir, { recursive: true, force: true });
|
|
272
273
|
});
|
|
273
274
|
});
|
|
275
|
+
|
|
276
|
+
describe('pruneManifest', () => {
|
|
277
|
+
it('removes entries for files that do not exist on disk', async () => {
|
|
278
|
+
const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
|
|
279
|
+
// Two entries: one file exists, one does not.
|
|
280
|
+
const existingPath = 'infra/main.tf';
|
|
281
|
+
const missingPath = 'infra/deleted.ts';
|
|
282
|
+
await mkdir(join(tmpDir, 'infra'), { recursive: true });
|
|
283
|
+
await writeFile(join(tmpDir, existingPath), 'content');
|
|
284
|
+
|
|
285
|
+
upsertManifestFile(manifest, {
|
|
286
|
+
path: existingPath,
|
|
287
|
+
module: 'core',
|
|
288
|
+
moduleVersion: '1.0.0',
|
|
289
|
+
sha256: 'abc',
|
|
290
|
+
wasTemplate: false,
|
|
291
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
292
|
+
policy: 'managed',
|
|
293
|
+
});
|
|
294
|
+
upsertManifestFile(manifest, {
|
|
295
|
+
path: missingPath,
|
|
296
|
+
module: 'core',
|
|
297
|
+
moduleVersion: '1.0.0',
|
|
298
|
+
sha256: 'def',
|
|
299
|
+
wasTemplate: false,
|
|
300
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
301
|
+
policy: 'managed',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const pruned = await pruneManifest(tmpDir, manifest);
|
|
305
|
+
|
|
306
|
+
expect(pruned).toBe(1);
|
|
307
|
+
expect(manifest.files).toHaveLength(1);
|
|
308
|
+
expect(manifest.files[0]?.path).toBe(existingPath);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('keeps entries for files that exist on disk', async () => {
|
|
312
|
+
const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
|
|
313
|
+
const filePath = 'infra/modules/app.ts';
|
|
314
|
+
await mkdir(join(tmpDir, 'infra', 'modules'), { recursive: true });
|
|
315
|
+
await writeFile(join(tmpDir, filePath), '// app');
|
|
316
|
+
|
|
317
|
+
upsertManifestFile(manifest, {
|
|
318
|
+
path: filePath,
|
|
319
|
+
module: 'core',
|
|
320
|
+
moduleVersion: '1.0.0',
|
|
321
|
+
sha256: 'ghi',
|
|
322
|
+
wasTemplate: true,
|
|
323
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
324
|
+
policy: 'managed',
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const pruned = await pruneManifest(tmpDir, manifest);
|
|
328
|
+
|
|
329
|
+
expect(pruned).toBe(0);
|
|
330
|
+
expect(manifest.files).toHaveLength(1);
|
|
331
|
+
expect(manifest.files[0]?.path).toBe(filePath);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('returns count of pruned entries', async () => {
|
|
335
|
+
const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
|
|
336
|
+
// Three entries, all missing on disk.
|
|
337
|
+
const paths = ['a/missing1.ts', 'b/missing2.ts', 'c/missing3.ts'];
|
|
338
|
+
for (const path of paths) {
|
|
339
|
+
upsertManifestFile(manifest, {
|
|
340
|
+
path,
|
|
341
|
+
module: 'core',
|
|
342
|
+
moduleVersion: '1.0.0',
|
|
343
|
+
sha256: 'x',
|
|
344
|
+
wasTemplate: false,
|
|
345
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
346
|
+
policy: 'managed',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const pruned = await pruneManifest(tmpDir, manifest);
|
|
351
|
+
|
|
352
|
+
expect(pruned).toBe(3);
|
|
353
|
+
expect(manifest.files).toHaveLength(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('handles empty manifest.files array', async () => {
|
|
357
|
+
const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
|
|
358
|
+
// manifest.files is [] by default.
|
|
359
|
+
|
|
360
|
+
const pruned = await pruneManifest(tmpDir, manifest);
|
|
361
|
+
|
|
362
|
+
expect(pruned).toBe(0);
|
|
363
|
+
expect(manifest.files).toEqual([]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('mutates manifest.files in place', async () => {
|
|
367
|
+
const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
|
|
368
|
+
const survivor = 'frontend/src/pages/auth/login.tsx';
|
|
369
|
+
await mkdir(join(tmpDir, 'frontend', 'src', 'pages', 'auth'), { recursive: true });
|
|
370
|
+
await writeFile(join(tmpDir, survivor), '// login');
|
|
371
|
+
|
|
372
|
+
upsertManifestFile(manifest, {
|
|
373
|
+
path: survivor,
|
|
374
|
+
module: 'frontend',
|
|
375
|
+
moduleVersion: '1.0.0',
|
|
376
|
+
sha256: 'kept',
|
|
377
|
+
wasTemplate: true,
|
|
378
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
379
|
+
policy: 'seed',
|
|
380
|
+
});
|
|
381
|
+
upsertManifestFile(manifest, {
|
|
382
|
+
path: 'frontend/src/pages/gone.tsx',
|
|
383
|
+
module: 'frontend',
|
|
384
|
+
moduleVersion: '1.0.0',
|
|
385
|
+
sha256: 'gone',
|
|
386
|
+
wasTemplate: true,
|
|
387
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
388
|
+
policy: 'seed',
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const initialLength = manifest.files.length;
|
|
392
|
+
const pruned = await pruneManifest(tmpDir, manifest);
|
|
393
|
+
|
|
394
|
+
// Mutation is observable through the same manifest reference — caller
|
|
395
|
+
// does not need a return value to update its own copy.
|
|
396
|
+
expect(manifest.files).not.toHaveLength(initialLength);
|
|
397
|
+
expect(manifest.files).toHaveLength(1);
|
|
398
|
+
expect(manifest.files[0]?.path).toBe(survivor);
|
|
399
|
+
expect(pruned).toBe(1);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
274
402
|
});
|
|
@@ -49,7 +49,6 @@ describe('buildRegistry action mapping (action-first migration)', () => {
|
|
|
49
49
|
it('reads backendAccess directly when raw action exposes it', async () => {
|
|
50
50
|
mockWalkDomainDir.mockResolvedValue({
|
|
51
51
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
52
|
-
apis: [],
|
|
53
52
|
webhooks: [],
|
|
54
53
|
subscribers: [],
|
|
55
54
|
actions: ['/fake/domains/billing/actions/charge-card.ts'],
|
|
@@ -97,7 +96,6 @@ describe('buildRegistry action mapping (action-first migration)', () => {
|
|
|
97
96
|
it('falls back from legacy visibility to backendAccess and collapses workspace -> domain', async () => {
|
|
98
97
|
mockWalkDomainDir.mockResolvedValue({
|
|
99
98
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
100
|
-
apis: [],
|
|
101
99
|
webhooks: [],
|
|
102
100
|
subscribers: [],
|
|
103
101
|
actions: [
|
|
@@ -133,7 +131,6 @@ describe('buildRegistry action mapping (action-first migration)', () => {
|
|
|
133
131
|
it('defaults missing exposure to { type: internal } for legacy actions', async () => {
|
|
134
132
|
mockWalkDomainDir.mockResolvedValue({
|
|
135
133
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
136
|
-
apis: [],
|
|
137
134
|
webhooks: [],
|
|
138
135
|
subscribers: [],
|
|
139
136
|
actions: ['/fake/domains/billing/actions/legacy.ts'],
|
|
@@ -159,7 +156,6 @@ describe('buildRegistry action mapping (action-first migration)', () => {
|
|
|
159
156
|
it('preserves inputSchema and outputSchema extraction', async () => {
|
|
160
157
|
mockWalkDomainDir.mockResolvedValue({
|
|
161
158
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
162
|
-
apis: [],
|
|
163
159
|
webhooks: [],
|
|
164
160
|
subscribers: [],
|
|
165
161
|
actions: ['/fake/domains/billing/actions/charge-card.ts'],
|
|
@@ -210,20 +206,17 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
210
206
|
|
|
211
207
|
/**
|
|
212
208
|
* Helper that wires the two file-walker / module-loader mocks so
|
|
213
|
-
* `buildRegistry` returns the supplied actions
|
|
214
|
-
* domain row
|
|
209
|
+
* `buildRegistry` returns the supplied actions plus the canonical
|
|
210
|
+
* domain row. Returns the ValidationResult
|
|
215
211
|
* produced by `runValidate`.
|
|
216
212
|
*/
|
|
217
213
|
async function runValidateWith({
|
|
218
214
|
actions = [],
|
|
219
|
-
apis = [],
|
|
220
215
|
}: {
|
|
221
216
|
actions?: Record<string, unknown>[];
|
|
222
|
-
apis?: Record<string, unknown>[];
|
|
223
217
|
}): Promise<{ errors: { code: string; message: string }[]; warnings: string[]; valid: boolean; }> {
|
|
224
218
|
mockWalkDomainDir.mockResolvedValue({
|
|
225
219
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
226
|
-
apis: apis.map(() => '/fake/domains/billing/api/x.ts'),
|
|
227
220
|
webhooks: [],
|
|
228
221
|
subscribers: [],
|
|
229
222
|
actions: actions.map(() => '/fake/domains/billing/actions/x.ts'),
|
|
@@ -234,19 +227,15 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
234
227
|
});
|
|
235
228
|
// Mock consumption order inside buildRegistry:
|
|
236
229
|
// 1. domain (paths.domain) -> mock #1
|
|
237
|
-
// 2.
|
|
238
|
-
// 3.
|
|
239
|
-
// 4.
|
|
240
|
-
// 5. publishes (paths.publishes) -> undefined, no call
|
|
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
|
|
241
233
|
// Each non-empty slot consumes exactly one mock per file (we
|
|
242
234
|
// generate one synthetic file path per item so the slot has length
|
|
243
235
|
// equal to the supplied arrays).
|
|
244
236
|
mockLoadModuleExports.mockResolvedValueOnce([
|
|
245
237
|
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
246
238
|
]);
|
|
247
|
-
if (apis.length > 0) {
|
|
248
|
-
mockLoadModuleExports.mockResolvedValueOnce(apis as never);
|
|
249
|
-
}
|
|
250
239
|
if (actions.length > 0) {
|
|
251
240
|
mockLoadModuleExports.mockResolvedValueOnce(actions as never);
|
|
252
241
|
}
|
|
@@ -504,51 +493,6 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
504
493
|
expect(errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBeUndefined();
|
|
505
494
|
});
|
|
506
495
|
|
|
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
496
|
it('combined: well-formed api action passes every rule', async () => {
|
|
553
497
|
const { errors } = await runValidateWith({
|
|
554
498
|
actions: [
|
|
@@ -568,7 +512,6 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
568
512
|
'ACTION_EXPOSURE_REQUIRED', 'API_EXPOSURE_AUTH_REQUIRED',
|
|
569
513
|
'TENANT_API_PATH_REQUIRED', 'AUTH_NONE_REQUIRES_EXCEPTION',
|
|
570
514
|
'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC', 'SYSTEM_API_REQUIRES_ROLE',
|
|
571
|
-
'DEFINE_API_LEGACY_USAGE',
|
|
572
515
|
];
|
|
573
516
|
const firedSecurityRules = errors.filter(e => securityCodes.includes(e.code));
|
|
574
517
|
expect(firedSecurityRules).toHaveLength(0);
|
|
@@ -579,12 +522,18 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
|
|
|
579
522
|
});
|
|
580
523
|
|
|
581
524
|
/**
|
|
582
|
-
* Deployment-time security rules (#4662 Task D).
|
|
525
|
+
* Deployment-time security rules (#4662 Task D, #4689).
|
|
583
526
|
*
|
|
584
527
|
* These mirror the CDK aspect's synth-time checks so CI can gate the
|
|
585
528
|
* build BEFORE synth is attempted. Each rule emits a structured
|
|
586
529
|
* `ValidationError` whose `code` matches the aspect annotation code,
|
|
587
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.
|
|
588
537
|
*/
|
|
589
538
|
describe('runValidate deployment-time security rules (#4662 Task D)', () => {
|
|
590
539
|
const DOMAIN_ROOT = '/fake/domains/billing';
|
|
@@ -595,14 +544,11 @@ describe('runValidate deployment-time security rules (#4662 Task D)', () => {
|
|
|
595
544
|
|
|
596
545
|
async function runValidateWith({
|
|
597
546
|
actions = [],
|
|
598
|
-
apis = [],
|
|
599
547
|
}: {
|
|
600
548
|
actions?: Record<string, unknown>[];
|
|
601
|
-
apis?: Record<string, unknown>[];
|
|
602
549
|
}): Promise<{ errors: { code: string; message: string }[]; warnings: string[]; valid: boolean; }> {
|
|
603
550
|
mockWalkDomainDir.mockResolvedValue({
|
|
604
551
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
605
|
-
apis: apis.map(() => '/fake/domains/billing/api/x.ts'),
|
|
606
552
|
webhooks: [],
|
|
607
553
|
subscribers: [],
|
|
608
554
|
actions: actions.map(() => '/fake/domains/billing/actions/x.ts'),
|
|
@@ -614,9 +560,6 @@ describe('runValidate deployment-time security rules (#4662 Task D)', () => {
|
|
|
614
560
|
mockLoadModuleExports.mockResolvedValueOnce([
|
|
615
561
|
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
616
562
|
]);
|
|
617
|
-
if (apis.length > 0) {
|
|
618
|
-
mockLoadModuleExports.mockResolvedValueOnce(apis as never);
|
|
619
|
-
}
|
|
620
563
|
if (actions.length > 0) {
|
|
621
564
|
mockLoadModuleExports.mockResolvedValueOnce(actions as never);
|
|
622
565
|
}
|
|
@@ -625,42 +568,17 @@ describe('runValidate deployment-time security rules (#4662 Task D)', () => {
|
|
|
625
568
|
return { errors: result.errors, warnings: result.warnings, valid: result.valid };
|
|
626
569
|
}
|
|
627
570
|
|
|
628
|
-
it('
|
|
571
|
+
it('does NOT emit SECURITY_MISSING_TENANT_PATH for an action that already includes the tenant placeholder', async () => {
|
|
629
572
|
const { errors } = await runValidateWith({
|
|
630
|
-
|
|
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: [
|
|
573
|
+
actions: [
|
|
660
574
|
{
|
|
661
|
-
_kind: '
|
|
662
|
-
id: '
|
|
663
|
-
|
|
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,
|
|
664
582
|
},
|
|
665
583
|
],
|
|
666
584
|
});
|