diff --git a/data/nodes.db b/data/nodes.db index 6437f95..9a6e8a3 100644 Binary files a/data/nodes.db and b/data/nodes.db differ diff --git a/src/templates/metadata-generator.ts b/src/templates/metadata-generator.ts index 3000c53..a476c5c 100644 --- a/src/templates/metadata-generator.ts +++ b/src/templates/metadata-generator.ts @@ -111,10 +111,15 @@ export class MetadataGenerator { // Extract node information for analysis const nodesSummary = this.summarizeNodes(template.nodes); - // Build context for the AI + // Sanitize template name and description to prevent prompt injection + const sanitizedName = this.sanitizeInput(template.name, 200); + const sanitizedDescription = template.description ? + this.sanitizeInput(template.description, 500) : ''; + + // Build context for the AI with sanitized inputs const context = [ - `Template: ${template.name}`, - template.description ? `Description: ${template.description}` : '', + `Template: ${sanitizedName}`, + sanitizedDescription ? `Description: ${sanitizedDescription}` : '', `Nodes Used (${template.nodes.length}): ${nodesSummary}`, template.workflow ? `Workflow has ${template.workflow.nodes?.length || 0} nodes with ${Object.keys(template.workflow.connections || {}).length} connections` : '' ].filter(Boolean).join('\n'); @@ -125,7 +130,7 @@ export class MetadataGenerator { url: '/v1/chat/completions', body: { model: this.model, - temperature: 1, + temperature: 0.3, // Lower temperature for more consistent structured outputs max_completion_tokens: 1000, response_format: { type: 'json_schema', @@ -145,6 +150,27 @@ export class MetadataGenerator { }; } + /** + * Sanitize input to prevent prompt injection and control token usage + */ + private sanitizeInput(input: string, maxLength: number): string { + // Truncate to max length + let sanitized = input.slice(0, maxLength); + + // Remove control characters and excessive whitespace + sanitized = sanitized.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); + + // Replace multiple spaces/newlines with single space + sanitized = sanitized.replace(/\s+/g, ' ').trim(); + + // Remove potential prompt injection patterns + sanitized = sanitized.replace(/\b(system|assistant|user|human|ai):/gi, ''); + sanitized = sanitized.replace(/```[\s\S]*?```/g, ''); // Remove code blocks + sanitized = sanitized.replace(/\[INST\]|\[\/INST\]/g, ''); // Remove instruction markers + + return sanitized; + } + /** * Summarize nodes for better context */ @@ -243,7 +269,7 @@ export class MetadataGenerator { try { const completion = await this.client.chat.completions.create({ model: this.model, - temperature: 1, + temperature: 0.3, // Lower temperature for more consistent structured outputs max_completion_tokens: 1000, response_format: { type: 'json_schema', diff --git a/src/templates/template-repository.ts b/src/templates/template-repository.ts index 082bfea..db780c8 100644 --- a/src/templates/template-repository.ts +++ b/src/templates/template-repository.ts @@ -640,10 +640,13 @@ export class TemplateRepository { const conditions: string[] = ['metadata_json IS NOT NULL']; const params: any[] = []; - // Build WHERE conditions based on filters + // Build WHERE conditions based on filters with proper parameterization if (filters.category) { - conditions.push("json_extract(metadata_json, '$.categories') LIKE ?"); - params.push(`%"${filters.category}"%`); + // Use parameterized LIKE with JSON array search - safe from injection + conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'"); + // Escape special characters and quotes for JSON string matching + const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1); + params.push(sanitizedCategory); } if (filters.complexity) { @@ -662,13 +665,19 @@ export class TemplateRepository { } if (filters.requiredService) { - conditions.push("json_extract(metadata_json, '$.required_services') LIKE ?"); - params.push(`%"${filters.requiredService}"%`); + // Use parameterized LIKE with JSON array search - safe from injection + conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'"); + // Escape special characters and quotes for JSON string matching + const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1); + params.push(sanitizedService); } if (filters.targetAudience) { - conditions.push("json_extract(metadata_json, '$.target_audience') LIKE ?"); - params.push(`%"${filters.targetAudience}"%`); + // Use parameterized LIKE with JSON array search - safe from injection + conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'"); + // Escape special characters and quotes for JSON string matching + const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1); + params.push(sanitizedAudience); } const query = ` @@ -700,8 +709,10 @@ export class TemplateRepository { const params: any[] = []; if (filters.category) { - conditions.push("json_extract(metadata_json, '$.categories') LIKE ?"); - params.push(`%"${filters.category}"%`); + // Use parameterized LIKE with JSON array search - safe from injection + conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'"); + const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1); + params.push(sanitizedCategory); } if (filters.complexity) { @@ -720,13 +731,17 @@ export class TemplateRepository { } if (filters.requiredService) { - conditions.push("json_extract(metadata_json, '$.required_services') LIKE ?"); - params.push(`%"${filters.requiredService}"%`); + // Use parameterized LIKE with JSON array search - safe from injection + conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'"); + const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1); + params.push(sanitizedService); } if (filters.targetAudience) { - conditions.push("json_extract(metadata_json, '$.target_audience') LIKE ?"); - params.push(`%"${filters.targetAudience}"%`); + // Use parameterized LIKE with JSON array search - safe from injection + conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'"); + const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1); + params.push(sanitizedAudience); } const query = `SELECT COUNT(*) as count FROM templates WHERE ${conditions.join(' AND ')}`; diff --git a/tests/integration/templates/metadata-operations.test.ts b/tests/integration/templates/metadata-operations.test.ts new file mode 100644 index 0000000..20d76f2 --- /dev/null +++ b/tests/integration/templates/metadata-operations.test.ts @@ -0,0 +1,626 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TemplateService } from '../../../src/templates/template-service'; +import { TemplateRepository } from '../../../src/templates/template-repository'; +import { MetadataGenerator } from '../../../src/templates/metadata-generator'; +import { BatchProcessor } from '../../../src/templates/batch-processor'; +import { DatabaseAdapter } from '../../../src/database/database-adapter'; +import { BetterSqlite3Adapter } from '../../../src/database/adapters/better-sqlite3-adapter'; +import Database from 'better-sqlite3'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { unlinkSync, existsSync } from 'fs'; + +// Mock logger +vi.mock('../../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + } +})); + +// Mock template sanitizer +vi.mock('../../../src/utils/template-sanitizer', () => { + class MockTemplateSanitizer { + sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false })); + detectTokens = vi.fn(() => []); + } + + return { + TemplateSanitizer: MockTemplateSanitizer + }; +}); + +// Mock OpenAI for MetadataGenerator and BatchProcessor +vi.mock('openai', () => { + const mockClient = { + chat: { + completions: { + create: vi.fn() + } + }, + files: { + create: vi.fn(), + content: vi.fn(), + del: vi.fn() + }, + batches: { + create: vi.fn(), + retrieve: vi.fn() + } + }; + + return { + default: vi.fn().mockImplementation(() => mockClient) + }; +}); + +describe('Template Metadata Operations - Integration Tests', () => { + let db: Database.Database; + let adapter: DatabaseAdapter; + let repository: TemplateRepository; + let service: TemplateService; + let dbPath: string; + + beforeEach(async () => { + // Create temporary database + dbPath = join(tmpdir(), `test-metadata-${Date.now()}.db`); + db = new Database(dbPath); + adapter = new BetterSqlite3Adapter(db); + + // Initialize repository and service + repository = new TemplateRepository(adapter); + service = new TemplateService(adapter); + + // Create test templates + await createTestTemplates(); + }); + + afterEach(() => { + if (db) { + db.close(); + } + if (existsSync(dbPath)) { + unlinkSync(dbPath); + } + vi.clearAllMocks(); + }); + + async function createTestTemplates() { + // Create test templates with metadata + const templates = [ + { + workflow: { + id: 1, + name: 'Simple Webhook Slack', + description: 'Basic webhook to Slack automation', + user: { id: 1, name: 'Test User', username: 'test', verified: true }, + nodes: [ + { id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' }, + { id: 2, name: 'n8n-nodes-base.slack', icon: 'fa:slack' } + ], + totalViews: 150, + createdAt: '2024-01-01T00:00:00Z' + }, + detail: { + id: 1, + name: 'Simple Webhook Slack', + description: 'Basic webhook to Slack automation', + views: 150, + createdAt: '2024-01-01T00:00:00Z', + workflow: { + nodes: [ + { type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }, + { type: 'n8n-nodes-base.slack', name: 'Slack', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 } + ], + connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } }, + settings: {} + } + }, + categories: ['automation', 'communication'], + metadata: { + categories: ['automation', 'communication'], + complexity: 'simple' as const, + use_cases: ['Webhook processing', 'Slack notifications'], + estimated_setup_minutes: 15, + required_services: ['Slack API'], + key_features: ['Real-time notifications', 'Easy setup'], + target_audience: ['developers', 'marketers'] + } + }, + { + workflow: { + id: 2, + name: 'Complex AI Data Pipeline', + description: 'Advanced data processing with AI analysis', + user: { id: 2, name: 'AI Expert', username: 'aiexpert', verified: true }, + nodes: [ + { id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' }, + { id: 2, name: '@n8n/n8n-nodes-langchain.openAi', icon: 'fa:brain' }, + { id: 3, name: 'n8n-nodes-base.postgres', icon: 'fa:database' }, + { id: 4, name: 'n8n-nodes-base.googleSheets', icon: 'fa:sheet' } + ], + totalViews: 450, + createdAt: '2024-01-15T00:00:00Z' + }, + detail: { + id: 2, + name: 'Complex AI Data Pipeline', + description: 'Advanced data processing with AI analysis', + views: 450, + createdAt: '2024-01-15T00:00:00Z', + workflow: { + nodes: [ + { type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }, + { type: '@n8n/n8n-nodes-langchain.openAi', name: 'OpenAI', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 }, + { type: 'n8n-nodes-base.postgres', name: 'Postgres', id: '3', position: [200, 0], parameters: {}, typeVersion: 1 }, + { type: 'n8n-nodes-base.googleSheets', name: 'Google Sheets', id: '4', position: [300, 0], parameters: {}, typeVersion: 1 } + ], + connections: { + '1': { main: [[{ node: '2', type: 'main', index: 0 }]] }, + '2': { main: [[{ node: '3', type: 'main', index: 0 }]] }, + '3': { main: [[{ node: '4', type: 'main', index: 0 }]] } + }, + settings: {} + } + }, + categories: ['ai', 'data_processing'], + metadata: { + categories: ['ai', 'data_processing', 'automation'], + complexity: 'complex' as const, + use_cases: ['Data analysis', 'AI processing', 'Report generation'], + estimated_setup_minutes: 120, + required_services: ['OpenAI API', 'PostgreSQL', 'Google Sheets API'], + key_features: ['AI analysis', 'Database integration', 'Automated reports'], + target_audience: ['developers', 'analysts'] + } + }, + { + workflow: { + id: 3, + name: 'Medium Email Automation', + description: 'Email automation with moderate complexity', + user: { id: 3, name: 'Marketing User', username: 'marketing', verified: false }, + nodes: [ + { id: 1, name: 'n8n-nodes-base.cron', icon: 'fa:clock' }, + { id: 2, name: 'n8n-nodes-base.gmail', icon: 'fa:mail' }, + { id: 3, name: 'n8n-nodes-base.googleSheets', icon: 'fa:sheet' } + ], + totalViews: 200, + createdAt: '2024-02-01T00:00:00Z' + }, + detail: { + id: 3, + name: 'Medium Email Automation', + description: 'Email automation with moderate complexity', + views: 200, + createdAt: '2024-02-01T00:00:00Z', + workflow: { + nodes: [ + { type: 'n8n-nodes-base.cron', name: 'Cron', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }, + { type: 'n8n-nodes-base.gmail', name: 'Gmail', id: '2', position: [100, 0], parameters: {}, typeVersion: 1 }, + { type: 'n8n-nodes-base.googleSheets', name: 'Google Sheets', id: '3', position: [200, 0], parameters: {}, typeVersion: 1 } + ], + connections: { + '1': { main: [[{ node: '2', type: 'main', index: 0 }]] }, + '2': { main: [[{ node: '3', type: 'main', index: 0 }]] } + }, + settings: {} + } + }, + categories: ['email_automation', 'scheduling'], + metadata: { + categories: ['email_automation', 'scheduling'], + complexity: 'medium' as const, + use_cases: ['Email campaigns', 'Scheduled reports'], + estimated_setup_minutes: 45, + required_services: ['Gmail API', 'Google Sheets API'], + key_features: ['Scheduled execution', 'Email automation'], + target_audience: ['marketers'] + } + } + ]; + + // Save templates + for (const template of templates) { + repository.saveTemplate(template.workflow, template.detail, template.categories); + repository.updateTemplateMetadata(template.workflow.id, template.metadata); + } + } + + describe('Repository Metadata Operations', () => { + it('should update template metadata successfully', () => { + const newMetadata = { + categories: ['test', 'updated'], + complexity: 'simple' as const, + use_cases: ['Testing'], + estimated_setup_minutes: 10, + required_services: [], + key_features: ['Test feature'], + target_audience: ['testers'] + }; + + repository.updateTemplateMetadata(1, newMetadata); + + // Verify metadata was updated + const templates = repository.searchTemplatesByMetadata({ + category: 'test', + limit: 10, + offset: 0 + }); + + expect(templates).toHaveLength(1); + expect(templates[0].id).toBe(1); + }); + + it('should batch update metadata for multiple templates', () => { + const metadataMap = new Map([ + [1, { + categories: ['batch_test'], + complexity: 'simple' as const, + use_cases: ['Batch testing'], + estimated_setup_minutes: 20, + required_services: [], + key_features: ['Batch update'], + target_audience: ['developers'] + }], + [2, { + categories: ['batch_test'], + complexity: 'complex' as const, + use_cases: ['Complex batch testing'], + estimated_setup_minutes: 60, + required_services: ['OpenAI'], + key_features: ['Advanced batch'], + target_audience: ['developers'] + }] + ]); + + repository.batchUpdateMetadata(metadataMap); + + // Verify both templates were updated + const templates = repository.searchTemplatesByMetadata({ + category: 'batch_test', + limit: 10, + offset: 0 + }); + + expect(templates).toHaveLength(2); + expect(templates.map(t => t.id).sort()).toEqual([1, 2]); + }); + + it('should search templates by category', () => { + const templates = repository.searchTemplatesByMetadata({ + category: 'automation', + limit: 10, + offset: 0 + }); + + expect(templates.length).toBeGreaterThan(0); + expect(templates[0]).toHaveProperty('id'); + expect(templates[0]).toHaveProperty('name'); + }); + + it('should search templates by complexity', () => { + const simpleTemplates = repository.searchTemplatesByMetadata({ + complexity: 'simple', + limit: 10, + offset: 0 + }); + + const complexTemplates = repository.searchTemplatesByMetadata({ + complexity: 'complex', + limit: 10, + offset: 0 + }); + + expect(simpleTemplates).toHaveLength(1); + expect(complexTemplates).toHaveLength(1); + expect(simpleTemplates[0].id).toBe(1); + expect(complexTemplates[0].id).toBe(2); + }); + + it('should search templates by setup time', () => { + const quickTemplates = repository.searchTemplatesByMetadata({ + maxSetupMinutes: 30, + limit: 10, + offset: 0 + }); + + const longTemplates = repository.searchTemplatesByMetadata({ + minSetupMinutes: 60, + limit: 10, + offset: 0 + }); + + expect(quickTemplates).toHaveLength(2); // 15 min and 45 min templates + expect(longTemplates).toHaveLength(1); // 120 min template + }); + + it('should search templates by required service', () => { + const slackTemplates = repository.searchTemplatesByMetadata({ + requiredService: 'slack', + limit: 10, + offset: 0 + }); + + const openaiTemplates = repository.searchTemplatesByMetadata({ + requiredService: 'OpenAI', + limit: 10, + offset: 0 + }); + + expect(slackTemplates).toHaveLength(1); + expect(openaiTemplates).toHaveLength(1); + }); + + it('should search templates by target audience', () => { + const developerTemplates = repository.searchTemplatesByMetadata({ + targetAudience: 'developers', + limit: 10, + offset: 0 + }); + + const marketerTemplates = repository.searchTemplatesByMetadata({ + targetAudience: 'marketers', + limit: 10, + offset: 0 + }); + + expect(developerTemplates).toHaveLength(2); + expect(marketerTemplates).toHaveLength(2); + }); + + it('should handle combined filters correctly', () => { + const filteredTemplates = repository.searchTemplatesByMetadata({ + complexity: 'medium', + targetAudience: 'marketers', + maxSetupMinutes: 60, + limit: 10, + offset: 0 + }); + + expect(filteredTemplates).toHaveLength(1); + expect(filteredTemplates[0].id).toBe(3); + }); + + it('should return correct counts for metadata searches', () => { + const automationCount = repository.getSearchTemplatesByMetadataCount({ + category: 'automation' + }); + + const complexCount = repository.getSearchTemplatesByMetadataCount({ + complexity: 'complex' + }); + + expect(automationCount).toBeGreaterThan(0); + expect(complexCount).toBe(1); + }); + + it('should get unique categories', () => { + const categories = repository.getUniqueCategories(); + + expect(categories).toContain('automation'); + expect(categories).toContain('communication'); + expect(categories).toContain('ai'); + expect(categories).toContain('data_processing'); + expect(categories).toContain('email_automation'); + expect(categories).toContain('scheduling'); + }); + + it('should get unique target audiences', () => { + const audiences = repository.getUniqueTargetAudiences(); + + expect(audiences).toContain('developers'); + expect(audiences).toContain('marketers'); + expect(audiences).toContain('analysts'); + }); + + it('should get templates by category', () => { + const aiTemplates = repository.getTemplatesByCategory('ai'); + expect(aiTemplates).toHaveLength(1); + expect(aiTemplates[0].id).toBe(2); + }); + + it('should get templates by complexity', () => { + const simpleTemplates = repository.getTemplatesByComplexity('simple'); + expect(simpleTemplates).toHaveLength(1); + expect(simpleTemplates[0].id).toBe(1); + }); + + it('should get templates without metadata', () => { + // Create a template without metadata + const workflow = { + id: 999, + name: 'No Metadata Template', + description: 'Template without metadata', + user: { id: 999, name: 'Test', username: 'test', verified: true }, + nodes: [{ id: 1, name: 'n8n-nodes-base.webhook', icon: 'fa:webhook' }], + totalViews: 10, + createdAt: '2024-03-01T00:00:00Z' + }; + + const detail = { + id: 999, + name: 'No Metadata Template', + description: 'Template without metadata', + views: 10, + createdAt: '2024-03-01T00:00:00Z', + workflow: { + nodes: [{ type: 'n8n-nodes-base.webhook', name: 'Webhook', id: '1', position: [0, 0], parameters: {}, typeVersion: 1 }], + connections: {}, + settings: {} + } + }; + + repository.saveTemplate(workflow, detail, []); + + const templatesWithoutMetadata = repository.getTemplatesWithoutMetadata(); + expect(templatesWithoutMetadata.some(t => t.id === 999)).toBe(true); + }); + + it('should get outdated metadata templates', () => { + // This test would require manipulating timestamps, + // for now just verify the method doesn't throw + const outdatedTemplates = repository.getTemplatesWithOutdatedMetadata(30); + expect(Array.isArray(outdatedTemplates)).toBe(true); + }); + + it('should get metadata statistics', () => { + const stats = repository.getMetadataStats(); + + expect(stats).toHaveProperty('totalWithMetadata'); + expect(stats).toHaveProperty('totalTemplates'); + expect(stats).toHaveProperty('metadataPercentage'); + expect(stats).toHaveProperty('outdatedMetadata'); + + expect(stats.totalWithMetadata).toBeGreaterThan(0); + expect(stats.totalTemplates).toBeGreaterThan(0); + expect(stats.metadataPercentage).toBeGreaterThan(0); + }); + }); + + describe('Service Layer Integration', () => { + it('should search templates with metadata through service', async () => { + const results = await service.searchTemplatesByMetadata({ + complexity: 'simple', + limit: 10, + offset: 0 + }); + + expect(results).toHaveProperty('items'); + expect(results).toHaveProperty('total'); + expect(results).toHaveProperty('hasMore'); + expect(results.items.length).toBeGreaterThan(0); + expect(results.items[0]).toHaveProperty('metadata'); + }); + + it('should handle pagination correctly in metadata search', async () => { + const page1 = await service.searchTemplatesByMetadata({ + limit: 1, + offset: 0 + }); + + const page2 = await service.searchTemplatesByMetadata({ + limit: 1, + offset: 1 + }); + + expect(page1.items).toHaveLength(1); + expect(page2.items).toHaveLength(1); + expect(page1.items[0].id).not.toBe(page2.items[0].id); + }); + + it('should return templates with metadata information', async () => { + const results = await service.searchTemplatesByMetadata({ + category: 'automation', + limit: 10, + offset: 0 + }); + + expect(results.items.length).toBeGreaterThan(0); + + const template = results.items[0]; + expect(template).toHaveProperty('metadata'); + expect(template.metadata).toHaveProperty('categories'); + expect(template.metadata).toHaveProperty('complexity'); + expect(template.metadata).toHaveProperty('estimated_setup_minutes'); + }); + }); + + describe('Security and Error Handling', () => { + it('should handle malicious input safely in metadata search', () => { + const maliciousInputs = [ + { category: "'; DROP TABLE templates; --" }, + { requiredService: "'; UNION SELECT * FROM sqlite_master; --" }, + { targetAudience: "administrators'; DELETE FROM templates WHERE '1'='1" } + ]; + + maliciousInputs.forEach(input => { + expect(() => { + repository.searchTemplatesByMetadata({ + ...input, + limit: 10, + offset: 0 + }); + }).not.toThrow(); + }); + }); + + it('should handle invalid metadata gracefully', () => { + const invalidMetadata = { + categories: null, + complexity: 'invalid_complexity', + use_cases: 'not_an_array', + estimated_setup_minutes: 'not_a_number', + required_services: undefined, + key_features: {}, + target_audience: 42 + }; + + expect(() => { + repository.updateTemplateMetadata(1, invalidMetadata); + }).not.toThrow(); + }); + + it('should handle empty search results gracefully', () => { + const results = repository.searchTemplatesByMetadata({ + category: 'nonexistent_category', + limit: 10, + offset: 0 + }); + + expect(results).toHaveLength(0); + }); + + it('should handle edge case parameters', () => { + // Test extreme values + const results = repository.searchTemplatesByMetadata({ + maxSetupMinutes: 0, + minSetupMinutes: 999999, + limit: 0, + offset: -1 + }); + + expect(Array.isArray(results)).toBe(true); + }); + }); + + describe('Performance and Scalability', () => { + it('should handle large result sets efficiently', () => { + // Test with maximum limit + const startTime = Date.now(); + const results = repository.searchTemplatesByMetadata({ + limit: 100, + offset: 0 + }); + const endTime = Date.now(); + + expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second + expect(Array.isArray(results)).toBe(true); + }); + + it('should handle concurrent metadata updates', () => { + const updates = []; + + for (let i = 0; i < 10; i++) { + updates.push(() => { + repository.updateTemplateMetadata(1, { + categories: [`concurrent_test_${i}`], + complexity: 'simple' as const, + use_cases: ['Testing'], + estimated_setup_minutes: 10, + required_services: [], + key_features: ['Concurrent'], + target_audience: ['developers'] + }); + }); + } + + // Execute all updates + expect(() => { + updates.forEach(update => update()); + }).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/mcp/tools.test.ts b/tests/unit/mcp/tools.test.ts index 7d8620f..802a235 100644 --- a/tests/unit/mcp/tools.test.ts +++ b/tests/unit/mcp/tools.test.ts @@ -371,8 +371,109 @@ describe('n8nDocumentationToolsFinal', () => { }); }); + describe('search_templates_by_metadata', () => { + const tool = n8nDocumentationToolsFinal.find(t => t.name === 'search_templates_by_metadata'); + + it('should exist in the tools array', () => { + expect(tool).toBeDefined(); + expect(tool?.name).toBe('search_templates_by_metadata'); + }); + + it('should have proper description', () => { + expect(tool?.description).toContain('Search templates by AI-generated metadata'); + expect(tool?.description).toContain('category'); + expect(tool?.description).toContain('complexity'); + }); + + it('should have correct input schema structure', () => { + expect(tool?.inputSchema.type).toBe('object'); + expect(tool?.inputSchema.properties).toBeDefined(); + expect(tool?.inputSchema.required).toBeUndefined(); // All parameters are optional + }); + + it('should have category parameter with proper schema', () => { + const categoryProp = tool?.inputSchema.properties?.category; + expect(categoryProp).toBeDefined(); + expect(categoryProp.type).toBe('string'); + expect(categoryProp.description).toContain('category'); + }); + + it('should have complexity parameter with enum values', () => { + const complexityProp = tool?.inputSchema.properties?.complexity; + expect(complexityProp).toBeDefined(); + expect(complexityProp.enum).toEqual(['simple', 'medium', 'complex']); + expect(complexityProp.description).toContain('complexity'); + }); + + it('should have time-based parameters with numeric constraints', () => { + const maxTimeProp = tool?.inputSchema.properties?.maxSetupMinutes; + const minTimeProp = tool?.inputSchema.properties?.minSetupMinutes; + + expect(maxTimeProp).toBeDefined(); + expect(maxTimeProp.type).toBe('number'); + expect(maxTimeProp.maximum).toBe(480); + expect(maxTimeProp.minimum).toBe(5); + + expect(minTimeProp).toBeDefined(); + expect(minTimeProp.type).toBe('number'); + expect(minTimeProp.maximum).toBe(480); + expect(minTimeProp.minimum).toBe(5); + }); + + it('should have service and audience parameters', () => { + const serviceProp = tool?.inputSchema.properties?.requiredService; + const audienceProp = tool?.inputSchema.properties?.targetAudience; + + expect(serviceProp).toBeDefined(); + expect(serviceProp.type).toBe('string'); + expect(serviceProp.description).toContain('service'); + + expect(audienceProp).toBeDefined(); + expect(audienceProp.type).toBe('string'); + expect(audienceProp.description).toContain('audience'); + }); + + it('should have pagination parameters', () => { + const limitProp = tool?.inputSchema.properties?.limit; + const offsetProp = tool?.inputSchema.properties?.offset; + + expect(limitProp).toBeDefined(); + expect(limitProp.type).toBe('number'); + expect(limitProp.default).toBe(20); + expect(limitProp.maximum).toBe(100); + expect(limitProp.minimum).toBe(1); + + expect(offsetProp).toBeDefined(); + expect(offsetProp.type).toBe('number'); + expect(offsetProp.default).toBe(0); + expect(offsetProp.minimum).toBe(0); + }); + + it('should include all expected properties', () => { + const properties = Object.keys(tool?.inputSchema.properties || {}); + const expectedProperties = [ + 'category', + 'complexity', + 'maxSetupMinutes', + 'minSetupMinutes', + 'requiredService', + 'targetAudience', + 'limit', + 'offset' + ]; + + expectedProperties.forEach(prop => { + expect(properties).toContain(prop); + }); + }); + + it('should have appropriate additionalProperties setting', () => { + expect(tool?.inputSchema.additionalProperties).toBe(false); + }); + }); + describe('Enhanced pagination support', () => { - const paginatedTools = ['list_node_templates', 'search_templates', 'get_templates_for_task']; + const paginatedTools = ['list_node_templates', 'search_templates', 'get_templates_for_task', 'search_templates_by_metadata']; paginatedTools.forEach(toolName => { describe(toolName, () => { diff --git a/tests/unit/services/template-service.test.ts b/tests/unit/services/template-service.test.ts index fda771b..d9517e4 100644 --- a/tests/unit/services/template-service.test.ts +++ b/tests/unit/services/template-service.test.ts @@ -56,7 +56,9 @@ describe('TemplateService', () => { created_at: overrides.created_at || '2024-01-01T00:00:00Z', updated_at: overrides.updated_at || '2024-01-01T00:00:00Z', url: overrides.url || `https://n8n.io/workflows/${id}`, - scraped_at: '2024-01-01T00:00:00Z' + scraped_at: '2024-01-01T00:00:00Z', + metadata_json: overrides.metadata_json || null, + metadata_generated_at: overrides.metadata_generated_at || null }); beforeEach(() => { @@ -79,7 +81,9 @@ describe('TemplateService', () => { getExistingTemplateIds: vi.fn(), clearTemplates: vi.fn(), saveTemplate: vi.fn(), - rebuildTemplateFTS: vi.fn() + rebuildTemplateFTS: vi.fn(), + searchTemplatesByMetadata: vi.fn(), + getSearchTemplatesByMetadataCount: vi.fn() } as any; // Mock the constructor @@ -520,6 +524,114 @@ describe('TemplateService', () => { }); }); + describe('searchTemplatesByMetadata', () => { + it('should return paginated metadata search results', async () => { + const mockTemplates = [ + createMockTemplate(1, { + name: 'AI Workflow', + metadata_json: JSON.stringify({ + categories: ['ai', 'automation'], + complexity: 'complex', + estimated_setup_minutes: 60 + }) + }), + createMockTemplate(2, { + name: 'Simple Webhook', + metadata_json: JSON.stringify({ + categories: ['automation'], + complexity: 'simple', + estimated_setup_minutes: 15 + }) + }) + ]; + + mockRepository.searchTemplatesByMetadata = vi.fn().mockReturnValue(mockTemplates); + mockRepository.getSearchTemplatesByMetadataCount = vi.fn().mockReturnValue(12); + + const result = await service.searchTemplatesByMetadata({ + complexity: 'simple', + maxSetupMinutes: 30 + }, 10, 5); + + expect(result).toEqual({ + items: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + name: 'AI Workflow', + metadata: { + categories: ['ai', 'automation'], + complexity: 'complex', + estimated_setup_minutes: 60 + } + }), + expect.objectContaining({ + id: 2, + name: 'Simple Webhook', + metadata: { + categories: ['automation'], + complexity: 'simple', + estimated_setup_minutes: 15 + } + }) + ]), + total: 12, + limit: 10, + offset: 5, + hasMore: false // 5 + 10 >= 12 + }); + + expect(mockRepository.searchTemplatesByMetadata).toHaveBeenCalledWith({ + complexity: 'simple', + maxSetupMinutes: 30 + }, 10, 5); + expect(mockRepository.getSearchTemplatesByMetadataCount).toHaveBeenCalledWith({ + complexity: 'simple', + maxSetupMinutes: 30 + }); + }); + + it('should use default pagination parameters', async () => { + mockRepository.searchTemplatesByMetadata = vi.fn().mockReturnValue([]); + mockRepository.getSearchTemplatesByMetadataCount = vi.fn().mockReturnValue(0); + + await service.searchTemplatesByMetadata({ category: 'test' }); + + expect(mockRepository.searchTemplatesByMetadata).toHaveBeenCalledWith({ category: 'test' }, 20, 0); + }); + + it('should handle templates without metadata gracefully', async () => { + const templatesWithoutMetadata = [ + createMockTemplate(1, { metadata_json: null }), + createMockTemplate(2, { metadata_json: undefined }), + createMockTemplate(3, { metadata_json: 'invalid json' }) + ]; + + mockRepository.searchTemplatesByMetadata = vi.fn().mockReturnValue(templatesWithoutMetadata); + mockRepository.getSearchTemplatesByMetadataCount = vi.fn().mockReturnValue(3); + + const result = await service.searchTemplatesByMetadata({ category: 'test' }); + + expect(result.items).toHaveLength(3); + result.items.forEach(item => { + expect(item.metadata).toBeNull(); + }); + }); + + it('should handle malformed metadata JSON', async () => { + const templateWithBadMetadata = createMockTemplate(1, { + metadata_json: '{"invalid": json syntax}' + }); + + mockRepository.searchTemplatesByMetadata = vi.fn().mockReturnValue([templateWithBadMetadata]); + mockRepository.getSearchTemplatesByMetadataCount = vi.fn().mockReturnValue(1); + + const result = await service.searchTemplatesByMetadata({ category: 'test' }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].metadata).toBeNull(); + }); + }); + describe('formatTemplateInfo (private method behavior)', () => { it('should format template data correctly through public methods', async () => { const mockTemplate = createMockTemplate(1, { diff --git a/tests/unit/templates/batch-processor.test.ts b/tests/unit/templates/batch-processor.test.ts new file mode 100644 index 0000000..0f60034 --- /dev/null +++ b/tests/unit/templates/batch-processor.test.ts @@ -0,0 +1,556 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { BatchProcessor, BatchProcessorOptions } from '../../../src/templates/batch-processor'; +import { MetadataRequest } from '../../../src/templates/metadata-generator'; + +// Mock fs operations +vi.mock('fs'); +const mockedFs = vi.mocked(fs); + +// Mock OpenAI +const mockClient = { + files: { + create: vi.fn(), + content: vi.fn(), + del: vi.fn() + }, + batches: { + create: vi.fn(), + retrieve: vi.fn() + } +}; + +vi.mock('openai', () => { + return { + default: vi.fn().mockImplementation(() => mockClient) + }; +}); + +// Mock MetadataGenerator +const mockGenerator = { + createBatchRequest: vi.fn(), + parseResult: vi.fn() +}; + +class MockMetadataGenerator { + createBatchRequest = mockGenerator.createBatchRequest; + parseResult = mockGenerator.parseResult; +} + +vi.mock('../../../src/templates/metadata-generator', () => { + return { + MetadataGenerator: MockMetadataGenerator + }; +}); + +// Mock logger +vi.mock('../../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + } +})); + +describe('BatchProcessor', () => { + let processor: BatchProcessor; + let options: BatchProcessorOptions; + let mockStream: any; + + beforeEach(() => { + vi.clearAllMocks(); + + options = { + apiKey: 'test-api-key', + model: 'gpt-4o-mini', + batchSize: 3, + outputDir: './test-temp' + }; + + // Mock stream for file writing + mockStream = { + write: vi.fn(), + end: vi.fn(), + on: vi.fn((event, callback) => { + if (event === 'finish') { + setTimeout(callback, 0); + } + }) + }; + + // Mock fs operations + mockedFs.existsSync = vi.fn().mockReturnValue(false); + mockedFs.mkdirSync = vi.fn(); + mockedFs.createWriteStream = vi.fn().mockReturnValue(mockStream); + mockedFs.createReadStream = vi.fn().mockReturnValue({}); + mockedFs.unlinkSync = vi.fn(); + + processor = new BatchProcessor(options); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create output directory if it does not exist', () => { + expect(mockedFs.existsSync).toHaveBeenCalledWith('./test-temp'); + expect(mockedFs.mkdirSync).toHaveBeenCalledWith('./test-temp', { recursive: true }); + }); + + it('should not create directory if it already exists', () => { + mockedFs.existsSync = vi.fn().mockReturnValue(true); + mockedFs.mkdirSync = vi.fn(); + + new BatchProcessor(options); + + expect(mockedFs.mkdirSync).not.toHaveBeenCalled(); + }); + + it('should use default options when not provided', () => { + const minimalOptions = { apiKey: 'test-key' }; + const proc = new BatchProcessor(minimalOptions); + + expect(proc).toBeDefined(); + // Default batchSize is 100, outputDir is './temp' + }); + }); + + describe('processTemplates', () => { + const mockTemplates: MetadataRequest[] = [ + { templateId: 1, name: 'Template 1', nodes: ['n8n-nodes-base.webhook'] }, + { templateId: 2, name: 'Template 2', nodes: ['n8n-nodes-base.slack'] }, + { templateId: 3, name: 'Template 3', nodes: ['n8n-nodes-base.httpRequest'] }, + { templateId: 4, name: 'Template 4', nodes: ['n8n-nodes-base.code'] } + ]; + + it('should process templates in batches correctly', async () => { + // Mock file operations + const mockFile = { id: 'file-123' }; + mockClient.files.create.mockResolvedValue(mockFile); + + // Mock batch job + const mockBatchJob = { + id: 'batch-123', + status: 'completed', + output_file_id: 'output-file-123' + }; + mockClient.batches.create.mockResolvedValue(mockBatchJob); + mockClient.batches.retrieve.mockResolvedValue(mockBatchJob); + + // Mock results + const mockFileContent = 'result1\nresult2\nresult3'; + mockClient.files.content.mockResolvedValue({ text: () => Promise.resolve(mockFileContent) }); + + const mockParsedResults = [ + { templateId: 1, metadata: { categories: ['automation'] } }, + { templateId: 2, metadata: { categories: ['communication'] } }, + { templateId: 3, metadata: { categories: ['integration'] } } + ]; + mockGenerator.parseResult.mockReturnValueOnce(mockParsedResults[0]) + .mockReturnValueOnce(mockParsedResults[1]) + .mockReturnValueOnce(mockParsedResults[2]); + + const progressCallback = vi.fn(); + const results = await processor.processTemplates(mockTemplates, progressCallback); + + // Should create 2 batches (batchSize = 3, templates = 4) + expect(mockClient.batches.create).toHaveBeenCalledTimes(2); + expect(results.size).toBe(3); // 3 successful results + expect(progressCallback).toHaveBeenCalled(); + }); + + it('should handle empty templates array', async () => { + const results = await processor.processTemplates([]); + expect(results.size).toBe(0); + }); + + it('should handle batch submission errors gracefully', async () => { + mockClient.files.create.mockRejectedValue(new Error('Upload failed')); + + const results = await processor.processTemplates([mockTemplates[0]]); + + // Should not throw, should return empty results + expect(results.size).toBe(0); + }); + + it('should handle batch job failures', async () => { + const mockFile = { id: 'file-123' }; + mockClient.files.create.mockResolvedValue(mockFile); + + const failedBatchJob = { + id: 'batch-123', + status: 'failed' + }; + mockClient.batches.create.mockResolvedValue(failedBatchJob); + mockClient.batches.retrieve.mockResolvedValue(failedBatchJob); + + const results = await processor.processTemplates([mockTemplates[0]]); + + expect(results.size).toBe(0); + }); + }); + + describe('createBatchFile', () => { + it('should create JSONL file with correct format', async () => { + const templates: MetadataRequest[] = [ + { templateId: 1, name: 'Test', nodes: ['node1'] }, + { templateId: 2, name: 'Test2', nodes: ['node2'] } + ]; + + const mockRequest = { custom_id: 'template-1', method: 'POST' }; + mockGenerator.createBatchRequest.mockReturnValue(mockRequest); + + // Access private method through type assertion + const filename = await (processor as any).createBatchFile(templates, 'test_batch'); + + expect(mockStream.write).toHaveBeenCalledTimes(2); + expect(mockStream.write).toHaveBeenCalledWith(JSON.stringify(mockRequest) + '\n'); + expect(mockStream.end).toHaveBeenCalled(); + expect(filename).toContain('test_batch'); + }); + + it('should handle stream errors', async () => { + const templates: MetadataRequest[] = [ + { templateId: 1, name: 'Test', nodes: ['node1'] } + ]; + + // Mock stream error + mockStream.on = vi.fn((event, callback) => { + if (event === 'error') { + setTimeout(() => callback(new Error('Stream error')), 0); + } + }); + + await expect( + (processor as any).createBatchFile(templates, 'error_batch') + ).rejects.toThrow('Stream error'); + }); + }); + + describe('uploadFile', () => { + it('should upload file to OpenAI', async () => { + const mockFile = { id: 'uploaded-file-123' }; + mockClient.files.create.mockResolvedValue(mockFile); + + const result = await (processor as any).uploadFile('/path/to/file.jsonl'); + + expect(mockClient.files.create).toHaveBeenCalledWith({ + file: expect.any(Object), + purpose: 'batch' + }); + expect(result).toEqual(mockFile); + }); + + it('should handle upload errors', async () => { + mockClient.files.create.mockRejectedValue(new Error('Upload failed')); + + await expect( + (processor as any).uploadFile('/path/to/file.jsonl') + ).rejects.toThrow('Upload failed'); + }); + }); + + describe('createBatchJob', () => { + it('should create batch job with correct parameters', async () => { + const mockBatchJob = { id: 'batch-123' }; + mockClient.batches.create.mockResolvedValue(mockBatchJob); + + const result = await (processor as any).createBatchJob('file-123'); + + expect(mockClient.batches.create).toHaveBeenCalledWith({ + input_file_id: 'file-123', + endpoint: '/v1/chat/completions', + completion_window: '24h' + }); + expect(result).toEqual(mockBatchJob); + }); + + it('should handle batch creation errors', async () => { + mockClient.batches.create.mockRejectedValue(new Error('Batch creation failed')); + + await expect( + (processor as any).createBatchJob('file-123') + ).rejects.toThrow('Batch creation failed'); + }); + }); + + describe('monitorBatchJob', () => { + it('should monitor job until completion', async () => { + const completedJob = { id: 'batch-123', status: 'completed' }; + mockClient.batches.retrieve.mockResolvedValue(completedJob); + + const result = await (processor as any).monitorBatchJob('batch-123'); + + expect(mockClient.batches.retrieve).toHaveBeenCalledWith('batch-123'); + expect(result).toEqual(completedJob); + }); + + it('should handle status progression', async () => { + const jobs = [ + { id: 'batch-123', status: 'validating' }, + { id: 'batch-123', status: 'in_progress' }, + { id: 'batch-123', status: 'finalizing' }, + { id: 'batch-123', status: 'completed' } + ]; + + mockClient.batches.retrieve.mockImplementation(() => { + return Promise.resolve(jobs.shift() || jobs[jobs.length - 1]); + }); + + // Mock sleep to speed up test + const originalSleep = (processor as any).sleep; + (processor as any).sleep = vi.fn().mockResolvedValue(undefined); + + const result = await (processor as any).monitorBatchJob('batch-123'); + + expect(result.status).toBe('completed'); + expect(mockClient.batches.retrieve).toHaveBeenCalledTimes(4); + + // Restore original sleep method + (processor as any).sleep = originalSleep; + }); + + it('should throw error for failed jobs', async () => { + const failedJob = { id: 'batch-123', status: 'failed' }; + mockClient.batches.retrieve.mockResolvedValue(failedJob); + + await expect( + (processor as any).monitorBatchJob('batch-123') + ).rejects.toThrow('Batch job failed with status: failed'); + }); + + it('should handle expired jobs', async () => { + const expiredJob = { id: 'batch-123', status: 'expired' }; + mockClient.batches.retrieve.mockResolvedValue(expiredJob); + + await expect( + (processor as any).monitorBatchJob('batch-123') + ).rejects.toThrow('Batch job failed with status: expired'); + }); + + it('should handle cancelled jobs', async () => { + const cancelledJob = { id: 'batch-123', status: 'cancelled' }; + mockClient.batches.retrieve.mockResolvedValue(cancelledJob); + + await expect( + (processor as any).monitorBatchJob('batch-123') + ).rejects.toThrow('Batch job failed with status: cancelled'); + }); + + it('should timeout after max attempts', async () => { + const inProgressJob = { id: 'batch-123', status: 'in_progress' }; + mockClient.batches.retrieve.mockResolvedValue(inProgressJob); + + // Mock sleep to speed up test + (processor as any).sleep = vi.fn().mockResolvedValue(undefined); + + await expect( + (processor as any).monitorBatchJob('batch-123') + ).rejects.toThrow('Batch job monitoring timed out'); + }); + }); + + describe('retrieveResults', () => { + it('should download and parse results correctly', async () => { + const batchJob = { output_file_id: 'output-123' }; + const fileContent = '{"custom_id": "template-1"}\n{"custom_id": "template-2"}'; + + mockClient.files.content.mockResolvedValue({ + text: () => Promise.resolve(fileContent) + }); + + const mockResults = [ + { templateId: 1, metadata: { categories: ['test'] } }, + { templateId: 2, metadata: { categories: ['test2'] } } + ]; + + mockGenerator.parseResult.mockReturnValueOnce(mockResults[0]) + .mockReturnValueOnce(mockResults[1]); + + const results = await (processor as any).retrieveResults(batchJob); + + expect(mockClient.files.content).toHaveBeenCalledWith('output-123'); + expect(mockGenerator.parseResult).toHaveBeenCalledTimes(2); + expect(results).toHaveLength(2); + }); + + it('should throw error when no output file available', async () => { + const batchJob = { output_file_id: null }; + + await expect( + (processor as any).retrieveResults(batchJob) + ).rejects.toThrow('No output file available for batch job'); + }); + + it('should handle malformed result lines gracefully', async () => { + const batchJob = { output_file_id: 'output-123' }; + const fileContent = '{"valid": "json"}\ninvalid json line\n{"another": "valid"}'; + + mockClient.files.content.mockResolvedValue({ + text: () => Promise.resolve(fileContent) + }); + + const mockValidResult = { templateId: 1, metadata: { categories: ['test'] } }; + mockGenerator.parseResult.mockReturnValue(mockValidResult); + + const results = await (processor as any).retrieveResults(batchJob); + + // Should parse valid lines and skip invalid ones + expect(results).toHaveLength(2); + expect(mockGenerator.parseResult).toHaveBeenCalledTimes(2); + }); + + it('should handle file download errors', async () => { + const batchJob = { output_file_id: 'output-123' }; + mockClient.files.content.mockRejectedValue(new Error('Download failed')); + + await expect( + (processor as any).retrieveResults(batchJob) + ).rejects.toThrow('Download failed'); + }); + }); + + describe('cleanup', () => { + it('should clean up all files successfully', async () => { + await (processor as any).cleanup('local-file.jsonl', 'input-123', 'output-456'); + + expect(mockedFs.unlinkSync).toHaveBeenCalledWith('local-file.jsonl'); + expect(mockClient.files.del).toHaveBeenCalledWith('input-123'); + expect(mockClient.files.del).toHaveBeenCalledWith('output-456'); + }); + + it('should handle local file deletion errors gracefully', async () => { + mockedFs.unlinkSync = vi.fn().mockImplementation(() => { + throw new Error('File not found'); + }); + + // Should not throw error + await expect( + (processor as any).cleanup('nonexistent.jsonl', 'input-123') + ).resolves.toBeUndefined(); + }); + + it('should handle OpenAI file deletion errors gracefully', async () => { + mockClient.files.del.mockRejectedValue(new Error('Delete failed')); + + // Should not throw error + await expect( + (processor as any).cleanup('local-file.jsonl', 'input-123', 'output-456') + ).resolves.toBeUndefined(); + }); + + it('should work without output file ID', async () => { + await (processor as any).cleanup('local-file.jsonl', 'input-123'); + + expect(mockedFs.unlinkSync).toHaveBeenCalledWith('local-file.jsonl'); + expect(mockClient.files.del).toHaveBeenCalledWith('input-123'); + expect(mockClient.files.del).toHaveBeenCalledTimes(1); // Only input file + }); + }); + + describe('createBatches', () => { + it('should split templates into correct batch sizes', () => { + const templates: MetadataRequest[] = [ + { templateId: 1, name: 'T1', nodes: [] }, + { templateId: 2, name: 'T2', nodes: [] }, + { templateId: 3, name: 'T3', nodes: [] }, + { templateId: 4, name: 'T4', nodes: [] }, + { templateId: 5, name: 'T5', nodes: [] } + ]; + + const batches = (processor as any).createBatches(templates); + + expect(batches).toHaveLength(2); // 3 + 2 templates + expect(batches[0]).toHaveLength(3); + expect(batches[1]).toHaveLength(2); + }); + + it('should handle single template correctly', () => { + const templates = [{ templateId: 1, name: 'T1', nodes: [] }]; + const batches = (processor as any).createBatches(templates); + + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(1); + }); + + it('should handle empty templates array', () => { + const batches = (processor as any).createBatches([]); + expect(batches).toHaveLength(0); + }); + }); + + describe('file system security', () => { + it('should sanitize file paths to prevent directory traversal', async () => { + // Test with malicious batch name + const maliciousBatchName = '../../../etc/passwd'; + const templates = [{ templateId: 1, name: 'Test', nodes: [] }]; + + await (processor as any).createBatchFile(templates, maliciousBatchName); + + // Should create file in the designated output directory, not escape it + const writtenPath = mockedFs.createWriteStream.mock.calls[0][0]; + expect(writtenPath).toMatch(/^\.\/test-temp\//); + expect(writtenPath).not.toContain('../'); + }); + + it('should handle very long file names gracefully', async () => { + const longBatchName = 'a'.repeat(300); // Very long name + const templates = [{ templateId: 1, name: 'Test', nodes: [] }]; + + await expect( + (processor as any).createBatchFile(templates, longBatchName) + ).resolves.toBeDefined(); + }); + }); + + describe('memory management', () => { + it('should clean up files even on processing errors', async () => { + const templates = [{ templateId: 1, name: 'Test', nodes: [] }]; + + // Mock file upload to fail + mockClient.files.create.mockRejectedValue(new Error('Upload failed')); + + const submitBatch = (processor as any).submitBatch.bind(processor); + + await expect( + submitBatch(templates, 'error_test') + ).rejects.toThrow('Upload failed'); + + // File should still be cleaned up + expect(mockedFs.unlinkSync).toHaveBeenCalled(); + }); + + it('should handle concurrent batch processing correctly', async () => { + const templates = Array.from({ length: 10 }, (_, i) => ({ + templateId: i + 1, + name: `Template ${i + 1}`, + nodes: ['node'] + })); + + // Mock successful processing + mockClient.files.create.mockResolvedValue({ id: 'file-123' }); + const completedJob = { + id: 'batch-123', + status: 'completed', + output_file_id: 'output-123' + }; + mockClient.batches.create.mockResolvedValue(completedJob); + mockClient.batches.retrieve.mockResolvedValue(completedJob); + mockClient.files.content.mockResolvedValue({ + text: () => Promise.resolve('{"custom_id": "template-1"}') + }); + mockGenerator.parseResult.mockReturnValue({ + templateId: 1, + metadata: { categories: ['test'] } + }); + + const results = await processor.processTemplates(templates); + + expect(results.size).toBeGreaterThan(0); + expect(mockClient.batches.create).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/templates/metadata-generator.test.ts b/tests/unit/templates/metadata-generator.test.ts index 6eebf68..b560e79 100644 --- a/tests/unit/templates/metadata-generator.test.ts +++ b/tests/unit/templates/metadata-generator.test.ts @@ -200,4 +200,272 @@ describe('MetadataGenerator', () => { expect(result.success).toBe(false); }); }); + + describe('Input Sanitization and Security', () => { + it('should handle malicious template names safely', () => { + const maliciousTemplate: MetadataRequest = { + templateId: 123, + name: '', + description: 'javascript:alert(1)', + nodes: ['n8n-nodes-base.webhook'] + }; + + const request = generator.createBatchRequest(maliciousTemplate); + const userMessage = request.body.messages[1].content; + + // Should contain the malicious content as-is (OpenAI will handle it) + // but should not cause any injection in our code + expect(userMessage).toContain(''); + expect(userMessage).toContain('javascript:alert(1)'); + expect(request.body.model).toBe('gpt-4o-mini'); + }); + + it('should handle extremely long template names', () => { + const longName = 'A'.repeat(10000); // Very long name + const template: MetadataRequest = { + templateId: 456, + name: longName, + nodes: ['n8n-nodes-base.webhook'] + }; + + const request = generator.createBatchRequest(template); + + expect(request.custom_id).toBe('template-456'); + expect(request.body.messages[1].content).toContain(longName); + }); + + it('should handle special characters in node names', () => { + const template: MetadataRequest = { + templateId: 789, + name: 'Test Workflow', + nodes: [ + 'n8n-nodes-base.webhook', + '@n8n/custom-node.with.dots', + 'custom-package/node-with-slashes', + 'node_with_underscore', + 'node-with-unicode-名前' + ] + }; + + const request = generator.createBatchRequest(template); + const userMessage = request.body.messages[1].content; + + expect(userMessage).toContain('HTTP/Webhooks'); + expect(userMessage).toContain('custom-node.with.dots'); + }); + + it('should handle empty or undefined descriptions safely', () => { + const template: MetadataRequest = { + templateId: 100, + name: 'Test', + description: undefined, + nodes: ['n8n-nodes-base.webhook'] + }; + + const request = generator.createBatchRequest(template); + const userMessage = request.body.messages[1].content; + + // Should not include undefined or null in the message + expect(userMessage).not.toContain('undefined'); + expect(userMessage).not.toContain('null'); + expect(userMessage).toContain('Test'); + }); + + it('should limit context size for very large workflows', () => { + const manyNodes = Array.from({ length: 1000 }, (_, i) => `n8n-nodes-base.node${i}`); + const template: MetadataRequest = { + templateId: 200, + name: 'Huge Workflow', + nodes: manyNodes, + workflow: { + nodes: Array.from({ length: 500 }, (_, i) => ({ id: `node${i}` })), + connections: {} + } + }; + + const request = generator.createBatchRequest(template); + const userMessage = request.body.messages[1].content; + + // Should handle large amounts of data gracefully + expect(userMessage.length).toBeLessThan(50000); // Reasonable limit + expect(userMessage).toContain('Huge Workflow'); + }); + }); + + describe('Error Handling and Edge Cases', () => { + it('should handle malformed OpenAI responses', () => { + const malformedResults = [ + { + custom_id: 'template-111', + response: { + body: { + choices: [{ + message: { + content: '{"invalid": json syntax}' + }, + finish_reason: 'stop' + }] + } + } + }, + { + custom_id: 'template-222', + response: { + body: { + choices: [{ + message: { + content: null + }, + finish_reason: 'stop' + }] + } + } + }, + { + custom_id: 'template-333', + response: { + body: { + choices: [] + } + } + } + ]; + + malformedResults.forEach(result => { + const parsed = generator.parseResult(result); + expect(parsed.error).toBeDefined(); + expect(parsed.metadata).toBeDefined(); + expect(parsed.metadata.complexity).toBe('medium'); // Default metadata + }); + }); + + it('should handle Zod validation failures', () => { + const invalidResponse = { + custom_id: 'template-444', + response: { + body: { + choices: [{ + message: { + content: JSON.stringify({ + categories: ['too', 'many', 'categories', 'here', 'way', 'too', 'many'], + complexity: 'invalid-complexity', + use_cases: [], + estimated_setup_minutes: -5, // Invalid negative time + required_services: 'not-an-array', + key_features: null, + target_audience: ['too', 'many', 'audiences', 'here'] + }) + }, + finish_reason: 'stop' + }] + } + } + }; + + const result = generator.parseResult(invalidResponse); + + expect(result.templateId).toBe(444); + expect(result.error).toBeDefined(); + expect(result.metadata).toEqual(generator['getDefaultMetadata']()); + }); + + it('should handle network timeouts gracefully in generateSingle', async () => { + // Mock OpenAI to simulate timeout + const mockClient = generator['client']; + const originalCreate = mockClient.chat.completions.create; + + mockClient.chat.completions.create = vi.fn().mockRejectedValue( + new Error('Request timed out') + ); + + const template: MetadataRequest = { + templateId: 555, + name: 'Timeout Test', + nodes: ['n8n-nodes-base.webhook'] + }; + + const result = await generator.generateSingle(template); + + // Should return default metadata instead of throwing + expect(result).toEqual(generator['getDefaultMetadata']()); + + // Restore original method + mockClient.chat.completions.create = originalCreate; + }); + }); + + describe('Node Summarization Logic', () => { + it('should group similar nodes correctly', () => { + const template: MetadataRequest = { + templateId: 666, + name: 'Complex Workflow', + nodes: [ + 'n8n-nodes-base.webhook', + 'n8n-nodes-base.httpRequest', + 'n8n-nodes-base.postgres', + 'n8n-nodes-base.mysql', + 'n8n-nodes-base.slack', + 'n8n-nodes-base.gmail', + '@n8n/n8n-nodes-langchain.openAi', + '@n8n/n8n-nodes-langchain.agent', + 'n8n-nodes-base.googleSheets', + 'n8n-nodes-base.excel' + ] + }; + + const request = generator.createBatchRequest(template); + const userMessage = request.body.messages[1].content; + + expect(userMessage).toContain('HTTP/Webhooks (2)'); + expect(userMessage).toContain('Database (2)'); + expect(userMessage).toContain('Communication (2)'); + expect(userMessage).toContain('AI/ML (2)'); + expect(userMessage).toContain('Spreadsheets (2)'); + }); + + it('should handle unknown node types gracefully', () => { + const template: MetadataRequest = { + templateId: 777, + name: 'Unknown Nodes', + nodes: [ + 'custom-package.unknownNode', + 'another-package.weirdNodeType', + 'someNodeTrigger', + 'anotherNode' + ] + }; + + const request = generator.createBatchRequest(template); + const userMessage = request.body.messages[1].content; + + // Should handle unknown nodes without crashing + expect(userMessage).toContain('unknownNode'); + expect(userMessage).toContain('weirdNodeType'); + expect(userMessage).toContain('someNode'); // Trigger suffix removed + }); + + it('should limit node summary length', () => { + const manyNodes = Array.from({ length: 50 }, (_, i) => + `n8n-nodes-base.customNode${i}` + ); + + const template: MetadataRequest = { + templateId: 888, + name: 'Many Nodes', + nodes: manyNodes + }; + + const request = generator.createBatchRequest(template); + const userMessage = request.body.messages[1].content; + + // Should limit to top 10 groups + const summaryLine = userMessage.split('\n').find(line => + line.includes('Nodes Used (50)') + ); + + expect(summaryLine).toBeDefined(); + const nodeGroups = summaryLine!.split(': ')[1].split(', '); + expect(nodeGroups.length).toBeLessThanOrEqual(10); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/templates/template-repository-security.test.ts b/tests/unit/templates/template-repository-security.test.ts new file mode 100644 index 0000000..5cad226 --- /dev/null +++ b/tests/unit/templates/template-repository-security.test.ts @@ -0,0 +1,532 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TemplateRepository } from '../../../src/templates/template-repository'; +import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter'; + +// Mock logger +vi.mock('../../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + } +})); + +// Mock template sanitizer +vi.mock('../../../src/utils/template-sanitizer', () => { + class MockTemplateSanitizer { + sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false })); + detectTokens = vi.fn(() => []); + } + + return { + TemplateSanitizer: MockTemplateSanitizer + }; +}); + +// Create mock database adapter +class MockDatabaseAdapter implements DatabaseAdapter { + private statements = new Map(); + private execCalls: string[] = []; + private _fts5Support = true; + + prepare = vi.fn((sql: string) => { + if (!this.statements.has(sql)) { + this.statements.set(sql, new MockPreparedStatement(sql)); + } + return this.statements.get(sql)!; + }); + + exec = vi.fn((sql: string) => { + this.execCalls.push(sql); + }); + close = vi.fn(); + pragma = vi.fn(); + transaction = vi.fn((fn: () => any) => fn()); + checkFTS5Support = vi.fn(() => this._fts5Support); + inTransaction = false; + + // Test helpers + _setFTS5Support(supported: boolean) { + this._fts5Support = supported; + } + + _getStatement(sql: string) { + return this.statements.get(sql); + } + + _getExecCalls() { + return this.execCalls; + } + + _clearExecCalls() { + this.execCalls = []; + } +} + +class MockPreparedStatement implements PreparedStatement { + public mockResults: any[] = []; + public capturedParams: any[][] = []; + + run = vi.fn((...params: any[]): RunResult => { + this.capturedParams.push(params); + return { changes: 1, lastInsertRowid: 1 }; + }); + + get = vi.fn((...params: any[]) => { + this.capturedParams.push(params); + return this.mockResults[0] || null; + }); + + all = vi.fn((...params: any[]) => { + this.capturedParams.push(params); + return this.mockResults; + }); + + iterate = vi.fn(); + pluck = vi.fn(() => this); + expand = vi.fn(() => this); + raw = vi.fn(() => this); + columns = vi.fn(() => []); + bind = vi.fn(() => this); + + constructor(private sql: string) {} + + // Test helpers + _setMockResults(results: any[]) { + this.mockResults = results; + } + + _getCapturedParams() { + return this.capturedParams; + } +} + +describe('TemplateRepository - Security Tests', () => { + let repository: TemplateRepository; + let mockAdapter: MockDatabaseAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + mockAdapter = new MockDatabaseAdapter(); + repository = new TemplateRepository(mockAdapter); + }); + + describe('SQL Injection Prevention', () => { + describe('searchTemplatesByMetadata', () => { + it('should prevent SQL injection in category parameter', () => { + const maliciousCategory = "'; DROP TABLE templates; --"; + + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + category: maliciousCategory, + limit: 10, + offset: 0 + }); + + // Should use parameterized queries, not inject SQL + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams.length).toBeGreaterThan(0); + expect(capturedParams[0]).toContain(`%"${maliciousCategory}"%`); + + // Verify the SQL doesn't contain the malicious content directly + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).not.toContain('DROP TABLE'); + expect(prepareCall).toContain('json_extract(metadata_json, \'$.categories\') LIKE ?'); + }); + + it('should prevent SQL injection in requiredService parameter', () => { + const maliciousService = "'; UNION SELECT * FROM sqlite_master; --"; + + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + requiredService: maliciousService, + limit: 10, + offset: 0 + }); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0]).toContain(`%"${maliciousService}"%`); + + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).not.toContain('UNION SELECT'); + expect(prepareCall).toContain('json_extract(metadata_json, \'$.required_services\') LIKE ?'); + }); + + it('should prevent SQL injection in targetAudience parameter', () => { + const maliciousAudience = "administrators'; DELETE FROM templates WHERE '1'='1"; + + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + targetAudience: maliciousAudience, + limit: 10, + offset: 0 + }); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0]).toContain(`%"${maliciousAudience}"%`); + + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).not.toContain('DELETE FROM'); + expect(prepareCall).toContain('json_extract(metadata_json, \'$.target_audience\') LIKE ?'); + }); + + it('should safely handle special characters in parameters', () => { + const specialChars = "test'with\"quotes\\and%wildcards_and[brackets]"; + + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + category: specialChars, + limit: 10, + offset: 0 + }); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0]).toContain(`%"${specialChars}"%`); + + // Should use parameterized query + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).toContain('json_extract(metadata_json, \'$.categories\') LIKE ?'); + }); + + it('should prevent injection through numeric parameters', () => { + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + // Try to inject through numeric parameters + repository.searchTemplatesByMetadata({ + maxSetupMinutes: 999999999, // Large number + minSetupMinutes: -999999999, // Negative number + limit: 10, + offset: 0 + }); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0]).toContain(999999999); + expect(capturedParams[0]).toContain(-999999999); + + // Should use CAST and parameterized queries + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).toContain('CAST(json_extract(metadata_json, \'$.estimated_setup_minutes\') AS INTEGER)'); + }); + }); + + describe('getSearchTemplatesByMetadataCount', () => { + it('should use parameterized queries for count operations', () => { + const maliciousCategory = "'; DROP TABLE templates; SELECT COUNT(*) FROM sqlite_master WHERE name LIKE '%"; + + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([{ count: 0 }]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.getSearchTemplatesByMetadataCount({ + category: maliciousCategory + }); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0]).toContain(`%"${maliciousCategory}"%`); + + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).not.toContain('DROP TABLE'); + expect(prepareCall).toContain('SELECT COUNT(*) as count FROM templates'); + }); + }); + + describe('updateTemplateMetadata', () => { + it('should safely handle metadata with special characters', () => { + const maliciousMetadata = { + categories: ["automation'; DROP TABLE templates; --"], + complexity: "simple", + use_cases: ['SQL injection"test'], + estimated_setup_minutes: 30, + required_services: ['api"with\\"quotes'], + key_features: ["feature's test"], + target_audience: ['developers\\administrators'] + }; + + const stmt = new MockPreparedStatement(''); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.updateTemplateMetadata(123, maliciousMetadata); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0][0]).toBe(JSON.stringify(maliciousMetadata)); + expect(capturedParams[0][1]).toBe(123); + + // Should use parameterized UPDATE + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).toContain('UPDATE templates SET metadata_json = ?'); + expect(prepareCall).not.toContain('DROP TABLE'); + }); + }); + + describe('batchUpdateMetadata', () => { + it('should safely handle batch updates with malicious data', () => { + const maliciousData = new Map(); + maliciousData.set(1, { categories: ["'; DROP TABLE templates; --"] }); + maliciousData.set(2, { categories: ["normal category"] }); + + const stmt = new MockPreparedStatement(''); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.batchUpdateMetadata(maliciousData); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams).toHaveLength(2); + + // Both calls should be parameterized + expect(capturedParams[0][0]).toContain('"; DROP TABLE templates; --'); + expect(capturedParams[0][1]).toBe(1); + expect(capturedParams[1][0]).toContain('normal category'); + expect(capturedParams[1][1]).toBe(2); + }); + }); + }); + + describe('JSON Extraction Security', () => { + it('should safely extract categories from JSON', () => { + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.getUniqueCategories(); + + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).toContain('json_extract(metadata_json, \'$.categories\')'); + expect(prepareCall).toContain('json_each('); + expect(prepareCall).not.toContain('eval('); + expect(prepareCall).not.toContain('exec('); + }); + + it('should safely extract target audiences from JSON', () => { + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.getUniqueTargetAudiences(); + + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).toContain('json_extract(metadata_json, \'$.target_audience\')'); + expect(prepareCall).toContain('json_each('); + }); + + it('should safely handle complex JSON structures', () => { + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.getTemplatesByCategory('test'); + + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).toContain('json_extract(metadata_json, \'$.categories\') LIKE ?'); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0]).toContain('%"test"%'); + }); + }); + + describe('Input Validation and Sanitization', () => { + it('should handle null and undefined parameters safely', () => { + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + category: undefined as any, + complexity: null as any, + limit: 10, + offset: 0 + }); + + // Should not break and should exclude undefined/null filters + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + expect(prepareCall).toContain('metadata_json IS NOT NULL'); + expect(prepareCall).not.toContain('undefined'); + expect(prepareCall).not.toContain('null'); + }); + + it('should handle empty string parameters', () => { + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + category: '', + requiredService: '', + targetAudience: '', + limit: 10, + offset: 0 + }); + + // Empty strings should still be processed (might be valid searches) + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0]).toContain('%""%'); + }); + + it('should validate numeric ranges', () => { + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + maxSetupMinutes: Number.MAX_SAFE_INTEGER, + minSetupMinutes: Number.MIN_SAFE_INTEGER, + limit: 10, + offset: 0 + }); + + // Should handle extreme values without breaking + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0]).toContain(Number.MAX_SAFE_INTEGER); + expect(capturedParams[0]).toContain(Number.MIN_SAFE_INTEGER); + }); + + it('should handle Unicode and international characters', () => { + const unicodeCategory = '自動化'; // Japanese for "automation" + const emojiAudience = '👩‍💻 developers'; + + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + category: unicodeCategory, + targetAudience: emojiAudience, + limit: 10, + offset: 0 + }); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0]).toContain(`%"${unicodeCategory}"%`); + expect(capturedParams[0]).toContain(`%"${emojiAudience}"%`); + }); + }); + + describe('Database Schema Security', () => { + it('should use proper column names without injection', () => { + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + category: 'test', + limit: 10, + offset: 0 + }); + + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + + // Should reference proper column names + expect(prepareCall).toContain('metadata_json'); + expect(prepareCall).toContain('templates'); + + // Should not contain dynamic column names that could be injected + expect(prepareCall).not.toMatch(/SELECT \* FROM \w+;/); + expect(prepareCall).not.toContain('information_schema'); + expect(prepareCall).not.toContain('sqlite_master'); + }); + + it('should use proper JSON path syntax', () => { + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.getUniqueCategories(); + + const prepareCall = mockAdapter.prepare.mock.calls[0][0]; + + // Should use safe JSON path syntax + expect(prepareCall).toContain('$.categories'); + expect(prepareCall).not.toContain('$['); + expect(prepareCall).not.toContain('eval('); + }); + }); + + describe('Transaction Safety', () => { + it('should handle transaction rollback on metadata update errors', () => { + const stmt = new MockPreparedStatement(''); + stmt.run = vi.fn().mockImplementation(() => { + throw new Error('Database error'); + }); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + const maliciousData = new Map(); + maliciousData.set(1, { categories: ["'; DROP TABLE templates; --"] }); + + expect(() => { + repository.batchUpdateMetadata(maliciousData); + }).toThrow('Database error'); + + // Transaction should have been attempted + expect(mockAdapter.transaction).toHaveBeenCalled(); + }); + }); + + describe('Error Message Security', () => { + it('should not expose sensitive information in error messages', () => { + const stmt = new MockPreparedStatement(''); + stmt.get = vi.fn().mockImplementation(() => { + throw new Error('SQLITE_ERROR: syntax error near "DROP TABLE"'); + }); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + expect(() => { + repository.getSearchTemplatesByMetadataCount({ + category: "'; DROP TABLE templates; --" + }); + }).toThrow(); // Should throw, but not expose SQL details + }); + }); + + describe('Performance and DoS Protection', () => { + it('should handle large limit values safely', () => { + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + limit: 999999999, // Very large limit + offset: 0 + }); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0]).toContain(999999999); + + // Should still work but might be limited by database constraints + expect(mockAdapter.prepare).toHaveBeenCalled(); + }); + + it('should handle very long string parameters', () => { + const veryLongString = 'a'.repeat(100000); // 100KB string + + const stmt = new MockPreparedStatement(''); + stmt._setMockResults([]); + mockAdapter.prepare = vi.fn().mockReturnValue(stmt); + + repository.searchTemplatesByMetadata({ + category: veryLongString, + limit: 10, + offset: 0 + }); + + const capturedParams = stmt._getCapturedParams(); + expect(capturedParams[0][0]).toContain(veryLongString); + + // Should handle without breaking + expect(mockAdapter.prepare).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file