Finalize turn-role invariants and legacy migration cutover
This commit is contained in:
@@ -1,261 +1,163 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
const {
|
||||
assignRoomMock,
|
||||
initDatabaseMock,
|
||||
emitStatusMock,
|
||||
loggerInfoMock,
|
||||
isValidGroupFolderMock,
|
||||
} = vi.hoisted(() => ({
|
||||
assignRoomMock: vi.fn(),
|
||||
initDatabaseMock: vi.fn(),
|
||||
emitStatusMock: vi.fn(),
|
||||
loggerInfoMock: vi.fn(),
|
||||
isValidGroupFolderMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Tests for the register step.
|
||||
*
|
||||
* Verifies: parameterized SQL (no injection), file templating,
|
||||
* apostrophe in names, .env updates.
|
||||
*/
|
||||
vi.mock('../src/config.js', () => ({
|
||||
GROUPS_DIR: '/tmp/ejclaw-groups',
|
||||
}));
|
||||
|
||||
function createTestDb(): Database {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
agent_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1,
|
||||
is_main INTEGER DEFAULT 0,
|
||||
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
||||
work_dir TEXT,
|
||||
PRIMARY KEY (jid, agent_type),
|
||||
UNIQUE (folder, agent_type)
|
||||
)`);
|
||||
return db;
|
||||
}
|
||||
vi.mock('../src/db.js', () => ({
|
||||
assignRoom: assignRoomMock,
|
||||
initDatabase: initDatabaseMock,
|
||||
}));
|
||||
|
||||
describe('parameterized SQL registration', () => {
|
||||
let db: Database;
|
||||
vi.mock('../src/group-folder.js', () => ({
|
||||
isValidGroupFolder: isValidGroupFolderMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
vi.mock('../src/logger.js', () => ({
|
||||
logger: {
|
||||
info: loggerInfoMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./status.js', () => ({
|
||||
emitStatus: emitStatusMock,
|
||||
}));
|
||||
|
||||
import { run } from './register.js';
|
||||
|
||||
describe('register step', () => {
|
||||
afterEach(() => {
|
||||
assignRoomMock.mockReset();
|
||||
initDatabaseMock.mockReset();
|
||||
emitStatusMock.mockReset();
|
||||
loggerInfoMock.mockReset();
|
||||
isValidGroupFolderMock.mockReset();
|
||||
isValidGroupFolderMock.mockReturnValue(true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers a group with parameterized query', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Test Group',
|
||||
'test-group',
|
||||
it('delegates room assignment to the canonical service without editing env or prompts', async () => {
|
||||
const mkdirSpy = vi
|
||||
.spyOn(fs, 'mkdirSync')
|
||||
.mockImplementation(() => undefined);
|
||||
const readSpy = vi.spyOn(fs, 'readFileSync');
|
||||
const writeSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
|
||||
await run([
|
||||
'--jid',
|
||||
'dc:test-room',
|
||||
'--name',
|
||||
'Test Room',
|
||||
'--trigger',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
'--folder',
|
||||
'test-room',
|
||||
]);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
||||
.get('123@g.us') as {
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
requires_trigger: number;
|
||||
};
|
||||
|
||||
expect(row.jid).toBe('123@g.us');
|
||||
expect(row.name).toBe('Test Group');
|
||||
expect(row.folder).toBe('test-group');
|
||||
expect(row.trigger_pattern).toBe('@Andy');
|
||||
expect(row.requires_trigger).toBe(1);
|
||||
expect(initDatabaseMock).toHaveBeenCalledTimes(1);
|
||||
expect(assignRoomMock).toHaveBeenCalledWith('dc:test-room', {
|
||||
name: 'Test Room',
|
||||
folder: 'test-room',
|
||||
trigger: '@Andy',
|
||||
requiresTrigger: true,
|
||||
isMain: false,
|
||||
});
|
||||
expect(mkdirSpy).toHaveBeenCalledWith('/tmp/ejclaw-groups/test-room/logs', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
expect(emitStatusMock).toHaveBeenCalledWith('REGISTER_CHANNEL', {
|
||||
JID: 'dc:test-room',
|
||||
NAME: 'Test Room',
|
||||
FOLDER: 'test-room',
|
||||
CHANNEL: 'discord',
|
||||
TRIGGER: '@Andy',
|
||||
REQUIRES_TRIGGER: true,
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles apostrophes in group names safely', () => {
|
||||
const name = "O'Brien's Group";
|
||||
it('fails fast when deprecated assistant-name is passed', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code?: string | number | null | undefined) => {
|
||||
throw new Error(`process.exit:${code}`);
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'456@g.us',
|
||||
name,
|
||||
'obriens-group',
|
||||
await expect(
|
||||
run([
|
||||
'--jid',
|
||||
'dc:test-room',
|
||||
'--name',
|
||||
'Test Room',
|
||||
'--trigger',
|
||||
'@Andy',
|
||||
'--folder',
|
||||
'test-room',
|
||||
'--assistant-name',
|
||||
'Nova',
|
||||
]),
|
||||
).rejects.toThrow('process.exit:4');
|
||||
|
||||
expect(initDatabaseMock).not.toHaveBeenCalled();
|
||||
expect(assignRoomMock).not.toHaveBeenCalled();
|
||||
expect(emitStatusMock).toHaveBeenCalledWith('REGISTER_CHANNEL', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'assistant_name_option_removed',
|
||||
NEXT_STEP:
|
||||
'Use a dedicated assistant identity configuration command instead',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('never writes prompt or env files during registration', async () => {
|
||||
const mkdirSpy = vi
|
||||
.spyOn(fs, 'mkdirSync')
|
||||
.mockImplementation(() => undefined);
|
||||
const readSpy = vi.spyOn(fs, 'readFileSync');
|
||||
const writeSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
|
||||
await run([
|
||||
'--jid',
|
||||
'dc:main-room',
|
||||
'--name',
|
||||
'Main Room',
|
||||
'--trigger',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
'--folder',
|
||||
'main-room',
|
||||
'--is-main',
|
||||
'--no-trigger-required',
|
||||
]);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT name FROM registered_groups WHERE jid = ?')
|
||||
.get('456@g.us') as {
|
||||
name: string;
|
||||
};
|
||||
|
||||
expect(row.name).toBe(name);
|
||||
});
|
||||
|
||||
it('prevents SQL injection in JID field', () => {
|
||||
const maliciousJid = "'; DROP TABLE registered_groups; --";
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
||||
|
||||
// Table should still exist and have the row
|
||||
const count = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as {
|
||||
count: number;
|
||||
};
|
||||
expect(count.count).toBe(1);
|
||||
|
||||
const row = db.prepare('SELECT jid FROM registered_groups').get() as {
|
||||
jid: string;
|
||||
};
|
||||
expect(row.jid).toBe(maliciousJid);
|
||||
});
|
||||
|
||||
it('handles requiresTrigger=false', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'dc:789',
|
||||
'Personal',
|
||||
'main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
|
||||
.get('dc:789') as { requires_trigger: number };
|
||||
|
||||
expect(row.requires_trigger).toBe(0);
|
||||
});
|
||||
|
||||
it('stores is_main flag', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
|
||||
).run(
|
||||
'dc:789',
|
||||
'Personal',
|
||||
'discord_main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
|
||||
.get('dc:789') as { is_main: number };
|
||||
|
||||
expect(row.is_main).toBe(1);
|
||||
});
|
||||
|
||||
it('defaults is_main to 0', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Some Group',
|
||||
'discord_some-group',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
|
||||
.get('123@g.us') as { is_main: number };
|
||||
|
||||
expect(row.is_main).toBe(0);
|
||||
});
|
||||
|
||||
it('upserts on conflict', () => {
|
||||
const stmt = db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
);
|
||||
|
||||
stmt.run(
|
||||
'123@g.us',
|
||||
'Original',
|
||||
'main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
stmt.run(
|
||||
'123@g.us',
|
||||
'Updated',
|
||||
'main',
|
||||
'@Bot',
|
||||
'2024-02-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
|
||||
const rows = db.prepare('SELECT * FROM registered_groups').all();
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
const row = rows[0] as {
|
||||
name: string;
|
||||
trigger_pattern: string;
|
||||
requires_trigger: number;
|
||||
};
|
||||
expect(row.name).toBe('Updated');
|
||||
expect(row.trigger_pattern).toBe('@Bot');
|
||||
expect(row.requires_trigger).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file templating', () => {
|
||||
it('replaces assistant name in CLAUDE.md content', () => {
|
||||
let content = '# Andy\n\nYou are Andy, a personal assistant.';
|
||||
|
||||
content = content.replace(/^# Andy$/m, '# Nova');
|
||||
content = content.replace(/You are Andy/g, 'You are Nova');
|
||||
|
||||
expect(content).toBe('# Nova\n\nYou are Nova, a personal assistant.');
|
||||
});
|
||||
|
||||
it('handles names with special regex characters', () => {
|
||||
let content = '# Andy\n\nYou are Andy.';
|
||||
|
||||
const newName = 'C.L.A.U.D.E';
|
||||
content = content.replace(/^# Andy$/m, `# ${newName}`);
|
||||
content = content.replace(/You are Andy/g, `You are ${newName}`);
|
||||
|
||||
expect(content).toContain('# C.L.A.U.D.E');
|
||||
expect(content).toContain('You are C.L.A.U.D.E.');
|
||||
});
|
||||
|
||||
it('updates .env ASSISTANT_NAME line', () => {
|
||||
let envContent = 'SOME_KEY=value\nASSISTANT_NAME="Andy"\nOTHER=test';
|
||||
|
||||
envContent = envContent.replace(
|
||||
/^ASSISTANT_NAME=.*$/m,
|
||||
'ASSISTANT_NAME="Nova"',
|
||||
);
|
||||
|
||||
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
|
||||
expect(envContent).toContain('SOME_KEY=value');
|
||||
});
|
||||
|
||||
it('appends ASSISTANT_NAME to .env if not present', () => {
|
||||
let envContent = 'SOME_KEY=value\n';
|
||||
|
||||
if (!envContent.includes('ASSISTANT_NAME=')) {
|
||||
envContent += '\nASSISTANT_NAME="Nova"';
|
||||
}
|
||||
|
||||
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
|
||||
expect(assignRoomMock).toHaveBeenCalledWith('dc:main-room', {
|
||||
name: 'Main Room',
|
||||
folder: 'main-room',
|
||||
trigger: '@Andy',
|
||||
requiresTrigger: false,
|
||||
isMain: true,
|
||||
});
|
||||
expect(mkdirSpy).toHaveBeenCalledTimes(1);
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user