mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
161 lines
5.7 KiB
TypeScript
161 lines
5.7 KiB
TypeScript
/**
|
|
* Unit tests for the pure docker export/import helpers (src/docker-export.ts).
|
|
* The IO paths no-op under VITEST; these cover the naming, tar-traversal guard,
|
|
* load-output parsing, and the sealed-mode refusal.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
dockerArgv,
|
|
exportBundleName,
|
|
exportImageTag,
|
|
importedImageTag,
|
|
isSafeTarMember,
|
|
parseLoadedImageRef,
|
|
exportDockerCase,
|
|
validateImportManifest,
|
|
DOCKER_EXPORT_SCHEMA,
|
|
type DockerExportManifest,
|
|
} from '../src/docker-export.js';
|
|
import { toSessionDocker } from '../src/docker-hosts.js';
|
|
import type { DockerCase, DockerHost } from '../src/types.js';
|
|
|
|
const HOST: DockerHost = { id: 'local', label: 'Local', image: 'codeman/agent:base' };
|
|
const CASE: DockerCase = {
|
|
name: 'myproj',
|
|
type: 'docker',
|
|
hostId: 'local',
|
|
hostWorkspacePath: '/home/arkon/cases/myproj',
|
|
};
|
|
|
|
describe('dockerArgv', () => {
|
|
it('is raw (unescaped) argv for spawn', () => {
|
|
expect(dockerArgv({ engine: 'docker' })).toEqual(['docker']);
|
|
expect(dockerArgv({ engine: 'podman', context: 'ctx', daemonHost: 'ssh://h' })).toEqual([
|
|
'podman',
|
|
'--context',
|
|
'ctx',
|
|
'-H',
|
|
'ssh://h',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('bundle / tag naming', () => {
|
|
it('names bundles by case + timestamp + mode', () => {
|
|
expect(exportBundleName('myproj', 1234, 'full')).toBe('myproj-1234.codeman-container.tgz');
|
|
expect(exportBundleName('myproj', 1234, 'workspace')).toBe('myproj-1234.codeman-workspace.tgz');
|
|
});
|
|
it('quarantines imported images and tags export intermediates uniquely', () => {
|
|
expect(importedImageTag('myproj', 99)).toBe('codeman/imported-myproj:99');
|
|
expect(exportImageTag('myproj', 99)).toBe('codeman/export-myproj:99');
|
|
});
|
|
});
|
|
|
|
describe('isSafeTarMember (import traversal guard)', () => {
|
|
it('accepts normal relative members', () => {
|
|
expect(isSafeTarMember('./')).toBe(true);
|
|
expect(isSafeTarMember('src/index.ts')).toBe(true);
|
|
expect(isSafeTarMember('./a/b/c.txt')).toBe(true);
|
|
});
|
|
it('rejects absolute and parent-escaping members', () => {
|
|
expect(isSafeTarMember('/etc/passwd')).toBe(false);
|
|
expect(isSafeTarMember('../outside')).toBe(false);
|
|
expect(isSafeTarMember('a/../../b')).toBe(false);
|
|
expect(isSafeTarMember('./../../x')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('validateImportManifest (untrusted cross-machine input)', () => {
|
|
const good = (): DockerExportManifest => ({
|
|
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
|
caseName: 'myproj',
|
|
mode: 'full',
|
|
engine: 'docker',
|
|
image: 'codeman/agent:base',
|
|
containerWorkdir: '/home/arkon/cases/myproj',
|
|
network: 'bridge',
|
|
createdAt: 1,
|
|
codemanVersion: '1.4.1',
|
|
mountCredentials: true,
|
|
secretFree: true,
|
|
checksums: {},
|
|
});
|
|
|
|
it('accepts a well-formed manifest', () => {
|
|
expect(() => validateImportManifest(good())).not.toThrow();
|
|
});
|
|
|
|
it('rejects a hostile engine (would select the probe/launch binary)', () => {
|
|
expect(() => validateImportManifest({ ...good(), engine: 'rm' as never })).toThrow(/engine/);
|
|
});
|
|
|
|
it('rejects shell metacharacters in containerWorkdir', () => {
|
|
expect(() => validateImportManifest({ ...good(), containerWorkdir: '/w; rm -rf ~' })).toThrow(/containerWorkdir/);
|
|
expect(() => validateImportManifest({ ...good(), containerWorkdir: 'relative/path' })).toThrow(/containerWorkdir/);
|
|
});
|
|
|
|
it('rejects bad image refs, case names, networks, and schema versions', () => {
|
|
expect(() => validateImportManifest({ ...good(), image: '-bad$(x)' })).toThrow(/image/);
|
|
expect(() => validateImportManifest({ ...good(), caseName: '../evil' })).toThrow(/caseName/);
|
|
expect(() => validateImportManifest({ ...good(), network: 'host' })).toThrow(/network/);
|
|
expect(() => validateImportManifest({ ...good(), schemaVersion: 99 })).toThrow(/schema version/);
|
|
});
|
|
});
|
|
|
|
describe('parseLoadedImageRef', () => {
|
|
it('parses "Loaded image ID: sha256:..."', () => {
|
|
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
|
|
});
|
|
it('parses "Loaded image: repo:tag"', () => {
|
|
expect(parseLoadedImageRef('Loaded image: codeman/export-x:1234')).toBe('codeman/export-x:1234');
|
|
});
|
|
it('returns null on unrecognized output', () => {
|
|
expect(parseLoadedImageRef('some other text')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('exportDockerCase (VITEST stub)', () => {
|
|
it('returns a deterministic stub manifest without touching docker', async () => {
|
|
const docker = toSessionDocker(HOST, CASE);
|
|
const res = await exportDockerCase({
|
|
docker,
|
|
caseName: 'myproj',
|
|
timestamp: 42,
|
|
exportsDir: '/tmp/exports',
|
|
mode: 'full',
|
|
codemanVersion: '9.9.9',
|
|
});
|
|
expect(res.manifest.schemaVersion).toBe(DOCKER_EXPORT_SCHEMA);
|
|
expect(res.manifest.caseName).toBe('myproj');
|
|
expect(res.manifest.mode).toBe('full');
|
|
expect(res.bundlePath).toBe('/tmp/exports/myproj-42.codeman-container.tgz');
|
|
});
|
|
|
|
it('refuses a full-image export for a sealed container', async () => {
|
|
const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE);
|
|
await expect(
|
|
exportDockerCase({
|
|
docker,
|
|
caseName: 'myproj',
|
|
timestamp: 42,
|
|
exportsDir: '/tmp/exports',
|
|
mode: 'full',
|
|
codemanVersion: '9.9.9',
|
|
})
|
|
).rejects.toThrow(/sealed/);
|
|
});
|
|
|
|
it('allows a workspace-only export for a sealed container', async () => {
|
|
const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE);
|
|
const res = await exportDockerCase({
|
|
docker,
|
|
caseName: 'myproj',
|
|
timestamp: 42,
|
|
exportsDir: '/tmp/exports',
|
|
mode: 'workspace',
|
|
codemanVersion: '9.9.9',
|
|
});
|
|
expect(res.manifest.mode).toBe('workspace');
|
|
});
|
|
});
|