mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-02 02:38:38 +00:00
Why
- Legacy installs can have a sessions table shape that predates provider/custom_name columns. Running migrateLegacySessionNames first caused its INSERT OR REPLACE INTO sessions (...) to target columns that may not exist and fail during startup migration.
- Some upgraded databases had projects.project_id as plain TEXT instead of a real PRIMARY KEY. That breaks assumptions used by id-based lookups and can allow invalid/duplicate identity semantics over time.
- projectsDb.createProjectPath inferred outcomes from
ow.isArchived, but the upsert path always returns the post-update row with isArchived=0, so archived-reactivation and fresh-create could be misclassified.
- git clone accepted user-controlled URLs directly in argv position, so inputs beginning with - could be interpreted as options instead of a repository argument.
What
- Added
ebuildProjectsTableWithPrimaryKeySchema in migrations: detect table shape via getTableInfo('projects'), verify project_id has pk=1, and rebuild when missing.
- Rebuild flow now creates a canonical projects__new table (project_id TEXT PRIMARY KEY), copies rows with transformation, backfills empty ids via SQLITE_UUID_SQL, deduplicates conflicting ids/paths, then swaps tables inside a transaction.
- Replaced the prior ddColumnToTableIfNotExists(...) + UPDATE project_id sequence with PK-aware detection/rebuild logic so legacy DBs converge to the required schema.
- Reordered migration sequence to run
ebuildSessionsTableWithProjectSchema before migrateLegacySessionNames, ensuring sessions is normalized before legacy session_names merge writes execute.
- Updated projectsDb.createProjectPath to generate an ttemptedId before insert, pass it into the prepared statement, and classify outcomes by comparing returned
ow.project_id to ttemptedId (created vs
eactivated_archived), with no-row remaining ctive_conflict.
- Hardened clone execution by inserting -- before clone URL in git argv and rejecting normalized GitHub URLs that start with - in startCloneProject.
Tests
- Added integration coverage for projectsDb.createProjectPath branches: fresh insert, archived reactivation, and active conflict.
- Added clone service test for option-prefixed githubUrl rejection (INVALID_GITHUB_URL).
184 lines
5.3 KiB
TypeScript
184 lines
5.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { EventEmitter } from 'node:events';
|
|
import path from 'node:path';
|
|
import { PassThrough } from 'node:stream';
|
|
import test from 'node:test';
|
|
|
|
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
|
|
import { AppError } from '@/shared/utils.js';
|
|
|
|
type TestDependencies = Parameters<typeof startCloneProject>[2];
|
|
|
|
function buildDependencies(overrides: Partial<NonNullable<TestDependencies>> = {}): NonNullable<TestDependencies> {
|
|
return {
|
|
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/root' }),
|
|
ensureDirectory: async () => undefined,
|
|
pathExists: async () => false,
|
|
removePath: async () => undefined,
|
|
getGithubTokenById: async () => ({ github_token: 'token-value' }),
|
|
spawnGitClone: () => {
|
|
throw new Error('spawnGitClone should be overridden in this test');
|
|
},
|
|
registerProject: async () => ({ project: { projectId: 'project-1' } }),
|
|
logError: () => undefined,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createMockGitProcess() {
|
|
const emitter = new EventEmitter() as EventEmitter & {
|
|
stdout: PassThrough;
|
|
stderr: PassThrough;
|
|
kill: () => void;
|
|
};
|
|
|
|
emitter.stdout = new PassThrough();
|
|
emitter.stderr = new PassThrough();
|
|
emitter.kill = () => {
|
|
emitter.emit('close', null);
|
|
};
|
|
|
|
return emitter;
|
|
}
|
|
|
|
test('startCloneProject rejects when workspace path is missing', async () => {
|
|
await assert.rejects(
|
|
async () =>
|
|
startCloneProject(
|
|
{
|
|
workspacePath: '',
|
|
githubUrl: 'https://github.com/example/repo',
|
|
userId: 1,
|
|
},
|
|
{
|
|
onProgress: () => undefined,
|
|
onComplete: () => undefined,
|
|
},
|
|
buildDependencies(),
|
|
),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof AppError);
|
|
assert.equal(error.code, 'WORKSPACE_PATH_REQUIRED');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('startCloneProject rejects when github URL is missing', async () => {
|
|
await assert.rejects(
|
|
async () =>
|
|
startCloneProject(
|
|
{
|
|
workspacePath: '/workspace/root',
|
|
githubUrl: '',
|
|
userId: 1,
|
|
},
|
|
{
|
|
onProgress: () => undefined,
|
|
onComplete: () => undefined,
|
|
},
|
|
buildDependencies(),
|
|
),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof AppError);
|
|
assert.equal(error.code, 'GITHUB_URL_REQUIRED');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('startCloneProject rejects github URL values that begin with option prefixes', async () => {
|
|
await assert.rejects(
|
|
async () =>
|
|
startCloneProject(
|
|
{
|
|
workspacePath: '/workspace/root',
|
|
githubUrl: '--upload-pack=malicious',
|
|
userId: 1,
|
|
},
|
|
{
|
|
onProgress: () => undefined,
|
|
onComplete: () => undefined,
|
|
},
|
|
buildDependencies(),
|
|
),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof AppError);
|
|
assert.equal(error.code, 'INVALID_GITHUB_URL');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('startCloneProject rejects when selected github token does not exist', async () => {
|
|
await assert.rejects(
|
|
async () =>
|
|
startCloneProject(
|
|
{
|
|
workspacePath: '/workspace/root',
|
|
githubUrl: 'https://github.com/example/repo',
|
|
githubTokenId: 12,
|
|
userId: 1,
|
|
},
|
|
{
|
|
onProgress: () => undefined,
|
|
onComplete: () => undefined,
|
|
},
|
|
buildDependencies({
|
|
getGithubTokenById: async () => null,
|
|
}),
|
|
),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof AppError);
|
|
assert.equal(error.code, 'GITHUB_TOKEN_NOT_FOUND');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('startCloneProject completes and emits complete payload when git exits successfully', async () => {
|
|
const gitProcess = createMockGitProcess();
|
|
const progressMessages: string[] = [];
|
|
let completePayload: { project: Record<string, unknown>; message: string } | null = null;
|
|
let capturedProjectPath = '';
|
|
let capturedCustomName = '';
|
|
|
|
const operation = await startCloneProject(
|
|
{
|
|
workspacePath: '/workspace/root',
|
|
githubUrl: 'https://github.com/example/repo.git',
|
|
userId: 1,
|
|
},
|
|
{
|
|
onProgress: (message) => {
|
|
progressMessages.push(message);
|
|
},
|
|
onComplete: (payload: { project: Record<string, unknown>; message: string }) => {
|
|
completePayload = payload;
|
|
},
|
|
},
|
|
buildDependencies({
|
|
spawnGitClone: () => gitProcess as any,
|
|
registerProject: async (projectPath, customName) => {
|
|
capturedProjectPath = projectPath;
|
|
capturedCustomName = customName;
|
|
return { project: { projectId: 'project-1', path: projectPath } };
|
|
},
|
|
}),
|
|
);
|
|
|
|
gitProcess.emit('close', 0);
|
|
await operation.waitForCompletion;
|
|
|
|
assert.ok(progressMessages.some((message) => message.includes("Cloning into 'repo'")));
|
|
assert.equal(capturedCustomName, 'repo');
|
|
assert.equal(path.basename(capturedProjectPath), 'repo');
|
|
assert.notEqual(completePayload, null);
|
|
const resolvedCompletePayload = completePayload as unknown as {
|
|
project: Record<string, unknown>;
|
|
message: string;
|
|
};
|
|
assert.equal(resolvedCompletePayload.message, 'Repository cloned successfully');
|
|
assert.equal((resolvedCompletePayload.project.projectId as string) || '', 'project-1');
|
|
});
|