2026-10-31 16:19:20 +08:00
|
|
|
/**
|
2026-10-31 16:43:11 +08:00
|
|
|
* @fileoverview POST /api/cases/clone with a `path`: clone INTO that folder (an empty or not yet existing one,
|
|
|
|
|
* with no subfolder added) and register it as a linked case. Real filesystem under test/setup.ts's temp
|
|
|
|
|
* HOME and a REAL `git` cloning a local bare repo (the path policy itself is in test/case-path.test.ts).
|
|
|
|
|
* Port: N/A (app.inject()).
|
2026-10-31 16:19:20 +08:00
|
|
|
*
|
|
|
|
|
* Like case-custom-path-routes.test.ts this suite rewrites the linked-cases registry, so it refuses to
|
|
|
|
|
* start outside the sandboxed HOME (a raw `npx vitest` would reach the real ~/.codeman).
|
|
|
|
|
*/
|
2026-10-31 19:13:56 +08:00
|
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
2026-10-31 16:19:20 +08:00
|
|
|
import { execFileSync } from 'node:child_process';
|
2026-10-31 16:43:11 +08:00
|
|
|
import {
|
|
|
|
|
existsSync,
|
|
|
|
|
mkdirSync,
|
|
|
|
|
mkdtempSync,
|
|
|
|
|
readdirSync,
|
|
|
|
|
readFileSync,
|
|
|
|
|
realpathSync,
|
|
|
|
|
rmSync,
|
|
|
|
|
writeFileSync,
|
|
|
|
|
} from 'node:fs';
|
2026-10-31 16:19:20 +08:00
|
|
|
import { homedir, tmpdir } from 'node:os';
|
2026-11-06 02:27:39 +01:00
|
|
|
import { dirname, join } from 'node:path';
|
2026-10-31 16:19:20 +08:00
|
|
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
|
|
|
|
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
|
|
|
|
import { dataPath } from '../../src/config/instance.js';
|
2026-10-31 19:13:56 +08:00
|
|
|
import {
|
2026-10-31 20:29:26 +08:00
|
|
|
classifyGitFailure,
|
2026-10-31 19:13:56 +08:00
|
|
|
cloneRepository,
|
|
|
|
|
getActiveGitOperationCount,
|
|
|
|
|
getQueuedGitOperationCount,
|
|
|
|
|
isGitAvailable,
|
|
|
|
|
} from '../../src/git-clone.js';
|
2026-10-31 16:19:20 +08:00
|
|
|
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
|
|
|
|
|
|
|
|
|
|
const LINKED = () => dataPath('linked-cases.json');
|
|
|
|
|
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
|
|
|
|
|
const clone = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
|
|
|
|
|
app.inject({ method: 'POST', url: '/api/cases/clone', payload });
|
|
|
|
|
|
|
|
|
|
describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder', () => {
|
|
|
|
|
let root: string;
|
|
|
|
|
let origin: string;
|
|
|
|
|
let workDir = '';
|
|
|
|
|
|
|
|
|
|
const git = (args: string[], cwd: string) =>
|
|
|
|
|
execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
|
|
|
|
|
|
|
|
beforeAll(() => {
|
|
|
|
|
root = mkdtempSync(join(tmpdir(), 'codeman-clone-custom-'));
|
|
|
|
|
origin = join(root, 'origin.git');
|
|
|
|
|
mkdirSync(origin);
|
|
|
|
|
git(['init', '--bare', '--quiet'], origin);
|
|
|
|
|
const work = join(root, 'work');
|
|
|
|
|
mkdirSync(work);
|
|
|
|
|
git(['init', '--quiet'], work);
|
|
|
|
|
git(['config', 'user.email', 'test@example.com'], work);
|
|
|
|
|
git(['config', 'user.name', 'Codeman Test'], work);
|
|
|
|
|
writeFileSync(join(work, 'README.md'), '# fixture\n');
|
|
|
|
|
git(['add', '.'], work);
|
|
|
|
|
git(['commit', '--quiet', '-m', 'initial'], work);
|
|
|
|
|
git(['branch', '-M', 'main'], work);
|
|
|
|
|
git(['remote', 'add', 'origin', origin], work);
|
|
|
|
|
git(['push', '--quiet', 'origin', 'main'], work);
|
|
|
|
|
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin);
|
|
|
|
|
});
|
|
|
|
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
if (!isSandboxedHome()) {
|
|
|
|
|
throw new Error('case-clone-custom-folder.test.ts rewrites the linked-cases registry: run it via npm test');
|
|
|
|
|
}
|
|
|
|
|
safeRmHomeTree(LINKED());
|
|
|
|
|
// Resolved, because the server answers with the symlink-resolved folder.
|
|
|
|
|
workDir = realpathSync(mkdtempSync(join(homedir(), 'clone-custom-')));
|
|
|
|
|
});
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
delete process.env.CODEMAN_MULTIUSER;
|
|
|
|
|
if (workDir) safeRmHomeTree(workDir);
|
|
|
|
|
workDir = '';
|
|
|
|
|
if (isSandboxedHome()) safeRmHomeTree(LINKED());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('clones into the chosen folder, scaffolds it and registers it as a linked case', async () => {
|
|
|
|
|
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const target = join(workDir, 'my-clone');
|
|
|
|
|
const res = await clone(app, { name: 'my-clone', repository: origin, path: target });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.json().data.case).toEqual({ name: 'my-clone', path: target });
|
|
|
|
|
expect(existsSync(join(target, 'README.md'))).toBe(true);
|
|
|
|
|
expect(existsSync(join(target, '.git'))).toBe(true);
|
|
|
|
|
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
|
|
|
|
|
expect(linked()).toEqual({ 'my-clone': target });
|
|
|
|
|
expect(ctx.broadcast).toHaveBeenCalledWith('case:created', { name: 'my-clone', path: target });
|
|
|
|
|
// Nothing lands under the cases directory.
|
|
|
|
|
expect(existsSync(join(homedir(), 'codeman-cases', 'my-clone'))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('expands ~ in the folder', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const folder = workDir.slice(homedir().length + 1);
|
|
|
|
|
const res = await clone(app, { name: 'tilde', repository: origin, path: `~/${folder}/tilde` });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(existsSync(join(workDir, 'tilde', 'README.md'))).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('refuses a parent folder that does not exist, and creates nothing', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const res = await clone(app, { name: 'orphan', repository: origin, path: join(workDir, 'nope', 'orphan') });
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
expect(existsSync(join(workDir, 'nope'))).toBe(false);
|
|
|
|
|
expect(linked()).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('refuses a target folder that already has files, and leaves them alone', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const target = join(workDir, 'busy');
|
|
|
|
|
mkdirSync(target);
|
|
|
|
|
writeFileSync(join(target, 'mine.txt'), 'keep me');
|
|
|
|
|
const res = await clone(app, { name: 'busy', repository: origin, path: target });
|
|
|
|
|
expect(res.statusCode).toBe(409);
|
2026-10-31 16:43:11 +08:00
|
|
|
expect(res.json().error).toMatch(/already has files/);
|
2026-10-31 16:19:20 +08:00
|
|
|
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
|
|
|
|
|
expect(linked()).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-31 16:43:11 +08:00
|
|
|
it('clones straight INTO the chosen empty folder, with no subfolder named after the case', async () => {
|
2026-10-31 16:19:20 +08:00
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
2026-10-31 16:43:11 +08:00
|
|
|
const target = join(workDir, 'picked');
|
2026-10-31 16:19:20 +08:00
|
|
|
mkdirSync(target);
|
2026-10-31 16:43:11 +08:00
|
|
|
const res = await clone(app, { name: 'a-different-name', repository: origin, path: target });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.json().data.case).toEqual({ name: 'a-different-name', path: target });
|
|
|
|
|
expect(existsSync(join(target, 'README.md'))).toBe(true);
|
|
|
|
|
expect(existsSync(join(target, '.git'))).toBe(true);
|
|
|
|
|
expect(existsSync(join(target, 'a-different-name'))).toBe(false);
|
|
|
|
|
expect(linked()).toEqual({ 'a-different-name': target });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves the empty folder in place, and empty, when the clone fails', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const target = join(workDir, 'stays');
|
|
|
|
|
mkdirSync(target);
|
|
|
|
|
const res = await clone(app, { name: 'stays', repository: join(root, 'no-such-repo.git'), path: target });
|
|
|
|
|
expect(res.json().success).toBe(false);
|
|
|
|
|
expect(existsSync(target)).toBe(true);
|
|
|
|
|
expect(readdirSync(target)).toEqual([]);
|
|
|
|
|
expect(linked()).toEqual({});
|
2026-10-31 16:19:20 +08:00
|
|
|
});
|
|
|
|
|
|
2026-10-31 17:57:51 +08:00
|
|
|
it('two clones into one empty folder at once: exactly one wins and its files survive', async () => {
|
|
|
|
|
const target = join(workDir, 'raced');
|
|
|
|
|
mkdirSync(target);
|
|
|
|
|
const results = await Promise.all([
|
|
|
|
|
cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }),
|
|
|
|
|
cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }),
|
|
|
|
|
]);
|
|
|
|
|
expect(results.filter((r) => r.ok)).toHaveLength(1);
|
|
|
|
|
const loser = results.find((r) => !r.ok);
|
|
|
|
|
expect(loser && !loser.ok && loser.failure.code).toBe('DESTINATION_EXISTS');
|
|
|
|
|
expect(existsSync(join(target, 'README.md'))).toBe(true);
|
|
|
|
|
expect(existsSync(join(target, '.git'))).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a name linked while the clone ran is a 409 that keeps the folder and names it', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const [a, b] = await Promise.all([
|
|
|
|
|
clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-a') }),
|
|
|
|
|
clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-b') }),
|
|
|
|
|
]);
|
|
|
|
|
const loserDir = a.json().success ? join(workDir, 'twin-b') : join(workDir, 'twin-a');
|
|
|
|
|
const loser = a.json().success ? b : a;
|
|
|
|
|
expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1);
|
|
|
|
|
expect(loser.statusCode).toBe(409);
|
|
|
|
|
expect(loser.json().errorCode).toBe('ALREADY_EXISTS');
|
|
|
|
|
expect(loser.json().error).toContain(loserDir);
|
|
|
|
|
expect(existsSync(join(loserDir, 'README.md'))).toBe(true);
|
|
|
|
|
expect(Object.keys(linked())).toEqual(['twin']);
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-06 02:27:39 +01:00
|
|
|
it('a registry that is not a JSON object is a 409 that keeps the folder, names it and leaves the file alone', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const target = join(workDir, 'refused');
|
|
|
|
|
mkdirSync(dirname(LINKED()), { recursive: true });
|
|
|
|
|
writeFileSync(LINKED(), '[]');
|
|
|
|
|
const res = await clone(app, { name: 'refused', repository: origin, path: target });
|
|
|
|
|
expect(res.statusCode).toBe(409);
|
|
|
|
|
expect(res.json().errorCode).toBe('CONFLICT');
|
|
|
|
|
expect(res.json().error).toContain(target);
|
|
|
|
|
expect(res.json().error).toContain('Use Link Existing');
|
|
|
|
|
expect(existsSync(join(target, 'README.md'))).toBe(true);
|
|
|
|
|
expect(readFileSync(LINKED(), 'utf8')).toBe('[]');
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-31 19:13:56 +08:00
|
|
|
it('a direct clone whose git-slot wait times out leaves what was written into the folder meanwhile', async () => {
|
|
|
|
|
const target = join(workDir, 'queued');
|
|
|
|
|
mkdirSync(target);
|
|
|
|
|
// Two slow ssh clones hold both git slots (no network needed: the ssh command just sleeps).
|
|
|
|
|
const prior = process.env.GIT_SSH_COMMAND;
|
|
|
|
|
process.env.GIT_SSH_COMMAND = 'sleep 4; true';
|
|
|
|
|
try {
|
|
|
|
|
const holders = [0, 1].map((i) =>
|
|
|
|
|
cloneRepository({
|
|
|
|
|
repository: 'ssh://localhost/x.git',
|
|
|
|
|
destination: join(workDir, `hold-${i}`),
|
|
|
|
|
timeoutMs: 10_000,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2));
|
|
|
|
|
const queued = cloneRepository({
|
|
|
|
|
repository: origin,
|
|
|
|
|
destination: target,
|
|
|
|
|
intoEmptyDir: true,
|
|
|
|
|
timeoutMs: 1_000,
|
|
|
|
|
});
|
|
|
|
|
await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1));
|
|
|
|
|
// Written during the wait, so not this attempt's: a user copying files in, or another case created there.
|
|
|
|
|
writeFileSync(join(target, 'mine.txt'), 'keep me');
|
|
|
|
|
mkdirSync(join(target, 'src'));
|
|
|
|
|
const result = await queued;
|
|
|
|
|
expect(!result.ok && result.failure.code).toBe('TIMEOUT');
|
|
|
|
|
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
|
|
|
|
|
expect(existsSync(join(target, 'src'))).toBe(true);
|
|
|
|
|
await Promise.all(holders);
|
|
|
|
|
} finally {
|
|
|
|
|
if (prior === undefined) delete process.env.GIT_SSH_COMMAND;
|
|
|
|
|
else process.env.GIT_SSH_COMMAND = prior;
|
|
|
|
|
}
|
|
|
|
|
}, 30_000);
|
|
|
|
|
|
|
|
|
|
it('refuses a folder at or inside a Docker case workspace, and creates nothing', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const workspace = join(workDir, 'dockerws');
|
|
|
|
|
mkdirSync(workspace);
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(dataPath('docker-cases.json')),
|
|
|
|
|
JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: workspace, container: 'c' }])
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
for (const path of [workspace, join(workspace, 'repo')]) {
|
|
|
|
|
const res = await clone(app, { name: 'inside', repository: origin, path });
|
|
|
|
|
expect(res.json().errorCode, path).toBe('INVALID_INPUT');
|
|
|
|
|
expect(res.json().error, path).toMatch(/Docker case workspace/);
|
|
|
|
|
}
|
|
|
|
|
expect(readdirSync(workspace)).toEqual([]);
|
|
|
|
|
expect(linked()).toEqual({});
|
|
|
|
|
// A sibling of the workspace is fine.
|
|
|
|
|
const ok = await clone(app, { name: 'beside', repository: origin, path: join(workDir, 'beside') });
|
|
|
|
|
expect(ok.statusCode).toBe(200);
|
|
|
|
|
} finally {
|
|
|
|
|
safeRmHomeTree(dataPath('docker-cases.json'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('fails closed when the Docker case list cannot be read', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
writeFileSync(dataPath('docker-cases.json'), '{not json');
|
|
|
|
|
try {
|
|
|
|
|
const res = await clone(app, { name: 'blind', repository: origin, path: join(workDir, 'blind') });
|
|
|
|
|
expect(res.statusCode).toBe(503);
|
|
|
|
|
expect(existsSync(join(workDir, 'blind'))).toBe(false);
|
|
|
|
|
} finally {
|
|
|
|
|
safeRmHomeTree(dataPath('docker-cases.json'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-31 20:29:26 +08:00
|
|
|
it.each(['plain', 'openssl', 'rustls', 'does not exist'])(
|
|
|
|
|
'a refused folder named %s keeps what was written meanwhile and reports DESTINATION_EXISTS',
|
|
|
|
|
async (folder) => {
|
|
|
|
|
const target = join(workDir, folder);
|
|
|
|
|
mkdirSync(target);
|
|
|
|
|
const prior = process.env.GIT_SSH_COMMAND;
|
|
|
|
|
process.env.GIT_SSH_COMMAND = 'sleep 2; true';
|
|
|
|
|
try {
|
|
|
|
|
const holders = [0, 1].map((i) =>
|
|
|
|
|
cloneRepository({
|
|
|
|
|
repository: 'ssh://localhost/x.git',
|
|
|
|
|
destination: join(workDir, `hold-${i}`),
|
|
|
|
|
timeoutMs: 10_000,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2));
|
|
|
|
|
// Queues behind the holders but has time to get a slot, so git runs and refuses the folder.
|
|
|
|
|
const queued = cloneRepository({
|
|
|
|
|
repository: origin,
|
|
|
|
|
destination: target,
|
|
|
|
|
intoEmptyDir: true,
|
|
|
|
|
timeoutMs: 15_000,
|
|
|
|
|
});
|
|
|
|
|
await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1));
|
|
|
|
|
writeFileSync(join(target, 'mine.txt'), 'keep me');
|
|
|
|
|
const result = await queued;
|
|
|
|
|
expect(!result.ok && result.failure.code).toBe('DESTINATION_EXISTS');
|
|
|
|
|
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
|
|
|
|
|
await Promise.all(holders);
|
|
|
|
|
} finally {
|
|
|
|
|
if (prior === undefined) delete process.env.GIT_SSH_COMMAND;
|
|
|
|
|
else process.env.GIT_SSH_COMMAND = prior;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
30_000
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
it('classifies git stderr by what it says, not by words in a path it quotes', () => {
|
|
|
|
|
const refusal = (dir: string) => `fatal: destination path '${dir}' already exists and is not an empty directory.`;
|
|
|
|
|
for (const dir of ['/home/u/src/openssl', '/home/u/rustls', '/x/tls-test', '/x/repository not found']) {
|
|
|
|
|
expect(classifyGitFailure(refusal(dir), false).code, dir).toBe('DESTINATION_EXISTS');
|
|
|
|
|
}
|
|
|
|
|
expect(classifyGitFailure('fatal: unable to access: SSL certificate problem: self signed', false).code).toBe(
|
|
|
|
|
'HOST_UNREACHABLE'
|
|
|
|
|
);
|
|
|
|
|
expect(classifyGitFailure('fatal: TLS handshake failure', false).code).toBe('HOST_UNREACHABLE');
|
2026-11-04 16:50:44 +01:00
|
|
|
// `_` is a word character, so a bare \bssl\b misses OpenSSL's and LibreSSL's usual flaky-network error.
|
|
|
|
|
expect(
|
|
|
|
|
classifyGitFailure(
|
|
|
|
|
"fatal: unable to access 'https://github.com/o/r/': OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443",
|
|
|
|
|
false
|
|
|
|
|
).code
|
|
|
|
|
).toBe('HOST_UNREACHABLE');
|
2026-10-31 20:29:26 +08:00
|
|
|
expect(classifyGitFailure('fatal: could not resolve host: example.invalid', false).code).toBe('HOST_UNREACHABLE');
|
|
|
|
|
expect(classifyGitFailure("fatal: repository 'https://x/y.git/' not found", false).code).toBe('NOT_FOUND');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('refuses a clone without a path into the cases folder when it is a Docker case workspace', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const casesDir = join(homedir(), 'codeman-cases');
|
|
|
|
|
mkdirSync(casesDir, { recursive: true });
|
|
|
|
|
writeFileSync(
|
|
|
|
|
dataPath('docker-cases.json'),
|
|
|
|
|
JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: casesDir, container: 'c' }])
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
const res = await clone(app, { name: 'default-inside', repository: origin });
|
|
|
|
|
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
|
|
|
|
expect(res.json().error).toMatch(/Docker case workspace/);
|
|
|
|
|
expect(existsSync(join(casesDir, 'default-inside'))).toBe(false);
|
|
|
|
|
} finally {
|
|
|
|
|
safeRmHomeTree(dataPath('docker-cases.json'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-04 16:50:44 +01:00
|
|
|
it('points Clone Repo at itself, not Create New, for the cases folder and a folder directly in it', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const casesDir = join(homedir(), 'codeman-cases');
|
|
|
|
|
mkdirSync(casesDir, { recursive: true });
|
|
|
|
|
for (const path of [casesDir, join(casesDir, 'direct-child')]) {
|
|
|
|
|
const res = await clone(app, { name: 'direct-child', repository: origin, path });
|
|
|
|
|
expect(res.statusCode, path).toBe(400);
|
|
|
|
|
expect(res.json().error, path).toMatch(/clone there without a custom folder/);
|
|
|
|
|
expect(res.json().error, path).not.toMatch(/Create New/);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-31 16:19:20 +08:00
|
|
|
it('refuses a blocked tree such as ~/.ssh', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
mkdirSync(join(homedir(), '.ssh'), { recursive: true });
|
|
|
|
|
const res = await clone(app, { name: 'sneaky', repository: origin, path: join(homedir(), '.ssh', 'sneaky') });
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
expect(existsSync(join(homedir(), '.ssh', 'sneaky'))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('refuses a name already linked, before running git', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
writeFileSync(LINKED(), JSON.stringify({ taken: join(workDir, 'elsewhere') }));
|
|
|
|
|
const res = await clone(app, { name: 'taken', repository: origin, path: join(workDir, 'taken') });
|
|
|
|
|
// The harness has no preSerialization status mapping, so read the envelope's code.
|
|
|
|
|
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
|
|
|
|
|
expect(existsSync(join(workDir, 'taken'))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves no folder behind when the clone fails', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const target = join(workDir, 'ghost');
|
|
|
|
|
const res = await clone(app, { name: 'ghost', repository: join(root, 'no-such-repo.git'), path: target });
|
|
|
|
|
expect(res.json().errorCode).toBe('NOT_FOUND');
|
|
|
|
|
expect(existsSync(target)).toBe(false);
|
|
|
|
|
expect(linked()).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('without a path the clone still goes under the cases directory and is not linked', async () => {
|
|
|
|
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
|
|
|
|
const res = await clone(app, { name: 'custom-folder-default', repository: origin });
|
|
|
|
|
try {
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.json().data.case.path).toBe(join(homedir(), 'codeman-cases', 'custom-folder-default'));
|
|
|
|
|
expect(linked()).toEqual({});
|
|
|
|
|
} finally {
|
|
|
|
|
safeRmHomeTree(join(homedir(), 'codeman-cases', 'custom-folder-default'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('multi-user: a non-admin is refused (403) and nothing is cloned; an admin is allowed', async () => {
|
|
|
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
|
|
|
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
|
|
|
|
|
const denied = await clone(user.app, { name: 'bobs', repository: origin, path: join(workDir, 'bobs') });
|
|
|
|
|
expect(denied.statusCode).toBe(403);
|
|
|
|
|
expect(existsSync(join(workDir, 'bobs'))).toBe(false);
|
|
|
|
|
expect(linked()).toEqual({});
|
|
|
|
|
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
|
|
|
|
|
const ok = await clone(admin.app, { name: 'roots', repository: origin, path: join(workDir, 'roots') });
|
|
|
|
|
expect(ok.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
});
|