@joystick.js/db-canary
Version:
JoystickDB - A minimalist database server for the Joystick framework
522 lines (439 loc) • 14.2 kB
JavaScript
import test from 'ava';
import sinon from 'sinon';
import net from 'net';
import { encode as encode_messagepack, decode as decode_messagepack } from 'msgpackr';
import { encode_message } from '../../../src/server/lib/tcp_protocol.js';
import { create_server } from '../../../src/server/index.js';
// Shared server instance
let shared_server = null;
let shared_port = null;
test.before(async () => {
// Set test environment
process.env.NODE_ENV = 'test';
// Create shared server instance
shared_server = await create_server();
shared_port = 0; // Use random available port
// Start server
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Server start timeout'));
}, 5000);
shared_server.listen(shared_port, (error) => {
clearTimeout(timeout);
if (error) {
reject(error);
} else {
shared_port = shared_server.address().port;
resolve();
}
});
});
});
test.after.always(async () => {
// Clean up shared server
if (shared_server) {
try {
if (shared_server.cleanup) {
await shared_server.cleanup();
}
} catch (error) {
// Ignore cleanup errors
}
await new Promise((resolve) => {
const timeout = setTimeout(() => {
try {
shared_server.close();
} catch (error) {
// Ignore errors
}
resolve();
}, 2000);
try {
shared_server.close(() => {
clearTimeout(timeout);
resolve();
});
} catch (error) {
clearTimeout(timeout);
resolve();
}
});
shared_server = null;
}
// Restore sinon mocks
sinon.restore();
// Force garbage collection if available
if (global.gc) {
global.gc();
}
});
const create_client = () => {
return new Promise((resolve, reject) => {
const client = net.createConnection({ port: shared_port }, () => {
// Set socket options for better cleanup
client.setKeepAlive(false);
client.setTimeout(5000);
resolve(client);
});
client.on('error', (error) => {
client.destroy();
reject(error);
});
client.on('timeout', () => {
client.destroy();
reject(new Error('Client connection timeout'));
});
});
};
const send_message = (client, message) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Message timeout'));
}, 5000);
const encoded_message = encode_message(message);
client.once('data', (data) => {
clearTimeout(timeout);
try {
// Parse the MessagePack response with length prefix
if (data.length < 4) {
reject(new Error('Response too short'));
return;
}
const length = data.readUInt32BE(0);
if (data.length < 4 + length) {
reject(new Error('Incomplete response'));
return;
}
const messagepack_data = data.slice(4, 4 + length);
const response = decode_messagepack(messagepack_data);
resolve(response);
} catch (error) {
reject(error);
}
});
client.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});
client.write(encoded_message);
});
};
test('admin backup operations - should handle test_s3_connection', async t => {
const client = await create_client();
try {
// First authenticate
const auth_response = await send_message(client, {
op: 'authentication',
data: { password: 'test-password' }
});
// Skip if authentication fails (expected in test environment)
if (auth_response.ok !== 1) {
t.pass('Authentication failed as expected in test environment');
return;
}
// Test S3 connection
const s3_test_response = await send_message(client, {
op: 'admin',
data: { admin_action: 'test_s3_connection' }
});
// Should fail without proper S3 configuration
t.is(s3_test_response.ok, 0);
t.truthy(s3_test_response.error);
} finally {
try {
client.end();
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
// Ignore cleanup errors
}
try {
client.destroy();
} catch (error) {
// Ignore cleanup errors
}
}
});
test('admin backup operations - should handle backup_now', async t => {
const client = await create_client();
try {
// First authenticate
const auth_response = await send_message(client, {
op: 'authentication',
data: { password: 'test-password' }
});
// Skip if authentication fails (expected in test environment)
if (auth_response.ok !== 1) {
t.pass('Authentication failed as expected in test environment');
return;
}
// Try to create backup
const backup_response = await send_message(client, {
op: 'admin',
data: { admin_action: 'backup_now' }
});
// Should fail without proper S3 configuration
t.is(backup_response.ok, 0);
t.truthy(backup_response.error);
} finally {
try {
client.end();
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
// Ignore cleanup errors
}
try {
client.destroy();
} catch (error) {
// Ignore cleanup errors
}
}
});
test('admin backup operations - should handle list_backups', async t => {
const client = await create_client();
try {
// First authenticate
const auth_response = await send_message(client, {
op: 'authentication',
data: { password: 'test-password' }
});
// Skip if authentication fails (expected in test environment)
if (auth_response.ok !== 1) {
t.pass('Authentication failed as expected in test environment');
return;
}
// List backups
const list_response = await send_message(client, {
op: 'admin',
data: { admin_action: 'list_backups' }
});
// Should fail without proper S3 configuration
t.is(list_response.ok, 0);
t.truthy(list_response.error);
} finally {
try {
client.end();
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
// Ignore cleanup errors
}
try {
client.destroy();
} catch (error) {
// Ignore cleanup errors
}
}
});
test('admin backup operations - should handle restore_backup', async t => {
const client = await create_client();
try {
// First authenticate
const auth_response = await send_message(client, {
op: 'authentication',
data: { password: 'test-password' }
});
// Skip if authentication fails (expected in test environment)
if (auth_response.ok !== 1) {
t.pass('Authentication failed as expected in test environment');
return;
}
// Try to restore backup
const restore_response = await send_message(client, {
op: 'admin',
data: {
admin_action: 'restore_backup',
backup_filename: 'joystickdb-backup-2025-08-31T12-00-00.tar.gz'
}
});
// Should fail without proper S3 configuration
t.is(restore_response.ok, 0);
t.truthy(restore_response.error);
} finally {
try {
client.end();
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
// Ignore cleanup errors
}
try {
client.destroy();
} catch (error) {
// Ignore cleanup errors
}
}
});
test('admin backup operations - should require backup_filename for restore', async t => {
const client = await create_client();
try {
// First authenticate
const auth_response = await send_message(client, {
op: 'authentication',
data: { password: 'test-password' }
});
// Skip if authentication fails (expected in test environment)
if (auth_response.ok !== 1) {
t.pass('Authentication failed as expected in test environment');
return;
}
// Try to restore without backup_filename
const restore_response = await send_message(client, {
op: 'admin',
data: { admin_action: 'restore_backup' }
});
// Should fail due to missing backup_filename
t.is(restore_response.ok, 0);
t.truthy(restore_response.error);
t.regex(restore_response.error, /backup_filename is required/);
} finally {
try {
client.end();
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
// Ignore cleanup errors
}
try {
client.destroy();
} catch (error) {
// Ignore cleanup errors
}
}
});
test('admin backup operations - should handle cleanup_backups', async t => {
const client = await create_client();
try {
// First authenticate
const auth_response = await send_message(client, {
op: 'authentication',
data: { password: 'test-password' }
});
// Skip if authentication fails (expected in test environment)
if (auth_response.ok !== 1) {
t.pass('Authentication failed as expected in test environment');
return;
}
// Cleanup backups
const cleanup_response = await send_message(client, {
op: 'admin',
data: { admin_action: 'cleanup_backups' }
});
// Should fail without proper S3 configuration
t.is(cleanup_response.ok, 0);
t.truthy(cleanup_response.error);
} finally {
try {
client.end();
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
// Ignore cleanup errors
}
try {
client.destroy();
} catch (error) {
// Ignore cleanup errors
}
}
});
test('backup operations - should require authentication', async t => {
// Temporarily set production mode to test authentication requirements
const original_env = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
try {
const client = await create_client();
try {
// Try backup operation without authentication
const backup_response = await send_message(client, {
op: 'admin',
data: { admin_action: 'backup_now' }
});
// Should fail due to lack of authentication
t.is(backup_response.ok, false);
t.truthy(backup_response.error);
// Handle both string and object error formats
const error_message = typeof backup_response.error === 'string'
? backup_response.error
: backup_response.error.message || JSON.stringify(backup_response.error);
t.regex(error_message, /Authentication required|Invalid message format/);
} finally {
try {
client.end();
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
// Ignore cleanup errors
}
try {
client.destroy();
} catch (error) {
// Ignore cleanup errors
}
}
} finally {
process.env.NODE_ENV = original_env;
}
});
test('backup filename validation - should accept valid backup filenames', t => {
const valid_filenames = [
'joystickdb-backup-2025-08-31T12-00-00.tar.gz',
'joystickdb-backup-2025-12-31T23-59-59-999Z.tar.gz',
'joystickdb-backup-2025-01-01T00-00-00-000Z.tar.gz'
];
const filename_pattern = /^joystickdb-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}.*\.tar\.gz$/;
for (const filename of valid_filenames) {
t.regex(filename, filename_pattern, `${filename} should match pattern`);
}
});
test('backup filename validation - should reject invalid backup filenames', t => {
const invalid_filenames = [
'invalid-backup.tar.gz',
'joystickdb-backup-invalid-date.tar.gz',
'joystickdb-backup-2025-08-31.zip',
'backup-2025-08-31T12-00-00.tar.gz'
];
const filename_pattern = /^joystickdb-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}.*\.tar\.gz$/;
for (const filename of invalid_filenames) {
t.notRegex(filename, filename_pattern, `${filename} should not match pattern`);
}
});
test('S3 configuration validation - should validate required fields', t => {
const valid_s3_config = {
bucket: 'test-bucket',
region: 'us-east-1',
access_key: 'AKIA...',
secret_key: 'secret...'
};
// Test that all required fields are present
t.truthy(valid_s3_config.bucket);
t.truthy(valid_s3_config.region);
t.truthy(valid_s3_config.access_key);
t.truthy(valid_s3_config.secret_key);
});
test('backup schedule validation - should accept valid schedules', t => {
const valid_schedules = ['hourly', 'daily', 'weekly'];
for (const schedule of valid_schedules) {
t.true(valid_schedules.includes(schedule));
}
});
test('backup schedule validation - should handle invalid schedules', t => {
const invalid_schedules = ['minutely', 'monthly', 'yearly', 'invalid'];
const valid_schedules = ['hourly', 'daily', 'weekly'];
for (const schedule of invalid_schedules) {
t.false(valid_schedules.includes(schedule));
}
});
test('backup retention calculation - should calculate correct retention periods', t => {
const now = new Date('2025-08-31T12:00:00Z');
const one_hour = 60 * 60 * 1000;
const one_day = 24 * one_hour;
// Test different backup ages
const backup_ages = [
{ age_hours: 1, should_keep_hourly: true, should_keep_daily: false },
{ age_hours: 25, should_keep_hourly: false, should_keep_daily: true },
{ age_hours: 31 * 24, should_keep_hourly: false, should_keep_daily: false }
];
for (const { age_hours, should_keep_hourly, should_keep_daily } of backup_ages) {
const backup_date = new Date(now.getTime() - age_hours * one_hour);
const age_ms = now - backup_date;
const keep_hourly = age_ms <= 24 * one_hour;
const keep_daily = age_ms > 24 * one_hour && age_ms <= 30 * one_day;
t.is(keep_hourly, should_keep_hourly, `${age_hours}h backup hourly retention`);
t.is(keep_daily, should_keep_daily, `${age_hours}h backup daily retention`);
}
});