326 lines
13 KiB
JavaScript
326 lines
13 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
loadDeploymentProfileArtifact,
|
|
createDeploymentBindingContract,
|
|
validateDeploymentProfileArtifact,
|
|
assertUseTimePathWithinRepoRoot,
|
|
} from '../src/storage/profile-artifact.mjs';
|
|
import { createRuntimeBinding } from '../src/adapters/index.mjs';
|
|
|
|
const packageRoot = path.resolve(import.meta.dirname, '..');
|
|
const repoRoot = path.resolve(packageRoot, '..', '..');
|
|
|
|
function createArtifact(overrides = {}) {
|
|
return {
|
|
kind: 'DeploymentProfileArtifact',
|
|
apiVersion: 'reporting-governance/v1alpha1',
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
runtime: 'openclaw',
|
|
entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs',
|
|
scripts: { watchdog: 'scripts/long_task_watchdog.mjs' },
|
|
artifact_roots: { queueItems: 'state/operator-notify-queue' },
|
|
},
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('deployment profile artifact loads from package profiles and preserves compatibility envelope metadata', () => {
|
|
const { artifactPath, artifact } = loadDeploymentProfileArtifact({ profileId: 'strict-manager-mode' });
|
|
|
|
assert.equal(path.relative(packageRoot, artifactPath), path.join('profiles', 'strict-manager-mode.profile.json'));
|
|
assert.equal(artifact.kind, 'DeploymentProfileArtifact');
|
|
assert.equal(artifact.metadata.id, 'strict-manager-mode');
|
|
assert.equal(artifact.metadata.compatibility_mode, 'strict_envelope');
|
|
assert.equal(artifact.spec.package.pluginVersion, '0.1.0-mainline');
|
|
});
|
|
|
|
test('deployment binding contract resolves package artifact into real repo script and artifact paths', () => {
|
|
const { artifact } = loadDeploymentProfileArtifact({ profileId: 'strict-manager-mode' });
|
|
const binding = createDeploymentBindingContract({ artifact });
|
|
|
|
assert.equal(binding.runtime, 'openclaw');
|
|
assert.equal(binding.pluginVersion, '0.1.0-mainline');
|
|
assert.equal(binding.compatibilityMode, 'strict_envelope');
|
|
assert.equal(binding.entrypoint, path.resolve(repoRoot, 'scripts/watchdog_auto_notify_orchestrator.mjs'));
|
|
assert.equal(binding.scripts.watchdog, path.resolve(repoRoot, 'scripts/long_task_watchdog.mjs'));
|
|
assert.equal(binding.artifactRoots.queueItems, path.resolve(repoRoot, 'state/operator-notify-queue'));
|
|
assert.equal(fs.existsSync(binding.scripts.orchestrator), true);
|
|
});
|
|
|
|
test('runtime binding can be instantiated from profile artifact binding contract', () => {
|
|
const { artifact } = loadDeploymentProfileArtifact({ profileId: 'strict-manager-mode' });
|
|
const contract = createDeploymentBindingContract({ artifact });
|
|
const runtimeBinding = createRuntimeBinding({
|
|
cwd: repoRoot,
|
|
scripts: contract.scripts,
|
|
});
|
|
|
|
assert.equal(runtimeBinding.cwd, repoRoot);
|
|
assert.equal(runtimeBinding.scripts.dispatcher, contract.scripts.dispatcher);
|
|
assert.equal(runtimeBinding.scripts.bridgeSupervisor, contract.scripts.bridgeSupervisor);
|
|
assert.equal(runtimeBinding.scripts.senderBinding, contract.scripts.senderBinding);
|
|
});
|
|
|
|
test('deployment profile artifact validation fails closed on boundary drift', () => {
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact({}),
|
|
/kind must be DeploymentProfileArtifact/
|
|
);
|
|
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: '',
|
|
scripts: { watchdog: 'scripts/long_task_watchdog.mjs' },
|
|
artifact_roots: { queueItems: 'state/operator-notify-queue' },
|
|
},
|
|
},
|
|
})),
|
|
/spec\.bindings\.entrypoint must be a non-empty string/
|
|
);
|
|
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '' },
|
|
bindings: {
|
|
entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs',
|
|
scripts: { watchdog: '' },
|
|
artifact_roots: { queueItems: 'state/operator-notify-queue' },
|
|
},
|
|
},
|
|
})),
|
|
/spec\.package\.pluginVersion must be a non-empty string/
|
|
);
|
|
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs',
|
|
scripts: [],
|
|
artifact_roots: { queueItems: 'state/operator-notify-queue' },
|
|
},
|
|
},
|
|
})),
|
|
/spec\.bindings\.scripts must be an object record/
|
|
);
|
|
});
|
|
|
|
test('deployment profile artifact validation rejects absolute binding paths', () => {
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: '/abs/path',
|
|
scripts: { watchdog: 'scripts/long_task_watchdog.mjs' },
|
|
artifact_roots: { queueItems: 'state/operator-notify-queue' },
|
|
},
|
|
},
|
|
})),
|
|
/spec\.bindings\.entrypoint must stay within repo root: absolute paths are not allowed/
|
|
);
|
|
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs',
|
|
scripts: { watchdog: '/abs/path' },
|
|
artifact_roots: { queueItems: 'state/operator-notify-queue' },
|
|
},
|
|
},
|
|
})),
|
|
/spec\.bindings\.scripts\.watchdog must stay within repo root: absolute paths are not allowed/
|
|
);
|
|
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs',
|
|
scripts: { watchdog: 'scripts/long_task_watchdog.mjs' },
|
|
artifact_roots: { queueItems: '/abs/path' },
|
|
},
|
|
},
|
|
})),
|
|
/spec\.bindings\.artifact_roots\.queueItems must stay within repo root: absolute paths are not allowed/
|
|
);
|
|
});
|
|
|
|
test('deployment profile artifact validation rejects escape paths after resolution', () => {
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: '../../escape',
|
|
scripts: { watchdog: 'scripts/long_task_watchdog.mjs' },
|
|
artifact_roots: { queueItems: 'state/operator-notify-queue' },
|
|
},
|
|
},
|
|
})),
|
|
/spec\.bindings\.entrypoint must stay within repo root: path escapes root boundary/
|
|
);
|
|
|
|
assert.throws(
|
|
() => createDeploymentBindingContract({
|
|
artifact: createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs',
|
|
scripts: { watchdog: '../../escape' },
|
|
artifact_roots: { queueItems: 'state/operator-notify-queue' },
|
|
},
|
|
},
|
|
}),
|
|
repoRootOverride: repoRoot,
|
|
}),
|
|
/spec\.bindings\.scripts\.watchdog must stay within repo root: path escapes root boundary/
|
|
);
|
|
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: 'scripts/watchdog_auto_notify_orchestrator.mjs',
|
|
scripts: { watchdog: 'scripts/long_task_watchdog.mjs' },
|
|
artifact_roots: { queueItems: '../escape' },
|
|
},
|
|
},
|
|
})),
|
|
/spec\.bindings\.artifact_roots\.queueItems must stay within repo root: path escapes root boundary/
|
|
);
|
|
});
|
|
|
|
test('deployment binding contract allows normalized in-root paths that contain dot segments', () => {
|
|
const binding = createDeploymentBindingContract({
|
|
artifact: createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
runtime: 'openclaw',
|
|
entrypoint: 'scripts/../scripts/watchdog_auto_notify_orchestrator.mjs',
|
|
scripts: {
|
|
watchdog: 'scripts/./long_task_watchdog.mjs',
|
|
},
|
|
artifact_roots: {
|
|
queueItems: 'state/../state/operator-notify-queue',
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
repoRootOverride: repoRoot,
|
|
});
|
|
|
|
assert.equal(binding.entrypoint, path.resolve(repoRoot, 'scripts/watchdog_auto_notify_orchestrator.mjs'));
|
|
assert.equal(binding.scripts.watchdog, path.resolve(repoRoot, 'scripts/long_task_watchdog.mjs'));
|
|
assert.equal(binding.artifactRoots.queueItems, path.resolve(repoRoot, 'state/operator-notify-queue'));
|
|
});
|
|
|
|
test('deployment profile artifact validation rejects artifact_roots symlink escape after realpath resolution', async (t) => {
|
|
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-profile-artifact-'));
|
|
t.after(() => fs.rmSync(sandbox, { recursive: true, force: true }));
|
|
|
|
const fakeRepoRoot = path.join(sandbox, 'repo');
|
|
const outsideRoot = path.join(sandbox, 'outside');
|
|
fs.mkdirSync(fakeRepoRoot, { recursive: true });
|
|
fs.mkdirSync(outsideRoot, { recursive: true });
|
|
fs.symlinkSync(outsideRoot, path.join(fakeRepoRoot, 'state-link'), 'dir');
|
|
fs.writeFileSync(path.join(fakeRepoRoot, 'entry.mjs'), 'export default true;\n');
|
|
fs.writeFileSync(path.join(fakeRepoRoot, 'watchdog.mjs'), 'export default true;\n');
|
|
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: 'entry.mjs',
|
|
scripts: { watchdog: 'watchdog.mjs' },
|
|
artifact_roots: { queueItems: 'state-link/queue' },
|
|
},
|
|
},
|
|
}), { repoRootOverride: fakeRepoRoot }),
|
|
/spec\.bindings\.artifact_roots\.queueItems must stay within repo root: symlink resolution escapes realpath boundary/
|
|
);
|
|
});
|
|
|
|
|
|
test('deployment profile artifact validation rejects entrypoint and scripts symlink escapes after realpath resolution', async (t) => {
|
|
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-profile-artifact-scripts-'));
|
|
t.after(() => fs.rmSync(sandbox, { recursive: true, force: true }));
|
|
|
|
const fakeRepoRoot = path.join(sandbox, 'repo');
|
|
const outsideRoot = path.join(sandbox, 'outside');
|
|
fs.mkdirSync(fakeRepoRoot, { recursive: true });
|
|
fs.mkdirSync(outsideRoot, { recursive: true });
|
|
fs.symlinkSync(outsideRoot, path.join(fakeRepoRoot, 'scripts-link'), 'dir');
|
|
fs.writeFileSync(path.join(fakeRepoRoot, 'safe-watchdog.mjs'), `export default true;\n`);
|
|
fs.writeFileSync(path.join(outsideRoot, 'entry.mjs'), `export default true;\n`);
|
|
fs.writeFileSync(path.join(fakeRepoRoot, 'entry.mjs'), `export default true;\n`);
|
|
fs.writeFileSync(path.join(outsideRoot, 'watchdog.mjs'), `export default true;\n`);
|
|
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: 'scripts-link/entry.mjs',
|
|
scripts: { watchdog: 'safe-watchdog.mjs' },
|
|
artifact_roots: { queueItems: 'state/operator-notify-queue' },
|
|
},
|
|
},
|
|
}), { repoRootOverride: fakeRepoRoot }),
|
|
/spec\.bindings\.entrypoint must stay within repo root: symlink resolution escapes realpath boundary/
|
|
);
|
|
|
|
assert.throws(
|
|
() => validateDeploymentProfileArtifact(createArtifact({
|
|
spec: {
|
|
package: { pluginVersion: '0.1.0-mainline' },
|
|
bindings: {
|
|
entrypoint: 'entry.mjs',
|
|
scripts: { watchdog: 'scripts-link/watchdog.mjs' },
|
|
artifact_roots: { queueItems: 'state/operator-notify-queue' },
|
|
},
|
|
},
|
|
}), { repoRootOverride: fakeRepoRoot }),
|
|
/spec\.bindings\.scripts\.watchdog must stay within repo root: symlink resolution escapes realpath boundary/
|
|
);
|
|
});
|
|
|
|
test('use-time repo-root boundary check rejects symlink escapes for missing artifact leaf', async (t) => {
|
|
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'reporting-governance-use-time-boundary-'));
|
|
t.after(() => fs.rmSync(sandbox, { recursive: true, force: true }));
|
|
|
|
const fakeRepoRoot = path.join(sandbox, 'repo');
|
|
const outsideRoot = path.join(sandbox, 'outside');
|
|
fs.mkdirSync(fakeRepoRoot, { recursive: true });
|
|
fs.mkdirSync(outsideRoot, { recursive: true });
|
|
fs.symlinkSync(outsideRoot, path.join(fakeRepoRoot, 'queue-link'), 'dir');
|
|
|
|
assert.throws(
|
|
() => assertUseTimePathWithinRepoRoot(path.join(fakeRepoRoot, 'queue-link', 'pending'), 'orchestrator adapter queueDir', {
|
|
repoRootOverride: fakeRepoRoot,
|
|
allowMissingLeaf: true,
|
|
}),
|
|
/orchestrator adapter queueDir must stay within repo root: symlink resolution escapes realpath boundary/
|
|
);
|
|
});
|