mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 05:23:08 +00:00
Fixes critical issue where Docker and cloud deployments generated new anonymous user IDs on every container recreation, causing 100-200x inflation in unique user counts. Changes: - Use host's boot_id for stable identification across container updates - Auto-detect Docker (IS_DOCKER=true) and 8 cloud platforms - Defensive fallback chain: boot_id → combined signals → generic ID - Zero configuration required Impact: - Resolves ~1000x/month inflation in stdio mode - Resolves ~180x/month inflation in HTTP mode (6 releases/day) - Improves telemetry accuracy: 3,996 apparent users → ~2,400-2,800 actual Testing: - 18 new unit tests for boot_id functionality - 16 new integration tests for Docker/cloud detection - All 60 telemetry tests passing (100%) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
865 lines
29 KiB
TypeScript
865 lines
29 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { TelemetryConfigManager } from '../../../src/telemetry/config-manager';
|
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { homedir } from 'os';
|
|
|
|
// Mock fs module
|
|
vi.mock('fs', async () => {
|
|
const actual = await vi.importActual<typeof import('fs')>('fs');
|
|
return {
|
|
...actual,
|
|
existsSync: vi.fn(),
|
|
readFileSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
mkdirSync: vi.fn()
|
|
};
|
|
});
|
|
|
|
describe('TelemetryConfigManager', () => {
|
|
let manager: TelemetryConfigManager;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Clear singleton instance
|
|
(TelemetryConfigManager as any).instance = null;
|
|
|
|
// Mock console.log to suppress first-run notice in tests
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('getInstance', () => {
|
|
it('should return singleton instance', () => {
|
|
const instance1 = TelemetryConfigManager.getInstance();
|
|
const instance2 = TelemetryConfigManager.getInstance();
|
|
expect(instance1).toBe(instance2);
|
|
});
|
|
});
|
|
|
|
describe('loadConfig', () => {
|
|
it('should create default config on first run', () => {
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const config = manager.loadConfig();
|
|
|
|
expect(config.enabled).toBe(true);
|
|
expect(config.userId).toMatch(/^[a-f0-9]{16}$/);
|
|
expect(config.firstRun).toBeDefined();
|
|
expect(vi.mocked(mkdirSync)).toHaveBeenCalledWith(
|
|
join(homedir(), '.n8n-mcp'),
|
|
{ recursive: true }
|
|
);
|
|
expect(vi.mocked(writeFileSync)).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should load existing config from disk', () => {
|
|
const mockConfig = {
|
|
enabled: false,
|
|
userId: 'test-user-id',
|
|
firstRun: '2024-01-01T00:00:00Z'
|
|
};
|
|
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const config = manager.loadConfig();
|
|
|
|
expect(config).toEqual(mockConfig);
|
|
});
|
|
|
|
it('should handle corrupted config file gracefully', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue('invalid json');
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const config = manager.loadConfig();
|
|
|
|
expect(config.enabled).toBe(false);
|
|
expect(config.userId).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it('should add userId to config if missing', () => {
|
|
const mockConfig = {
|
|
enabled: true,
|
|
firstRun: '2024-01-01T00:00:00Z'
|
|
};
|
|
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const config = manager.loadConfig();
|
|
|
|
expect(config.userId).toMatch(/^[a-f0-9]{16}$/);
|
|
expect(vi.mocked(writeFileSync)).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('isEnabled', () => {
|
|
it('should return true when telemetry is enabled', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: true,
|
|
userId: 'test-id'
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
expect(manager.isEnabled()).toBe(true);
|
|
});
|
|
|
|
it('should return false when telemetry is disabled', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: false,
|
|
userId: 'test-id'
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
expect(manager.isEnabled()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getUserId', () => {
|
|
it('should return consistent user ID', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: true,
|
|
userId: 'test-user-id-123'
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
expect(manager.getUserId()).toBe('test-user-id-123');
|
|
});
|
|
});
|
|
|
|
describe('isFirstRun', () => {
|
|
it('should return true if config file does not exist', () => {
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
expect(manager.isFirstRun()).toBe(true);
|
|
});
|
|
|
|
it('should return false if config file exists', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
expect(manager.isFirstRun()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('enable/disable', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: false,
|
|
userId: 'test-id'
|
|
}));
|
|
});
|
|
|
|
it('should enable telemetry', () => {
|
|
manager = TelemetryConfigManager.getInstance();
|
|
manager.enable();
|
|
|
|
const calls = vi.mocked(writeFileSync).mock.calls;
|
|
expect(calls.length).toBeGreaterThan(0);
|
|
const lastCall = calls[calls.length - 1];
|
|
expect(lastCall[1]).toContain('"enabled": true');
|
|
});
|
|
|
|
it('should disable telemetry', () => {
|
|
manager = TelemetryConfigManager.getInstance();
|
|
manager.disable();
|
|
|
|
const calls = vi.mocked(writeFileSync).mock.calls;
|
|
expect(calls.length).toBeGreaterThan(0);
|
|
const lastCall = calls[calls.length - 1];
|
|
expect(lastCall[1]).toContain('"enabled": false');
|
|
});
|
|
});
|
|
|
|
describe('getStatus', () => {
|
|
it('should return formatted status string', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: true,
|
|
userId: 'test-id',
|
|
firstRun: '2024-01-01T00:00:00Z'
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const status = manager.getStatus();
|
|
|
|
expect(status).toContain('ENABLED');
|
|
expect(status).toContain('test-id');
|
|
expect(status).toContain('2024-01-01T00:00:00Z');
|
|
expect(status).toContain('npx n8n-mcp telemetry');
|
|
});
|
|
});
|
|
|
|
describe('edge cases and error handling', () => {
|
|
it('should handle file system errors during config creation', () => {
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
vi.mocked(mkdirSync).mockImplementation(() => {
|
|
throw new Error('Permission denied');
|
|
});
|
|
|
|
// Should not crash on file system errors
|
|
expect(() => TelemetryConfigManager.getInstance()).not.toThrow();
|
|
});
|
|
|
|
it('should handle write errors during config save', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: false,
|
|
userId: 'test-id'
|
|
}));
|
|
vi.mocked(writeFileSync).mockImplementation(() => {
|
|
throw new Error('Disk full');
|
|
});
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
|
|
// Should not crash on write errors
|
|
expect(() => manager.enable()).not.toThrow();
|
|
expect(() => manager.disable()).not.toThrow();
|
|
});
|
|
|
|
it('should handle missing home directory', () => {
|
|
// Mock homedir to return empty string
|
|
const originalHomedir = require('os').homedir;
|
|
vi.doMock('os', () => ({
|
|
homedir: () => ''
|
|
}));
|
|
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
|
|
expect(() => TelemetryConfigManager.getInstance()).not.toThrow();
|
|
});
|
|
|
|
it('should generate valid user ID when crypto.randomBytes fails', () => {
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
|
|
// Mock crypto to fail
|
|
vi.doMock('crypto', () => ({
|
|
randomBytes: () => {
|
|
throw new Error('Crypto not available');
|
|
}
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const config = manager.loadConfig();
|
|
|
|
expect(config.userId).toBeDefined();
|
|
expect(config.userId).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it('should handle concurrent access to config file', () => {
|
|
let readCount = 0;
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockImplementation(() => {
|
|
readCount++;
|
|
if (readCount === 1) {
|
|
return JSON.stringify({
|
|
enabled: false,
|
|
userId: 'test-id-1'
|
|
});
|
|
}
|
|
return JSON.stringify({
|
|
enabled: true,
|
|
userId: 'test-id-2'
|
|
});
|
|
});
|
|
|
|
const manager1 = TelemetryConfigManager.getInstance();
|
|
const manager2 = TelemetryConfigManager.getInstance();
|
|
|
|
// Should be same instance due to singleton pattern
|
|
expect(manager1).toBe(manager2);
|
|
});
|
|
|
|
it('should handle environment variable overrides', () => {
|
|
const originalEnv = process.env.N8N_MCP_TELEMETRY_DISABLED;
|
|
|
|
// Test with environment variable set to disable telemetry
|
|
process.env.N8N_MCP_TELEMETRY_DISABLED = 'true';
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: true,
|
|
userId: 'test-id'
|
|
}));
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
|
|
expect(manager.isEnabled()).toBe(false);
|
|
|
|
// Test with environment variable set to enable telemetry
|
|
process.env.N8N_MCP_TELEMETRY_DISABLED = 'false';
|
|
(TelemetryConfigManager as any).instance = null;
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: true,
|
|
userId: 'test-id'
|
|
}));
|
|
manager = TelemetryConfigManager.getInstance();
|
|
|
|
expect(manager.isEnabled()).toBe(true);
|
|
|
|
// Restore original environment
|
|
process.env.N8N_MCP_TELEMETRY_DISABLED = originalEnv;
|
|
});
|
|
|
|
it('should handle invalid JSON in config file gracefully', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue('{ invalid json syntax');
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const config = manager.loadConfig();
|
|
|
|
expect(config.enabled).toBe(false); // Default to disabled on corrupt config
|
|
expect(config.userId).toMatch(/^[a-f0-9]{16}$/); // Should generate new user ID
|
|
});
|
|
|
|
it('should handle config file with partial structure', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: true
|
|
// Missing userId and firstRun
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const config = manager.loadConfig();
|
|
|
|
expect(config.enabled).toBe(true);
|
|
expect(config.userId).toMatch(/^[a-f0-9]{16}$/);
|
|
// firstRun might not be defined if config is partial and loaded from disk
|
|
// The implementation only adds firstRun on first creation
|
|
});
|
|
|
|
it('should handle config file with invalid data types', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: 'not-a-boolean',
|
|
userId: 12345, // Not a string
|
|
firstRun: null
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const config = manager.loadConfig();
|
|
|
|
// The config manager loads the data as-is, so we get the original types
|
|
// The validation happens during usage, not loading
|
|
expect(config.enabled).toBe('not-a-boolean');
|
|
expect(config.userId).toBe(12345);
|
|
});
|
|
|
|
it('should handle very large config files', () => {
|
|
const largeConfig = {
|
|
enabled: true,
|
|
userId: 'test-id',
|
|
firstRun: '2024-01-01T00:00:00Z',
|
|
extraData: 'x'.repeat(1000000) // 1MB of data
|
|
};
|
|
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(largeConfig));
|
|
|
|
expect(() => TelemetryConfigManager.getInstance()).not.toThrow();
|
|
});
|
|
|
|
it('should handle config directory creation race conditions', () => {
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
let mkdirCallCount = 0;
|
|
vi.mocked(mkdirSync).mockImplementation(() => {
|
|
mkdirCallCount++;
|
|
if (mkdirCallCount === 1) {
|
|
throw new Error('EEXIST: file already exists');
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
expect(() => TelemetryConfigManager.getInstance()).not.toThrow();
|
|
});
|
|
|
|
it('should handle file system permission changes', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: false,
|
|
userId: 'test-id'
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
|
|
// Simulate permission denied on subsequent write
|
|
vi.mocked(writeFileSync).mockImplementationOnce(() => {
|
|
throw new Error('EACCES: permission denied');
|
|
});
|
|
|
|
expect(() => manager.enable()).not.toThrow();
|
|
});
|
|
|
|
it('should handle system clock changes affecting timestamps', () => {
|
|
const futureDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); // 1 year in future
|
|
const pastDate = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); // 1 year in past
|
|
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: true,
|
|
userId: 'test-id',
|
|
firstRun: futureDate.toISOString()
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const config = manager.loadConfig();
|
|
|
|
expect(config.firstRun).toBeDefined();
|
|
expect(new Date(config.firstRun as string).getTime()).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle config updates during runtime', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: false,
|
|
userId: 'test-id'
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
expect(manager.isEnabled()).toBe(false);
|
|
|
|
// Simulate external config change by clearing cache first
|
|
(manager as any).config = null;
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: true,
|
|
userId: 'test-id'
|
|
}));
|
|
|
|
// Now calling loadConfig should pick up changes
|
|
const newConfig = manager.loadConfig();
|
|
expect(newConfig.enabled).toBe(true);
|
|
expect(manager.isEnabled()).toBe(true);
|
|
});
|
|
|
|
it('should handle multiple rapid enable/disable calls', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: false,
|
|
userId: 'test-id'
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
|
|
// Rapidly toggle state
|
|
for (let i = 0; i < 100; i++) {
|
|
if (i % 2 === 0) {
|
|
manager.enable();
|
|
} else {
|
|
manager.disable();
|
|
}
|
|
}
|
|
|
|
// Should not crash and maintain consistent state
|
|
expect(typeof manager.isEnabled()).toBe('boolean');
|
|
});
|
|
|
|
it('should handle user ID collision (extremely unlikely)', () => {
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
|
|
// Mock crypto to always return same bytes
|
|
const mockBytes = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
vi.doMock('crypto', () => ({
|
|
randomBytes: () => mockBytes
|
|
}));
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
const manager1 = TelemetryConfigManager.getInstance();
|
|
const userId1 = manager1.getUserId();
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
const manager2 = TelemetryConfigManager.getInstance();
|
|
const userId2 = manager2.getUserId();
|
|
|
|
// Should generate same ID from same random bytes
|
|
expect(userId1).toBe(userId2);
|
|
expect(userId1).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it('should handle status generation with missing fields', () => {
|
|
vi.mocked(existsSync).mockReturnValue(true);
|
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
|
enabled: true
|
|
// Missing userId and firstRun
|
|
}));
|
|
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const status = manager.getStatus();
|
|
|
|
expect(status).toContain('ENABLED');
|
|
expect(status).toBeDefined();
|
|
expect(typeof status).toBe('string');
|
|
});
|
|
});
|
|
|
|
describe('Docker/Cloud user ID generation', () => {
|
|
let originalIsDocker: string | undefined;
|
|
let originalRailway: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
originalIsDocker = process.env.IS_DOCKER;
|
|
originalRailway = process.env.RAILWAY_ENVIRONMENT;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalIsDocker === undefined) {
|
|
delete process.env.IS_DOCKER;
|
|
} else {
|
|
process.env.IS_DOCKER = originalIsDocker;
|
|
}
|
|
|
|
if (originalRailway === undefined) {
|
|
delete process.env.RAILWAY_ENVIRONMENT;
|
|
} else {
|
|
process.env.RAILWAY_ENVIRONMENT = originalRailway;
|
|
}
|
|
});
|
|
|
|
describe('boot_id reading', () => {
|
|
it('should read valid boot_id from /proc/sys/kernel/random/boot_id', () => {
|
|
const mockBootId = 'f3c371fe-8a77-4592-8332-7a4d0d88d4ac';
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return true;
|
|
return false;
|
|
});
|
|
|
|
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return mockBootId;
|
|
throw new Error('File not found');
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
expect(vi.mocked(readFileSync)).toHaveBeenCalledWith(
|
|
'/proc/sys/kernel/random/boot_id',
|
|
'utf-8'
|
|
);
|
|
});
|
|
|
|
it('should validate boot_id UUID format', () => {
|
|
const invalidBootId = 'not-a-valid-uuid';
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return true;
|
|
if (path === '/proc/cpuinfo') return true;
|
|
if (path === '/proc/meminfo') return true;
|
|
return false;
|
|
});
|
|
|
|
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return invalidBootId;
|
|
if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\n';
|
|
if (path === '/proc/meminfo') return 'MemTotal: 8040052 kB\n';
|
|
throw new Error('File not found');
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
// Should fallback to combined fingerprint, not use invalid boot_id
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it('should handle boot_id file not existing', () => {
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return false;
|
|
if (path === '/proc/cpuinfo') return true;
|
|
if (path === '/proc/meminfo') return true;
|
|
return false;
|
|
});
|
|
|
|
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\n';
|
|
if (path === '/proc/meminfo') return 'MemTotal: 8040052 kB\n';
|
|
throw new Error('File not found');
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
// Should fallback to combined fingerprint
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it('should handle boot_id read errors gracefully', () => {
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return true;
|
|
return false;
|
|
});
|
|
|
|
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') {
|
|
throw new Error('Permission denied');
|
|
}
|
|
throw new Error('File not found');
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
// Should fallback gracefully
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it('should generate consistent user ID from same boot_id', () => {
|
|
const mockBootId = 'f3c371fe-8a77-4592-8332-7a4d0d88d4ac';
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return true;
|
|
return false;
|
|
});
|
|
|
|
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return mockBootId;
|
|
throw new Error('File not found');
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
const manager1 = TelemetryConfigManager.getInstance();
|
|
const userId1 = manager1.getUserId();
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
const manager2 = TelemetryConfigManager.getInstance();
|
|
const userId2 = manager2.getUserId();
|
|
|
|
// Same boot_id should produce same user_id
|
|
expect(userId1).toBe(userId2);
|
|
});
|
|
});
|
|
|
|
describe('combined fingerprint fallback', () => {
|
|
it('should generate fingerprint from CPU, memory, and kernel', () => {
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return false;
|
|
if (path === '/proc/cpuinfo') return true;
|
|
if (path === '/proc/meminfo') return true;
|
|
if (path === '/proc/version') return true;
|
|
return false;
|
|
});
|
|
|
|
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\nprocessor: 2\nprocessor: 3\n';
|
|
if (path === '/proc/meminfo') return 'MemTotal: 8040052 kB\n';
|
|
if (path === '/proc/version') return 'Linux version 5.15.49-linuxkit';
|
|
throw new Error('File not found');
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it('should require at least 3 signals for combined fingerprint', () => {
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return false;
|
|
// Only platform and arch available (2 signals)
|
|
return false;
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
// Should fallback to generic Docker ID
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it('should handle partial /proc data', () => {
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return false;
|
|
if (path === '/proc/cpuinfo') return true;
|
|
// meminfo missing
|
|
return false;
|
|
});
|
|
|
|
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\n';
|
|
throw new Error('File not found');
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
// Should include platform and arch, so 4 signals total
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
});
|
|
|
|
describe('environment detection', () => {
|
|
it('should use Docker method when IS_DOCKER=true', () => {
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
// Should attempt to read boot_id
|
|
expect(vi.mocked(existsSync)).toHaveBeenCalledWith('/proc/sys/kernel/random/boot_id');
|
|
});
|
|
|
|
it('should use Docker method for Railway environment', () => {
|
|
process.env.RAILWAY_ENVIRONMENT = 'production';
|
|
delete process.env.IS_DOCKER;
|
|
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
// Should attempt to read boot_id
|
|
expect(vi.mocked(existsSync)).toHaveBeenCalledWith('/proc/sys/kernel/random/boot_id');
|
|
});
|
|
|
|
it('should use file-based method for local installation', () => {
|
|
delete process.env.IS_DOCKER;
|
|
delete process.env.RAILWAY_ENVIRONMENT;
|
|
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
// Should NOT attempt to read boot_id
|
|
const calls = vi.mocked(existsSync).mock.calls;
|
|
const bootIdCalls = calls.filter(call => call[0] === '/proc/sys/kernel/random/boot_id');
|
|
expect(bootIdCalls.length).toBe(0);
|
|
});
|
|
|
|
it('should detect cloud platforms', () => {
|
|
const cloudEnvVars = [
|
|
'RAILWAY_ENVIRONMENT',
|
|
'RENDER',
|
|
'FLY_APP_NAME',
|
|
'HEROKU_APP_NAME',
|
|
'AWS_EXECUTION_ENV',
|
|
'KUBERNETES_SERVICE_HOST',
|
|
'GOOGLE_CLOUD_PROJECT',
|
|
'AZURE_FUNCTIONS_ENVIRONMENT'
|
|
];
|
|
|
|
cloudEnvVars.forEach(envVar => {
|
|
// Clear all env vars
|
|
cloudEnvVars.forEach(v => delete process.env[v]);
|
|
delete process.env.IS_DOCKER;
|
|
|
|
// Set one cloud env var
|
|
process.env[envVar] = 'true';
|
|
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
|
|
// Should attempt to read boot_id
|
|
const calls = vi.mocked(existsSync).mock.calls;
|
|
const bootIdCalls = calls.filter(call => call[0] === '/proc/sys/kernel/random/boot_id');
|
|
expect(bootIdCalls.length).toBeGreaterThan(0);
|
|
|
|
// Clean up
|
|
delete process.env[envVar];
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('fallback chain execution', () => {
|
|
it('should fallback from boot_id → combined → generic', () => {
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
// All methods fail
|
|
vi.mocked(existsSync).mockReturnValue(false);
|
|
vi.mocked(readFileSync).mockImplementation(() => {
|
|
throw new Error('File not found');
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
manager = TelemetryConfigManager.getInstance();
|
|
const userId = manager.getUserId();
|
|
|
|
// Should still generate a generic Docker ID
|
|
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
|
|
it('should use boot_id if available (highest priority)', () => {
|
|
const mockBootId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
|
process.env.IS_DOCKER = 'true';
|
|
|
|
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return true;
|
|
return true; // All other files available too
|
|
});
|
|
|
|
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return mockBootId;
|
|
if (path === '/proc/cpuinfo') return 'processor: 0\n';
|
|
if (path === '/proc/meminfo') return 'MemTotal: 1000000 kB\n';
|
|
return 'mock data';
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
const manager1 = TelemetryConfigManager.getInstance();
|
|
const userId1 = manager1.getUserId();
|
|
|
|
// Now break boot_id but keep combined signals
|
|
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
if (path === '/proc/sys/kernel/random/boot_id') return false;
|
|
return true;
|
|
});
|
|
|
|
(TelemetryConfigManager as any).instance = null;
|
|
const manager2 = TelemetryConfigManager.getInstance();
|
|
const userId2 = manager2.getUserId();
|
|
|
|
// Different methods should produce different IDs
|
|
expect(userId1).not.toBe(userId2);
|
|
expect(userId1).toMatch(/^[a-f0-9]{16}$/);
|
|
expect(userId2).toMatch(/^[a-f0-9]{16}$/);
|
|
});
|
|
});
|
|
});
|
|
}); |