Compare commits

...

11 commits

Author SHA1 Message Date
92cabddc3b
Add SocialClientSessionTest class for unit testing
Some checks failed
CI / release (push) Has been cancelled
CI / debug (push) Has been cancelled
CI / release_executable (push) Has been cancelled
CI / debug_executable (push) Has been cancelled
CI / check-phpunit (push) Has been cancelled
CI / check-phpdoc (push) Has been cancelled
CI / generate-phpdoc (push) Has been cancelled
CI / test (push) Has been cancelled
CI / release-documentation (push) Has been cancelled
CI / release-artifacts (push) Has been cancelled
2025-03-14 15:26:03 -04:00
748b0b2c37
Add Helper class with random username generator method 2025-03-14 15:25:59 -04:00
53673b596a
Removed unnecessary blank line in SessionFlagsTest.php 2025-03-14 15:25:54 -04:00
d097d2bb78
Removed unused ping.http 2025-03-14 15:25:49 -04:00
01a6447c13
Updated phpunit.xml to removed unused property 2025-03-14 15:25:43 -04:00
6fee098407
Added logger.py for Docker 2025-03-14 15:25:34 -04:00
167a010332
Removed unused utilities.js 2025-03-14 15:25:25 -04:00
f157eee07c
Added test signatures 2025-03-14 15:25:15 -04:00
dc65836b8b
Update Docker configuration for test environment and add logging server 2025-03-14 15:25:06 -04:00
0764ca9bde
Add test environment definitions and mock DNS records in bootstrap.php 2025-03-14 15:24:51 -04:00
0dc9e032d2
Add paths to .gitignore for Docker test data directories 2025-03-14 15:24:47 -04:00
18 changed files with 455 additions and 55 deletions

2
.gitignore vendored
View file

@ -9,3 +9,5 @@
/coffee_socialbox/logs/
/teapot_socialbox/data/
/teapot_socialbox/logs/
/tests/docker/coffee/data/
/tests/docker/teapot/data/

View file

@ -107,6 +107,8 @@ COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Copy docker.conf & zz-docker.conf for PHP-FPM
COPY docker/docker.conf /usr/local/etc/php-fpm.d/docker.conf
COPY docker/zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf
# Copy the logging server script over
COPY docker/logger.py /logger.py
# Configure php.ini and enable error and log it to /var/log rather than stdout
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini && \

View file

@ -1,3 +1,52 @@
<?php
/** @noinspection PhpDefineCanBeReplacedWithConstInspection */
use Socialbox\Classes\ServerResolver;
require 'ncc';
import('net.nosial.socialbox');
// Definitions for the test environment
if(!defined('SB_TEST'))
{
$dockerTestPath = __DIR__ . DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'docker' . DIRECTORY_SEPARATOR;
$helperClassPath = __DIR__ . DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'Helper.php';
if(!file_exists($dockerTestPath))
{
throw new RuntimeException('Docker test path not found: ' . $dockerTestPath);
}
if(!file_exists($helperClassPath))
{
throw new RuntimeException('Helper class not found: ' . $helperClassPath);
}
require $helperClassPath;
// global
define('SB_TEST', 1);
putenv('LOG_LEVEL=debug');
// coffee.com
define('COFFEE_DOMAIN', 'coffee.com');
define('COFFEE_RPC_HOST', '127.0.0.0');
define('COFFEE_RPC_PORT', 8086);
define('COFFEE_RPC_SSL', false);
define('COFFEE_PUBLIC_KEY', file_get_contents($dockerTestPath . 'coffee' . DIRECTORY_SEPARATOR . 'signature.pub'));
define('COFFEE_PRIVATE_KEY', file_get_contents($dockerTestPath . 'coffee' . DIRECTORY_SEPARATOR . 'signature.pk'));
// teapot.com
define('TEAPOT_DOMAIN', 'teapot.com');
define('TEAPOT_RPC_HOST', '127.0.0.0');
define('TEAPOT_RPC_PORT', 8087);
define('TEAPOT_RPC_SSL', false);
define('TEAPOT_PUBLIC_KEY', file_get_contents($dockerTestPath . 'teapot' . DIRECTORY_SEPARATOR . 'signature.pub'));
define('TEAPOT_PRIVATE_KEY', file_get_contents($dockerTestPath . 'teapot' . DIRECTORY_SEPARATOR . 'signature.pk'));
// Define mocked dns server records for testing purposes
ServerResolver::addMock(COFFEE_DOMAIN, sprintf('v=socialbox;sb-rpc=%s://%s:%d/;sb-key=%s;sb-exp=0', (COFFEE_RPC_SSL ? 'https' : 'http'), COFFEE_RPC_HOST, COFFEE_RPC_PORT, COFFEE_PUBLIC_KEY));
ServerResolver::addMock(TEAPOT_DOMAIN, sprintf('v=socialbox;sb-rpc=%s://%s:%d/;sb-key=%s;sb-exp=0', (TEAPOT_RPC_SSL ? 'https' : 'http'), TEAPOT_RPC_HOST, TEAPOT_RPC_PORT, TEAPOT_PUBLIC_KEY));
}

View file

@ -22,8 +22,8 @@ services:
- shared_network
restart: unless-stopped
volumes:
- ./coffee_socialbox/config:/etc/config
- ./coffee_socialbox/data:/etc/socialbox
- ./tests/docker/coffee/config:/etc/config
- ./tests/docker/coffee/data:/etc/socialbox
environment:
# No need to change these values
LOG_LEVEL: ${LOG_LEVEL:-debug}
@ -132,8 +132,8 @@ services:
- shared_network
restart: unless-stopped
volumes:
- ./teapot_socialbox/config:/etc/config
- ./teapot_socialbox/data:/etc/socialbox
- ./tests/docker/teapot/config:/etc/config
- ./tests/docker/teapot/data:/etc/socialbox
environment:
# No need to change these values
LOG_LEVEL: ${LOG_LEVEL:-debug}

345
docker/logger.py Normal file
View file

@ -0,0 +1,345 @@
import argparse
import json
import logging
import os
import socket
import threading
from datetime import datetime
from queue import Queue, Empty
from enum import Enum
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
import colorama
from colorama import Fore, Back, Style
# Initialize colorama for cross-platform color support
colorama.init()
class LogLevel(str, Enum):
DEBUG = "DBG"
VERBOSE = "VRB"
INFO = "INFO"
WARNING = "WRN"
ERROR = "ERR"
CRITICAL = "CRT"
@classmethod
def to_python_level(cls, level: str) -> int:
return {
cls.DEBUG: logging.DEBUG,
cls.VERBOSE: logging.DEBUG,
cls.INFO: logging.INFO,
cls.WARNING: logging.WARNING,
cls.ERROR: logging.ERROR,
cls.CRITICAL: logging.CRITICAL
}.get(level, logging.INFO)
@classmethod
def get_color(cls, level: str) -> str:
return {
cls.DEBUG: Fore.CYAN,
cls.VERBOSE: Fore.BLUE,
cls.INFO: Fore.GREEN,
cls.WARNING: Fore.YELLOW,
cls.ERROR: Fore.RED,
cls.CRITICAL: Fore.RED + Back.WHITE
}.get(level, Fore.WHITE)
@dataclass
class StackFrame:
file: Optional[str]
line: Optional[int]
function: Optional[str]
args: Optional[List[Any]]
class_name: Optional[str]
call_type: str = 'static'
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'StackFrame':
return cls(
file=str(data.get('file')) if data.get('file') else None,
line=int(data['line']) if data.get('line') is not None else None,
function=str(data.get('function')) if data.get('function') else None,
args=data.get('args'),
class_name=str(data.get('class')) if data.get('class') else None,
call_type=str(data.get('callType', 'static'))
)
def format(self) -> str:
location = f"{self.file or '?'}:{self.line or '?'}"
if self.class_name:
call = f"{self.class_name}{self.call_type}{self.function or ''}"
else:
call = self.function or ''
args_str = ""
if self.args:
args_str = f"({', '.join(str(arg) for arg in self.args)})"
return f"{Fore.BLUE}{call}{Style.RESET_ALL}{args_str} in {Fore.CYAN}{location}{Style.RESET_ALL}"
class ExceptionDetails:
def __init__(self, name: str, message: str, code: Optional[int],
file: Optional[str], line: Optional[int],
trace: List[StackFrame], previous: Optional['ExceptionDetails']):
self.name = name
self.message = message
self.code = code
self.file = file
self.line = line
self.trace = trace
self.previous = previous
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Optional['ExceptionDetails']:
if not data:
return None
trace = []
if 'trace' in data and isinstance(data['trace'], list):
trace = [StackFrame.from_dict(frame) for frame in data['trace']
if isinstance(frame, dict)]
previous = None
if 'previous' in data and isinstance(data['previous'], dict):
previous = cls.from_dict(data['previous'])
return cls(
name=str(data.get('name', '')),
message=str(data.get('message', '')),
code=int(data['code']) if data.get('code') is not None else None,
file=str(data.get('file')) if data.get('file') else None,
line=int(data['line']) if data.get('line') is not None else None,
trace=trace,
previous=previous
)
def format(self, level: int = 0) -> str:
indent = " " * level
parts = []
# Exception header
header = f"{indent}{Fore.RED}{self.name}"
if self.code is not None and 0:
header += f" {Fore.YELLOW}:{self.code}{Style.RESET_ALL}"
# Message
header += f"{Fore.WHITE}:{Style.RESET_ALL} {self.message}"
# Location
if self.file and self.line:
header += f"{Fore.WHITE} at {Style.RESET_ALL}{self.file}:{self.line}"
parts.append(header)
# Stack trace
if self.trace:
parts.append(f"{indent}{Fore.WHITE}Stack trace:{Style.RESET_ALL}")
for frame in self.trace:
parts.append(f"{indent}{frame.format()}")
# Previous exception
if self.previous:
parts.append(f"{indent}{Fore.YELLOW}Caused by:{Style.RESET_ALL}")
parts.append(self.previous.format(level + 1))
return "\n".join(parts)
class ColoredLogger(logging.Logger):
def __init__(self, name: str):
super().__init__(name)
self.formatter = logging.Formatter(
f'%(asctime)s {Fore.WHITE}[%(levelname)s]{Style.RESET_ALL} %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
console_handler = logging.StreamHandler()
console_handler.setFormatter(self.formatter)
self.addHandler(console_handler)
class MultiProtocolServer:
def __init__(self, host: str, port: int, working_directory: str):
self.host = host
self.port = port
self.working_directory = working_directory
self.log_queue: Queue = Queue()
self.current_date = datetime.now().strftime('%Y-%m-%d')
self.log_file = None
self.stop_event = threading.Event()
os.makedirs(self.working_directory, exist_ok=True)
# Set up colored logging
logging.setLoggerClass(ColoredLogger)
self.logger = logging.getLogger("MultiProtocolServer")
self.logger.setLevel(logging.DEBUG)
def _handle_log_event(self, data: Dict[str, Any], address: tuple) -> None:
"""Process and format a structured log event with colors and proper formatting."""
try:
app_name = data.get('application_name', 'Unknown')
timestamp = data.get('timestamp')
if timestamp:
try:
timestamp = datetime.fromtimestamp(int(timestamp))
timestamp = timestamp.strftime('%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
else:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
level = data.get('level', 'INFO')
message = data.get('message', '')
# Format the log message with colors
color = LogLevel.get_color(level)
log_message = f"{color}[{app_name}]{Style.RESET_ALL} {message}"
# Handle exception if present
exception_data = data.get('exception')
if exception_data:
exception = ExceptionDetails.from_dict(exception_data)
if exception:
log_message += f"\n{exception.format()}"
# Log with appropriate level
python_level = LogLevel.to_python_level(level)
self.logger.log(python_level, log_message)
# Add to log queue for file logging
self.log_queue.put({
"timestamp": timestamp,
"address": address,
"data": data
})
except Exception as e:
self.logger.error(f"Error processing log event: {e}", exc_info=True)
def _handle_data(self, data: bytes, address: tuple) -> None:
"""Process incoming data and attempt to parse as JSON."""
try:
decoded_data = data.decode('utf-8').strip()
try:
json_data = json.loads(decoded_data)
# Handle structured log event
self._handle_log_event(json_data, address)
except json.JSONDecodeError:
# Log raw data if not valid JSON
self.logger.info(f"Received non-JSON data from {address}: {decoded_data}")
self.log_queue.put({
"timestamp": datetime.now().isoformat(),
"address": address,
"data": decoded_data
})
except Exception as e:
self.logger.error(f"Data handling error: {e}")
# Rest of the class remains the same...
def _get_log_file(self):
date = datetime.now().strftime('%Y-%m-%d')
if date != self.current_date or self.log_file is None:
if self.log_file:
self.log_file.close()
self.current_date = date
filename = os.path.join(self.working_directory, f"log{date}.jsonl")
self.log_file = open(filename, 'a')
return self.log_file
def _log_writer(self):
while not self.stop_event.is_set() or not self.log_queue.empty():
try:
data = self.log_queue.get(timeout=1)
log_file = self._get_log_file()
json.dump(data, log_file)
log_file.write('\n')
log_file.flush()
except Empty:
continue
except Exception as e:
self.logger.error(f"Error writing to log file: {e}")
def _handle_tcp_client(self, client_socket, address):
self.logger.info(f"TCP connection established from {address}")
try:
with client_socket:
while True:
data = client_socket.recv(4096)
if not data:
break
self._handle_data(data, address)
except Exception as e:
self.logger.error(f"TCP client error: {e}")
self.logger.info(f"TCP connection closed from {address}")
def _handle_udp_client(self, data, address):
try:
self._handle_data(data, address)
except Exception as e:
self.logger.error(f"UDP client error: {e}")
def _start_tcp_server(self):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as tcp_socket:
tcp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
tcp_socket.bind((self.host, self.port))
tcp_socket.listen(5)
self.logger.debug(f"TCP server running on {self.host}:{self.port}")
while not self.stop_event.is_set():
try:
client_socket, address = tcp_socket.accept()
threading.Thread(target=self._handle_tcp_client,
args=(client_socket, address),
daemon=True).start()
except Exception as e:
self.logger.error(f"TCP server error: {e}")
def _start_udp_server(self):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp_socket:
udp_socket.bind((self.host, self.port))
self.logger.debug(f"UDP server running on {self.host}:{self.port}")
while not self.stop_event.is_set():
try:
data, address = udp_socket.recvfrom(4096)
self._handle_udp_client(data, address)
except Exception as e:
self.logger.error(f"UDP server error: {e}")
def start(self):
self.logger.info("Starting MultiProtocolServer...")
threading.Thread(target=self._log_writer, daemon=True).start()
tcp_thread = threading.Thread(target=self._start_tcp_server, daemon=True)
udp_thread = threading.Thread(target=self._start_udp_server, daemon=True)
tcp_thread.start()
udp_thread.start()
try:
tcp_thread.join()
udp_thread.join()
except KeyboardInterrupt:
self.stop()
def stop(self):
self.logger.info("Stopping Logging Server...")
self.stop_event.set()
if self.log_file:
self.log_file.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Logging Server")
parser.add_argument("-p", "--port", type=int, default=8080,
help="Port to listen on")
parser.add_argument("-w", "--working-directory", type=str,
default="./logs", help="Directory to store log files")
args = parser.parse_args()
server = MultiProtocolServer("0.0.0.0", args.port, args.working_directory)
server.start()

View file

@ -10,6 +10,20 @@ nodaemon=true
minfds=1024
minprocs=200
[program:logger]
command=python3 -m /logger.py --port 5131
autostart=true
autorestart=true
priority=1
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile=/var/log/socialbox.log
stderr_logfile=/var/log/socialbox_error.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=5
stderr_logfile_maxbytes=20MB
stderr_logfile_backups=5
[program:php-fpm]
command=/usr/local/sbin/php-fpm --nodaemonize
autostart=true
@ -22,11 +36,6 @@ stdout_logfile_backups=5
stderr_logfile_maxbytes=0
stderr_logfile_backups=5
[program:php-fpm-log]
command=tail -f /var/log/fpm.log /var/log/fpm_error.log
stdout_events_enabled=true
stderr_events_enabled=true
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;" -c /etc/nginx/nginx.conf
autostart=true

View file

@ -6,6 +6,5 @@
</testsuites>
<php>
<ini name="error_reporting" value="-1"/>
<server name="KERNEL_DIR" value="app/"/>
</php>
</phpunit>

26
tests/Helper.php Normal file
View file

@ -0,0 +1,26 @@
<?php
class Helper
{
/**
* Generates a random username based on the given domain.
*
* @param string $domain The domain to be appended to the generated username.
* @param int $length The length of the random string.
* @param string $prefix The prefix to be appended to the generated username.
* @return string Returns a randomly generated username in the format 'user<randomString>@<domain>'.
*/
public static function generateRandomPeer(string $domain, int $length=16, string $prefix='userTest'): string
{
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < $length; $i++)
{
$randomString .= $characters[rand(0, $charactersLength - 1)];
}
return sprintf('%s%s@%s', $prefix, $randomString, $domain);
}
}

View file

@ -101,7 +101,6 @@
$flags = [];
$result = SessionFlags::isComplete($flags);
$this->assertTrue($result);
}

View file

@ -0,0 +1,9 @@
<?php
namespace Socialbox;
use PHPUnit\Framework\TestCase;
class SocialClientSessionTest extends TestCase
{
}

View file

@ -0,0 +1 @@
sig:tTVe59Ko5XuwgS8PneR92FAOqbgSHTKYn8U-lQRB9KODn0J_yPXCZCZGDUyS95hul2Jn7X7-EVT15FEmZADCZw

View file

@ -0,0 +1 @@
sig:g59Cf8j1wmQmRg1MkveYbpdiZ-1-_hFU9eRRJmQAwmc

View file

@ -0,0 +1 @@
sig:kPfGxpsnisJIp5pKuD1AI7-T1bLk1S-EGOr7jBq5AO4wNdS6uKkCj8gC_4RlMSgWGkh2GxfF8ws26dKdDPFiJg

View file

@ -0,0 +1 @@
sig:MDXUuripAo_IAv-EZTEoFhpIdhsXxfMLNunSnQzxYiY

View file

@ -1,11 +0,0 @@
< {%
import {randomCrc32String} from "./utilities.js";
request.variables.set("id", randomCrc32String());
%}
POST http://172.27.7.211/
Content-Type: application/json
{
"method": "ping",
"id": "{{id}}"
}

View file

@ -1,33 +0,0 @@
export function crc32(str) {
var crcTable = [];
for (var i = 0; i < 256; i++) {
var crc = i;
for (var j = 8; j > 0; j--) {
if (crc & 1) {
crc = (crc >>> 1) ^ 0xEDB88320;
} else {
crc = crc >>> 1;
}
}
crcTable[i] = crc;
}
var crc32val = 0xFFFFFFFF;
for (var i = 0; i < str.length; i++) {
var charCode = str.charCodeAt(i);
crc32val = (crc32val >>> 8) ^ crcTable[(crc32val ^ charCode) & 0xFF];
}
return (crc32val ^ 0xFFFFFFFF) >>> 0;
}
export function randomCrc32String(length = 8) {
var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var randomStr = '';
for (var i = 0; i < length; i++) {
randomStr += characters.charAt(Math.floor(Math.random() * characters.length));
}
return crc32(randomStr).toString(16); // Convert to hexadecimal string
}
console.log(randomCrc32String()); // Example usage