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

178
includes/Config.php Normal file
View file

@ -0,0 +1,178 @@
<?php
/**
* Configuration Management Class
* Loads and manages application configuration from .env files
*/
class Config
{
private static $config = [];
private static $loaded = false;
/**
* Load configuration from environment and .env files
*/
public static function load()
{
if (self::$loaded) {
return self::$config;
}
// Load environment variables
self::loadEnvironment();
// Load .env file if it exists
self::loadDotEnv();
self::$loaded = true;
return self::$config;
}
/**
* Get a configuration value
*/
public static function get($key, $default = null)
{
$config = self::load();
// Support dot notation (e.g., 'database.host')
$keys = explode('.', $key);
$value = $config;
foreach ($keys as $k) {
if (!isset($value[$k])) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Set a configuration value (runtime only)
*/
public static function set($key, $value)
{
$keys = explode('.', $key);
$config = &self::$config;
foreach ($keys as $i => $k) {
if ($i === count($keys) - 1) {
$config[$k] = $value;
} else {
if (!isset($config[$k]) || !is_array($config[$k])) {
$config[$k] = [];
}
$config = &$config[$k];
}
}
}
/**
* Load system environment variables
*/
private static function loadEnvironment()
{
// System environment variables get precedence
foreach ($_ENV as $key => $value) {
self::setFromEnv($key, $value);
}
foreach ($_SERVER as $key => $value) {
if (strpos($key, 'APP_') === 0 || strpos($key, 'DB_') === 0 ||
strpos($key, 'STREAM_') === 0 || strpos($key, 'ADMIN_') === 0) {
self::setFromEnv($key, $value);
}
}
}
/**
* Load .env file
*/
private static function loadDotEnv()
{
$envFile = __DIR__ . '/../.env';
if (!file_exists($envFile)) {
error_log("Warning: .env file not found. Using default configuration.");
return;
}
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
// Skip comments
if (strpos($line, '#') === 0) {
continue;
}
// Parse key=value pairs
if (strpos($line, '=') !== false) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove quotes if present
if ((strpos($value, '"') === 0 && strrpos($value, '"') === strlen($value) - 1) ||
(strpos($value, "'") === 0 && strrpos($value, "'") === strlen($value) - 1)) {
$value = substr($value, 1, -1);
}
self::setFromEnv($key, $value);
}
}
}
/**
* Set configuration value from environment variable
*/
private static function setFromEnv($key, $value)
{
// Convert environment naming to configuration naming
$configKey = strtolower(str_replace('_', '.', $key));
// Type coercion for boolean values
if (in_array($value, ['true', 'false'])) {
$value = $value === 'true';
}
// Type coercion for numeric values
if (is_numeric($value) && !is_string($value)) {
$value = strpos($value, '.') !== false ? (float)$value : (int)$value;
}
// Handle arrays (comma-separated)
if (strpos($value, ',') !== false) {
$value = array_map('trim', explode(',', $value));
}
self::set($configKey, $value);
}
/**
* Get all configuration as a flattened array
*/
public static function all()
{
return self::load();
}
/**
* Check if we are in a specific environment
*/
public static function isEnvironment($env)
{
return self::get('app.env') === $env;
}
/**
* Check if debug mode is enabled
*/
public static function isDebug()
{
return self::get('app.debug', false);
}
}

298
includes/Database.php Normal file
View file

@ -0,0 +1,298 @@
<?php
/**
* Database Connection and Query Management Class
* Handles SQLite database operations with prepared statements and error handling
*/
class Database
{
private static $instance = null;
private $pdo;
private $dbPath;
/**
* Get database instance (singleton pattern)
*/
public static function getInstance()
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Private constructor for singleton
*/
private function __construct()
{
$this->dbPath = Config::get('db.path', __DIR__ . '/../data/app.db');
// Ensure data directory exists
$dataDir = dirname($this->dbPath);
if (!is_dir($dataDir)) {
mkdir($dataDir, 0755, true);
}
$this->connect();
$this->runMigrations();
}
/**
* Connect to SQLite database
*/
private function connect()
{
try {
$this->pdo = new PDO("sqlite:{$this->dbPath}");
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
// Enable WAL mode for better performance
$this->pdo->exec('PRAGMA journal_mode = WAL');
$this->pdo->exec('PRAGMA synchronous = NORMAL');
$this->pdo->exec('PRAGMA cache_size = 10000');
$this->pdo->exec('PRAGMA temp_store = MEMORY');
if (Config::isDebug()) {
error_log("Database connected successfully: {$this->dbPath}");
}
} catch (PDOException $e) {
error_log("Database connection failed: " . $e->getMessage());
throw new Exception("Database connection error");
}
}
/**
* Run database migrations
*/
private function runMigrations()
{
$migrationDir = __DIR__ . '/../migrations';
if (!is_dir($migrationDir)) {
error_log("Migrations directory not found: {$migrationDir}");
return;
}
$migrationsRun = [];
// Check if migrations table exists
try {
$result = $this->pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'");
if ($result->fetch()) {
// Get already run migrations
$stmt = $this->pdo->query("SELECT migration_name FROM migrations");
$migrationsRun = $stmt->fetchAll(PDO::FETCH_COLUMN);
} else {
// Create migrations table
$this->pdo->exec("CREATE TABLE migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migration_name VARCHAR(255) UNIQUE NOT NULL,
run_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
}
} catch (PDOException $e) {
error_log("Error checking migrations table: " . $e->getMessage());
return;
}
// Get migration files
$files = glob($migrationDir . '/*.sql');
sort($files); // Run in order
foreach ($files as $file) {
$migrationName = basename($file);
if (in_array($migrationName, $migrationsRun)) {
continue; // Already run
}
try {
$sql = file_get_contents($file);
if (empty($sql)) {
error_log("Empty migration file: {$migrationName}");
continue;
}
$this->pdo->exec($sql);
// Record migration as run
$stmt = $this->pdo->prepare("INSERT INTO migrations (migration_name) VALUES (?)");
$stmt->execute([$migrationName]);
error_log("Migration completed: {$migrationName}");
} catch (PDOException $e) {
error_log("Migration failed {$migrationName}: " . $e->getMessage());
// Continue with other migrations rather than stopping
}
}
}
/**
* Execute a prepared statement
*/
public function query($sql, $params = [])
{
try {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
} catch (PDOException $e) {
error_log("Database query error: " . $e->getMessage() . " | SQL: {$sql}");
throw $e;
}
}
/**
* Execute a query and return all results
*/
public function fetchAll($sql, $params = [])
{
return $this->query($sql, $params)->fetchAll();
}
/**
* Execute a query and return single result
*/
public function fetch($sql, $params = [])
{
return $this->query($sql, $params)->fetch();
}
/**
* Execute a query and return single column
*/
public function fetchColumn($sql, $params = [])
{
return $this->query($sql, $params)->fetchColumn();
}
/**
* Insert and return last insert ID
*/
public function insert($sql, $params = [])
{
$this->query($sql, $params);
return $this->pdo->lastInsertId();
}
/**
* Update records and return affected row count
*/
public function update($sql, $params = [])
{
$stmt = $this->query($sql, $params);
return $stmt->rowCount();
}
/**
* Delete records and return affected row count
*/
public function delete($sql, $params = [])
{
return $this->update($sql, $params);
}
/**
* Begin transaction
*/
public function beginTransaction()
{
$this->pdo->beginTransaction();
}
/**
* Commit transaction
*/
public function commit()
{
$this->pdo->commit();
}
/**
* Rollback transaction
*/
public function rollback()
{
$this->pdo->rollBack();
}
/**
* Get PDO instance (for advanced operations)
*/
public function getPDO()
{
return $this->pdo;
}
/**
* Check if table exists
*/
public function tableExists($tableName)
{
$result = $this->pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='{$tableName}'");
return $result->fetch() !== false;
}
/**
* Get database statistics
*/
public function getStats()
{
$stats = [];
// Table counts
$tables = ['users', 'chat_messages', 'active_viewers', 'banned_users'];
foreach ($tables as $table) {
if ($this->tableExists($table)) {
$count = $this->fetchColumn("SELECT COUNT(*) FROM {$table}");
$stats["{$table}_count"] = $count;
}
}
// Database size
$stats['db_size'] = filesize($this->dbPath);
return $stats;
}
/**
* Backup database
*/
public function backup($backupPath = null)
{
if (!$backupPath) {
$backupPath = $this->dbPath . '.backup.' . date('Y-m-d_H-i-s');
}
if (copy($this->dbPath, $backupPath)) {
// Backup WAL file if exists
$walFile = $this->dbPath . '-wal';
if (file_exists($walFile)) {
copy($walFile, $backupPath . '-wal');
}
return $backupPath;
}
return false;
}
/**
* Optimize database
*/
public function optimize()
{
try {
$this->pdo->exec('VACUUM');
$this->pdo->exec('REINDEX');
$this->pdo->exec('ANALYZE');
error_log("Database optimization completed");
} catch (PDOException $e) {
error_log("Database optimization failed: " . $e->getMessage());
}
}
}

364
includes/ErrorHandler.php Normal file
View file

@ -0,0 +1,364 @@
<?php
/**
* Global Error Handler
* Provides centralized error handling and logging for the application
*/
class ErrorHandler
{
private static $app = null;
private static $logFile = null;
/**
* Initialize error handling
*/
public static function initialize()
{
// Set up basic error reporting
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
// Set custom error handlers
set_error_handler([__CLASS__, 'errorHandler']);
set_exception_handler([__CLASS__, 'exceptionHandler']);
register_shutdown_function([__CLASS__, 'shutdownHandler']);
// Get log file path
self::$logFile = Config::get('log.file', __DIR__ . '/../logs/app.log');
// Ensure log directory exists
$logDir = dirname(self::$logFile);
if (!is_dir($logDir) && !mkdir($logDir, 0755, true)) {
error_log("Cannot create log directory: {$logDir}");
}
if (Config::isDebug()) {
error_log("Error handler initialized. Log file: " . self::$logFile);
}
}
/**
* Custom error handler
*/
public static function errorHandler($errno, $errstr, $errfile, $errline, $errcontext = null)
{
// Convert error level to readable format
$errorLevels = [
E_ERROR => 'Error',
E_WARNING => 'Warning',
E_PARSE => 'Parse Error',
E_NOTICE => 'Notice',
E_CORE_ERROR => 'Core Error',
E_CORE_WARNING => 'Core Warning',
E_COMPILE_ERROR => 'Compile Error',
E_COMPILE_WARNING => 'Compile Warning',
E_USER_ERROR => 'User Error',
E_USER_WARNING => 'User Warning',
E_USER_NOTICE => 'User Notice',
E_STRICT => 'Strict Notice',
E_RECOVERABLE_ERROR => 'Recoverable Error',
E_DEPRECATED => 'Deprecated',
E_USER_DEPRECATED => 'User Deprecated'
];
$errorType = $errorLevels[$errno] ?? 'Unknown Error';
$error = [
'type' => 'php_error',
'level' => $errno,
'error_type' => $errorType,
'message' => $errstr,
'file' => $errfile,
'line' => $errline,
'context' => isset($errcontext['this']) ? get_class($errcontext['this']) : 'global',
'timestamp' => date('Y-m-d H:i:s'),
'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
'remote_ip' => Security::getClientIP(),
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'session_id' => session_id(),
'backtrace' => self::getBacktrace()
];
// Log the error
self::logError($error);
// In development, show errors
if (Config::isEnvironment('development')) {
// Return error info for debugging without exposing sensitive data
return [
'error' => true,
'type' => $errorType,
'message' => $errstr,
'file' => basename($errfile),
'line' => $errline
];
}
// In production, don't show errors
if (Config::isEnvironment('production')) {
return false; // Let PHP handle it
}
// For PHP's error handling, still need to return false for some errors
return ($errno & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR)) ? false : true;
}
/**
* Uncaught exception handler
*/
public static function exceptionHandler($exception)
{
$error = [
'type' => 'uncaught_exception',
'class' => get_class($exception),
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'timestamp' => date('Y-m-d H:i:s'),
'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
'remote_ip' => Security::getClientIP(),
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'session_id' => session_id(),
'backtrace' => self::getBacktrace(),
'previous' => $exception->getPrevious() ? $exception->getPrevious()->getMessage() : null
];
// Log the exception
self::logError($error);
// Handle display based on environment
if (Config::isEnvironment('development')) {
// Show detailed error page
self::renderErrorPage($error, 500);
exit;
} else {
// Show generic error page in production
self::renderErrorPage([
'message' => 'An unexpected error occurred. Please try again later.'
], 500);
exit;
}
}
/**
* Shutdown handler for fatal errors
*/
public static function shutdownHandler()
{
$error = error_get_last();
if ($error !== null) {
$fatalErrors = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];
if (in_array($error['type'], $fatalErrors)) {
$error['type'] = 'fatal_error';
$error['timestamp'] = date('Y-m-d H:i:s');
$error['request_uri'] = $_SERVER['REQUEST_URI'] ?? '';
$error['remote_ip'] = Security::getClientIP();
$error['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
$error['session_id'] = session_id();
$error['backtrace'] = self::getBacktrace();
self::logError($error);
if (Config::isEnvironment('development')) {
echo "<h1>Fatal Error</h1>";
echo "<pre>" . htmlspecialchars(print_r($error, true)) . "</pre>";
} else {
self::renderErrorPage([
'message' => 'A critical error occurred. Our team has been notified.'
], 500);
}
}
}
// Clean up tasks (optional)
self::performCleanup();
}
/**
* Log error to file and/or external service
*/
private static function logError($error)
{
$logMessage = sprintf(
"[%s] %s: %s in %s:%d\nContext: %s\nIP: %s\nURI: %s\nBacktrace:\n%s\n---\n",
$error['timestamp'],
$error['type'] ?? 'unknown',
$error['message'] ?? 'Unknown error',
$error['file'] ?? 'unknown',
$error['line'] ?? 0,
$error['context'] ?? 'unknown',
$error['remote_ip'] ?? 'unknown',
$error['request_uri'] ?? 'unknown',
implode("\n", $error['backtrace'] ?? [])
);
// Write to log file
if (self::$logFile) {
$result = file_put_contents(self::$logFile, $logMessage, FILE_APPEND | LOCK_EX);
if ($result === false) {
error_log("Failed to write to error log: " . self::$logFile);
}
}
// Also log via PHP's error_log if our file fails
if (!self::$logFile || !file_exists(self::$logFile)) {
error_log("ErrorHandler: " . json_encode($error));
}
}
/**
* Get backtrace safely
*/
private static function getBacktrace()
{
try {
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
$formatted = [];
foreach ($backtrace as $i => $trace) {
$formatted[] = sprintf(
"#%d %s(%s): %s(%s)",
$i,
$trace['file'] ?? 'unknown',
$trace['line'] ?? 'unknown',
isset($trace['class']) ? $trace['class'] . '::' . $trace['function'] : $trace['function'] ?? 'unknown',
isset($trace['args']) ? json_encode(count($trace['args'])) . ' args' : 'unknown args'
);
}
return $formatted;
} catch (Exception $e) {
return ["Failed to generate backtrace: " . $e->getMessage()];
}
}
/**
* Render error page
*/
private static function renderErrorPage($error, $httpCode = 500)
{
http_response_code($httpCode);
if (!headers_sent()) {
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
}
$title = $httpCode === 404 ? 'Page Not Found' : 'Server Error';
$message = $error['message'] ?? 'An unexpected error occurred';
echo "<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>{$title}</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f9f9f9; }
.error-container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #e74c3c; margin-bottom: 20px; }
p { color: #666; line-height: 1.6; }
.error-details { text-align: left; margin-top: 30px; padding: 15px; background: #f8f8f8; border-radius: 5px; font-family: monospace; font-size: 12px; }
pre { white-space: pre-wrap; word-wrap: break-word; }
</style>
</head>
<body>
<div class='error-container'>
<h1>{$title}</h1>
<p>{$message}</p>";
if (Config::isEnvironment('development') && isset($error['file'])) {
echo "<div class='error-details'>
<strong>File:</strong> {$error['file']}<br>
<strong>Line:</strong> {$error['line']}<br>
<strong>Type:</strong> " . ($error['error_type'] ?? $error['type'] ?? 'unknown') . "
</div>";
}
echo "</div>
</body>
</html>";
}
/**
* Perform cleanup tasks on shutdown
*/
private static function performCleanup()
{
// Clean up expired sessions or temporary files if needed
// This could be expanded based on application needs
// Example: Clean up old temp files
$tempDir = sys_get_temp_dir();
$pattern = $tempDir . '/dodgers_*';
foreach (glob($pattern) as $file) {
// Remove files older than 1 hour
if (filemtime($file) < time() - 3600) {
@unlink($file);
}
}
}
/**
* Handle API errors with JSON response
*/
public static function apiError($message, $code = 500, $details = null)
{
$error = [
'success' => false,
'error' => $message,
'code' => $code,
'timestamp' => time()
];
if (Config::isEnvironment('development') && $details) {
$error['details'] = $details;
}
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($error);
}
/**
* Log security events
*/
public static function logSecurityEvent($event, $details = [])
{
$logEntry = [
'type' => 'security_event',
'event' => $event,
'details' => $details,
'timestamp' => date('Y-m-d H:i:s'),
'remote_ip' => Security::getClientIP(),
'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'session_id' => session_id()
];
self::logError($logEntry);
}
/**
* Log performance metrics
*/
public static function logPerformance($operation, $duration, $details = [])
{
$logEntry = [
'type' => 'performance',
'operation' => $operation,
'duration' => $duration,
'details' => $details,
'timestamp' => date('Y-m-d H:i:s'),
'remote_ip' => Security::getClientIP(),
'session_id' => session_id()
];
self::logError($logEntry);
}
}

103
includes/autoloader.php Normal file
View file

@ -0,0 +1,103 @@
<?php
/**
* PSR-4 Autoloader
* Automatically loads classes based on PSR-4 standards
*/
spl_autoload_register(function ($className) {
// PSR-4 mapping for the application
$prefixes = [
'App\\' => __DIR__ . '/../app/',
'Models\\' => __DIR__ . '/../models/',
'Controllers\\' => __DIR__ . '/../controllers/',
'Utils\\' => __DIR__ . '/../utils/',
'Services\\' => __DIR__ . '/../services/',
'Middleware\\' => __DIR__ . '/../middleware/'
];
// Check for exact class match first (for legacy classes)
$legacyMappings = [
'Config' => __DIR__ . '/Config.php',
'Security' => __DIR__ . '/../utils/Security.php',
'Validation' => __DIR__ . '/../utils/Validation.php',
'Database' => __DIR__ . '/Database.php',
'UserModel' => __DIR__ . '/../models/UserModel.php',
'ChatMessageModel' => __DIR__ . '/../models/ChatMessageModel.php',
'ActiveViewerModel' => __DIR__ . '/../models/ActiveViewerModel.php'
];
// First check legacy mappings
if (isset($legacyMappings[$className])) {
$file = $legacyMappings[$className];
if (file_exists($file)) {
require_once $file;
return;
}
}
// Check PSR-4 mappings
foreach ($prefixes as $prefix => $baseDir) {
$len = strlen($prefix);
if (strncmp($prefix, $className, $len) !== 0) {
continue;
}
$relativeClass = substr($className, $len);
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require_once $file;
if (Config::isDebug()) {
error_log("Autoloaded: {$className} from {$file}");
}
return;
}
}
// Class not found - this will throw an exception from spl_autoload_register
if (Config::isDebug()) {
error_log("Autoloader: Class {$className} not found in any mapping");
}
});
/**
* Optional: Load additional helper functions
*/
if (file_exists(__DIR__ . '/helpers.php')) {
require_once __DIR__ . '/helpers.php';
}
/**
* Optional: Load composer autoloader if it exists (for future dependencies)
*/
$composerAutoloader = __DIR__ . '/../vendor/autoload.php';
if (file_exists($composerAutoloader)) {
require_once $composerAutoloader;
}
// Verify critical classes are loaded
$criticalClasses = [
'Config',
'Security',
'Validation',
'Database'
];
foreach ($criticalClasses as $class) {
if (!class_exists($class, false)) {
// Try to load manually
$legacyPaths = [
'Config' => 'includes/Config.php',
'Security' => 'utils/Security.php',
'Validation' => 'utils/Validation.php',
'Database' => 'includes/Database.php'
];
if (isset($legacyPaths[$class])) {
$path = __DIR__ . '/../' . $legacyPaths[$class];
if (file_exists($path)) {
require_once $path;
}
}
}
}