Initial project documentation and status

This commit is contained in:
Clawdia
2026-08-30 14:46:23 -04:00
commit 765f372c72
50 changed files with 12916 additions and 0 deletions
@@ -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();
});
});
+238
View File
@@ -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;
}
+41
View File
@@ -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);
});
});