mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-26 12:13:12 +00:00
Adds zero-configuration anonymous usage statistics to track: - Number of active users with deterministic user IDs - Which MCP tools AI agents use most - What workflows are built (sanitized to protect privacy) - Common errors and issues Key features: - Zero-configuration design with hardcoded write-only credentials - Privacy-first approach with comprehensive data sanitization - Opt-out support via config file and environment variables - Docker-friendly with environment variable support - Multi-process safe with immediate flush strategy - Row Level Security (RLS) policies for write-only access Technical implementation: - Supabase backend with anon key for INSERT-only operations - Workflow sanitization removes all sensitive data - Environment variables checked for opt-out (TELEMETRY_DISABLED, etc.) - Telemetry enabled by default but respects user preferences - Cleaned up all debug logging for production readiness 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
310 lines
9.6 KiB
TypeScript
310 lines
9.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { WorkflowSanitizer } from '../../../src/telemetry/workflow-sanitizer';
|
|
|
|
describe('WorkflowSanitizer', () => {
|
|
describe('sanitizeWorkflow', () => {
|
|
it('should remove API keys from parameters', () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {
|
|
url: 'https://api.example.com',
|
|
apiKey: 'sk-1234567890abcdef1234567890abcdef',
|
|
headers: {
|
|
'Authorization': 'Bearer sk-1234567890abcdef1234567890abcdef'
|
|
}
|
|
}
|
|
}
|
|
],
|
|
connections: {}
|
|
};
|
|
|
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
|
|
|
expect(sanitized.nodes[0].parameters.apiKey).toBe('[REDACTED]');
|
|
expect(sanitized.nodes[0].parameters.headers.Authorization).toBe('[REDACTED]');
|
|
});
|
|
|
|
it('should sanitize webhook URLs but keep structure', () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {
|
|
path: 'my-webhook',
|
|
webhookUrl: 'https://n8n.example.com/webhook/abc-def-ghi',
|
|
method: 'POST'
|
|
}
|
|
}
|
|
],
|
|
connections: {}
|
|
};
|
|
|
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
|
|
|
expect(sanitized.nodes[0].parameters.webhookUrl).toBe('[REDACTED]');
|
|
expect(sanitized.nodes[0].parameters.method).toBe('POST'); // Method should remain
|
|
expect(sanitized.nodes[0].parameters.path).toBe('my-webhook'); // Path should remain
|
|
});
|
|
|
|
it('should remove credentials entirely', () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Slack',
|
|
type: 'n8n-nodes-base.slack',
|
|
position: [100, 100],
|
|
parameters: {
|
|
channel: 'general',
|
|
text: 'Hello World'
|
|
},
|
|
credentials: {
|
|
slackApi: {
|
|
id: 'cred-123',
|
|
name: 'My Slack'
|
|
}
|
|
}
|
|
}
|
|
],
|
|
connections: {}
|
|
};
|
|
|
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
|
|
|
expect(sanitized.nodes[0].credentials).toBeUndefined();
|
|
expect(sanitized.nodes[0].parameters.channel).toBe('general'); // Channel should remain
|
|
expect(sanitized.nodes[0].parameters.text).toBe('Hello World'); // Text should remain
|
|
});
|
|
|
|
it('should sanitize URLs in parameters', () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {
|
|
url: 'https://api.example.com/endpoint',
|
|
endpoint: 'https://another.example.com/api',
|
|
baseUrl: 'https://base.example.com'
|
|
}
|
|
}
|
|
],
|
|
connections: {}
|
|
};
|
|
|
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
|
|
|
expect(sanitized.nodes[0].parameters.url).toBe('[REDACTED]');
|
|
expect(sanitized.nodes[0].parameters.endpoint).toBe('[REDACTED]');
|
|
expect(sanitized.nodes[0].parameters.baseUrl).toBe('[REDACTED]');
|
|
});
|
|
|
|
it('should calculate workflow metrics correctly', () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [200, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '3',
|
|
name: 'Slack',
|
|
type: 'n8n-nodes-base.slack',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'1': {
|
|
main: [[{ node: '2', type: 'main', index: 0 }]]
|
|
},
|
|
'2': {
|
|
main: [[{ node: '3', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
};
|
|
|
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
|
|
|
expect(sanitized.nodeCount).toBe(3);
|
|
expect(sanitized.nodeTypes).toContain('n8n-nodes-base.webhook');
|
|
expect(sanitized.nodeTypes).toContain('n8n-nodes-base.httpRequest');
|
|
expect(sanitized.nodeTypes).toContain('n8n-nodes-base.slack');
|
|
expect(sanitized.hasTrigger).toBe(true);
|
|
expect(sanitized.hasWebhook).toBe(true);
|
|
expect(sanitized.complexity).toBe('simple');
|
|
});
|
|
|
|
it('should calculate complexity based on node count', () => {
|
|
const createWorkflow = (nodeCount: number) => ({
|
|
nodes: Array.from({ length: nodeCount }, (_, i) => ({
|
|
id: String(i),
|
|
name: `Node ${i}`,
|
|
type: 'n8n-nodes-base.function',
|
|
position: [i * 100, 100],
|
|
parameters: {}
|
|
})),
|
|
connections: {}
|
|
});
|
|
|
|
const simple = WorkflowSanitizer.sanitizeWorkflow(createWorkflow(5));
|
|
expect(simple.complexity).toBe('simple');
|
|
|
|
const medium = WorkflowSanitizer.sanitizeWorkflow(createWorkflow(15));
|
|
expect(medium.complexity).toBe('medium');
|
|
|
|
const complex = WorkflowSanitizer.sanitizeWorkflow(createWorkflow(25));
|
|
expect(complex.complexity).toBe('complex');
|
|
});
|
|
|
|
it('should generate consistent workflow hash', () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: { path: 'test' }
|
|
}
|
|
],
|
|
connections: {}
|
|
};
|
|
|
|
const hash1 = WorkflowSanitizer.generateWorkflowHash(workflow);
|
|
const hash2 = WorkflowSanitizer.generateWorkflowHash(workflow);
|
|
|
|
expect(hash1).toBe(hash2);
|
|
expect(hash1).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it('should sanitize nested objects in parameters', () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Complex Node',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {
|
|
options: {
|
|
headers: {
|
|
'X-API-Key': 'secret-key-1234567890abcdef',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: {
|
|
data: 'some data',
|
|
token: 'another-secret-token-xyz123'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
],
|
|
connections: {}
|
|
};
|
|
|
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
|
|
|
expect(sanitized.nodes[0].parameters.options.headers['X-API-Key']).toBe('[REDACTED]');
|
|
expect(sanitized.nodes[0].parameters.options.headers['Content-Type']).toBe('application/json');
|
|
expect(sanitized.nodes[0].parameters.options.body.data).toBe('some data');
|
|
expect(sanitized.nodes[0].parameters.options.body.token).toBe('[REDACTED]');
|
|
});
|
|
|
|
it('should preserve connections structure', () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Node 1',
|
|
type: 'n8n-nodes-base.start',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Node 2',
|
|
type: 'n8n-nodes-base.function',
|
|
position: [200, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'1': {
|
|
main: [[{ node: '2', type: 'main', index: 0 }]],
|
|
error: [[{ node: '2', type: 'error', index: 0 }]]
|
|
}
|
|
}
|
|
};
|
|
|
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
|
|
|
expect(sanitized.connections).toEqual({
|
|
'1': {
|
|
main: [[{ node: '2', type: 'main', index: 0 }]],
|
|
error: [[{ node: '2', type: 'error', index: 0 }]]
|
|
}
|
|
});
|
|
});
|
|
|
|
it('should remove sensitive workflow metadata', () => {
|
|
const workflow = {
|
|
id: 'workflow-123',
|
|
name: 'My Workflow',
|
|
nodes: [],
|
|
connections: {},
|
|
settings: {
|
|
errorWorkflow: 'error-workflow-id',
|
|
timezone: 'America/New_York'
|
|
},
|
|
staticData: { some: 'data' },
|
|
pinData: { node1: 'pinned' },
|
|
credentials: { slack: 'cred-123' },
|
|
sharedWorkflows: ['user-456'],
|
|
ownedBy: 'user-123',
|
|
createdBy: 'user-123',
|
|
updatedBy: 'user-456'
|
|
};
|
|
|
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
|
|
|
// Verify that sensitive workflow-level properties are not in the sanitized output
|
|
// The sanitized workflow should only have specific fields as defined in SanitizedWorkflow interface
|
|
expect(sanitized.nodes).toEqual([]);
|
|
expect(sanitized.connections).toEqual({});
|
|
expect(sanitized.nodeCount).toBe(0);
|
|
expect(sanitized.nodeTypes).toEqual([]);
|
|
|
|
// Verify these fields don't exist in the sanitized output
|
|
const sanitizedAsAny = sanitized as any;
|
|
expect(sanitizedAsAny.settings).toBeUndefined();
|
|
expect(sanitizedAsAny.staticData).toBeUndefined();
|
|
expect(sanitizedAsAny.pinData).toBeUndefined();
|
|
expect(sanitizedAsAny.credentials).toBeUndefined();
|
|
expect(sanitizedAsAny.sharedWorkflows).toBeUndefined();
|
|
expect(sanitizedAsAny.ownedBy).toBeUndefined();
|
|
expect(sanitizedAsAny.createdBy).toBeUndefined();
|
|
expect(sanitizedAsAny.updatedBy).toBeUndefined();
|
|
});
|
|
});
|
|
}); |