import fs from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; /** * Tests for the register step. * * Verifies: parameterized SQL (no injection), file templating, * apostrophe in names, .env updates, CLAUDE.md template copy. */ function createTestDb(): Database.Database { const db = new Database(':memory:'); db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( jid TEXT PRIMARY KEY, name TEXT NOT NULL, folder TEXT NOT NULL UNIQUE, trigger_pattern TEXT NOT NULL, added_at TEXT NOT NULL, container_config TEXT, requires_trigger INTEGER DEFAULT 1, is_main INTEGER DEFAULT 0 )`); return db; } describe('parameterized SQL registration', () => { let db: Database.Database; beforeEach(() => { db = createTestDb(); }); it('registers a group with parameterized query', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '123@g.us', 'Test Group', 'test-group', '@Andy', '2024-01-01T00:00:00.000Z', 1, ); 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); }); it('handles apostrophes in group names safely', () => { const name = "O'Brien's Group"; db.prepare( `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '456@g.us', name, 'obriens-group', '@Andy', '2024-01-01T00:00:00.000Z', 0, ); 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, container_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, container_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '789@s.whatsapp.net', 'Personal', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 0, ); const row = db .prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?') .get('789@s.whatsapp.net') 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, container_config, requires_trigger, is_main) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, ).run( '789@s.whatsapp.net', 'Personal', 'whatsapp_main', '@Andy', '2024-01-01T00:00:00.000Z', 0, 1, ); const row = db .prepare('SELECT is_main FROM registered_groups WHERE jid = ?') .get('789@s.whatsapp.net') 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, container_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '123@g.us', 'Some Group', 'whatsapp_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, container_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"'); }); }); describe('CLAUDE.md template copy', () => { let tmpDir: string; let groupsDir: string; // Replicates register.ts template copy + name update logic function simulateRegister( folder: string, isMain: boolean, assistantName = 'Andy', ): void { const folderDir = path.join(groupsDir, folder); fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); // Template copy — never overwrite existing (register.ts lines 119-135) const dest = path.join(folderDir, 'CLAUDE.md'); if (!fs.existsSync(dest)) { const templatePath = isMain ? path.join(groupsDir, 'main', 'CLAUDE.md') : path.join(groupsDir, 'global', 'CLAUDE.md'); if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, dest); } } // Name update across all groups (register.ts lines 140-165) if (assistantName !== 'Andy') { const mdFiles = fs .readdirSync(groupsDir) .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) .filter((f) => fs.existsSync(f)); for (const mdFile of mdFiles) { let content = fs.readFileSync(mdFile, 'utf-8'); content = content.replace(/^# Andy$/m, `# ${assistantName}`); content = content.replace( /You are Andy/g, `You are ${assistantName}`, ); fs.writeFileSync(mdFile, content); } } } function readGroupMd(folder: string): string { return fs.readFileSync( path.join(groupsDir, folder, 'CLAUDE.md'), 'utf-8', ); } beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-')); groupsDir = path.join(tmpDir, 'groups'); fs.mkdirSync(path.join(groupsDir, 'main'), { recursive: true }); fs.mkdirSync(path.join(groupsDir, 'global'), { recursive: true }); fs.writeFileSync( path.join(groupsDir, 'main', 'CLAUDE.md'), '# Andy\n\nYou are Andy, a personal assistant.\n\n## Admin Context\n\nThis is the **main channel**.', ); fs.writeFileSync( path.join(groupsDir, 'global', 'CLAUDE.md'), '# Andy\n\nYou are Andy, a personal assistant.', ); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('copies global template for non-main group', () => { simulateRegister('telegram_dev-team', false); const content = readGroupMd('telegram_dev-team'); expect(content).toContain('You are Andy'); expect(content).not.toContain('Admin Context'); }); it('copies main template for main group', () => { simulateRegister('whatsapp_main', true); expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); }); it('each channel can have its own main with admin context', () => { simulateRegister('whatsapp_main', true); simulateRegister('telegram_main', true); simulateRegister('slack_main', true); simulateRegister('discord_main', true); for (const folder of [ 'whatsapp_main', 'telegram_main', 'slack_main', 'discord_main', ]) { const content = readGroupMd(folder); expect(content).toContain('Admin Context'); expect(content).toContain('You are Andy'); } }); it('non-main groups across channels get global template', () => { simulateRegister('whatsapp_main', true); simulateRegister('telegram_friends', false); simulateRegister('slack_engineering', false); simulateRegister('discord_general', false); expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); for (const folder of [ 'telegram_friends', 'slack_engineering', 'discord_general', ]) { const content = readGroupMd(folder); expect(content).toContain('You are Andy'); expect(content).not.toContain('Admin Context'); } }); it('custom name propagates to all channels and groups', () => { // Register multiple channels, last one sets custom name simulateRegister('whatsapp_main', true); simulateRegister('telegram_main', true); simulateRegister('slack_devs', false); // Final registration triggers name update across all simulateRegister('discord_main', true, 'Luna'); for (const folder of [ 'main', 'global', 'whatsapp_main', 'telegram_main', 'slack_devs', 'discord_main', ]) { const content = readGroupMd(folder); expect(content).toContain('# Luna'); expect(content).toContain('You are Luna'); expect(content).not.toContain('Andy'); } }); it('never overwrites existing CLAUDE.md on re-registration', () => { simulateRegister('slack_main', true); // User customizes the file extensively (persona, workspace, rules) const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md'); fs.writeFileSync( mdPath, '# Gambi\n\nCustom persona with workspace rules and family context.', ); // Re-registering same folder (e.g. re-running /add-slack) simulateRegister('slack_main', true); const content = readGroupMd('slack_main'); expect(content).toContain('Custom persona'); expect(content).not.toContain('Admin Context'); }); it('never overwrites when non-main becomes main (isMain changes)', () => { // User registers a family group as non-main simulateRegister('whatsapp_casa', false); // User extensively customizes it (PARA system, task management, etc.) const mdPath = path.join(groupsDir, 'whatsapp_casa', 'CLAUDE.md'); fs.writeFileSync( mdPath, '# Casa\n\nFamily group with PARA system, task management, shopping lists.', ); // Later, user promotes to main (no trigger required) — CLAUDE.md must be preserved simulateRegister('whatsapp_casa', true); const content = readGroupMd('whatsapp_casa'); expect(content).toContain('PARA system'); expect(content).not.toContain('Admin Context'); }); it('preserves custom CLAUDE.md across channels when changing main', () => { // Real-world scenario: WhatsApp main + customized Discord research channel simulateRegister('whatsapp_main', true); simulateRegister('discord_main', false); const discordPath = path.join(groupsDir, 'discord_main', 'CLAUDE.md'); fs.writeFileSync( discordPath, '# Gambi HQ — Research Assistant\n\nResearch workflows for Laura and Ethan.', ); // Discord becomes main too — custom content must survive simulateRegister('discord_main', true); expect(readGroupMd('discord_main')).toContain('Research Assistant'); // WhatsApp main also untouched expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); }); it('handles missing templates gracefully', () => { fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md')); fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md')); simulateRegister('discord_general', false); expect( fs.existsSync(path.join(groupsDir, 'discord_general', 'CLAUDE.md')), ).toBe(false); }); });