Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion server/__tests__/gemini-session-index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,57 @@ const originalUserProfile = process.env.USERPROFILE;
const originalDatabasePath = process.env.DATABASE_PATH;

let tempRoot = null;
let activeDatabaseModule = null;

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function closeTestDatabase() {
if (!activeDatabaseModule?.db?.close) {
return;
}

const maxAttempts = 6;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
activeDatabaseModule.db.close();
activeDatabaseModule = null;
return;
} catch (error) {
if (attempt === maxAttempts) {
throw error;
}
await sleep(30 * attempt);
}
}
}

async function removeTempRootWithRetry(targetPath) {
if (!targetPath) {
return;
}

const maxAttempts = 8;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await rm(targetPath, { recursive: true, force: true });
return;
} catch (error) {
if (error?.code !== 'EBUSY' || attempt === maxAttempts) {
throw error;
}
await sleep(50 * attempt);
}
}
}

async function loadTestModules() {
vi.resetModules();
const projects = await import('../projects.js');
const database = await import('../database/db.js');
await database.initializeDatabase();
activeDatabaseModule = database;
return { projects, database };
}

Expand All @@ -26,6 +71,8 @@ describe('Gemini API session indexing', () => {
});

afterEach(async () => {
await closeTestDatabase();

vi.resetModules();

if (originalHome === undefined) delete process.env.HOME;
Expand All @@ -38,7 +85,7 @@ describe('Gemini API session indexing', () => {
else process.env.DATABASE_PATH = originalDatabasePath;

if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true });
await removeTempRootWithRetry(tempRoot);
tempRoot = null;
}
});
Expand Down
134 changes: 134 additions & 0 deletions server/__tests__/project-config-path.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';

const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalDatabasePath = process.env.DATABASE_PATH;

let tempRoot = null;
let activeDatabaseModule = null;

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function closeTestDatabase() {
if (!activeDatabaseModule?.db?.close) {
return;
}

const maxAttempts = 6;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
activeDatabaseModule.db.close();
activeDatabaseModule = null;
return;
} catch (error) {
if (attempt === maxAttempts) {
throw error;
}
await sleep(30 * attempt);
}
}
}

async function removeTempRootWithRetry(targetPath) {
if (!targetPath) {
return;
}

const maxAttempts = 8;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await rm(targetPath, { recursive: true, force: true });
return;
} catch (error) {
if (error?.code !== 'EBUSY' || attempt === maxAttempts) {
throw error;
}
await sleep(50 * attempt);
}
}
}

async function loadProjectsModule() {
vi.resetModules();
const projects = await import('../projects.js');
activeDatabaseModule = await import('../database/db.js');
return projects;
}

describe('project config path migration', () => {
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'dr-claw-project-config-'));
process.env.HOME = tempRoot;
process.env.USERPROFILE = tempRoot;
process.env.DATABASE_PATH = path.join(tempRoot, 'db', 'auth.db');
});

afterEach(async () => {
await closeTestDatabase();
vi.resetModules();

if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;

if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;

if (originalDatabasePath === undefined) delete process.env.DATABASE_PATH;
else process.env.DATABASE_PATH = originalDatabasePath;

if (tempRoot) {
await removeTempRootWithRetry(tempRoot);
tempRoot = null;
}
});

it('prefers ~/.dr-claw/project-config.json when both current and legacy files exist', async () => {
const currentConfigPath = path.join(tempRoot, '.dr-claw', 'project-config.json');
const legacyConfigPath = path.join(tempRoot, '.claude', 'project-config.json');

await mkdir(path.dirname(currentConfigPath), { recursive: true });
await mkdir(path.dirname(legacyConfigPath), { recursive: true });

await writeFile(currentConfigPath, JSON.stringify({ marker: 'current', _workspacesRoot: path.join(tempRoot, 'dr-claw') }, null, 2), 'utf8');
await writeFile(legacyConfigPath, JSON.stringify({ marker: 'legacy', _workspacesRoot: path.join(tempRoot, 'legacy-root') }, null, 2), 'utf8');

const projects = await loadProjectsModule();
const config = await projects.loadProjectConfig();

expect(config.marker).toBe('current');
expect(config._workspacesRoot).toBe(path.join(tempRoot, 'dr-claw'));
});

it('migrates legacy ~/.claude/project-config.json into ~/.dr-claw/project-config.json once', async () => {
const currentConfigPath = path.join(tempRoot, '.dr-claw', 'project-config.json');
const legacyConfigPath = path.join(tempRoot, '.claude', 'project-config.json');
const legacyConfig = {
marker: 'legacy-only',
_workspacesRoot: path.join(tempRoot, 'workspaces'),
};

await mkdir(path.dirname(legacyConfigPath), { recursive: true });
await writeFile(legacyConfigPath, JSON.stringify(legacyConfig, null, 2), 'utf8');

const projects = await loadProjectsModule();
const loadedConfig = await projects.loadProjectConfig();
expect(loadedConfig).toEqual(legacyConfig);

const migratedRaw = await readFile(currentConfigPath, 'utf8');
expect(JSON.parse(migratedRaw)).toEqual(legacyConfig);

const updated = { ...loadedConfig, marker: 'saved-to-current' };
await projects.saveProjectConfig(updated);

const currentAfterSave = JSON.parse(await readFile(currentConfigPath, 'utf8'));
expect(currentAfterSave.marker).toBe('saved-to-current');

const legacyAfterSave = JSON.parse(await readFile(legacyConfigPath, 'utf8'));
expect(legacyAfterSave.marker).toBe('legacy-only');
});
});
105 changes: 103 additions & 2 deletions server/__tests__/project-sync-dedup.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, rm } from 'fs/promises';
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';

Expand All @@ -8,11 +8,56 @@ const originalUserProfile = process.env.USERPROFILE;
const originalDatabasePath = process.env.DATABASE_PATH;

let tempRoot = null;
let activeDatabaseModule = null;

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function closeTestDatabase() {
if (!activeDatabaseModule?.db?.close) {
return;
}

const maxAttempts = 6;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
activeDatabaseModule.db.close();
activeDatabaseModule = null;
return;
} catch (error) {
if (attempt === maxAttempts) {
throw error;
}
await sleep(30 * attempt);
}
}
}

async function removeTempRootWithRetry(targetPath) {
if (!targetPath) {
return;
}

const maxAttempts = 8;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await rm(targetPath, { recursive: true, force: true });
return;
} catch (error) {
if (error?.code !== 'EBUSY' || attempt === maxAttempts) {
throw error;
}
await sleep(50 * attempt);
}
}
}

async function loadTestModules() {
vi.resetModules();
const database = await import('../database/db.js');
await database.initializeDatabase();
activeDatabaseModule = database;
const projects = await import('../projects.js');
return { projects, database };
}
Expand All @@ -32,6 +77,8 @@ describe('project sync and dedup (PR #89)', () => {
});

afterEach(async () => {
await closeTestDatabase();

vi.resetModules();

if (originalHome === undefined) delete process.env.HOME;
Expand All @@ -44,7 +91,7 @@ describe('project sync and dedup (PR #89)', () => {
else process.env.DATABASE_PATH = originalDatabasePath;

if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true });
await removeTempRootWithRetry(tempRoot);
tempRoot = null;
}
});
Expand Down Expand Up @@ -170,4 +217,58 @@ describe('project sync and dedup (PR #89)', () => {
upsertSpy.mockRestore();
});
});

describe('owner id fallback guard', () => {
it('falls back to the authenticated user when project config owner is invalid', async () => {
const { projects, database } = await loadTestModules();
const userId = createTestUser(database, 'owner-fallback-user');

const resolvedOwner = await projects.resolveValidProjectOwnerUserId(
{ ownerUserId: 9999 },
null,
userId,
);

expect(resolvedOwner).toBe(userId);
});

it('does not assign unowned projects during anonymous bootstrap in multi-user mode', async () => {
const { projects, database } = await loadTestModules();
createTestUser(database, 'multi-user-1');
createTestUser(database, 'multi-user-2');

const workspaceRoot = path.join(tempRoot, 'dr-claw');
const projectDir = path.join(workspaceRoot, 'ownerless-bootstrap-project');
await mkdir(projectDir, { recursive: true });

const projectName = projects.encodeProjectPath(projectDir);
const configDir = path.join(tempRoot, '.dr-claw');
await mkdir(configDir, { recursive: true });
await writeFile(
path.join(configDir, 'project-config.json'),
JSON.stringify({
[projectName]: {
originalPath: projectDir,
},
}, null, 2),
'utf8',
);

database.projectDb.upsertProject(
projectName,
null,
'Ownerless Bootstrap Project',
projectDir,
0,
null,
null,
);

await projects.getProjects(null);

const dbRow = database.projectDb.getProjectById(projectName);
expect(dbRow).not.toBeNull();
expect(dbRow.user_id).toBeNull();
});
});
});
Loading
Loading