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

View file

@ -0,0 +1,199 @@
<?php
/**
* Active Viewer Model
* Handles real-time viewer tracking and activity monitoring
*/
class ActiveViewerModel
{
private $db;
public function __construct()
{
$this->db = Database::getInstance();
}
/**
* Update or create active viewer record
*/
public function heartbeat($userId, $data = [])
{
$sql = "INSERT OR REPLACE INTO active_viewers
(user_id, nickname, ip_address, session_id, is_admin)
VALUES (?, ?, ?, ?, ?)";
$params = [
$userId,
$data['nickname'] ?? 'Anonymous',
$data['ip_address'] ?? Security::getClientIP(),
$data['session_id'] ?? session_id(),
$data['is_admin'] ?? false
];
return $this->db->insert($sql, $params);
}
/**
* Get count of active viewers (seen within last X seconds)
*/
public function getActiveCount($thresholdSeconds = 30)
{
return $this->db->fetchColumn(
"SELECT COUNT(*) FROM active_viewers WHERE last_seen >= datetime('now', '-{$thresholdSeconds} seconds')"
);
}
/**
* Get list of active viewers with details
*/
public function getActiveViewers($thresholdSeconds = 30)
{
return $this->db->fetchAll(
"SELECT * FROM active_viewers WHERE last_seen >= datetime('now', '-{$thresholdSeconds} seconds') ORDER BY last_seen DESC"
);
}
/**
* Clean up inactive viewers
*/
public function cleanupInactive($thresholdSeconds = 60)
{
return $this->db->delete(
"DELETE FROM active_viewers WHERE last_seen < datetime('now', '-{$thresholdSeconds} seconds')"
);
}
/**
* Update specific viewer's last seen time
*/
public function updateLastSeen($userId)
{
return $this->db->update(
"UPDATE active_viewers SET last_seen = ? WHERE user_id = ?",
[date('Y-m-d H:i:s'), $userId]
);
}
/**
* Remove viewer from active list
*/
public function removeViewer($userId)
{
return $this->db->delete(
"DELETE FROM active_viewers WHERE user_id = ?",
[$userId]
);
}
/**
* Get viewer by user ID
*/
public function getViewer($userId)
{
return $this->db->fetch(
"SELECT * FROM active_viewers WHERE user_id = ?",
[$userId]
);
}
/**
* Bulk update multiple viewers (for mass heartbeat handling)
*/
public function bulkHeartbeat($viewersData)
{
$this->db->beginTransaction();
try {
foreach ($viewersData as $viewerData) {
$userId = $viewerData['user_id'] ?? '';
if (!empty($userId)) {
$this->heartbeat($userId, $viewerData);
}
}
$this->db->commit();
return true;
} catch (Exception $e) {
$this->db->rollback();
error_log("Bulk heartbeat failed: " . $e->getMessage());
return false;
}
}
/**
* Get viewer activity statistics
*/
public function getActivityStats()
{
$stats = [];
// Current active count
$stats['currently_active'] = $this->db->fetchColumn(
"SELECT COUNT(*) FROM active_viewers WHERE last_seen >= datetime('now', '-30 seconds')"
);
// Peak today
$stats['peak_today'] = $this->db->fetchColumn(
"SELECT COUNT(*) FROM active_viewers WHERE DATE(last_seen) = DATE('now')"
);
// Viewer types
$stats['admin_count'] = $this->db->fetchColumn(
"SELECT COUNT(*) FROM active_viewers WHERE is_admin = 1 AND last_seen >= datetime('now', '-30 seconds')"
);
// Recent joiners (joined in last 5 minutes)
$stats['recent_joiners'] = $this->db->fetchColumn(
"SELECT COUNT(*) FROM active_viewers WHERE last_seen >= datetime('now', '-5 minutes')"
);
// Top user agents
$stats['user_agents'] = $this->db->fetchAll(
"SELECT user_agent, COUNT(*) as count FROM active_viewers
WHERE last_seen >= datetime('now', '-1 hour')
GROUP BY user_agent
ORDER BY count DESC LIMIT 5"
);
return $stats;
}
/**
* Migrate old file-based viewer data if exists
*/
public function migrateFromFileIfNeeded()
{
$oldViewersFile = __DIR__ . '/../active_viewers.json';
if (!file_exists($oldViewersFile)) {
return;
}
try {
$oldData = json_decode(file_get_contents($oldViewersFile), true);
if (is_array($oldData)) {
$this->db->beginTransaction();
foreach ($oldData as $viewer) {
if (!empty($viewer['user_id'])) {
$this->heartbeat($viewer['user_id'], [
'nickname' => $viewer['nickname'] ?? 'Anonymous',
'ip_address' => $viewer['ip_address'] ?? '',
'session_id' => $viewer['session_id'] ?? '',
'is_admin' => $viewer['is_admin'] ?? false
]);
}
}
$this->db->commit();
// Backup old file and remove it
rename($oldViewersFile, $oldViewersFile . '.migrated.' . date('Y-m-d_H-i-s'));
error_log("Migrated " . count($oldData) . " viewers from file to database");
}
} catch (Exception $e) {
error_log("Migration from file failed: " . $e->getMessage());
}
}
}

187
models/ChatMessageModel.php Normal file
View file

@ -0,0 +1,187 @@
<?php
/**
* Chat Message Model
* Handles chat message-related database operations
*/
class ChatMessageModel
{
private $db;
public function __construct()
{
$this->db = Database::getInstance();
}
/**
* Create new chat message
*/
public function create($data)
{
$sql = "INSERT INTO chat_messages
(user_id, nickname, message, is_admin, ip_address, time_formatted)
VALUES (?, ?, ?, ?, ?, ?)";
$params = [
$data['user_id'],
$data['nickname'] ?? 'Anonymous',
$data['message'],
$data['is_admin'] ?? false,
Security::getClientIP(),
date('M j, H:i')
];
return $this->db->insert($sql, $params);
}
/**
* Get messages with pagination (newest first, limit count)
*/
public function getRecent($limit = 100, $offset = 0)
{
return $this->db->fetchAll(
"SELECT * FROM chat_messages ORDER BY timestamp DESC LIMIT ? OFFSET ?",
[$limit, $offset]
);
}
/**
* Get messages since specific ID (for incremental updates)
*/
public function getMessagesAfterId($lastId)
{
return $this->db->fetchAll(
"SELECT * FROM chat_messages WHERE id > ? ORDER BY timestamp ASC",
[$lastId]
);
}
/**
* Get messages since specific timestamp
*/
public function getMessagesAfterTimestamp($timestamp)
{
return $this->db->fetchAll(
"SELECT * FROM chat_messages WHERE timestamp > ? ORDER BY timestamp ASC",
[$timestamp]
);
}
/**
* Delete message by ID (admin function)
*/
public function deleteById($messageId)
{
return $this->db->delete(
"DELETE FROM chat_messages WHERE id = ?",
[$messageId]
);
}
/**
* Delete messages by user ID (bulk operation)
*/
public function deleteByUserId($userId)
{
return $this->db->delete(
"DELETE FROM chat_messages WHERE user_id = ?",
[$userId]
);
}
/**
* Clear all chat messages
*/
public function clearAll()
{
return $this->db->delete("DELETE FROM chat_messages");
}
/**
* Get message by ID
*/
public function getById($messageId)
{
return $this->db->fetch(
"SELECT * FROM chat_messages WHERE id = ?",
[$messageId]
);
}
/**
* Get total message count
*/
public function getTotalCount()
{
return $this->db->fetchColumn("SELECT COUNT(*) FROM chat_messages");
}
/**
* Get messages by user ID
*/
public function getByUserId($userId, $limit = 50)
{
return $this->db->fetchAll(
"SELECT * FROM chat_messages WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?",
[$userId, $limit]
);
}
/**
* Search messages by content
*/
public function searchMessages($query, $limit = 50)
{
$searchTerm = '%' . $query . '%';
return $this->db->fetchAll(
"SELECT * FROM chat_messages WHERE message LIKE ? ORDER BY timestamp DESC LIMIT ?",
[$searchTerm, $limit]
);
}
/**
* Get message statistics
*/
public function getStats()
{
$stats = [];
// Total messages
$stats['total_messages'] = $this->db->fetchColumn("SELECT COUNT(*) FROM chat_messages");
// Messages in last 24 hours
$stats['messages_24h'] = $this->db->fetchColumn(
"SELECT COUNT(*) FROM chat_messages WHERE timestamp >= datetime('now', '-1 day')"
);
// Messages in last hour
$stats['messages_1h'] = $this->db->fetchColumn(
"SELECT COUNT(*) FROM chat_messages WHERE timestamp >= datetime('now', '-1 hour')"
);
// Unique users who posted today
$stats['active_users_today'] = $this->db->fetchColumn(
"SELECT COUNT(DISTINCT user_id) FROM chat_messages WHERE DATE(timestamp) = DATE('now')"
);
// Most active user today
$stats['most_active_user'] = $this->db->fetch(
"SELECT user_id, COUNT(*) as message_count FROM chat_messages
WHERE DATE(timestamp) = DATE('now')
GROUP BY user_id
ORDER BY message_count DESC LIMIT 1"
);
return $stats;
}
/**
* Clean up old messages (older than specified days)
*/
public function cleanupOldMessages($days = 7)
{
return $this->db->delete(
"DELETE FROM chat_messages WHERE timestamp < datetime('now', '-{$days} days')"
);
}
}

138
models/UserModel.php Normal file
View file

@ -0,0 +1,138 @@
<?php
/**
* User Model
* Handles user-related database operations
*/
class UserModel
{
private $db;
public function __construct()
{
$this->db = Database::getInstance();
}
/**
* Create or update user record
*/
public function createOrUpdate($userId, $data = [])
{
$sql = "INSERT OR REPLACE INTO users
(user_id, nickname, ip_address, user_agent, last_seen, session_id)
VALUES (?, ?, ?, ?, ?, ?)";
$params = [
$userId,
$data['nickname'] ?? 'Anonymous',
$data['ip_address'] ?? Security::getClientIP(),
$data['user_agent'] ?? $_SERVER['HTTP_USER_AGENT'] ?? '',
date('Y-m-d H:i:s'),
$data['session_id'] ?? session_id()
];
return $this->db->insert($sql, $params);
}
/**
* Get user by user_id
*/
public function getByUserId($userId)
{
return $this->db->fetch(
"SELECT * FROM users WHERE user_id = ?",
[$userId]
);
}
/**
* Update user last seen
*/
public function updateLastSeen($userId)
{
return $this->db->update(
"UPDATE users SET last_seen = ? WHERE user_id = ?",
[date('Y-m-d H:i:s'), $userId]
);
}
/**
* Get all active users (seen within last 30 seconds)
*/
public function getActiveUsers($seconds = 30)
{
return $this->db->fetchAll(
"SELECT * FROM users WHERE last_seen >= datetime('now', '-{$seconds} seconds') ORDER BY last_seen DESC"
);
}
/**
* Clean up old user records (older than specified days)
*/
public function cleanupOldRecords($days = 30)
{
return $this->db->delete(
"DELETE FROM users WHERE last_seen < datetime('now', '-{$days} days')"
);
}
/**
* Check if user is banned
*/
public function isBanned($userId)
{
$result = $this->db->fetch(
"SELECT * FROM banned_users WHERE user_id = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
[$userId]
);
return $result !== false;
}
/**
* Ban user
*/
public function banUser($userId, $adminUserId, $reason = '', $expiresAt = null)
{
if ($this->isBanned($userId)) {
// Already banned, update if needed
$sql = "UPDATE banned_users SET reason = ?, expires_at = ?, banned_by = ? WHERE user_id = ?";
return $this->db->update($sql, [$reason, $expiresAt, $adminUserId, $userId]);
} else {
// New ban
$sql = "INSERT INTO banned_users (user_id, reason, banned_by, banned_at, expires_at) VALUES (?, ?, ?, ?, ?)";
return $this->db->insert($sql, [$userId, $reason, $adminUserId, date('Y-m-d H:i:s'), $expiresAt]);
}
}
/**
* Unban user
*/
public function unbanUser($userId)
{
return $this->db->delete(
"DELETE FROM banned_users WHERE user_id = ?",
[$userId]
);
}
/**
* Get banned users
*/
public function getBannedUsers()
{
return $this->db->fetchAll(
"SELECT bu.*, u.nickname FROM banned_users bu LEFT JOIN users u ON bu.user_id = u.user_id ORDER BY bu.banned_at DESC"
);
}
/**
* Update user nickname
*/
public function updateNickname($userId, $nickname)
{
return $this->db->update(
"UPDATE users SET nickname = ? WHERE user_id = ?",
[$nickname, $userId]
);
}
}