UNPKG

@joystick.js/db-canary

Version:

JoystickDB - A minimalist database server for the Joystick framework

415 lines (330 loc) 13.3 kB
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); });