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