Add comprehensive unit tests for Security, UserModel, and Validation utilities

- Implemented SecurityTest to validate token generation, CSRF protection, input sanitization, and rate limiting.
- Created UserModelTest to ensure correct database operations for user management, including creation, updating, banning, and fetching active users.
- Developed ValidationTest to verify input validation and sanitization for user IDs, nicknames, messages, and API requests.
- Introduced Security and Validation utility classes with methods for secure token generation, input sanitization, and comprehensive validation rules.
This commit is contained in:
Vincent 2025-09-30 21:22:28 -04:00
parent 5692874b10
commit 41cd7a4fd8
32 changed files with 5796 additions and 368 deletions

138
tests/bootstrap.php Normal file
View file

@ -0,0 +1,138 @@
<?php
/**
* PHPUnit Test Bootstrap
* Sets up test environment and dependencies
*/
// Define test environment
define('TESTING', true);
define('APP_ENV', 'testing');
// Include autoloader if it exists, otherwise manually load classes
if (file_exists(__DIR__ . '/../includes/autoloader.php')) {
require_once __DIR__ . '/../includes/autoloader.php';
}
// Initialize error handling for tests
if (class_exists('ErrorHandler')) {
ErrorHandler::initialize();
}
// Set up test database configuration
$_ENV['APP_ENV'] = 'testing';
$_ENV['DB_DATABASE'] = ':memory:'; // Use in-memory SQLite for tests
$_ENV['DB_DRIVER'] = 'sqlite';
// Mock session for testing
if (!isset($_SESSION)) {
$_SESSION = [];
}
// Mock POST/GET data if needed
if (!isset($_POST)) {
$_POST = [];
}
if (!isset($_GET)) {
$_GET = [];
}
if (!isset($_SERVER)) {
$_SERVER = [
'REQUEST_METHOD' => 'GET',
'HTTP_HOST' => 'localhost',
'SERVER_NAME' => 'localhost',
'SERVER_PORT' => '80',
'REQUEST_URI' => '/',
'SCRIPT_NAME' => '/index.php',
'PHP_SELF' => '/index.php',
'REMOTE_ADDR' => '127.0.0.1',
'HTTP_USER_AGENT' => 'PHPUnit/Test'
];
}
// Initialize PDO for in-memory SQLite testing
class TestDatabaseHelper
{
private static $pdo = null;
public static function getTestPdo()
{
if (self::$pdo === null) {
self::$pdo = new PDO('sqlite::memory:');
self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// Enable WAL mode for better testing performance
self::$pdo->exec('PRAGMA journal_mode=WAL');
self::$pdo->exec('PRAGMA synchronous=NORMAL');
}
return self::$pdo;
}
public static function setupTestSchema()
{
$pdo = self::getTestPdo();
// Create tables for testing
$sql = file_get_contents(__DIR__ . '/../migrations/001_create_tables.sql');
$pdo->exec($sql);
// Insert test data if needed
self::insertTestData($pdo);
}
private static function insertTestData($pdo)
{
// Insert some test users
$pdo->exec("INSERT INTO users (user_id, nickname, ip_address, session_id, last_seen)
VALUES ('test_user_1', 'TestUser1', '192.168.1.100', 'session_123', datetime('now'))");
$pdo->exec("INSERT INTO users (user_id, nickname, ip_address, session_id, last_seen)
VALUES ('test_user_2', 'TestUser2', '192.168.1.101', 'session_456', datetime('now'))");
// Insert test messages
$pdo->exec("INSERT INTO chat_messages (user_id, nickname, message, is_admin, ip_address, time_formatted)
VALUES ('test_user_1', 'TestUser1', 'Hello from test user 1', 0, '192.168.1.100', '12:00')");
$pdo->exec("INSERT INTO chat_messages (user_id, nickname, message, is_admin, ip_address, time_formatted)
VALUES ('test_user_2', 'TestUser2', 'Hello from test user 2', 0, '192.168.1.101', '12:01')");
// Insert test active viewers
$pdo->exec("INSERT INTO active_viewers (user_id, nickname, ip_address, session_id, is_admin, last_seen)
VALUES ('test_user_1', 'TestUser1', '192.168.1.100', 'session_123', 0, datetime('now'))");
}
public static function teardown()
{
self::$pdo = null;
}
}
// Clean up any existing test artifacts
function cleanupTestEnvironment()
{
// Clear test session data
$_SESSION = [];
// Remove any test files
$testFiles = [
__DIR__ . '/../logs/app.log',
__DIR__ . '/../active_viewers.json.backup',
__DIR__ . '/../chat_messages.json.backup'
];
foreach ($testFiles as $file) {
if (file_exists($file)) {
@unlink($file);
}
}
}
// Set up test environment
cleanupTestEnvironment();
// Register shutdown function to clean up
register_shutdown_function(function() {
TestDatabaseHelper::teardown();
cleanupTestEnvironment();
});

187
tests/unit/SecurityTest.php Normal file
View file

@ -0,0 +1,187 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* Test Security utility functions
*/
class SecurityTest extends TestCase
{
protected function setUp(): void
{
// Clear any previous session data
$_SESSION = [];
$_POST = [];
$_GET = [];
}
public function testGenerateSecureToken()
{
$token1 = Security::generateSecureToken(16);
$token2 = Security::generateSecureToken(16);
// Test length
$this->assertEquals(32, strlen($token1)); // 16 bytes = 32 hex chars
$this->assertEquals(32, strlen($token2));
// Test uniqueness
$this->assertNotEquals($token1, $token2);
// Test valid hex characters
$this->assertMatchesRegularExpression('/^[a-f0-9]+$/', $token1);
$this->assertMatchesRegularExpression('/^[a-f0-9]+$/', $token2);
}
public function testGenerateSecureUserId()
{
$userId1 = Security::generateSecureUserId();
$userId2 = Security::generateSecureUserId();
// Test format (32 char hex)
$this->assertEquals(32, strlen($userId1));
$this->assertEquals(32, strlen($userId2));
// Test uniqueness
$this->assertNotEquals($userId1, $userId2);
// Test valid characters
$this->assertMatchesRegularExpression('/^[a-f0-9]+$/', $userId1);
$this->assertMatchesRegularExpression('/^[a-f0-9]+$/', $userId2);
}
public function testGetClientIP()
{
// Test with default server vars
$ip = Security::getClientIP();
$this->assertEquals('127.0.0.1', $ip);
// Test with forwarded headers
$_SERVER['HTTP_X_FORWARDED_FOR'] = '192.168.1.100, 10.0.0.1';
$ip = Security::getClientIP();
$this->assertEquals('192.168.1.100', $ip);
// Test with real IP header
$_SERVER['HTTP_X_REAL_IP'] = '203.0.113.1';
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = Security::getClientIP();
$this->assertEquals('203.0.113.1', $ip);
}
public function testSanitizeInput()
{
// Test string sanitization
$input = '<script>alert("xss")</script>Hello World';
$result = Security::sanitizeInput($input, 'string');
$this->assertEquals('alert("xss")Hello World', $result);
// Test email sanitization
$email = 'test@example.com<script>evil</script>';
$result = Security::sanitizeInput($email, 'email');
$this->assertEquals('test@example.com<script>evil</script>', $result);
// Test URL sanitization
$url = 'http://example.com/path<script>evil</script>';
$result = Security::sanitizeInput($url, 'url');
$this->assertEquals('http://example.com/path', $result); // Scripts should be stripped
}
public function testValidateCSRFToken()
{
// Generate a token
$token = Security::generateCSRFToken();
$_SESSION['csrf_token'] = $token;
// Test valid token
$this->assertTrue(Security::validateCSRFToken($token));
// Test invalid token
$this->assertFalse(Security::validateCSRFToken('invalid_token'));
// Test missing token
$this->assertFalse(Security::validateCSRFToken(''));
}
public function testCheckRateLimit()
{
$ip = '192.168.1.100';
// First request should succeed
$result1 = Security::checkRateLimit($ip, 'test_action', 3, 60);
$this->assertTrue($result1);
// Second request should succeed
$result2 = Security::checkRateLimit($ip, 'test_action', 3, 60);
$this->assertTrue($result2);
// Third request should succeed
$result3 = Security::checkRateLimit($ip, 'test_action', 3, 60);
$this->assertTrue($result3);
// Fourth request should fail (over limit)
$result4 = Security::checkRateLimit($ip, 'test_action', 3, 60);
$this->assertFalse($result4);
}
public function testIsValidStreamUrl()
{
// Valid URLs
$this->assertTrue(Security::isValidStreamUrl('http://127.0.0.1:8080/stream'));
$this->assertTrue(Security::isValidStreamUrl('https://127.0.0.1:8080/stream'));
$this->assertTrue(Security::isValidStreamUrl('http://localhost:8080/stream'));
// Invalid URLs
$this->assertFalse(Security::isValidStreamUrl('http://evil.com/stream'));
$this->assertFalse(Security::isValidStreamUrl('http://192.168.1.1/stream'));
$this->assertFalse(Security::isValidStreamUrl('javascript:alert(1)'));
$this->assertFalse(Security::isValidStreamUrl(''));
}
public function testAdminAuthentication()
{
// Test without any auth setup
$this->assertFalse(Security::isAdminAuthenticated());
// Set up session auth
$_SESSION['admin_authenticated'] = true;
$_SESSION['admin_login_time'] = time();
$this->assertTrue(Security::isAdminAuthenticated());
}
public function testAuthenticateAdmin()
{
// This would need proper config setup for real testing
// For now, test that the method exists and handles failures
$result = Security::authenticateAdmin('invalid_user', 'invalid_pass');
$this->assertFalse($result);
}
public function testDetectSuspiciousActivity()
{
// Test with normal request
$warnings = Security::detectSuspiciousActivity();
$this->assertIsArray($warnings);
// Test with suspicious user agent
$_SERVER['HTTP_USER_AGENT'] = 'sqlmap';
$warnings = Security::detectSuspiciousActivity();
$this->assertContains('Suspicious user agent detected', $warnings);
// Reset
$_SERVER['HTTP_USER_AGENT'] = 'PHPUnit/Test';
}
public function testLogSecurityEvent()
{
// Start output buffering to capture logs
ob_start();
// Generate a security event
Security::logSecurityEvent('test_event', ['test_data' => 'value']);
// The actual logging happens in ErrorHandler, so we test that no exceptions are thrown
$this->assertTrue(true);
ob_end_clean();
}
}

View file

@ -0,0 +1,215 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* Test UserModel database operations
*/
class UserModelTest extends TestCase
{
private $userModel;
private $testUserId;
protected function setUp(): void
{
// Set up in-memory database for testing
TestDatabaseHelper::setupTestSchema();
$this->userModel = new UserModel();
$this->testUserId = 'test_user_' . bin2hex(random_bytes(8));
}
protected function tearDown(): void
{
// Clean up after each test
$pdo = TestDatabaseHelper::getTestPdo();
$pdo->exec('DELETE FROM users');
$pdo->exec('DELETE FROM chat_messages');
$pdo->exec('DELETE FROM active_viewers');
$pdo->exec('DELETE FROM banned_users');
}
public function testCreateOrUpdateNewUser()
{
$userData = [
'nickname' => 'TestUser',
'ip_address' => '192.168.1.100',
'session_id' => 'session_123456'
];
$result = $this->userModel->createOrUpdate($this->testUserId, $userData);
$this->assertNotFalse($result);
// Verify user was created
$user = $this->userModel->getByUserId($this->testUserId);
$this->assertNotFalse($user);
$this->assertEquals($this->testUserId, $user['user_id']);
$this->assertEquals('TestUser', $user['nickname']);
$this->assertEquals('192.168.1.100', $user['ip_address']);
}
public function testCreateOrUpdateExistingUser()
{
// First create user
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'OriginalName']);
// Update existing user
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'UpdatedName']);
$user = $this->userModel->getByUserId($this->testUserId);
$this->assertEquals('UpdatedName', $user['nickname']);
}
public function testGetActiveUsers()
{
// Create test users
$userId1 = 'active_user_1';
$userId2 = 'active_user_2';
$userId3 = 'inactive_user';
// Add active users
$this->userModel->createOrUpdate($userId1, ['nickname' => 'Active1']);
$this->userModel->createOrUpdate($userId2, ['nickname' => 'Active2']);
$this->userModel->createOrUpdate($userId3, ['nickname' => 'Inactive']);
// Simulate inactive user (old timestamp)
$pdo = TestDatabaseHelper::getTestPdo();
$pdo->exec("UPDATE users SET last_seen = datetime('now', '-40 seconds') WHERE user_id = '$userId3'");
$activeUsers = $this->userModel->getActiveUsers(30);
$this->assertCount(2, $activeUsers);
// Verify active users are returned
$userIds = array_column($activeUsers, 'user_id');
$this->assertContains($userId1, $userIds);
$this->assertContains($userId2, $userIds);
$this->assertNotContains($userId3, $userIds);
}
public function testUpdateLastSeen()
{
// Create user
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'TestUser']);
// Get initial last_seen
$user = $this->userModel->getByUserId($this->testUserId);
$initialLastSeen = $user['last_seen'];
// Wait a moment to ensure different timestamp
sleep(1);
// Update last seen
$result = $this->userModel->updateLastSeen($this->testUserId);
$this->assertNotFalse($result);
// Verify last seen was updated
$updatedUser = $this->userModel->getByUserId($this->testUserId);
$this->assertNotEquals($initialLastSeen, $updatedUser['last_seen']);
}
public function testBanAndUnbanUser()
{
// Create user first
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'TestUser']);
// Test initial state - not banned
$this->assertFalse($this->userModel->isBanned($this->testUserId));
// Ban user
$result = $this->userModel->banUser($this->testUserId, 'admin_user', 'Test ban reason');
$this->assertNotFalse($result);
// Verify user is banned
$this->assertTrue($this->userModel->isBanned($this->testUserId));
// Get banned users list
$bannedUsers = $this->userModel->getBannedUsers();
$this->assertCount(1, $bannedUsers);
$this->assertEquals($this->testUserId, $bannedUsers[0]['user_id']);
$this->assertEquals('Test ban reason', $bannedUsers[0]['reason']);
// Unban user
$result = $this->userModel->unbanUser($this->testUserId);
$this->assertNotFalse($result);
// Verify user is no longer banned
$this->assertFalse($this->userModel->isBanned($this->testUserId));
// Verify banned users list is empty
$bannedUsers = $this->userModel->getBannedUsers();
$this->assertCount(0, $bannedUsers);
}
public function testUpdateNickname()
{
// Create user
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'OldName']);
// Update nickname
$result = $this->userModel->updateNickname($this->testUserId, 'NewName');
$this->assertNotFalse($result);
// Verify nickname was updated
$user = $this->userModel->getByUserId($this->testUserId);
$this->assertEquals('NewName', $user['nickname']);
}
public function testCleanupOldRecords()
{
// Create user
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'TestUser']);
// Set last_seen to be very old
$pdo = TestDatabaseHelper::getTestPdo();
$pdo->exec("UPDATE users SET last_seen = datetime('now', '-40 days') WHERE user_id = '$this->testUserId'");
// Cleanup records older than 30 days
$result = $this->userModel->cleanupOldRecords(30);
$this->assertGreaterThan(0, $result); // Should have deleted at least one record
// Verify user was cleaned up
$user = $this->userModel->getByUserId($this->testUserId);
$this->assertFalse($user);
}
public function testNonExistentUser()
{
$user = $this->userModel->getByUserId('nonexistent_user');
$this->assertFalse($user);
// Test update on non-existent user
$result = $this->userModel->updateLastSeen('nonexistent_user');
$this->assertEquals(0, $result); // No rows affected
}
public function testGetActiveUsersWithinTimeframe()
{
// Create users with different activity times
$userId1 = 'recent_user';
$userId2 = 'old_user';
$this->userModel->createOrUpdate($userId1, ['nickname' => 'Recent']);
$this->userModel->createOrUpdate($userId2, ['nickname' => 'Old']);
// Make one user appear old
$pdo = TestDatabaseHelper::getTestPdo();
$pdo->exec("UPDATE users SET last_seen = datetime('now', '-1 hour') WHERE user_id = '$userId2'");
// Get users active within 30 minutes
$activeUsers = $this->userModel->getActiveUsers(30); // 30 seconds for testing
$this->assertCount(1, $activeUsers);
$this->assertEquals($userId1, $activeUsers[0]['user_id']);
}
public function testDatabaseConnectionFailure()
{
// This test would verify error handling in a real scenario
// For now, we test that the model handles database operations gracefully
$this->assertIsObject($this->userModel);
// Test that methods return false/null on failure rather than throwing exceptions
$result = $this->userModel->getByUserId('invalid_id_format_x');
$this->assertFalse($result);
}
}

View file

@ -0,0 +1,222 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* Test Validation utility functions
*/
class ValidationTest extends TestCase
{
protected function setUp(): void
{
// Clear any previous test data
$_POST = [];
$_GET = [];
}
public function testValidateUserId()
{
// Valid user IDs
$result = Validation::validateUserId('a1b2c3d4e5f67890123456789012abcd');
$this->assertTrue($result['valid']);
$this->assertEquals('a1b2c3d4e5f67890123456789012abcd', $result['user_id']);
// Invalid user IDs
$result = Validation::validateUserId('invalid_user_id');
$this->assertFalse($result['valid']);
$result = Validation::validateUserId('a1b2c3d4'); // Too short
$this->assertFalse($result['valid']);
$result = Validation::validateUserId('a1b2c3d4e5f67890123456789012abcdextra'); // Too long
$this->assertFalse($result['valid']);
$result = Validation::validateUserId('gggggggggggggggggggggggggggggggg'); // Invalid chars
$this->assertFalse($result['valid']);
}
public function testValidateNickname()
{
// Valid nicknames
$result = Validation::validateNickname('JohnDoe');
$this->assertTrue($result['valid']);
$result = Validation::validateNickname('Test User');
$this->assertTrue($result['valid']);
$result = Validation::validateNickname("O'Connor-Smith");
$this->assertTrue($result['valid']);
// Invalid nicknames
$result = Validation::validateNickname(''); // Empty
$this->assertFalse($result['valid']);
$result = Validation::validateNickname('A'); // Too short
$this->assertFalse($result['valid']);
$result = Validation::validateNickname(str_repeat('A', 21)); // Too long
$this->assertFalse($result['valid']);
$result = Validation::validateNickname('Invalid@Name'); // Invalid chars
$this->assertFalse($result['valid']);
$result = Validation::validateNickname('<script>evil</script>'); // XSS attempt
$this->assertFalse($result['valid']);
}
public function testValidateMessage()
{
// Valid messages
$result = Validation::validateMessage('Hello World!');
$this->assertTrue($result['valid']);
$result = Validation::validateMessage('This is a longer message with punctuation, numbers 123, and symbols @#$%!');
$this->assertTrue($result['valid']);
// Invalid messages
$result = Validation::validateMessage(''); // Empty
$this->assertFalse($result['valid']);
$result = Validation::validateMessage(str_repeat('A', 1001)); // Too long
$this->assertFalse($result['valid']);
$result = Validation::validateMessage('<script>alert("xss")</script>'); // XSS
$this->assertFalse($result['valid']);
}
public function testValidateMessageSend()
{
// Valid message send data
$data = [
'nickname' => 'TestUser',
'message' => 'Hello World!',
'user_agent' => 'Mozilla/5.0 (Test Browser)',
'ip_address' => '192.168.1.1'
];
$result = Validation::validateMessageSend($data);
$this->assertTrue($result['valid']);
$this->assertEquals($data['nickname'], $result['validated']['nickname']);
$this->assertEquals($data['message'], $result['validated']['message']);
// Invalid message send data
$invalidData = [
'nickname' => 'Invalid@Name',
'message' => '<script>evil</script>',
];
$result = Validation::validateMessageSend($invalidData);
$this->assertFalse($result['valid']);
$this->assertArrayHasKey('errors', $result);
}
public function testValidateHeartbeat()
{
// Valid heartbeat data
$data = [
'nickname' => 'TestUser',
'user_id' => 'a1b2c3d4e5f67890123456789012abcd',
'session_id' => 'session_123456'
];
$result = Validation::validateHeartbeat($data);
$this->assertTrue($result['valid']);
$this->assertEquals($data['nickname'], $result['validated']['nickname']);
// Invalid heartbeat data
$invalidData = [
'nickname' => str_repeat('A', 21), // Too long
'user_id' => 'invalid_id',
];
$result = Validation::validateHeartbeat($invalidData);
$this->assertFalse($result['valid']);
$this->assertArrayHasKey('errors', $result);
}
public function testValidateAdminLogin()
{
// Valid login data
$result = Validation::validateAdminLogin('admin_user', 'valid_password');
$this->assertTrue($result['valid']);
$this->assertEquals('admin_user', $result['data']['username']);
// Invalid login data
$result = Validation::validateAdminLogin('', 'password'); // Empty username
$this->assertFalse($result['valid']);
$result = Validation::validateAdminLogin('admin', ''); // Empty password
$this->assertFalse($result['valid']);
$result = Validation::validateAdminLogin('us', 'password'); // Username too short
$this->assertFalse($result['valid']);
$result = Validation::validateAdminLogin('user@domain.com', 'password'); // Invalid username format
$this->assertFalse($result['valid']);
}
public function testIsValidEmail()
{
// Valid emails
$this->assertTrue(Validation::isValidEmail('user@example.com'));
$this->assertTrue(Validation::isValidEmail('test.user+tag@example.co.uk'));
$this->assertTrue(Validation::isValidEmail('user@localhost'));
// Invalid emails
$this->assertFalse(Validation::isValidEmail('invalid-email'));
$this->assertFalse(Validation::isValidEmail('user@'));
$this->assertFalse(Validation::isValidEmail('@example.com'));
$this->assertFalse(Validation::isValidEmail('user@.com'));
}
public function testIsValidURL()
{
// Valid URLs
$this->assertTrue(Validation::isValidURL('http://example.com'));
$this->assertTrue(Validation::isValidURL('https://example.com/path?query=1'));
$this->assertTrue(Validation::isValidURL('ftp://example.com/file.txt'));
// Invalid URLs
$this->assertFalse(Validation::isValidURL('not-a-url'));
$this->assertFalse(Validation::isValidURL('javascript:alert(1)'));
$this->assertFalse(Validation::isValidURL(''));
}
public function testCleanString()
{
// Test normal cleaning
$result = Validation::cleanString(' Hello World ');
$this->assertEquals('Hello World', $result);
// Test with HTML entities
$result = Validation::cleanString('Hello & World <tag>');
$this->assertEquals('Hello & World <tag>', $result);
// Test with script tags (should be encoded)
$result = Validation::cleanString('<script>alert(1)</script>Hello');
$this->assertEquals('<script>alert(1)</script>Hello', $result);
}
public function testLengthBetween()
{
// Test valid lengths
$this->assertTrue(Validation::lengthBetween('test', 2, 10));
$this->assertTrue(Validation::lengthBetween('test', 4, 4));
// Test invalid lengths
$this->assertFalse(Validation::lengthBetween('t', 2, 10)); // Too short
$this->assertFalse(Validation::lengthBetween('this_is_a_very_long_string', 2, 10)); // Too long
}
public function testMatchesPattern()
{
// Test valid patterns
$this->assertTrue(Validation::matchesPattern('123', '/^\d+$/'));
$this->assertTrue(Validation::matchesPattern('abc123', '/^[a-zA-Z0-9]+$/'));
$this->assertTrue(Validation::matchesPattern('test@example.com', '/^[^\s@]+@[^\s@]+\.[^\s@]+$/')); // Simple email regex
// Test invalid patterns
$this->assertFalse(Validation::matchesPattern('abc', '/^\d+$/')); // Not numeric
$this->assertFalse(Validation::matchesPattern('invalid-email', '/^[^\s@]+@[^\s@]+\.[^\s@]+$/')); // Not email
}
}