@joystick.js/db-canary
Version:
JoystickDB - A minimalist database server for the Joystick framework
318 lines (241 loc) • 10.7 kB
JavaScript
import test from 'ava';
import bcrypt from 'bcrypt';
import {
setup_authentication,
verify_password,
get_client_ip,
get_auth_stats,
initialize_auth_manager,
reset_auth_state
} from '../../../src/server/lib/auth_manager.js';
test.beforeEach(() => {
// Reset module state and clean up environment variables
reset_auth_state();
delete process.env.JOYSTICK_DB_SETTINGS;
});
test.afterEach(() => {
// Reset module state and clean up environment variables after each test
reset_auth_state();
delete process.env.JOYSTICK_DB_SETTINGS;
});
test('setup_authentication creates authentication in environment variable with valid structure', (t) => {
const password = setup_authentication();
t.true(process.env.JOYSTICK_DB_SETTINGS !== undefined);
t.is(typeof password, 'string');
t.is(password.length, 32);
const settings_data = JSON.parse(process.env.JOYSTICK_DB_SETTINGS);
const auth_data = settings_data.authentication;
t.true(typeof auth_data.password_hash === 'string');
t.true(typeof auth_data.created_at === 'string');
t.true(typeof auth_data.last_updated === 'string');
t.true(typeof auth_data.failed_attempts === 'object');
t.true(typeof auth_data.rate_limits === 'object');
// Verify password hash is valid bcrypt hash
t.true(bcrypt.compareSync(password, auth_data.password_hash));
});
test('setup_authentication throws error when authentication already exists', (t) => {
setup_authentication();
const error = t.throws(() => {
setup_authentication();
});
t.is(error.message, 'Authentication already configured. Update JOYSTICK_DB_SETTINGS environment variable to reconfigure.');
});
test('setup_authentication generates unique passwords', (t) => {
const password1 = setup_authentication();
delete process.env.JOYSTICK_DB_SETTINGS;
reset_auth_state(); // Reset in-memory state after clearing environment variable
const password2 = setup_authentication();
t.not(password1, password2);
});
test('verify_password returns true for correct password', async (t) => {
const password = setup_authentication();
const client_ip = '192.168.1.100';
const result = await verify_password(password, client_ip);
t.true(result);
});
test('verify_password returns false for incorrect password', async (t) => {
setup_authentication();
const client_ip = '192.168.1.100';
const result = await verify_password('wrong_password', client_ip);
t.false(result);
});
test('verify_password throws error when authentication not configured', async (t) => {
// Ensure no environment variable exists and use a fresh IP
delete process.env.JOYSTICK_DB_SETTINGS;
const client_ip = '192.168.1.200'; // Use completely fresh IP
const error = await t.throwsAsync(async () => {
await verify_password('any_password', client_ip);
});
t.is(error.message, 'Authentication not configured. Run setup first.');
});
test('verify_password implements timing attack protection', async (t) => {
const password = setup_authentication();
const client_ip = '192.168.1.100';
const start_time = Date.now();
await verify_password('wrong_password', client_ip);
const wrong_duration = Date.now() - start_time;
const start_time2 = Date.now();
await verify_password(password, client_ip);
const correct_duration = Date.now() - start_time2;
// Both should take at least 100ms due to timing protection
t.true(wrong_duration >= 100);
t.true(correct_duration >= 100);
});
test('verify_password records failed attempts for non-whitelisted IPs', async (t) => {
setup_authentication();
const client_ip = '192.168.1.100';
await verify_password('wrong_password', client_ip);
const settings_data = JSON.parse(process.env.JOYSTICK_DB_SETTINGS);
const auth_data = settings_data.authentication;
t.true(Array.isArray(auth_data.failed_attempts[client_ip]));
t.is(auth_data.failed_attempts[client_ip].length, 1);
});
test('verify_password records failed attempts for all IPs in test environment', async (t) => {
setup_authentication();
const test_ip = '127.0.0.1';
await verify_password('wrong_password', test_ip);
const settings_data = JSON.parse(process.env.JOYSTICK_DB_SETTINGS);
const auth_data = settings_data.authentication;
// In test environment, IP whitelisting is disabled, so failed attempts are recorded for all IPs
t.true(Array.isArray(auth_data.failed_attempts[test_ip]));
t.is(auth_data.failed_attempts[test_ip].length, 1);
});
test('verify_password clears failed attempts on successful authentication', async (t) => {
const password = setup_authentication();
const client_ip = '192.168.1.101'; // Use different IP to avoid cross-test contamination
// Record a failed attempt
await verify_password('wrong_password', client_ip);
let settings_data = JSON.parse(process.env.JOYSTICK_DB_SETTINGS);
let auth_data = settings_data.authentication;
t.is(auth_data.failed_attempts[client_ip].length, 1);
// Successful authentication should clear failed attempts
await verify_password(password, client_ip);
settings_data = JSON.parse(process.env.JOYSTICK_DB_SETTINGS);
auth_data = settings_data.authentication;
t.is(auth_data.failed_attempts[client_ip], undefined);
});
test('rate limiting blocks after maximum failed attempts', async (t) => {
setup_authentication();
const client_ip = '192.168.1.102'; // Use different IP to avoid cross-test contamination
// Make 5 failed attempts
for (let i = 0; i < 5; i++) {
await verify_password('wrong_password', client_ip);
}
// 6th attempt should be rate limited
const error = await t.throwsAsync(async () => {
await verify_password('wrong_password', client_ip);
});
t.is(error.message, 'Too many failed attempts. Please try again later.');
});
test('rate limiting affects all IPs in test environment', async (t) => {
setup_authentication();
const test_ip = '192.168.1.200'; // Use different IP to avoid cross-test contamination
// Make 5 failed attempts to trigger rate limiting
for (let i = 0; i < 5; i++) {
const result = await verify_password('wrong_password', test_ip);
t.false(result);
}
// 6th attempt should be rate limited (even for localhost in test environment)
const error = await t.throwsAsync(async () => {
await verify_password('wrong_password', test_ip);
});
t.is(error.message, 'Too many failed attempts. Please try again later.');
});
test('get_client_ip extracts IP from socket', (t) => {
const mock_socket = {
remoteAddress: '192.168.1.100'
};
const ip = get_client_ip(mock_socket);
t.is(ip, '192.168.1.100');
});
test('get_client_ip returns localhost for socket without remoteAddress', (t) => {
const mock_socket = {};
const ip = get_client_ip(mock_socket);
t.is(ip, '127.0.0.1');
});
test('get_auth_stats returns correct structure when configured', (t) => {
setup_authentication();
const stats = get_auth_stats();
t.true(stats.configured);
t.is(typeof stats.failed_attempts_count, 'number');
t.is(typeof stats.rate_limited_ips, 'number');
t.is(typeof stats.created_at, 'string');
t.is(typeof stats.last_updated, 'string');
});
test('initialize_auth_manager loads existing authentication data', (t) => {
// Create auth file first
setup_authentication();
// Initialize should load the data without error
t.notThrows(() => {
initialize_auth_manager();
});
});
test('initialize_auth_manager handles missing authentication file gracefully', (t) => {
// Should not throw when no auth file exists
t.notThrows(() => {
initialize_auth_manager();
});
});
test('authentication data is stored in environment variable', (t) => {
setup_authentication();
// Verify environment variable exists and contains valid JSON
t.true(process.env.JOYSTICK_DB_SETTINGS !== undefined);
const settings_data = JSON.parse(process.env.JOYSTICK_DB_SETTINGS);
t.true(typeof settings_data.authentication === 'object');
t.true(typeof settings_data.authentication.password_hash === 'string');
});
test('password generation creates 32-character hex string', (t) => {
const password = setup_authentication();
// Should be 32 characters long
t.is(password.length, 32);
// Should be valid hex string
t.true(/^[0-9a-f]{32}$/.test(password));
});
test('bcrypt hashing works correctly', (t) => {
const password = setup_authentication();
const settings_data = JSON.parse(process.env.JOYSTICK_DB_SETTINGS);
const auth_data = settings_data.authentication;
// Should be able to verify the password with the stored hash
t.true(bcrypt.compareSync(password, auth_data.password_hash));
// Should not verify with wrong password
t.false(bcrypt.compareSync('wrong_password', auth_data.password_hash));
});
test('authentication environment variable corruption is handled gracefully', (t) => {
// Create corrupted environment variable
process.env.JOYSTICK_DB_SETTINGS = 'invalid json content';
// initialize_auth_manager doesn't throw, it logs warnings and continues
// So we test that it doesn't throw and the environment variable remains corrupted
t.notThrows(() => {
initialize_auth_manager();
});
// The corrupted environment variable should still exist
t.true(process.env.JOYSTICK_DB_SETTINGS !== undefined);
// And should still be corrupted
t.is(process.env.JOYSTICK_DB_SETTINGS, 'invalid json content');
});
test('rate limiting implements exponential backoff', async (t) => {
setup_authentication();
const client_ip = '192.168.1.103'; // Use different IP to avoid cross-test contamination
// Make 5 failed attempts to trigger rate limiting
for (let i = 0; i < 5; i++) {
await verify_password('wrong_password', client_ip);
}
// Make the 6th attempt that should trigger rate limiting
try {
await verify_password('wrong_password', client_ip);
t.fail('Should have thrown rate limiting error');
} catch (error) {
t.is(error.message, 'Too many failed attempts. Please try again later.');
}
// Now check that rate limit info is stored
const settings_data = JSON.parse(process.env.JOYSTICK_DB_SETTINGS);
const auth_data = settings_data.authentication;
t.true(typeof auth_data.rate_limits[client_ip] === 'object');
t.true(typeof auth_data.rate_limits[client_ip].expires_at === 'number');
t.true(typeof auth_data.rate_limits[client_ip].attempts === 'number');
});
test('whitelisted IPs behavior is tested through other tests', (t) => {
// This test verifies that whitelisted IP behavior is covered
// by the other tests (like rate limiting not affecting whitelisted IPs)
t.pass();
});