@joystick.js/db-canary
Version:
JoystickDB - A minimalist database server for the Joystick framework
415 lines (330 loc) • 13.3 kB
JavaScript
import test from 'ava';
import fs from 'fs';
import bcrypt from 'bcrypt';
import {
create_recovery_token,
is_token_valid,
record_failed_recovery_attempt,
change_password,
get_recovery_status,
initialize_recovery_manager,
reset_recovery_state
} from '../../../src/server/lib/recovery_manager.js';
const RECOVERY_TOKEN_FILE = './recovery_token.json';
test.beforeEach(() => {
// Clean up any existing files and environment variables
reset_recovery_state();
if (fs.existsSync(RECOVERY_TOKEN_FILE)) {
fs.unlinkSync(RECOVERY_TOKEN_FILE);
}
delete process.env.JOYSTICK_DB_SETTINGS;
});
test.afterEach(() => {
// Clean up files and environment variables after each test
reset_recovery_state();
if (fs.existsSync(RECOVERY_TOKEN_FILE)) {
fs.unlinkSync(RECOVERY_TOKEN_FILE);
}
delete process.env.JOYSTICK_DB_SETTINGS;
});
test('create_recovery_token generates valid token with expiration', (t) => {
const recovery_info = create_recovery_token();
t.true(typeof recovery_info.token === 'string');
t.true(recovery_info.token.length > 0);
t.true(typeof recovery_info.expires_at === 'number');
t.true(recovery_info.expires_at > Date.now());
t.true(recovery_info.url.includes(recovery_info.token));
// Verify token file was created
t.true(fs.existsSync(RECOVERY_TOKEN_FILE));
const token_data = JSON.parse(fs.readFileSync(RECOVERY_TOKEN_FILE, 'utf8'));
t.is(token_data.token, recovery_info.token);
t.is(token_data.expires_at, recovery_info.expires_at);
});
test('create_recovery_token sets 10 minute expiration', (t) => {
const before_creation = Date.now();
const recovery_info = create_recovery_token();
const after_creation = Date.now();
const expected_min_expiry = before_creation + (10 * 60 * 1000);
const expected_max_expiry = after_creation + (10 * 60 * 1000);
t.true(recovery_info.expires_at >= expected_min_expiry);
t.true(recovery_info.expires_at <= expected_max_expiry);
});
test('is_token_valid returns true for valid token', (t) => {
const recovery_info = create_recovery_token();
const validation = is_token_valid(recovery_info.token);
t.true(validation.valid);
});
test('is_token_valid returns false for invalid token', (t) => {
create_recovery_token();
const validation = is_token_valid('invalid-token');
t.false(validation.valid);
t.is(validation.reason, 'invalid');
});
test('is_token_valid returns false when no token exists', (t) => {
const validation = is_token_valid('any-token');
t.false(validation.valid);
t.is(validation.reason, 'no_token');
});
test('is_token_valid returns false for expired token', (t) => {
const recovery_info = create_recovery_token();
// Manually expire the token by modifying the file
const token_data = JSON.parse(fs.readFileSync(RECOVERY_TOKEN_FILE, 'utf8'));
token_data.expires_at = Date.now() - 1000; // 1 second ago
fs.writeFileSync(RECOVERY_TOKEN_FILE, JSON.stringify(token_data, null, 2));
// Reload state to pick up the expired token (but don't initialize, which would clean it up)
// Instead, directly test the validation which will detect expiration and clean up
const validation = is_token_valid(recovery_info.token);
t.false(validation.valid);
t.is(validation.reason, 'expired');
});
test('record_failed_recovery_attempt increments counter', (t) => {
create_recovery_token();
record_failed_recovery_attempt('192.168.1.100');
const status = get_recovery_status();
t.is(status.failed_attempts, 1);
});
test('record_failed_recovery_attempt locks after max attempts', (t) => {
create_recovery_token();
// Make 3 failed attempts (the maximum)
for (let i = 0; i < 3; i++) {
record_failed_recovery_attempt('192.168.1.100');
}
const status = get_recovery_status();
t.is(status.failed_attempts, 3);
t.true(status.is_locked);
t.true(typeof status.locked_until === 'number');
t.true(status.locked_until > Date.now());
});
test('is_token_valid returns locked when recovery is locked', (t) => {
const recovery_info = create_recovery_token();
// Lock the recovery by making too many failed attempts
for (let i = 0; i < 3; i++) {
record_failed_recovery_attempt('192.168.1.100');
}
const validation = is_token_valid(recovery_info.token);
t.false(validation.valid);
t.is(validation.reason, 'locked');
});
test('change_password validates password strength', async (t) => {
// Create environment variable with authentication
const settings_data = {
authentication: {
password_hash: await bcrypt.hash('old_password', 12),
created_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
failed_attempts: {},
rate_limits: {}
}
};
process.env.JOYSTICK_DB_SETTINGS = JSON.stringify(settings_data);
// Test short password
const error = await t.throwsAsync(async () => {
await change_password('short', '192.168.1.100');
});
t.is(error.message, 'Password must be at least 12 characters long');
});
test('change_password requires environment variable', async (t) => {
const error = await t.throwsAsync(async () => {
await change_password('valid_password_123', '192.168.1.100');
});
t.is(error.message, 'JOYSTICK_DB_SETTINGS environment variable not found');
});
test('change_password requires authentication configuration', async (t) => {
// Create environment variable without authentication
const settings_data = { port: 1983 };
process.env.JOYSTICK_DB_SETTINGS = JSON.stringify(settings_data);
const error = await t.throwsAsync(async () => {
await change_password('valid_password_123', '192.168.1.100');
});
t.is(error.message, 'Authentication not configured');
});
test('change_password successfully updates password', async (t) => {
// Create environment variable with authentication
const old_password = 'old_password_123';
const new_password = 'new_password_456';
const settings_data = {
authentication: {
password_hash: await bcrypt.hash(old_password, 12),
created_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
failed_attempts: { '192.168.1.50': [Date.now()] },
rate_limits: { '192.168.1.50': { expires_at: Date.now() + 60000, attempts: 1 } }
}
};
process.env.JOYSTICK_DB_SETTINGS = JSON.stringify(settings_data);
const result = await change_password(new_password, '192.168.1.100');
t.true(result.success);
t.is(result.message, 'Password changed successfully');
t.true(typeof result.timestamp === 'string');
// Verify password was updated in environment variable
const updated_settings = JSON.parse(process.env.JOYSTICK_DB_SETTINGS);
t.true(await bcrypt.compare(new_password, updated_settings.authentication.password_hash));
t.false(await bcrypt.compare(old_password, updated_settings.authentication.password_hash));
// Verify failed attempts and rate limits were reset
t.deepEqual(updated_settings.authentication.failed_attempts, {});
t.deepEqual(updated_settings.authentication.rate_limits, {});
});
test('change_password calls connection terminator', async (t) => {
// Create environment variable with authentication
const settings_data = {
authentication: {
password_hash: await bcrypt.hash('old_password_123', 12),
created_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
failed_attempts: {},
rate_limits: {}
}
};
process.env.JOYSTICK_DB_SETTINGS = JSON.stringify(settings_data);
let terminator_called = false;
const connection_terminator = () => {
terminator_called = true;
};
await change_password('new_password_456', '192.168.1.100', connection_terminator);
t.true(terminator_called);
});
test('change_password cleans up recovery token', async (t) => {
// Create recovery token
create_recovery_token();
t.true(fs.existsSync(RECOVERY_TOKEN_FILE));
// Create environment variable with authentication
const settings_data = {
authentication: {
password_hash: await bcrypt.hash('old_password_123', 12),
created_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
failed_attempts: {},
rate_limits: {}
}
};
process.env.JOYSTICK_DB_SETTINGS = JSON.stringify(settings_data);
await change_password('new_password_456', '192.168.1.100');
// Verify recovery token was cleaned up
t.false(fs.existsSync(RECOVERY_TOKEN_FILE));
const status = get_recovery_status();
t.false(status.token_active);
});
test('get_recovery_status returns correct status', (t) => {
// Test with no token
let status = get_recovery_status();
t.false(status.token_active);
t.is(status.failed_attempts, 0);
t.false(status.is_locked);
// Test with active token
create_recovery_token();
status = get_recovery_status();
t.true(status.token_active);
t.true(typeof status.expires_at === 'number');
// Test with failed attempts
record_failed_recovery_attempt('192.168.1.100');
status = get_recovery_status();
t.is(status.failed_attempts, 1);
});
test('initialize_recovery_manager loads existing state', (t) => {
// Create a recovery token file manually
const token_data = {
token: 'test-token-123',
expires_at: Date.now() + 600000, // 10 minutes from now
failed_attempts: 2,
locked_until: null
};
fs.writeFileSync(RECOVERY_TOKEN_FILE, JSON.stringify(token_data, null, 2));
initialize_recovery_manager();
const status = get_recovery_status();
t.true(status.token_active);
t.is(status.failed_attempts, 2);
const validation = is_token_valid('test-token-123');
t.true(validation.valid);
});
test('initialize_recovery_manager cleans up expired tokens', (t) => {
// Create an expired recovery token file
const token_data = {
token: 'expired-token-123',
expires_at: Date.now() - 1000, // 1 second ago
failed_attempts: 1,
locked_until: null
};
fs.writeFileSync(RECOVERY_TOKEN_FILE, JSON.stringify(token_data, null, 2));
initialize_recovery_manager();
const status = get_recovery_status();
t.false(status.token_active);
t.is(status.failed_attempts, 0);
// Token file should be cleaned up
t.false(fs.existsSync(RECOVERY_TOKEN_FILE));
});
test('initialize_recovery_manager cleans up expired locks', (t) => {
// Create a recovery state with expired lock
const token_data = {
token: 'test-token-123',
expires_at: Date.now() + 600000, // 10 minutes from now
failed_attempts: 3,
locked_until: Date.now() - 1000 // 1 second ago (expired)
};
fs.writeFileSync(RECOVERY_TOKEN_FILE, JSON.stringify(token_data, null, 2));
initialize_recovery_manager();
const status = get_recovery_status();
t.false(status.is_locked);
t.is(status.failed_attempts, 0); // Should be reset when lock expires
});
test('reset_recovery_state cleans up everything', (t) => {
// Create recovery token and make some failed attempts
create_recovery_token();
record_failed_recovery_attempt('192.168.1.100');
t.true(fs.existsSync(RECOVERY_TOKEN_FILE));
reset_recovery_state();
t.false(fs.existsSync(RECOVERY_TOKEN_FILE));
const status = get_recovery_status();
t.false(status.token_active);
t.is(status.failed_attempts, 0);
t.false(status.is_locked);
});
test('recovery token file has secure permissions', (t) => {
create_recovery_token();
const stats = fs.statSync(RECOVERY_TOKEN_FILE);
const mode = stats.mode & parseInt('777', 8);
// Should have 600 permissions (owner read/write only)
t.is(mode, parseInt('600', 8));
});
test('multiple recovery tokens not allowed', (t) => {
const first_token = create_recovery_token();
const second_token = create_recovery_token();
// Second token should replace the first
t.not(first_token.token, second_token.token);
// First token should no longer be valid
const first_validation = is_token_valid(first_token.token);
t.false(first_validation.valid);
// Second token should be valid
const second_validation = is_token_valid(second_token.token);
t.true(second_validation.valid);
});
test('password strength validation edge cases', async (t) => {
// Create environment variable with authentication
const settings_data = {
authentication: {
password_hash: await bcrypt.hash('old_password_123', 12),
created_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
failed_attempts: {},
rate_limits: {}
}
};
process.env.JOYSTICK_DB_SETTINGS = JSON.stringify(settings_data);
// Test null password
let error = await t.throwsAsync(async () => {
await change_password(null, '192.168.1.100');
});
t.is(error.message, 'Password is required');
// Test undefined password
error = await t.throwsAsync(async () => {
await change_password(undefined, '192.168.1.100');
});
t.is(error.message, 'Password is required');
// Test non-string password
error = await t.throwsAsync(async () => {
await change_password(123456789012, '192.168.1.100');
});
t.is(error.message, 'Password is required');
// Test exactly 12 characters (should pass)
const result = await change_password('exactly12chr', '192.168.1.100');
t.true(result.success);
});