Initial project documentation and status
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Tests for the adapter factory function.
|
||||
*/
|
||||
|
||||
import { createMeshCentralAdapter } from '../adapter';
|
||||
import { BridgeError } from '../contracts';
|
||||
|
||||
describe('createMeshCentralAdapter', () => {
|
||||
it('should return mock adapter by default', () => {
|
||||
const adapter = createMeshCentralAdapter();
|
||||
expect(adapter.name).toBe('mock');
|
||||
});
|
||||
|
||||
it('should return mock adapter when adapterType is mock', () => {
|
||||
const adapter = createMeshCentralAdapter({ adapterType: 'mock' });
|
||||
expect(adapter.name).toBe('mock');
|
||||
});
|
||||
|
||||
it('should throw for real adapter without credentials', async () => {
|
||||
// Real adapter requires credentials
|
||||
expect(() =>
|
||||
createMeshCentralAdapter({ adapterType: 'real', meshCentralUrl: 'http://test:8080' }),
|
||||
).toThrow(BridgeError);
|
||||
});
|
||||
|
||||
it('should accept custom configuration', () => {
|
||||
const config = {
|
||||
meshCentralUrl: 'http://custom-url:8080',
|
||||
timeoutMs: 5_000,
|
||||
maxOutputLength: 32_768,
|
||||
};
|
||||
|
||||
const adapter = createMeshCentralAdapter(config);
|
||||
expect(adapter.name).toBe('mock');
|
||||
|
||||
// Verify mock adapter receives config (would be used by real adapter)
|
||||
expect(adapter).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Unit tests for audit logging and secret redaction.
|
||||
*/
|
||||
|
||||
import {
|
||||
createAuditEntry,
|
||||
sanitizeForLogging,
|
||||
redactSecrets,
|
||||
isSecurityCritical,
|
||||
AuditAction,
|
||||
AuditStatus,
|
||||
AuditLogEntry,
|
||||
} from '../audit';
|
||||
|
||||
describe('Audit Logging - Redaction', () => {
|
||||
describe('redactSecrets', () => {
|
||||
it('should redact base64-encoded credentials', () => {
|
||||
const input = 'Authorization: Basic dXNlcjpwYXNzd29yZA==';
|
||||
const result = redactSecrets(input);
|
||||
expect(result).not.toContain('dXNlcjpwYXNzd29yZA==');
|
||||
expect(result).toContain('[REDACTED_BASE64]');
|
||||
});
|
||||
|
||||
it('should redact hex-encoded secrets', () => {
|
||||
// Use a 32+ char hex string that doesn't look like base64
|
||||
const input = 'secret: 1234567890abcdef1234567890abcdef';
|
||||
const result = redactSecrets(input);
|
||||
expect(result).not.toContain('1234567890abcdef1234567890abcdef');
|
||||
expect(result).toContain('[REDACTED_HEX]');
|
||||
});
|
||||
|
||||
it('should redact passwords in JSON objects', () => {
|
||||
const input = { username: 'admin', password: 'secret123' };
|
||||
const result = redactSecrets(input);
|
||||
expect(result).not.toContain('secret123');
|
||||
});
|
||||
|
||||
it('should redact sensitive header values', () => {
|
||||
const input = { 'x-meshauth': 'dXNlcjpwYXNzd29yZA==' };
|
||||
const result = redactSecrets(input);
|
||||
expect(result).not.toContain('dXNlcjpwYXNzd29yZA==');
|
||||
});
|
||||
|
||||
it('should preserve non-sensitive data', () => {
|
||||
const input = { username: 'admin', url: 'http://example.com' };
|
||||
const result = redactSecrets(input);
|
||||
expect(result).toContain('admin');
|
||||
expect(result).toContain('http://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeForLogging', () => {
|
||||
it('should redact known secret keys', () => {
|
||||
const input = {
|
||||
username: 'admin',
|
||||
password: 'secret123',
|
||||
token: 'abc123',
|
||||
secret: 'hidden',
|
||||
};
|
||||
const result = sanitizeForLogging(input);
|
||||
expect(result.password).toBe('[REDACTED]');
|
||||
expect(result.token).toBe('[REDACTED]');
|
||||
expect(result.secret).toBe('[REDACTED]');
|
||||
expect(result.username).toBe('admin');
|
||||
});
|
||||
|
||||
it('should handle nested objects', () => {
|
||||
const input = {
|
||||
credentials: {
|
||||
username: 'admin',
|
||||
password: 'secret123',
|
||||
},
|
||||
url: 'http://example.com',
|
||||
};
|
||||
const result = sanitizeForLogging(input);
|
||||
expect((result as { credentials: { password: unknown } }).credentials.password).toBe('[REDACTED]');
|
||||
expect(result.url).toBe('http://example.com');
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const input = [
|
||||
{ username: 'admin1', password: 'secret1' },
|
||||
{ username: 'admin2', password: 'secret2' },
|
||||
];
|
||||
const result = sanitizeForLogging(input);
|
||||
expect((result as Array<{ password: unknown }>)[0].password).toBe('[REDACTED]');
|
||||
expect((result as Array<{ password: unknown }>)[1].password).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('should return primitives unchanged', () => {
|
||||
expect(sanitizeForLogging('string')).toBe('string');
|
||||
expect(sanitizeForLogging(123)).toBe(123);
|
||||
expect(sanitizeForLogging(true)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audit Logging - Entry Creation', () => {
|
||||
it('should create a complete audit entry', () => {
|
||||
const entry = createAuditEntry({
|
||||
action: AuditAction.AUTH_ATTEMPT,
|
||||
status: AuditStatus.SUCCESS,
|
||||
actor: 'admin',
|
||||
resource: 'device-001',
|
||||
details: { reason: 'login', success: true },
|
||||
});
|
||||
|
||||
expect(entry.timestamp).toBeDefined();
|
||||
expect(entry.action).toBe(AuditAction.AUTH_ATTEMPT);
|
||||
expect(entry.status).toBe(AuditStatus.SUCCESS);
|
||||
expect(entry.actor).toBe('admin');
|
||||
expect(entry.resource).toBe('device-001');
|
||||
expect(entry.details).toBeDefined();
|
||||
expect(entry.details?.reason).toBe('login');
|
||||
});
|
||||
|
||||
it('should redact secrets in details', () => {
|
||||
const entry = createAuditEntry({
|
||||
action: AuditAction.AUTH_ATTEMPT,
|
||||
status: AuditStatus.SUCCESS,
|
||||
actor: 'admin',
|
||||
resource: 'device-001',
|
||||
details: {
|
||||
password: 'secret123',
|
||||
username: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
expect(entry.details?.password).toBe('[REDACTED]');
|
||||
expect(entry.details?.username).toBe('admin');
|
||||
});
|
||||
|
||||
it('should use defaults when fields are missing', () => {
|
||||
const entry = createAuditEntry({
|
||||
action: AuditAction.CONFIG_CHANGE,
|
||||
status: AuditStatus.PENDING,
|
||||
});
|
||||
|
||||
expect(entry.actor).toBe('unknown');
|
||||
expect(entry.resource).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audit Logging - Security Critical Actions', () => {
|
||||
it('should identify security-critical actions', () => {
|
||||
expect(isSecurityCritical(AuditAction.AUTH_ATTEMPT)).toBe(true);
|
||||
expect(isSecurityCritical(AuditAction.CREDENTIAL_UPDATE)).toBe(true);
|
||||
expect(isSecurityCritical(AuditAction.CONNECTION_ATTEMPT)).toBe(true);
|
||||
expect(isSecurityCritical(AuditAction.CONFIG_CHANGE)).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify non-security-critical actions', () => {
|
||||
expect(isSecurityCritical(AuditAction.DEVICE_LISTED)).toBe(false);
|
||||
expect(isSecurityCritical(AuditAction.GROUP_LISTED)).toBe(false);
|
||||
expect(isSecurityCritical(AuditAction.SERVICE_START)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audit Logging - Schema Validation', () => {
|
||||
it('should validate a proper audit entry', () => {
|
||||
const validEntry: AuditLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action: AuditAction.AUTH_ATTEMPT,
|
||||
status: AuditStatus.SUCCESS,
|
||||
actor: 'admin',
|
||||
resource: 'device-001',
|
||||
};
|
||||
|
||||
// Import isAuditLogEntry from types if available, or test manually
|
||||
const isValid = validateEntry(validEntry);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid entries', () => {
|
||||
const validTimestamp = new Date().toISOString();
|
||||
|
||||
// Invalid timestamp
|
||||
const invalidEntry1 = {
|
||||
timestamp: 'invalid-date',
|
||||
action: AuditAction.AUTH_ATTEMPT,
|
||||
status: AuditStatus.SUCCESS,
|
||||
};
|
||||
|
||||
const isValid1 = validateEntry(invalidEntry1);
|
||||
expect(isValid1).toBe(false);
|
||||
|
||||
// Invalid action string (not in enum)
|
||||
const invalidEntry2 = {
|
||||
timestamp: validTimestamp,
|
||||
action: 'INVALID_ACTION' as AuditAction,
|
||||
status: AuditStatus.SUCCESS,
|
||||
};
|
||||
|
||||
const isValid2 = validateEntry(invalidEntry2);
|
||||
expect(isValid2).toBe(false);
|
||||
|
||||
// Missing required fields
|
||||
const invalidEntry3 = {
|
||||
timestamp: validTimestamp,
|
||||
};
|
||||
|
||||
const isValid3 = validateEntry(invalidEntry3);
|
||||
expect(isValid3).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to validate an entry against the audit schema.
|
||||
*/
|
||||
function validateEntry(entry: unknown): boolean {
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
// Check required fields
|
||||
if (typeof e.timestamp !== 'string' || Number.isNaN(Date.parse(e.timestamp))) return false;
|
||||
if (!e.action || typeof e.action !== 'string') return false;
|
||||
if (!e.status || typeof e.status !== 'string') return false;
|
||||
|
||||
// Validate action enum
|
||||
const validActions = Object.values(AuditAction);
|
||||
if (!validActions.includes(e.action as AuditAction)) return false;
|
||||
|
||||
// Validate status enum
|
||||
const validStatuses = Object.values(AuditStatus);
|
||||
if (!validStatuses.includes(e.status as AuditStatus)) return false;
|
||||
|
||||
// Actor and resource are optional but must be strings if present
|
||||
if (e.actor !== undefined && typeof e.actor !== 'string') return false;
|
||||
if (e.resource !== undefined && typeof e.resource !== 'string') return false;
|
||||
|
||||
// Details must be an object or undefined
|
||||
if (e.details !== undefined && typeof e.details !== 'object') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Unit tests for bridge contracts (schemas, types, errors).
|
||||
*/
|
||||
|
||||
import { DeviceSchema, DeviceGroupSchema, DiagnosticResultSchema } from '../contracts';
|
||||
|
||||
describe('Contracts - Schemas', () => {
|
||||
describe('DeviceSchema', () => {
|
||||
it('should validate a complete device object', () => {
|
||||
const validDevice = {
|
||||
id: 'dev-001',
|
||||
name: 'test-server',
|
||||
groupId: 'grp-servers',
|
||||
groupName: 'Servers',
|
||||
status: 'online',
|
||||
os: 'Ubuntu 24.04 LTS',
|
||||
uptimeSeconds: 86400,
|
||||
lastSeen: '2026-08-24T10:00:00Z',
|
||||
ipAddresses: ['192.168.1.10'],
|
||||
agentVersion: '2.4.1',
|
||||
};
|
||||
|
||||
const result = DeviceSchema.safeParse(validDevice);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate a minimal device object', () => {
|
||||
const minimalDevice = {
|
||||
id: 'dev-002',
|
||||
name: 'minimal-device',
|
||||
groupId: 'grp-devices',
|
||||
groupName: 'Devices',
|
||||
status: 'offline',
|
||||
};
|
||||
|
||||
const result = DeviceSchema.safeParse(minimalDevice);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid status', () => {
|
||||
const invalidDevice = {
|
||||
id: 'dev-001',
|
||||
name: 'test-server',
|
||||
groupId: 'grp-servers',
|
||||
groupName: 'Servers',
|
||||
status: 'unknown-status' as const,
|
||||
};
|
||||
|
||||
const result = DeviceSchema.safeParse(invalidDevice);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate device status enum values', () => {
|
||||
const statuses = ['online', 'offline', 'unknown'];
|
||||
for (const status of statuses) {
|
||||
const device = {
|
||||
id: `dev-${status}`,
|
||||
name: `device-${status}`,
|
||||
groupId: 'grp-test',
|
||||
groupName: 'Test',
|
||||
status,
|
||||
};
|
||||
const result = DeviceSchema.safeParse(device);
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeviceGroupSchema', () => {
|
||||
it('should validate a complete group object', () => {
|
||||
const validGroup = {
|
||||
id: 'grp-servers',
|
||||
name: 'Servers',
|
||||
description: 'Production servers',
|
||||
deviceCount: 10,
|
||||
};
|
||||
|
||||
const result = DeviceGroupSchema.safeParse(validGroup);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate a minimal group object', () => {
|
||||
const minimalGroup = {
|
||||
id: 'grp-devices',
|
||||
name: 'Devices',
|
||||
deviceCount: 5,
|
||||
};
|
||||
|
||||
const result = DeviceGroupSchema.safeParse(minimalGroup);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiagnosticResultSchema', () => {
|
||||
it('should validate a complete diagnostic result', () => {
|
||||
const validResult = {
|
||||
deviceId: 'dev-001',
|
||||
checkType: 'diskSpace',
|
||||
timestamp: '2026-08-24T10:00:00Z',
|
||||
success: true,
|
||||
output: 'Filesystem Size Used Avail Use% ...\n',
|
||||
exitCode: 0,
|
||||
outputLength: 1234,
|
||||
maxOutputLength: 65_536,
|
||||
};
|
||||
|
||||
const result = DiagnosticResultSchema.safeParse(validResult);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate with truncated flag', () => {
|
||||
const result = DiagnosticResultSchema.safeParse({
|
||||
deviceId: 'dev-001',
|
||||
checkType: 'diskSpace',
|
||||
timestamp: '2026-08-24T10:00:00Z',
|
||||
success: true,
|
||||
output: 'truncated output...',
|
||||
exitCode: 0,
|
||||
outputLength: 65_536,
|
||||
maxOutputLength: 65_536,
|
||||
truncated: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Tests for device status handling.
|
||||
*/
|
||||
|
||||
import { createMockAdapter } from '../index';
|
||||
|
||||
describe('Device Status', () => {
|
||||
let adapter: ReturnType<typeof createMockAdapter>;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = createMockAdapter();
|
||||
});
|
||||
|
||||
it('should include all device statuses in mock data', async () => {
|
||||
const devices = await adapter.listDevices();
|
||||
|
||||
const statuses = devices.map((d: { status: string }) => d.status);
|
||||
expect(statuses).toContain('online');
|
||||
expect(statuses).toContain('offline');
|
||||
|
||||
// Verify status enum values
|
||||
for (const device of devices) {
|
||||
expect(['online', 'offline', 'unknown']).toContain(device.status);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle offline devices correctly', async () => {
|
||||
const devices = await adapter.listDevices();
|
||||
const offlineDevice = devices.find((d: { status: string }) => d.status === 'offline');
|
||||
|
||||
expect(offlineDevice).toBeDefined();
|
||||
|
||||
// Offline devices should still be retrievable
|
||||
if (offlineDevice) {
|
||||
const detailed = await adapter.getDevice(offlineDevice.id);
|
||||
expect(detailed.status).toBe('offline');
|
||||
// Uptime may not be available for offline devices
|
||||
}
|
||||
});
|
||||
|
||||
it('should include lastSeen timestamp', async () => {
|
||||
const devices = await adapter.listDevices();
|
||||
|
||||
for (const device of devices) {
|
||||
expect(device.lastSeen).toBeDefined();
|
||||
// Verify ISO 8601 format
|
||||
expect(Date.parse(device.lastSeen!)).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include IP addresses when available', async () => {
|
||||
const devices = await adapter.listDevices();
|
||||
|
||||
// At least some devices should have IP addresses
|
||||
const devicesWithIPs = devices.filter((d: { ipAddresses?: string[] }) => d.ipAddresses && d.ipAddresses.length > 0);
|
||||
expect(devicesWithIPs.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify IP format
|
||||
for (const device of devicesWithIPs) {
|
||||
if (device.ipAddresses) {
|
||||
for (const ip of device.ipAddresses) {
|
||||
expect(typeof ip).toBe('string');
|
||||
// Basic IP address format validation
|
||||
if (ip.includes(':')) {
|
||||
expect(ip).toMatch(/^[0-9a-fA-F:]+$/); // IPv6
|
||||
} else {
|
||||
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/); // IPv4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"id": "dev-001",
|
||||
"name": "home-server",
|
||||
"groupId": "grp-servers",
|
||||
"groupName": "Servers",
|
||||
"status": "online",
|
||||
"os": "Ubuntu 24.04 LTS",
|
||||
"uptimeSeconds": 1296000,
|
||||
"lastSeen": "2026-08-24T21:30:00Z",
|
||||
"ipAddresses": ["192.168.1.10", "fd00::1"],
|
||||
"agentVersion": "2.4.1"
|
||||
},
|
||||
{
|
||||
"id": "dev-002",
|
||||
"name": "proxmox-prod",
|
||||
"groupId": "grp-servers",
|
||||
"groupName": "Servers",
|
||||
"status": "online",
|
||||
"os": "Proxmox VE 8.2",
|
||||
"uptimeSeconds": 259200,
|
||||
"lastSeen": "2026-08-24T21:28:00Z",
|
||||
"ipAddresses": ["192.168.1.13", "fd00::2"],
|
||||
"agentVersion": "2.4.1"
|
||||
},
|
||||
{
|
||||
"id": "dev-003",
|
||||
"name": "test-lab-client",
|
||||
"groupId": "grp-workstations",
|
||||
"groupName": "Workstations",
|
||||
"status": "offline",
|
||||
"os": "Windows 11 Pro",
|
||||
"lastSeen": "2026-08-23T21:30:00Z",
|
||||
"ipAddresses": ["192.168.1.50"],
|
||||
"agentVersion": "2.4.1"
|
||||
},
|
||||
{
|
||||
"id": "dev-004",
|
||||
"name": "raspberry-pi-01",
|
||||
"groupId": "grp-devices",
|
||||
"groupName": "IoT Devices",
|
||||
"status": "online",
|
||||
"os": "Raspberry Pi OS 12 (Bookworm)",
|
||||
"uptimeSeconds": 604800,
|
||||
"lastSeen": "2026-08-24T21:35:00Z",
|
||||
"ipAddresses": ["192.168.1.100"],
|
||||
"agentVersion": "2.4.1"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "grp-servers",
|
||||
"name": "Servers",
|
||||
"description": "Production and infrastructure servers",
|
||||
"deviceCount": 2
|
||||
},
|
||||
{
|
||||
"id": "grp-workstations",
|
||||
"name": "Workstations",
|
||||
"description": "User workstations and laptops",
|
||||
"deviceCount": 1
|
||||
},
|
||||
{
|
||||
"id": "grp-devices",
|
||||
"name": "IoT Devices",
|
||||
"description": "Raspberry Pi and other IoT devices",
|
||||
"deviceCount": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Tests for group filtering functionality.
|
||||
*/
|
||||
|
||||
import { createMockAdapter } from '../mock-adapter';
|
||||
|
||||
describe('Group Filtering', () => {
|
||||
let adapter: ReturnType<typeof createMockAdapter>;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = createMockAdapter();
|
||||
});
|
||||
|
||||
it('should list groups with correct device counts', async () => {
|
||||
const groups = await adapter.listGroups();
|
||||
|
||||
expect(groups).toHaveLength(3);
|
||||
|
||||
// Verify device counts match actual devices in each group
|
||||
for (const group of groups) {
|
||||
const devices = await adapter.listDevices({ groupId: group.id });
|
||||
expect(devices.length).toBe(group.deviceCount);
|
||||
}
|
||||
});
|
||||
|
||||
it('should filter devices by group ID', async () => {
|
||||
const groups = await adapter.listGroups();
|
||||
const serversGroup = groups.find((g) => g.name === 'Servers');
|
||||
|
||||
expect(serversGroup).toBeDefined();
|
||||
|
||||
if (serversGroup) {
|
||||
const devices = await adapter.listDevices({ groupId: serversGroup.id });
|
||||
expect(devices).toHaveLength(2);
|
||||
expect(devices.every((d) => d.groupId === serversGroup.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent group', async () => {
|
||||
const devices = await adapter.listDevices({ groupId: 'nonexistent' });
|
||||
expect(devices).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include group name in device objects', async () => {
|
||||
const devices = await adapter.listDevices();
|
||||
|
||||
for (const device of devices) {
|
||||
expect(device.groupName).toBeDefined();
|
||||
expect(typeof device.groupName).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Integration tests that validate the full bridge workflow.
|
||||
*/
|
||||
|
||||
import { createMeshCentralAdapter, DiagnosticCheckType } from '../index';
|
||||
|
||||
describe('Bridge Integration', () => {
|
||||
let adapter: ReturnType<typeof createMeshCentralAdapter>;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = createMeshCentralAdapter({ adapterType: 'mock' });
|
||||
});
|
||||
|
||||
describe('Device Discovery Workflow', () => {
|
||||
it('should list all devices and groups, then fetch device details', async () => {
|
||||
// Step 1: List all devices
|
||||
const allDevices = await adapter.listDevices();
|
||||
expect(allDevices).toHaveLength(4);
|
||||
|
||||
// Step 2: Get a specific device
|
||||
const serverDevice = allDevices.find((d) => d.groupId === 'grp-servers');
|
||||
if (!serverDevice) {
|
||||
throw new Error('No server device found for testing');
|
||||
}
|
||||
|
||||
const detailedDevice = await adapter.getDevice(serverDevice.id);
|
||||
expect(detailedDevice.name).toBe(serverDevice.name);
|
||||
|
||||
// Step 3: Verify device belongs to expected group
|
||||
expect(detailedDevice.groupId).toBe('grp-servers');
|
||||
|
||||
const group = await adapter.getGroup(detailedDevice.groupId);
|
||||
expect(group.name).toBe('Servers');
|
||||
});
|
||||
|
||||
it('should handle offline devices gracefully', async () => {
|
||||
const allDevices = await adapter.listDevices();
|
||||
const offlineDevices = allDevices.filter((d: { status: string }) => d.status === 'offline');
|
||||
|
||||
expect(offlineDevices).toHaveLength(1);
|
||||
const offlineDevice = offlineDevices[0];
|
||||
|
||||
// Offline devices should still be retrievable
|
||||
const detailed = await adapter.getDevice(offlineDevice.id);
|
||||
expect(detailed.status).toBe('offline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Diagnostics Workflow', () => {
|
||||
it('should run multiple diagnostic checks on a device', async () => {
|
||||
const testDeviceId = 'dev-001';
|
||||
|
||||
const checks: DiagnosticCheckType[] = [
|
||||
DiagnosticCheckType.DiskSpace,
|
||||
DiagnosticCheckType.OSDetails,
|
||||
DiagnosticCheckType.Uptime,
|
||||
];
|
||||
|
||||
for (const checkType of checks) {
|
||||
const result = await adapter.runDiagnostic({
|
||||
deviceId: testDeviceId,
|
||||
checkType,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle diagnostics on offline devices', async () => {
|
||||
const offlineDevice = await adapter.getDevice('dev-003');
|
||||
expect(offlineDevice.status).toBe('offline');
|
||||
|
||||
// Diagnostics should still run (mock returns consistent data)
|
||||
const result = await adapter.runDiagnostic({
|
||||
deviceId: offlineDevice.id,
|
||||
checkType: DiagnosticCheckType.OSDetails,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should provide clear error messages for missing resources', async () => {
|
||||
await expect(adapter.getDevice('nonexistent-device'))
|
||||
.rejects
|
||||
.toThrow('Device not found: nonexistent-device');
|
||||
|
||||
await expect(adapter.getGroup('nonexistent-group'))
|
||||
.rejects
|
||||
.toThrow('Group not found: nonexistent-group');
|
||||
});
|
||||
|
||||
it('should include error codes in exceptions', async () => {
|
||||
try {
|
||||
await adapter.getDevice('nonexistent');
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((error as any).code).toBe('RESOURCE_NOT_FOUND');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Health Check', () => {
|
||||
it('should report adapter health status', async () => {
|
||||
const healthy = await adapter.healthCheck();
|
||||
expect(healthy).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Limits', () => {
|
||||
it('should truncate large output when maxOutputLength is specified', async () => {
|
||||
const result = await adapter.runDiagnostic({
|
||||
deviceId: 'dev-001',
|
||||
checkType: DiagnosticCheckType.DiskSpace,
|
||||
maxOutputLength: 100,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.output.length).toBeLessThan(200); // 100 + truncation marker
|
||||
});
|
||||
|
||||
it('should report output length and truncation status', async () => {
|
||||
const result = await adapter.runDiagnostic({
|
||||
deviceId: 'dev-001',
|
||||
checkType: DiagnosticCheckType.DiskSpace,
|
||||
maxOutputLength: 500,
|
||||
});
|
||||
|
||||
expect(result.outputLength).toBeGreaterThan(0);
|
||||
expect(result.maxOutputLength).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
describe('live read-only test safety contract', () => {
|
||||
it('does not expose a general-purpose command or diagnostic action', async () => {
|
||||
const source = await import('node:fs').then(({ readFileSync }) =>
|
||||
readFileSync(require.resolve('../live-readonly-test'), 'utf8'));
|
||||
expect(source).toContain("MeshCtrlAction.ServerVersion");
|
||||
expect(source).toContain("MeshCtrlAction.Meshes");
|
||||
expect(source).toContain("MeshCtrlAction.Nodes");
|
||||
expect(source).toContain("MeshCtrlAction.DeviceInfo");
|
||||
expect(source).toContain("nodeid: approvedDevice.id");
|
||||
expect(source).not.toContain('runcommand');
|
||||
expect(source).not.toContain('diagnostic');
|
||||
expect(source).not.toContain('process.argv');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Unit tests for bounded request concurrency and rate limiting in MeshCtrlClient.
|
||||
*/
|
||||
|
||||
import { TokenBucket, ConcurrencyLimiter } from '../meshctrl-client';
|
||||
|
||||
describe('ConcurrencyLimiter safe defaults', () => {
|
||||
it('should have a safe default of 5 max concurrent requests', () => {
|
||||
const limiter = new ConcurrencyLimiter();
|
||||
expect(limiter.getMaxPending()).toBe(5);
|
||||
});
|
||||
|
||||
it('should allow entry up to the safe default', () => {
|
||||
const limiter = new ConcurrencyLimiter();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(limiter.tryEnter()).toBe(true);
|
||||
}
|
||||
// 6th should fail
|
||||
expect(limiter.tryEnter()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TokenBucket rate limiter', () => {
|
||||
describe('acquire()', () => {
|
||||
it('should consume tokens and return true when available', () => {
|
||||
const bucket = new TokenBucket(5, 0.01, 30_000); // Minimum valid refill rate
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no tokens are available', () => {
|
||||
const bucket = new TokenBucket(2, 0.01, 30_000); // Minimum valid refill rate
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
// Force reset to ensure no tokens left
|
||||
(bucket as unknown as { tokens: number }).tokens = 0;
|
||||
expect(bucket.acquire()).toBe(false); // No tokens left
|
||||
});
|
||||
|
||||
it('should refill tokens over time based on rate', async () => {
|
||||
// Create bucket with 2 tokens, refill at 1 token/second
|
||||
const bucket = new TokenBucket(2, 1, 30_000);
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
expect(bucket.acquire()).toBe(false);
|
||||
|
||||
// Wait 1.5 seconds for ~1.5 tokens to refill
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// Should be able to acquire again
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect maxTokens limit on refill', async () => {
|
||||
const bucket = new TokenBucket(3, 0.01, 30_000); // Minimum valid refill rate
|
||||
// Consume all tokens
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
|
||||
// Force set tokens to 0 and lastRefill to now to prevent refilling during test
|
||||
(bucket as unknown as { tokens: number; lastRefill: number }).tokens = 0;
|
||||
(bucket as unknown as { tokens: number; lastRefill: number }).lastRefill = Date.now();
|
||||
|
||||
expect(bucket.acquire()).toBe(false); // No tokens left
|
||||
|
||||
// Wait for more than enough time (50ms -> 0.05 tokens at 0.01/sec, still < 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should not have gained any tokens due to very slow refill rate
|
||||
expect(bucket.acquire()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireWithWait()', () => {
|
||||
it('should resolve immediately when tokens are available', async () => {
|
||||
const bucket = new TokenBucket(5, 10, 30_000);
|
||||
const start = Date.now();
|
||||
await bucket.acquireWithWait();
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(10); // Should be nearly instant
|
||||
});
|
||||
|
||||
it('should wait for tokens and resolve when available', async () => {
|
||||
const bucket = new TokenBucket(1, 10, 30_000); // 1 token, refill at 10/sec
|
||||
bucket.acquire(); // Consume the only token
|
||||
|
||||
const start = Date.now();
|
||||
await bucket.acquireWithWait(); // Should wait ~100ms for refill
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeGreaterThanOrEqual(50); // Allow some timing variance
|
||||
expect(elapsed).toBeLessThan(300);
|
||||
});
|
||||
|
||||
it('should reject on timeout', async () => {
|
||||
const bucket = new TokenBucket(1, 1, 100); // 1 token, refill at 1/sec, timeout after 100ms
|
||||
bucket.acquire(); // Consume the only token
|
||||
|
||||
await expect(bucket.acquireWithWait()).rejects.toThrow(/Rate limit wait timeout/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with MeshCtrlClient options', () => {
|
||||
it('should accept rate limit configuration via options', async () => {
|
||||
// Test that the types are properly exported and usable
|
||||
const config = {
|
||||
maxRequestsPerSecond: 5,
|
||||
burstCapacity: 3,
|
||||
rateLimitTimeoutMs: 5000,
|
||||
};
|
||||
expect(config.maxRequestsPerSecond).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConcurrencyLimiter semaphore', () => {
|
||||
describe('tryEnter()', () => {
|
||||
it('should allow entry up to maxPending', () => {
|
||||
const limiter = new ConcurrencyLimiter(2);
|
||||
expect(limiter.tryEnter()).toBe(true);
|
||||
expect(limiter.tryEnter()).toBe(true);
|
||||
expect(limiter.tryEnter()).toBe(false); // At capacity
|
||||
});
|
||||
|
||||
it('should track pending count correctly', () => {
|
||||
const limiter = new ConcurrencyLimiter(3);
|
||||
expect(limiter.tryEnter()).toBe(true);
|
||||
expect(limiter.pendingCount).toBe(1);
|
||||
expect(limiter.tryEnter()).toBe(true);
|
||||
expect(limiter.pendingCount).toBe(2);
|
||||
limiter.exit();
|
||||
expect(limiter.pendingCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enter() and exit()', () => {
|
||||
it('should block until slot is available', async () => {
|
||||
const limiter = new ConcurrencyLimiter(1);
|
||||
await limiter.enter();
|
||||
|
||||
let entered = false;
|
||||
const enterPromise = limiter.enter().then(() => { entered = true; });
|
||||
|
||||
// Should not have entered yet
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(entered).toBe(false);
|
||||
|
||||
// Exit to allow the waiting caller
|
||||
limiter.exit();
|
||||
await enterPromise;
|
||||
expect(entered).toBe(true);
|
||||
});
|
||||
|
||||
it('should properly exit slots', () => {
|
||||
const limiter = new ConcurrencyLimiter(2);
|
||||
expect(limiter.tryEnter()).toBe(true);
|
||||
expect(limiter.tryEnter()).toBe(true);
|
||||
limiter.exit();
|
||||
expect(limiter.pendingCount).toBe(1);
|
||||
limiter.exit();
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with MeshCtrlClient options', () => {
|
||||
it('should accept concurrency configuration via options', async () => {
|
||||
const config = {
|
||||
maxConcurrentRequests: 10,
|
||||
};
|
||||
expect(config.maxConcurrentRequests).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TokenBucket + ConcurrencyLimiter combined', () => {
|
||||
it('should work together for rate limiting and concurrency control', async () => {
|
||||
const bucket = new TokenBucket(2, 10, 30_000);
|
||||
const limiter = new ConcurrencyLimiter(2);
|
||||
|
||||
// Both should allow 2 initial operations
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
expect(limiter.tryEnter()).toBe(true);
|
||||
expect(bucket.acquire()).toBe(true);
|
||||
expect(limiter.tryEnter()).toBe(true);
|
||||
|
||||
// Both should be at capacity
|
||||
expect(bucket.acquire()).toBe(false);
|
||||
expect(limiter.tryEnter()).toBe(false);
|
||||
|
||||
// Exit one from each
|
||||
limiter.exit();
|
||||
expect(limiter.tryEnter()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrency with async enter/exit', () => {
|
||||
it('should properly serialize concurrent requests when using async enter', async () => {
|
||||
const limiter = new ConcurrencyLimiter(2);
|
||||
let concurrentCount = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
const simulateRequest = async (_id: number): Promise<void> => {
|
||||
await limiter.enter();
|
||||
concurrentCount++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
|
||||
|
||||
// Simulate some async work
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
concurrentCount--;
|
||||
limiter.exit();
|
||||
};
|
||||
|
||||
// Launch 5 concurrent requests
|
||||
const promises = Array.from({ length: 5 }, (_, i) => simulateRequest(i));
|
||||
await Promise.all(promises);
|
||||
|
||||
// Should never exceed maxPending (2)
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should handle rapid enter/exit cycles without deadlock', async () => {
|
||||
const limiter = new ConcurrencyLimiter(3);
|
||||
let completed = 0;
|
||||
|
||||
const processItem = async (_id: number): Promise<void> => {
|
||||
await limiter.enter();
|
||||
try {
|
||||
// Simulate quick work
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
completed++;
|
||||
} finally {
|
||||
limiter.exit();
|
||||
}
|
||||
};
|
||||
|
||||
// Process 10 items with limited concurrency
|
||||
const promises = Array.from({ length: 10 }, (_, i) => processItem(i));
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(completed).toBe(10);
|
||||
expect(limiter.pendingCount).toBe(0); // All slots released
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
MeshCtrlAction,
|
||||
encodeAuthHeader,
|
||||
parseResponse,
|
||||
sanitizeCredentials,
|
||||
validateResponseForAction,
|
||||
} from '../meshctrl-client';
|
||||
|
||||
describe('MeshCtrl client protocol validation', () => {
|
||||
it('encodes valid credentials without exposing them in the returned metadata', () => {
|
||||
expect(encodeAuthHeader({ username: 'bridge-user', password: 'strong-pass' })).toBe(
|
||||
`${Buffer.from('bridge-user').toString('base64')},${Buffer.from('strong-pass').toString('base64')}`,
|
||||
);
|
||||
expect(encodeAuthHeader({ username: 'bridge-user', password: 'strong-pass', totpToken: '123456' })).toBe(
|
||||
`${Buffer.from('bridge-user').toString('base64')},${Buffer.from('strong-pass').toString('base64')},${Buffer.from('123456').toString('base64')}`,
|
||||
);
|
||||
expect(sanitizeCredentials({ username: 'bridge-user', password: 'strong-pass' })).toEqual({
|
||||
hasCredentials: true,
|
||||
redacted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed credentials', () => {
|
||||
expect(() => encodeAuthHeader({ username: 'bad user', password: 'strong-pass' })).toThrow();
|
||||
expect(() => encodeAuthHeader({ username: 'bridge-user', password: 'short' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects malformed and oversized protocol responses', () => {
|
||||
expect(() => parseResponse('{"id":1}')).toThrow(/protocol validation/);
|
||||
expect(() => parseResponse('x'.repeat(20), 10)).toThrow(/size limit/);
|
||||
});
|
||||
|
||||
it('validates the response shape and action', () => {
|
||||
const response = parseResponse(JSON.stringify({
|
||||
action: MeshCtrlAction.ServerVersion,
|
||||
id: 1,
|
||||
ver: '1.2.5',
|
||||
}));
|
||||
expect(validateResponseForAction(response, MeshCtrlAction.ServerVersion)).toEqual(response);
|
||||
expect(() => validateResponseForAction(response, MeshCtrlAction.Nodes)).toThrow(/action/);
|
||||
expect(() => validateResponseForAction(parseResponse(JSON.stringify({
|
||||
action: MeshCtrlAction.Nodes,
|
||||
id: 1,
|
||||
nodes: [{ id: 'n1', name: 'node', meshid: 'm1', online: 'yes' }],
|
||||
})), MeshCtrlAction.Nodes)).toThrow(/schema/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Unit tests for the mock adapter.
|
||||
*/
|
||||
|
||||
import { createMockAdapter, DiagnosticCheckType } from '../index';
|
||||
|
||||
describe('MockAdapter', () => {
|
||||
let adapter: ReturnType<typeof createMockAdapter>;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = createMockAdapter();
|
||||
});
|
||||
|
||||
describe('listDevices', () => {
|
||||
it('should return all devices when no filter provided', async () => {
|
||||
const devices = await adapter.listDevices();
|
||||
expect(devices).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should filter by group ID', async () => {
|
||||
const devices = await adapter.listDevices({ groupId: 'grp-servers' });
|
||||
expect(devices).toHaveLength(2);
|
||||
expect(devices.every((d: { groupId: string }) => d.groupId === 'grp-servers')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent group', async () => {
|
||||
const devices = await adapter.listDevices({ groupId: 'nonexistent' });
|
||||
expect(devices).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include expected device properties', async () => {
|
||||
const devices = await adapter.listDevices();
|
||||
const device = devices[0];
|
||||
|
||||
expect(device).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: expect.any(String),
|
||||
groupId: expect.any(String),
|
||||
groupName: expect.any(String),
|
||||
status: expect.stringMatching(/online|offline|unknown/),
|
||||
});
|
||||
|
||||
// Properties that may be undefined for offline devices
|
||||
expect(device.os).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDevice', () => {
|
||||
it('should return a specific device by ID', async () => {
|
||||
const device = await adapter.getDevice('dev-001');
|
||||
expect(device.id).toBe('dev-001');
|
||||
expect(device.name).toBe('home-server');
|
||||
});
|
||||
|
||||
it('should throw error for non-existent device', async () => {
|
||||
await expect(adapter.getDevice('nonexistent')).rejects.toThrow('Device not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listGroups', () => {
|
||||
it('should return all groups', async () => {
|
||||
const groups = await adapter.listGroups();
|
||||
expect(groups).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should include expected group properties', async () => {
|
||||
const groups = await adapter.listGroups();
|
||||
const group = groups[0];
|
||||
|
||||
expect(group).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: expect.any(String),
|
||||
deviceCount: expect.any(Number),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroup', () => {
|
||||
it('should return a specific group by ID', async () => {
|
||||
const group = await adapter.getGroup('grp-servers');
|
||||
expect(group.id).toBe('grp-servers');
|
||||
expect(group.name).toBe('Servers');
|
||||
});
|
||||
|
||||
it('should throw error for non-existent group', async () => {
|
||||
await expect(adapter.getGroup('nonexistent')).rejects.toThrow('Group not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runDiagnostic', () => {
|
||||
it('should return diagnostic result for diskSpace check', async () => {
|
||||
const result = await adapter.runDiagnostic({
|
||||
deviceId: 'dev-001',
|
||||
checkType: DiagnosticCheckType.DiskSpace,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
deviceId: 'dev-001',
|
||||
checkType: DiagnosticCheckType.DiskSpace,
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
outputLength: expect.any(Number),
|
||||
});
|
||||
|
||||
expect(result.output).toContain('Filesystem');
|
||||
});
|
||||
|
||||
it('should support all diagnostic check types', async () => {
|
||||
const checkTypes = [
|
||||
DiagnosticCheckType.DiskSpace,
|
||||
DiagnosticCheckType.Services,
|
||||
DiagnosticCheckType.Processes,
|
||||
DiagnosticCheckType.OSDetails,
|
||||
DiagnosticCheckType.NetworkState,
|
||||
DiagnosticCheckType.Uptime,
|
||||
];
|
||||
|
||||
for (const checkType of checkTypes) {
|
||||
const result = await adapter.runDiagnostic({
|
||||
deviceId: 'dev-001',
|
||||
checkType,
|
||||
});
|
||||
|
||||
expect(result.checkType).toBe(checkType);
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should truncate output when maxOutputLength is exceeded', async () => {
|
||||
const result = await adapter.runDiagnostic({
|
||||
deviceId: 'dev-001',
|
||||
checkType: DiagnosticCheckType.DiskSpace,
|
||||
maxOutputLength: 50,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.output.length).toBeLessThan(200); // 50 + truncation marker
|
||||
});
|
||||
|
||||
it('should respect timeoutMs parameter', async () => {
|
||||
const startTime = Date.now();
|
||||
await adapter.runDiagnostic({
|
||||
deviceId: 'dev-001',
|
||||
checkType: DiagnosticCheckType.DiskSpace,
|
||||
timeoutMs: 50,
|
||||
});
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Should complete within timeout + small buffer
|
||||
expect(elapsed).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('healthCheck', () => {
|
||||
it('should return true for mock adapter', async () => {
|
||||
const healthy = await adapter.healthCheck();
|
||||
expect(healthy).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adapter metadata', () => {
|
||||
it('should have name and version properties', () => {
|
||||
expect(adapter.name).toBe('mock');
|
||||
expect(adapter.version).toBe('0.1.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Tests for the mock data loader.
|
||||
*/
|
||||
|
||||
import { loadTestDeviceData } from './mock-data-loader';
|
||||
|
||||
describe('MockDataLoader', () => {
|
||||
it('should load test device data from JSON file', () => {
|
||||
const data = loadTestDeviceData();
|
||||
|
||||
expect(Array.isArray(data.devices)).toBe(true);
|
||||
expect(Array.isArray(data.groups)).toBe(true);
|
||||
expect(data.devices.length).toBeGreaterThan(0);
|
||||
expect(data.groups.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should load valid device structures', () => {
|
||||
const data = loadTestDeviceData();
|
||||
|
||||
for (const device of data.devices) {
|
||||
expect(typeof device.id).toBe('string');
|
||||
expect(typeof device.name).toBe('string');
|
||||
expect(typeof device.groupId).toBe('string');
|
||||
expect(typeof device.groupName).toBe('string');
|
||||
expect(['online', 'offline', 'unknown']).toContain(device.status);
|
||||
}
|
||||
});
|
||||
|
||||
it('should load valid group structures', () => {
|
||||
const data = loadTestDeviceData();
|
||||
|
||||
for (const group of data.groups) {
|
||||
expect(typeof group.id).toBe('string');
|
||||
expect(typeof group.name).toBe('string');
|
||||
expect(typeof group.deviceCount).toBe('number');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Test data loader for consistent test fixtures.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface TestDeviceData {
|
||||
devices: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
os?: string;
|
||||
uptimeSeconds?: number;
|
||||
lastSeen?: string;
|
||||
ipAddresses?: string[];
|
||||
agentVersion?: string;
|
||||
}>;
|
||||
groups: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
deviceCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function loadTestDeviceData(): TestDeviceData {
|
||||
const dataPath = join(__dirname, 'example-device-data.json');
|
||||
const content = readFileSync(dataPath, 'utf-8');
|
||||
return JSON.parse(content) as TestDeviceData;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Jest setup file.
|
||||
*/
|
||||
|
||||
import 'jest-extended';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace jest {
|
||||
interface Expect {
|
||||
toBeArray(): void;
|
||||
toBeString(): void;
|
||||
toBeNumber(): void;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom matchers
|
||||
expect.extend({
|
||||
toBeArray(received: unknown) {
|
||||
const pass = Array.isArray(received);
|
||||
return {
|
||||
message: () => `expected ${received} to be an array`,
|
||||
pass,
|
||||
};
|
||||
},
|
||||
toBeString(received: unknown) {
|
||||
const pass = typeof received === 'string';
|
||||
return {
|
||||
message: () => `expected ${received} to be a string`,
|
||||
pass,
|
||||
};
|
||||
},
|
||||
toBeNumber(received: unknown) {
|
||||
const pass = typeof received === 'number';
|
||||
return {
|
||||
message: () => `expected ${received} to be a number`,
|
||||
pass,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Tests for timeout and output limit handling.
|
||||
*/
|
||||
|
||||
import { createMockAdapter, DiagnosticCheckType } from '../index';
|
||||
|
||||
describe('Timeout and Output Limits', () => {
|
||||
it('should respect timeoutMs parameter', async () => {
|
||||
const adapter = createMockAdapter({ timeoutMs: 100 });
|
||||
|
||||
const startTime = Date.now();
|
||||
await adapter.runDiagnostic({
|
||||
deviceId: 'dev-001',
|
||||
checkType: DiagnosticCheckType.DiskSpace,
|
||||
timeoutMs: 50,
|
||||
});
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
expect(elapsed).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should truncate output when maxOutputLength is small', async () => {
|
||||
const adapter = createMockAdapter({ maxOutputLength: 100 });
|
||||
|
||||
const result = await adapter.runDiagnostic({
|
||||
deviceId: 'dev-001',
|
||||
checkType: DiagnosticCheckType.DiskSpace,
|
||||
maxOutputLength: 50,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.output.length).toBeLessThan(200); // 50 + truncation marker
|
||||
});
|
||||
|
||||
it('should report maxOutputLength in result', async () => {
|
||||
const adapter = createMockAdapter({ maxOutputLength: 65_536 });
|
||||
|
||||
const result = await adapter.runDiagnostic({
|
||||
deviceId: 'dev-001',
|
||||
checkType: DiagnosticCheckType.DiskSpace,
|
||||
});
|
||||
|
||||
expect(result.maxOutputLength).toBe(65_536);
|
||||
});
|
||||
|
||||
it('should handle zero timeout gracefully', async () => {
|
||||
const adapter = createMockAdapter({ timeoutMs: 1000 });
|
||||
|
||||
// Zero or negative timeout should not cause errors
|
||||
const result = await adapter.runDiagnostic({
|
||||
deviceId: 'dev-001',
|
||||
checkType: DiagnosticCheckType.DiskSpace,
|
||||
timeoutMs: 0,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user