@wildo-ai/wildo-module-client 1.1.1
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/LICENSE +34 -0
- package/dist/esm/.builder.pid +9 -0
- package/dist/esm/__tests__/lockfile-repair.service.test.d.ts +0 -0
- package/dist/esm/__tests__/lockfile-repair.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/lockfile-repair.service.test.js.map +0 -0
- package/dist/esm/__tests__/module-adopter.service.test.d.ts +0 -0
- package/dist/esm/__tests__/module-adopter.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/module-adopter.service.test.js.map +0 -0
- package/dist/esm/__tests__/module-packager.service.test.d.ts +0 -0
- package/dist/esm/__tests__/module-packager.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/module-packager.service.test.js.map +0 -0
- package/dist/esm/__tests__/provider-materialization-hook.test.d.ts +0 -0
- package/dist/esm/__tests__/provider-materialization-hook.test.d.ts.map +0 -0
- package/dist/esm/__tests__/provider-materialization-hook.test.js.map +0 -0
- package/dist/esm/__tests__/provider-sync-runner.test.d.ts +0 -0
- package/dist/esm/__tests__/provider-sync-runner.test.d.ts.map +0 -0
- package/dist/esm/__tests__/provider-sync-runner.test.js.map +0 -0
- package/dist/esm/__tests__/registry-lockfile.service.test.d.ts +0 -0
- package/dist/esm/__tests__/registry-lockfile.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/registry-lockfile.service.test.js.map +0 -0
- package/dist/esm/__tests__/registry-server-client.test.d.ts +0 -0
- package/dist/esm/__tests__/registry-server-client.test.d.ts.map +0 -0
- package/dist/esm/__tests__/registry-server-client.test.js.map +0 -0
- package/dist/esm/index.d.ts +18 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +18 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/services/_http-client.d.ts +44 -0
- package/dist/esm/services/_http-client.d.ts.map +1 -0
- package/dist/esm/services/_http-client.js +75 -0
- package/dist/esm/services/_http-client.js.map +1 -0
- package/dist/esm/services/_zod-issue-formatting.d.ts +12 -0
- package/dist/esm/services/_zod-issue-formatting.d.ts.map +1 -0
- package/dist/esm/services/_zod-issue-formatting.js +47 -0
- package/dist/esm/services/_zod-issue-formatting.js.map +1 -0
- package/dist/esm/services/index.d.ts +10 -0
- package/dist/esm/services/index.d.ts.map +1 -0
- package/dist/esm/services/index.js +10 -0
- package/dist/esm/services/index.js.map +1 -0
- package/dist/esm/services/lockfile-repair.service.d.ts +68 -0
- package/dist/esm/services/lockfile-repair.service.d.ts.map +1 -0
- package/dist/esm/services/lockfile-repair.service.js +160 -0
- package/dist/esm/services/lockfile-repair.service.js.map +1 -0
- package/dist/esm/services/module-adopter.service.d.ts +59 -0
- package/dist/esm/services/module-adopter.service.d.ts.map +1 -0
- package/dist/esm/services/module-adopter.service.js +126 -0
- package/dist/esm/services/module-adopter.service.js.map +1 -0
- package/dist/esm/services/module-packager.service.d.ts +105 -0
- package/dist/esm/services/module-packager.service.d.ts.map +1 -0
- package/dist/esm/services/module-packager.service.js +212 -0
- package/dist/esm/services/module-packager.service.js.map +1 -0
- package/dist/esm/services/provider-materialization-hook.d.ts +32 -0
- package/dist/esm/services/provider-materialization-hook.d.ts.map +1 -0
- package/dist/esm/services/provider-materialization-hook.js +33 -0
- package/dist/esm/services/provider-materialization-hook.js.map +1 -0
- package/dist/esm/services/provider-sync-runner.d.ts +87 -0
- package/dist/esm/services/provider-sync-runner.d.ts.map +1 -0
- package/dist/esm/services/provider-sync-runner.js +207 -0
- package/dist/esm/services/provider-sync-runner.js.map +1 -0
- package/dist/esm/services/registry-companion-client.d.ts +39 -0
- package/dist/esm/services/registry-companion-client.d.ts.map +1 -0
- package/dist/esm/services/registry-companion-client.js +109 -0
- package/dist/esm/services/registry-companion-client.js.map +1 -0
- package/dist/esm/services/registry-lockfile.service.d.ts +56 -0
- package/dist/esm/services/registry-lockfile.service.d.ts.map +1 -0
- package/dist/esm/services/registry-lockfile.service.js +99 -0
- package/dist/esm/services/registry-lockfile.service.js.map +1 -0
- package/dist/esm/services/registry-server-client.d.ts +94 -0
- package/dist/esm/services/registry-server-client.d.ts.map +1 -0
- package/dist/esm/services/registry-server-client.js +225 -0
- package/dist/esm/services/registry-server-client.js.map +1 -0
- package/dist/esm/services/types.d.ts +22 -0
- package/dist/esm/services/types.d.ts.map +1 -0
- package/dist/esm/services/types.js +16 -0
- package/dist/esm/services/types.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +51 -0
- package/src/__tests__/lockfile-repair.service.test.ts +106 -0
- package/src/__tests__/module-adopter.service.test.ts +96 -0
- package/src/__tests__/module-packager.service.test.ts +258 -0
- package/src/__tests__/provider-materialization-hook.test.ts +47 -0
- package/src/__tests__/provider-sync-runner.test.ts +113 -0
- package/src/__tests__/registry-lockfile.service.test.ts +134 -0
- package/src/__tests__/registry-server-client.test.ts +312 -0
- package/src/index.ts +18 -0
- package/src/services/_http-client.ts +91 -0
- package/src/services/_zod-issue-formatting.ts +50 -0
- package/src/services/index.ts +9 -0
- package/src/services/lockfile-repair.service.ts +215 -0
- package/src/services/module-adopter.service.ts +155 -0
- package/src/services/module-packager.service.ts +251 -0
- package/src/services/provider-materialization-hook.ts +54 -0
- package/src/services/provider-sync-runner.ts +298 -0
- package/src/services/registry-companion-client.ts +163 -0
- package/src/services/registry-lockfile.service.ts +122 -0
- package/src/services/registry-server-client.ts +334 -0
- package/src/services/types.ts +22 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 tests for `LockfileRepairService`.
|
|
3
|
+
*
|
|
4
|
+
* The service is read-only and produces a structured report.
|
|
5
|
+
* Tests pin every finding kind:
|
|
6
|
+
* - missing lockfile → empty ok-report.
|
|
7
|
+
* - malformed envelope → error finding.
|
|
8
|
+
* - malformed modules section → error finding.
|
|
9
|
+
* - lockfile entry pointing at a missing file → warn finding.
|
|
10
|
+
* - lockfile entry with non-sha256 digest → warn finding.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { promises as fs } from 'node:fs';
|
|
14
|
+
import * as os from 'node:os';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
17
|
+
import { WILDO_SAAS_LOCK_RELATIVE_PATH } from '@wildo-ai/platform-config-lib';
|
|
18
|
+
import { LockfileRepairService } from '../services/lockfile-repair.service';
|
|
19
|
+
|
|
20
|
+
let saasRoot: string;
|
|
21
|
+
let lockPath: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
saasRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wildo-repair-spec-'));
|
|
25
|
+
lockPath = path.join(saasRoot, WILDO_SAAS_LOCK_RELATIVE_PATH);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await fs.rm(saasRoot, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function writeEnvelope(envelope: unknown): Promise<void> {
|
|
33
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
34
|
+
await fs.writeFile(lockPath, JSON.stringify(envelope, null, 2), 'utf8');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const VALID_DIGEST = 'sha256:' + 'a'.repeat(64);
|
|
38
|
+
|
|
39
|
+
const validEntry = (overrides: Record<string, unknown> = {}) => ({
|
|
40
|
+
moduleId: 'acme/billing',
|
|
41
|
+
registryId: 'wildo',
|
|
42
|
+
registryUrl: 'https://registry.wildo.ai',
|
|
43
|
+
domain: 'acme',
|
|
44
|
+
version: '1.2.3',
|
|
45
|
+
artifactDigest: VALID_DIGEST,
|
|
46
|
+
installedAt: '2026-05-05T12:00:00.000Z',
|
|
47
|
+
files: ['shared-lib/src/modules/acme-billing/index.ts'],
|
|
48
|
+
source: 'install' as const,
|
|
49
|
+
...overrides,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('LockfileRepairService.inspect', () => {
|
|
53
|
+
it('returns ok+empty when the lockfile does not exist', async () => {
|
|
54
|
+
const report = await LockfileRepairService.inspect(saasRoot);
|
|
55
|
+
expect(report.ok).toBe(true);
|
|
56
|
+
expect(report.findings).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reports envelope-malformed on top-level Zod failure', async () => {
|
|
60
|
+
await writeEnvelope({ schemaVersion: 1, generatedAt: '2026', sections: {} });
|
|
61
|
+
const report = await LockfileRepairService.inspect(saasRoot);
|
|
62
|
+
expect(report.ok).toBe(false);
|
|
63
|
+
expect(report.findings[0].kind).toBe('envelope-malformed');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('reports modules-section-malformed when sections.modules fails the section schema', async () => {
|
|
67
|
+
await writeEnvelope({
|
|
68
|
+
schemaVersion: 2,
|
|
69
|
+
generatedAt: '2026-05-05T12:00:00.000Z',
|
|
70
|
+
sections: { modules: { installed: 'not-an-object' } },
|
|
71
|
+
});
|
|
72
|
+
const report = await LockfileRepairService.inspect(saasRoot);
|
|
73
|
+
expect(report.ok).toBe(false);
|
|
74
|
+
expect(report.findings[0].kind).toBe('modules-section-malformed');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('reports module-file-missing when an installed entry points at a non-existent file', async () => {
|
|
78
|
+
await writeEnvelope({
|
|
79
|
+
schemaVersion: 2,
|
|
80
|
+
generatedAt: '2026-05-05T12:00:00.000Z',
|
|
81
|
+
sections: { modules: { installed: { 'acme/billing': validEntry() } } },
|
|
82
|
+
});
|
|
83
|
+
const report = await LockfileRepairService.inspect(saasRoot);
|
|
84
|
+
expect(report.ok).toBe(true); // warnings only
|
|
85
|
+
expect(report.findings).toHaveLength(1);
|
|
86
|
+
expect(report.findings[0]).toMatchObject({
|
|
87
|
+
severity: 'warn',
|
|
88
|
+
kind: 'module-file-missing',
|
|
89
|
+
moduleId: 'acme/billing',
|
|
90
|
+
filePath: 'shared-lib/src/modules/acme-billing/index.ts',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does NOT flag files that exist on disk', async () => {
|
|
95
|
+
const filePath = 'shared-lib/src/modules/acme-billing/index.ts';
|
|
96
|
+
await fs.mkdir(path.join(saasRoot, path.dirname(filePath)), { recursive: true });
|
|
97
|
+
await fs.writeFile(path.join(saasRoot, filePath), 'export const x = 1;\n');
|
|
98
|
+
await writeEnvelope({
|
|
99
|
+
schemaVersion: 2,
|
|
100
|
+
generatedAt: '2026-05-05T12:00:00.000Z',
|
|
101
|
+
sections: { modules: { installed: { 'acme/billing': validEntry() } } },
|
|
102
|
+
});
|
|
103
|
+
const report = await LockfileRepairService.inspect(saasRoot);
|
|
104
|
+
expect(report.findings).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 tests for `ModuleAdopterService`.
|
|
3
|
+
*
|
|
4
|
+
* Adopter is read-only against the host app's source — it walks
|
|
5
|
+
* declared `boundaries.*` and stages a copy. Tests pin:
|
|
6
|
+
* - `discoverModuleFiles` returns only files inside the
|
|
7
|
+
* declared boundaries.
|
|
8
|
+
* - `adoptToStaging` is idempotent: same source → same digest.
|
|
9
|
+
* - emptyBoundaries reports declared paths that contributed zero files.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { promises as fs } from 'node:fs';
|
|
13
|
+
import * as os from 'node:os';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
|
+
import { ModuleAdopterService } from '../services/module-adopter.service';
|
|
17
|
+
|
|
18
|
+
let saasRoot: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
saasRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wildo-adopter-spec-'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await fs.rm(saasRoot, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const manifest = {
|
|
29
|
+
id: 'acme/billing',
|
|
30
|
+
version: '1.2.3',
|
|
31
|
+
boundaries: {
|
|
32
|
+
specifications: [],
|
|
33
|
+
shared: ['shared-lib/src/modules/acme-billing'],
|
|
34
|
+
frontend: [],
|
|
35
|
+
backend: ['backend-api/src/modules/acme-billing'],
|
|
36
|
+
minions: [],
|
|
37
|
+
workers: [],
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function seedFile(rel: string, contents: string): Promise<void> {
|
|
42
|
+
const abs = path.join(saasRoot, rel);
|
|
43
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
44
|
+
await fs.writeFile(abs, contents, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('ModuleAdopterService.discoverModuleFiles', () => {
|
|
48
|
+
it('returns the union of files inside every declared boundary', async () => {
|
|
49
|
+
await seedFile('shared-lib/src/modules/acme-billing/index.ts', 'a');
|
|
50
|
+
await seedFile('shared-lib/src/modules/acme-billing/util.ts', 'b');
|
|
51
|
+
await seedFile('backend-api/src/modules/acme-billing/handler.ts', 'c');
|
|
52
|
+
await seedFile('app/src/elsewhere.ts', 'd'); // outside boundaries — must not appear
|
|
53
|
+
const plan = await ModuleAdopterService.discoverModuleFiles(saasRoot, manifest);
|
|
54
|
+
expect(plan.files).toEqual([
|
|
55
|
+
path.join('backend-api/src/modules/acme-billing/handler.ts'),
|
|
56
|
+
path.join('shared-lib/src/modules/acme-billing/index.ts'),
|
|
57
|
+
path.join('shared-lib/src/modules/acme-billing/util.ts'),
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('records empty boundaries when a declared path matches nothing', async () => {
|
|
62
|
+
// `backend-api/src/modules/acme-billing` has no files on disk
|
|
63
|
+
await seedFile('shared-lib/src/modules/acme-billing/index.ts', 'a');
|
|
64
|
+
const plan = await ModuleAdopterService.discoverModuleFiles(saasRoot, manifest);
|
|
65
|
+
expect(plan.emptyBoundaries).toEqual(['backend']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns an empty plan when the manifest declares no boundaries', async () => {
|
|
69
|
+
await seedFile('anywhere.ts', 'x');
|
|
70
|
+
const plan = await ModuleAdopterService.discoverModuleFiles(saasRoot, {
|
|
71
|
+
id: 'acme/x',
|
|
72
|
+
version: '0.0.1',
|
|
73
|
+
});
|
|
74
|
+
expect(plan.files).toEqual([]);
|
|
75
|
+
expect(plan.emptyBoundaries).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('ModuleAdopterService.adoptToStaging', () => {
|
|
80
|
+
it('produces a stable digest for the same source', async () => {
|
|
81
|
+
await seedFile('shared-lib/src/modules/acme-billing/a.ts', 'A\n');
|
|
82
|
+
await seedFile('shared-lib/src/modules/acme-billing/b.ts', 'B\n');
|
|
83
|
+
const r1 = await ModuleAdopterService.adoptToStaging(saasRoot, manifest);
|
|
84
|
+
const r2 = await ModuleAdopterService.adoptToStaging(saasRoot, manifest);
|
|
85
|
+
expect(r1.digest).toBe(r2.digest);
|
|
86
|
+
expect(r1.staged).toEqual(r2.staged);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('changes the digest when a file mutates between adopt runs', async () => {
|
|
90
|
+
await seedFile('shared-lib/src/modules/acme-billing/a.ts', 'V1\n');
|
|
91
|
+
const r1 = await ModuleAdopterService.adoptToStaging(saasRoot, manifest);
|
|
92
|
+
await seedFile('shared-lib/src/modules/acme-billing/a.ts', 'V2\n');
|
|
93
|
+
const r2 = await ModuleAdopterService.adoptToStaging(saasRoot, manifest);
|
|
94
|
+
expect(r1.digest).not.toBe(r2.digest);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 tests for `ModulePackagerService`.
|
|
3
|
+
*
|
|
4
|
+
* The staging primitive's hard contracts (KD-8 + KD-12) are
|
|
5
|
+
* verified here:
|
|
6
|
+
* - `paths()` returns the canonical staging root.
|
|
7
|
+
* - `stageFresh()` removes the destination first (digest
|
|
8
|
+
* determinism).
|
|
9
|
+
* - `copyFile()` honors `manifest.boundaries`; out-of-boundary
|
|
10
|
+
* paths throw `RegistryBoundaryViolationError`.
|
|
11
|
+
* - `writeManifest()` validates via Zod and persists JSON.
|
|
12
|
+
* - `computeStagingDigest()` is stable across runs and order-
|
|
13
|
+
* independent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from 'node:fs';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
20
|
+
import {
|
|
21
|
+
ModulePackagerService,
|
|
22
|
+
MODULES_ARTIFACTS_RELATIVE_DIR,
|
|
23
|
+
getModuleStagingPaths,
|
|
24
|
+
assertWithinBoundaries,
|
|
25
|
+
} from '../services/module-packager.service';
|
|
26
|
+
|
|
27
|
+
let saasRoot: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
saasRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wildo-packager-spec-'));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await fs.rm(saasRoot, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const baseManifest = (id = 'acme/billing', version = '1.2.3') => ({
|
|
38
|
+
id,
|
|
39
|
+
version,
|
|
40
|
+
description: 'Billing module',
|
|
41
|
+
boundaries: {
|
|
42
|
+
specifications: [],
|
|
43
|
+
shared: ['shared-lib/src/modules/acme-billing'],
|
|
44
|
+
frontend: [],
|
|
45
|
+
backend: ['backend-api/src/modules/acme-billing'],
|
|
46
|
+
minions: [],
|
|
47
|
+
workers: [],
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function seedFile(saasRoot: string, rel: string, contents: string): Promise<void> {
|
|
52
|
+
const abs = path.join(saasRoot, rel);
|
|
53
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
54
|
+
await fs.writeFile(abs, contents, 'utf8');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('ModulePackagerService.paths', () => {
|
|
58
|
+
it('rejects module ids without a domain segment', () => {
|
|
59
|
+
expect(() => getModuleStagingPaths(saasRoot, 'no-slash')).toThrow();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns absolute paths under .wildo-saas/generated/modules-artifacts/<id>/', () => {
|
|
63
|
+
const paths = getModuleStagingPaths(saasRoot, 'acme/billing');
|
|
64
|
+
expect(paths.stagingRoot.startsWith(saasRoot)).toBe(true);
|
|
65
|
+
expect(paths.stagingRoot.endsWith('acme/billing')).toBe(true);
|
|
66
|
+
expect(paths.manifestPath.endsWith('wildo.module.json')).toBe(true);
|
|
67
|
+
expect(paths.stagingRoot).toContain(MODULES_ARTIFACTS_RELATIVE_DIR);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('ModulePackagerService.stageFresh', () => {
|
|
72
|
+
it('removes existing staging contents before recreating', async () => {
|
|
73
|
+
await ModulePackagerService.stageFresh(saasRoot, 'acme/billing');
|
|
74
|
+
const stagingRoot = path.join(saasRoot, MODULES_ARTIFACTS_RELATIVE_DIR, 'acme/billing');
|
|
75
|
+
await fs.writeFile(path.join(stagingRoot, 'leftover.txt'), 'old');
|
|
76
|
+
await ModulePackagerService.stageFresh(saasRoot, 'acme/billing');
|
|
77
|
+
const entries = await fs.readdir(stagingRoot);
|
|
78
|
+
expect(entries).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('assertWithinBoundaries (KD-12)', () => {
|
|
83
|
+
it('rejects paths that escape the saas root via `..`', () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
assertWithinBoundaries(saasRoot, 'acme/billing', '../../etc/passwd', baseManifest().boundaries),
|
|
86
|
+
).toThrow();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects paths outside every declared boundary', () => {
|
|
90
|
+
expect(() =>
|
|
91
|
+
assertWithinBoundaries(
|
|
92
|
+
saasRoot,
|
|
93
|
+
'acme/billing',
|
|
94
|
+
'app/src/modules/acme-billing/index.ts',
|
|
95
|
+
baseManifest().boundaries,
|
|
96
|
+
),
|
|
97
|
+
).toThrow();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('accepts paths inside a declared boundary', () => {
|
|
101
|
+
const result = assertWithinBoundaries(
|
|
102
|
+
saasRoot,
|
|
103
|
+
'acme/billing',
|
|
104
|
+
'shared-lib/src/modules/acme-billing/index.ts',
|
|
105
|
+
baseManifest().boundaries,
|
|
106
|
+
);
|
|
107
|
+
expect(result.endsWith('shared-lib/src/modules/acme-billing/index.ts')).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('skips boundary checks when no boundaries are declared (manifest.boundaries undefined)', () => {
|
|
111
|
+
const result = assertWithinBoundaries(saasRoot, 'acme/billing', 'anywhere.ts', undefined);
|
|
112
|
+
expect(result.endsWith('anywhere.ts')).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('ModulePackagerService.copyFile', () => {
|
|
117
|
+
it('copies an in-boundary file into the staging tree at the same relative path', async () => {
|
|
118
|
+
await seedFile(
|
|
119
|
+
saasRoot,
|
|
120
|
+
'shared-lib/src/modules/acme-billing/index.ts',
|
|
121
|
+
'export const billing = true;\n',
|
|
122
|
+
);
|
|
123
|
+
await ModulePackagerService.stageFresh(saasRoot, 'acme/billing');
|
|
124
|
+
const result = await ModulePackagerService.copyFile({
|
|
125
|
+
saasRoot,
|
|
126
|
+
moduleId: 'acme/billing',
|
|
127
|
+
appRelativePath: 'shared-lib/src/modules/acme-billing/index.ts',
|
|
128
|
+
manifest: baseManifest(),
|
|
129
|
+
});
|
|
130
|
+
const staged = await fs.readFile(result.stagedAbsolute, 'utf8');
|
|
131
|
+
expect(staged).toBe('export const billing = true;\n');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('rejects out-of-boundary copies', async () => {
|
|
135
|
+
await seedFile(saasRoot, 'app/src/leak.ts', 'no');
|
|
136
|
+
await ModulePackagerService.stageFresh(saasRoot, 'acme/billing');
|
|
137
|
+
await expect(
|
|
138
|
+
ModulePackagerService.copyFile({
|
|
139
|
+
saasRoot,
|
|
140
|
+
moduleId: 'acme/billing',
|
|
141
|
+
appRelativePath: 'app/src/leak.ts',
|
|
142
|
+
manifest: baseManifest(),
|
|
143
|
+
}),
|
|
144
|
+
).rejects.toThrow();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('ModulePackagerService.writeManifest', () => {
|
|
149
|
+
it('rejects an invalid manifest (Zod parse failure) before writing', async () => {
|
|
150
|
+
await expect(
|
|
151
|
+
ModulePackagerService.writeManifest({
|
|
152
|
+
saasRoot,
|
|
153
|
+
manifest: { id: '', version: 'not-semver' } as never,
|
|
154
|
+
}),
|
|
155
|
+
).rejects.toThrow();
|
|
156
|
+
const stagingRoot = path.join(saasRoot, MODULES_ARTIFACTS_RELATIVE_DIR);
|
|
157
|
+
await expect(fs.access(stagingRoot)).rejects.toThrow();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('writes a JSON file matching the validated manifest', async () => {
|
|
161
|
+
await ModulePackagerService.stageFresh(saasRoot, 'acme/billing');
|
|
162
|
+
const result = await ModulePackagerService.writeManifest({ saasRoot, manifest: baseManifest() });
|
|
163
|
+
const raw = await fs.readFile(result.manifestPath, 'utf8');
|
|
164
|
+
const parsed = JSON.parse(raw);
|
|
165
|
+
expect(parsed.id).toBe('acme/billing');
|
|
166
|
+
expect(parsed.version).toBe('1.2.3');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('ModulePackagerService.computeStagingDigest', () => {
|
|
171
|
+
it('produces a stable sha256 digest across runs', async () => {
|
|
172
|
+
await seedFile(saasRoot, 'shared-lib/src/modules/acme-billing/a.ts', 'a\n');
|
|
173
|
+
await seedFile(saasRoot, 'shared-lib/src/modules/acme-billing/b.ts', 'b\n');
|
|
174
|
+
await ModulePackagerService.stageFresh(saasRoot, 'acme/billing');
|
|
175
|
+
await ModulePackagerService.writeManifest({ saasRoot, manifest: baseManifest() });
|
|
176
|
+
await ModulePackagerService.copyFile({
|
|
177
|
+
saasRoot,
|
|
178
|
+
moduleId: 'acme/billing',
|
|
179
|
+
appRelativePath: 'shared-lib/src/modules/acme-billing/a.ts',
|
|
180
|
+
manifest: baseManifest(),
|
|
181
|
+
});
|
|
182
|
+
await ModulePackagerService.copyFile({
|
|
183
|
+
saasRoot,
|
|
184
|
+
moduleId: 'acme/billing',
|
|
185
|
+
appRelativePath: 'shared-lib/src/modules/acme-billing/b.ts',
|
|
186
|
+
manifest: baseManifest(),
|
|
187
|
+
});
|
|
188
|
+
const d1 = await ModulePackagerService.computeStagingDigest(saasRoot, 'acme/billing');
|
|
189
|
+
const d2 = await ModulePackagerService.computeStagingDigest(saasRoot, 'acme/billing');
|
|
190
|
+
expect(d1).toBe(d2);
|
|
191
|
+
expect(d1).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('produces a different digest when a file changes', async () => {
|
|
195
|
+
await seedFile(saasRoot, 'shared-lib/src/modules/acme-billing/a.ts', 'v1\n');
|
|
196
|
+
await ModulePackagerService.stageFresh(saasRoot, 'acme/billing');
|
|
197
|
+
await ModulePackagerService.writeManifest({ saasRoot, manifest: baseManifest() });
|
|
198
|
+
await ModulePackagerService.copyFile({
|
|
199
|
+
saasRoot,
|
|
200
|
+
moduleId: 'acme/billing',
|
|
201
|
+
appRelativePath: 'shared-lib/src/modules/acme-billing/a.ts',
|
|
202
|
+
manifest: baseManifest(),
|
|
203
|
+
});
|
|
204
|
+
const d1 = await ModulePackagerService.computeStagingDigest(saasRoot, 'acme/billing');
|
|
205
|
+
// Mutate source + restage.
|
|
206
|
+
await seedFile(saasRoot, 'shared-lib/src/modules/acme-billing/a.ts', 'v2\n');
|
|
207
|
+
await ModulePackagerService.stageFresh(saasRoot, 'acme/billing');
|
|
208
|
+
await ModulePackagerService.writeManifest({ saasRoot, manifest: baseManifest() });
|
|
209
|
+
await ModulePackagerService.copyFile({
|
|
210
|
+
saasRoot,
|
|
211
|
+
moduleId: 'acme/billing',
|
|
212
|
+
appRelativePath: 'shared-lib/src/modules/acme-billing/a.ts',
|
|
213
|
+
manifest: baseManifest(),
|
|
214
|
+
});
|
|
215
|
+
const d2 = await ModulePackagerService.computeStagingDigest(saasRoot, 'acme/billing');
|
|
216
|
+
expect(d1).not.toBe(d2);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 7th-pass C-L1 — root edge case for `isPathInside`. The helper is
|
|
222
|
+
* invoked indirectly through `assertWithinBoundaries`; we exercise
|
|
223
|
+
* the degenerate filesystem-root case by pointing `saasRoot` at `/`
|
|
224
|
+
* and confirming a child path inside `/` is accepted as "within
|
|
225
|
+
* boundaries" when the manifest declares `boundaries.shared: ['./']`
|
|
226
|
+
* (relative path that resolves to `/`).
|
|
227
|
+
*
|
|
228
|
+
* Vanishingly unlikely in production (containerized SaaS roots are
|
|
229
|
+
* never literally `/`) but the helper is also reusable for zip-slip
|
|
230
|
+
* guards where the assumption is weaker — pinning the behavior
|
|
231
|
+
* prevents a future "tighten startsWith" refactor from rejecting
|
|
232
|
+
* every child.
|
|
233
|
+
*/
|
|
234
|
+
describe('assertWithinBoundaries — root edge case (C-L1)', () => {
|
|
235
|
+
it('accepts a child path when saasRoot is the filesystem root', () => {
|
|
236
|
+
// Use POSIX path.sep as the boundary; the helper resolves
|
|
237
|
+
// `path.resolve('/', '.')` → `/` and then `isPathInside('/foo', '/')`
|
|
238
|
+
// → true via the new root special-case.
|
|
239
|
+
const manifestWithRootBoundary = {
|
|
240
|
+
...baseManifest(),
|
|
241
|
+
boundaries: {
|
|
242
|
+
specifications: [],
|
|
243
|
+
shared: ['.'],
|
|
244
|
+
frontend: [],
|
|
245
|
+
backend: [],
|
|
246
|
+
minions: [],
|
|
247
|
+
workers: [],
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
// Run only on POSIX; Windows root is `C:\` and tests run on
|
|
251
|
+
// POSIX CI.
|
|
252
|
+
if (path.sep === '/') {
|
|
253
|
+
expect(() =>
|
|
254
|
+
assertWithinBoundaries('/', 'acme/billing', 'foo/bar.ts', manifestWithRootBoundary.boundaries),
|
|
255
|
+
).not.toThrow();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 0.5 sanity tests for `runProviderMaterializationHook`.
|
|
3
|
+
*
|
|
4
|
+
* The Phase 0.A spike asserted that codegen does not write any
|
|
5
|
+
* provider runtime artifact directly. These tests are the runtime
|
|
6
|
+
* shadow of that assertion: invoking the hook from any module-
|
|
7
|
+
* registry flow must not write provider artifacts and must report
|
|
8
|
+
* `mode: 'dry-run'` until Phase 5 wires the real shell-out.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { promises as fs } from 'node:fs';
|
|
12
|
+
import * as os from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
15
|
+
import { runProviderMaterializationHook } from '../services/provider-materialization-hook';
|
|
16
|
+
|
|
17
|
+
let saasRoot: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
saasRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wildo-module-client-hook-spec-'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await fs.rm(saasRoot, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('runProviderMaterializationHook (Phase 0.5 dry-run)', () => {
|
|
28
|
+
it('returns mode:dry-run + performedFsWrite:false when invoked', async () => {
|
|
29
|
+
const result = await runProviderMaterializationHook({ saasRoot, reason: 'install' });
|
|
30
|
+
expect(result.invoked).toBe(true);
|
|
31
|
+
expect(result.mode).toBe('dry-run');
|
|
32
|
+
expect(result.performedFsWrite).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('does not write any provider runtime artifact under saasRoot', async () => {
|
|
36
|
+
const result = await runProviderMaterializationHook({ saasRoot, reason: 'smoke' });
|
|
37
|
+
for (const rel of result.providerJsonRelativePaths) {
|
|
38
|
+
await expect(fs.access(path.join(saasRoot, rel))).rejects.toThrow();
|
|
39
|
+
}
|
|
40
|
+
await expect(fs.access(path.join(saasRoot, result.providerSourceRelativePath))).rejects.toThrow();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('always reports the canonical providerLockSectionPath', async () => {
|
|
44
|
+
const result = await runProviderMaterializationHook({ saasRoot, reason: 'update' });
|
|
45
|
+
expect(result.providerLockSectionPath).toBe('sections.providers.runtimeScopes');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 tests for `runProviderSync`.
|
|
3
|
+
*
|
|
4
|
+
* The runner spawns `wildo config sync --domain config` and
|
|
5
|
+
* classifies failures into `RegistryProviderSyncFailedError`.
|
|
6
|
+
* We avoid a real `wildo` dependency by providing a stub script
|
|
7
|
+
* via the `wildoBinPath` override.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { promises as fs } from 'node:fs';
|
|
11
|
+
import * as os from 'node:os';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
import {
|
|
15
|
+
RegistryProviderSyncFailedError,
|
|
16
|
+
isRegistryError,
|
|
17
|
+
} from '@wildo-ai/platform-config-lib';
|
|
18
|
+
import { runProviderSync } from '../services/provider-sync-runner';
|
|
19
|
+
|
|
20
|
+
let saasRoot: string;
|
|
21
|
+
let stubDir: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
saasRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wildo-provider-sync-spec-'));
|
|
25
|
+
stubDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wildo-provider-sync-stub-'));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await fs.rm(saasRoot, { recursive: true, force: true });
|
|
30
|
+
await fs.rm(stubDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function writeStub(name: string, body: string): Promise<string> {
|
|
34
|
+
const stubPath = path.join(stubDir, name);
|
|
35
|
+
await fs.writeFile(stubPath, body, 'utf8');
|
|
36
|
+
await fs.chmod(stubPath, 0o755);
|
|
37
|
+
return stubPath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('runProviderSync (dry-run)', () => {
|
|
41
|
+
it('returns mode:dry-run without spawning anything', async () => {
|
|
42
|
+
const result = await runProviderSync({ saasRoot, reason: 'install', dryRun: true });
|
|
43
|
+
expect(result.mode).toBe('dry-run');
|
|
44
|
+
expect(result.exitCode).toBe(0);
|
|
45
|
+
expect(result.providerLockSectionPath).toBe('sections.providers.runtimeScopes');
|
|
46
|
+
expect(result.reason).toBe('install');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('runProviderSync (shell-out — success)', () => {
|
|
51
|
+
it('returns mode:shell-out and the stub exit code (0)', async () => {
|
|
52
|
+
const stub = await writeStub('wildo', '#!/bin/sh\nexit 0\n');
|
|
53
|
+
const result = await runProviderSync({
|
|
54
|
+
saasRoot,
|
|
55
|
+
reason: 'install',
|
|
56
|
+
wildoBinPath: stub,
|
|
57
|
+
timeoutMs: 5000,
|
|
58
|
+
});
|
|
59
|
+
expect(result.mode).toBe('shell-out');
|
|
60
|
+
expect(result.exitCode).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('runProviderSync (shell-out — failure classification)', () => {
|
|
65
|
+
async function expectFailureKind(
|
|
66
|
+
stderrBody: string,
|
|
67
|
+
expectedKind: 'companion-discovery' | 'runtime-import-reachability' | 'invalid-scope' | 'unknown',
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const stub = await writeStub(
|
|
70
|
+
'wildo',
|
|
71
|
+
`#!/bin/sh\ncat 1>&2 <<'EOF'\n${stderrBody}\nEOF\nexit 1\n`,
|
|
72
|
+
);
|
|
73
|
+
let caught: unknown = null;
|
|
74
|
+
try {
|
|
75
|
+
await runProviderSync({
|
|
76
|
+
saasRoot,
|
|
77
|
+
reason: 'install',
|
|
78
|
+
wildoBinPath: stub,
|
|
79
|
+
timeoutMs: 5000,
|
|
80
|
+
});
|
|
81
|
+
} catch (err) {
|
|
82
|
+
caught = err;
|
|
83
|
+
}
|
|
84
|
+
expect(isRegistryError(caught)).toBe(true);
|
|
85
|
+
expect(caught).toBeInstanceOf(RegistryProviderSyncFailedError);
|
|
86
|
+
expect((caught as RegistryProviderSyncFailedError).failureKind).toBe(expectedKind);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
it('classifies companion-export failures', async () => {
|
|
90
|
+
await expectFailureKind(
|
|
91
|
+
'Error: companion export missing for provider "resend" (DiscoveredProviderCompanionContribution)',
|
|
92
|
+
'companion-discovery',
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('classifies runtime-import reachability failures', async () => {
|
|
97
|
+
await expectFailureKind(
|
|
98
|
+
"Error: Cannot find module '@wildo-ai/auth-stub' from runtimeImport graph",
|
|
99
|
+
'runtime-import-reachability',
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('classifies invalid-scope failures', async () => {
|
|
104
|
+
await expectFailureKind(
|
|
105
|
+
'Error: defineSaaSProviders rejected invalid-scope "frontEnd": expected one of backend|companion|...',
|
|
106
|
+
'invalid-scope',
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('falls through to "unknown" on unrecognized stderr', async () => {
|
|
111
|
+
await expectFailureKind('Error: kaboom for unknown reasons', 'unknown');
|
|
112
|
+
});
|
|
113
|
+
});
|