dev
This commit is contained in:
parent
b3c8188ee3
commit
b6a8133170
2
.gitignore
vendored
2
.gitignore
vendored
@ -7,6 +7,8 @@
|
|||||||
config.json
|
config.json
|
||||||
App/config/config.local.php
|
App/config/config.local.php
|
||||||
|
|
||||||
|
/App/logs/
|
||||||
|
|
||||||
**/files/**/*
|
**/files/**/*
|
||||||
!**/files/**/
|
!**/files/**/
|
||||||
!**/files/**/.gitkeep
|
!**/files/**/.gitkeep
|
||||||
|
|||||||
@ -27,4 +27,9 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'path' => APPPATH . '/logs',
|
||||||
|
'threshold' => 'debug',
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
33
CLAUDE.md
33
CLAUDE.md
@ -84,6 +84,7 @@ PhpStorm может показывать предупреждение «Namespac
|
|||||||
| `System\Classes\Cookie` | Статический хелпер для работы с куками: `get()`, `set()`, `delete()` |
|
| `System\Classes\Cookie` | Статический хелпер для работы с куками: `get()`, `set()`, `delete()` |
|
||||||
| `System\Classes\Session` | Синглтон сессии: `instance(string $name)`, `get/set/delete/destroy/regenerate` |
|
| `System\Classes\Session` | Синглтон сессии: `instance(string $name)`, `get/set/delete/destroy/regenerate` |
|
||||||
| `System\Classes\Config` | Статический конфиг: lazy-load `App/config/config.php` + merge `config.local.php` |
|
| `System\Classes\Config` | Статический конфиг: lazy-load `App/config/config.php` + merge `config.local.php` |
|
||||||
|
| `System\Classes\Log` | Файловый логгер: уровни, аудит запросов, лог ошибок; `info()/error()`, маскировка |
|
||||||
| `System\Classes\Validation` | Валидация входных данных по декларативным правилам; `factory()`, `rule()`, `check()`, `errors()` |
|
| `System\Classes\Validation` | Валидация входных данных по декларативным правилам; `factory()`, `rule()`, `check()`, `errors()` |
|
||||||
| `System\Classes\CSRF` | Защита форм от CSRF; токен в сессии: `token()`, `validate()`, `field()` |
|
| `System\Classes\CSRF` | Защита форм от CSRF; токен в сессии: `token()`, `validate()`, `field()` |
|
||||||
| `System\Classes\HTTP` | `redirect()`, `requestHeaders()` |
|
| `System\Classes\HTTP` | `redirect()`, `requestHeaders()` |
|
||||||
@ -240,6 +241,35 @@ if (!CSRF::validate(Request::$current->post(CSRF::$key))) {
|
|||||||
- `field()` — готовый `<input type="hidden">` с токеном.
|
- `field()` — готовый `<input type="hidden">` с токеном.
|
||||||
- `CSRF::$key` — имя поля/ключа (по умолчанию `csrf_token`).
|
- `CSRF::$key` — имя поля/ключа (по умолчанию `csrf_token`).
|
||||||
|
|
||||||
|
### Log (System\Classes\Log)
|
||||||
|
|
||||||
|
Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой:
|
||||||
|
`App/logs/action-{Y-m-d}.log` (действия пользователя) и `App/logs/error-{Y-m-d}.log`
|
||||||
|
(ошибки и предупреждения). Канал выбирается по уровню.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use System\Classes\Log;
|
||||||
|
|
||||||
|
Log::info('Пользователь :id вошёл', [':id' => 42]); // подстановка :key через strtr
|
||||||
|
Log::error('Сбой оплаты');
|
||||||
|
Log::debug(...); Log::warning(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Уровни: `debug(100) < info(200) < warning(300) < error(400)`. Пишутся только уровни не ниже
|
||||||
|
порога `Log::$threshold` (по умолчанию из `Config::get('log','threshold')` → `debug`).
|
||||||
|
- Канал по уровню: `info`/`debug` → файл `action-…`, `warning`/`error` → файл `error-…`.
|
||||||
|
- Каталог — `Log::$directory` → `Config::get('log','path')` → `APPPATH/logs`. Создаётся на лету.
|
||||||
|
- `Log::requestInfo()` — строка контекста текущего запроса (`METHOD /uri Controller::action
|
||||||
|
params=… query=… post=…`) из `Request::$current`; чувствительные ключи (`Log::$mask_keys`:
|
||||||
|
`password`, `pass`, `csrf_token`, `token`) маскируются `***`.
|
||||||
|
|
||||||
|
**Интеграция (автоматически):**
|
||||||
|
- `Request::execute()` пишет INFO на **каждый** входящий запрос (аудит — кто что делал).
|
||||||
|
- `MyException::handler()` пишет ERROR на каждое неперехваченное исключение (текст + контекст
|
||||||
|
запроса), и в DEVELOPMENT, и в PRODUCTION.
|
||||||
|
|
||||||
|
Каталог логов в `.gitignore` (`/App/logs/`).
|
||||||
|
|
||||||
### HTTPException (System\Classes\HTTP\HTTPException)
|
### HTTPException (System\Classes\HTTP\HTTPException)
|
||||||
|
|
||||||
```php
|
```php
|
||||||
@ -318,8 +348,9 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
|
|||||||
| `tests/Unit/ValidationTest.php` | правила, `matches`, пропуск пустых, first-error, label/плейсхолдеры, fluent |
|
| `tests/Unit/ValidationTest.php` | правила, `matches`, пропуск пустых, first-error, label/плейсхолдеры, fluent |
|
||||||
| `tests/Unit/CSRFTest.php` | `token()` стабильность/формат, `validate()`, `field()`, нет токена в сессии |
|
| `tests/Unit/CSRFTest.php` | `token()` стабильность/формат, `validate()`, `field()`, нет токена в сессии |
|
||||||
| `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки |
|
| `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки |
|
||||||
|
| `tests/Unit/LogTest.php` | уровни/порог, каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` |
|
||||||
|
|
||||||
**145 тестов, 229 assertion — все проходят.**
|
**154 теста, 253 assertion — все проходят.**
|
||||||
|
|
||||||
### Frontend dependencies (через Composer)
|
### Frontend dependencies (через Composer)
|
||||||
|
|
||||||
|
|||||||
209
System/Classes/Log.php
Normal file
209
System/Classes/Log.php
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description Log.php
|
||||||
|
* @copyright (c) 24/06/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace System\Classes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Файловый логгер. Пишет в {directory}/{Y-m-d}.log с фильтрацией по уровню.
|
||||||
|
* Используется для аудита запросов (info) и логирования ошибок (error).
|
||||||
|
*
|
||||||
|
* Log::info('Пользователь :id вошёл', [':id' => 42]);
|
||||||
|
* Log::error('Сбой оплаты');
|
||||||
|
*/
|
||||||
|
class Log
|
||||||
|
{
|
||||||
|
public const DEBUG = 'debug';
|
||||||
|
public const INFO = 'info';
|
||||||
|
public const WARNING = 'warning';
|
||||||
|
public const ERROR = 'error';
|
||||||
|
|
||||||
|
/** @var array<string,int> Веса уровней для фильтрации по порогу */
|
||||||
|
private const WEIGHTS = [
|
||||||
|
self::DEBUG => 100,
|
||||||
|
self::INFO => 200,
|
||||||
|
self::WARNING => 300,
|
||||||
|
self::ERROR => 400,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var array<string,string> Канал (файл) для каждого уровня */
|
||||||
|
private const CHANNELS = [
|
||||||
|
self::DEBUG => 'action', // действия пользователя (аудит)
|
||||||
|
self::INFO => 'action',
|
||||||
|
self::WARNING => 'error', // ошибки и предупреждения
|
||||||
|
self::ERROR => 'error',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var string|null Каталог логов; null → Config('log','path') → APPPATH/logs */
|
||||||
|
public static ?string $directory = null;
|
||||||
|
|
||||||
|
/** @var string|null Минимальный уровень записи; null → Config('log','threshold') → debug */
|
||||||
|
public static ?string $threshold = null;
|
||||||
|
|
||||||
|
/** @var array Ключи, значения которых маскируются (***) при логировании query/post */
|
||||||
|
public static array $mask_keys = ['password', 'pass', 'csrf_token', 'token'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пишет сообщение в лог, если его уровень не ниже порога.
|
||||||
|
*
|
||||||
|
* @param string $level Уровень (см. константы)
|
||||||
|
* @param string $message Сообщение, может содержать плейсхолдеры для $variables
|
||||||
|
* @param array $variables Карта замен [':key' => 'value'] для strtr
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function write(string $level, string $message, array $variables = []): void
|
||||||
|
{
|
||||||
|
if (self::weight($level) < self::weight(self::threshold())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variables) {
|
||||||
|
$message = strtr($message, $variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = self::directory();
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$line = sprintf("[%s] %s: %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message);
|
||||||
|
$file = $dir . '/' . self::channel($level) . '-' . date('Y-m-d') . '.log';
|
||||||
|
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $message Сообщение
|
||||||
|
* @param array $variables Карта замен для strtr
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function debug(string $message, array $variables = []): void
|
||||||
|
{
|
||||||
|
self::write(self::DEBUG, $message, $variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $message Сообщение
|
||||||
|
* @param array $variables Карта замен для strtr
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function info(string $message, array $variables = []): void
|
||||||
|
{
|
||||||
|
self::write(self::INFO, $message, $variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $message Сообщение
|
||||||
|
* @param array $variables Карта замен для strtr
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function warning(string $message, array $variables = []): void
|
||||||
|
{
|
||||||
|
self::write(self::WARNING, $message, $variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $message Сообщение
|
||||||
|
* @param array $variables Карта замен для strtr
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function error(string $message, array $variables = []): void
|
||||||
|
{
|
||||||
|
self::write(self::ERROR, $message, $variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает строку контекста текущего запроса для аудита/лога ошибок.
|
||||||
|
* Чувствительные значения query/post маскируются.
|
||||||
|
*
|
||||||
|
* @return string '' если текущего запроса нет (CLI/тесты)
|
||||||
|
*/
|
||||||
|
public static function requestInfo(): string
|
||||||
|
{
|
||||||
|
$request = Request::$current;
|
||||||
|
if ($request === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||||
|
$params = json_encode($request->params(), $flags);
|
||||||
|
$query = json_encode(self::mask($request->query()), $flags);
|
||||||
|
$post = json_encode(self::mask($request->post()), $flags);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%s /%s %s::%s params=%s query=%s post=%s',
|
||||||
|
$request->method(), $request->uri(), $request->controller(), $request->action(),
|
||||||
|
$params, $query, $post
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный уровень записи (из свойства, конфига или дефолт debug).
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function threshold(): string
|
||||||
|
{
|
||||||
|
return self::$threshold ?? Config::get('log', 'threshold') ?? self::DEBUG;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каталог логов (из свойства, конфига или дефолт APPPATH/logs).
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function directory(): string
|
||||||
|
{
|
||||||
|
return self::$directory ?? Config::get('log', 'path') ?? APPPATH . '/logs';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вес уровня для сравнения с порогом.
|
||||||
|
*
|
||||||
|
* @param string $level Уровень
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
private static function weight(string $level): int
|
||||||
|
{
|
||||||
|
return self::WEIGHTS[strtolower($level)] ?? self::WEIGHTS[self::DEBUG];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Канал (имя файла без даты) для уровня: error/warning → 'error',
|
||||||
|
* info/debug → 'action'.
|
||||||
|
*
|
||||||
|
* @param string $level Уровень
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function channel(string $level): string
|
||||||
|
{
|
||||||
|
return self::CHANNELS[strtolower($level)] ?? 'action';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рекурсивно маскирует значения чувствительных ключей (без учёта регистра).
|
||||||
|
*
|
||||||
|
* @param array $data Данные
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private static function mask(array $data): array
|
||||||
|
{
|
||||||
|
$mask = array_map('strtolower', self::$mask_keys);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (in_array(strtolower((string)$key), $mask, true)) {
|
||||||
|
$result[$key] = '***';
|
||||||
|
} elseif (is_array($value)) {
|
||||||
|
$result[$key] = self::mask($value);
|
||||||
|
} else {
|
||||||
|
$result[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,6 +44,14 @@ class MyException extends Exception
|
|||||||
$http_code = ($e->getCode() >= 100 && $e->getCode() <= 599) ? $e->getCode() : 500;
|
$http_code = ($e->getCode() >= 100 && $e->getCode() <= 599) ? $e->getCode() : 500;
|
||||||
http_response_code($http_code);
|
http_response_code($http_code);
|
||||||
|
|
||||||
|
Log::error(
|
||||||
|
sprintf(
|
||||||
|
'%s [%d]: %s в %s:%d | %s',
|
||||||
|
get_class($e), $e->getCode(), $e->getMessage(), $e->getFile(), $e->getLine(),
|
||||||
|
Log::requestInfo()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (Core::$environment >= Core::DEVELOPMENT) {
|
if (Core::$environment >= Core::DEVELOPMENT) {
|
||||||
$class = get_class($e);
|
$class = get_class($e);
|
||||||
$code = $e->getCode();
|
$code = $e->getCode();
|
||||||
|
|||||||
@ -113,6 +113,9 @@ class Request implements HTTPRequest
|
|||||||
*/
|
*/
|
||||||
public function execute(): Response
|
public function execute(): Response
|
||||||
{
|
{
|
||||||
|
// Аудит: каждый входящий запрос пишется в лог (включая 404).
|
||||||
|
Log::info(Log::requestInfo());
|
||||||
|
|
||||||
$method = lcfirst($this->_action) . 'Action';
|
$method = lcfirst($this->_action) . 'Action';
|
||||||
$parts = explode('/', $this->_controller);
|
$parts = explode('/', $this->_controller);
|
||||||
$file = DOCROOT . '/App/Controller/' . implode('/', array_map('ucfirst', $parts)) . 'Controller.php';
|
$file = DOCROOT . '/App/Controller/' . implode('/', array_map('ucfirst', $parts)) . 'Controller.php';
|
||||||
|
|||||||
66
tests/Unit/CSRFTest.php
Normal file
66
tests/Unit/CSRFTest.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use System\Classes\CSRF;
|
||||||
|
use System\Classes\Session;
|
||||||
|
|
||||||
|
class CSRFTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Session::instance()->destroy();
|
||||||
|
|
||||||
|
// Сбрасываем синглтон сессии через рефлексию
|
||||||
|
$ref = new \ReflectionClass(Session::class);
|
||||||
|
$prop = $ref->getProperty('instances');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTokenIsNonEmptyHex(): void
|
||||||
|
{
|
||||||
|
$token = CSRF::token();
|
||||||
|
$this->assertNotEmpty($token);
|
||||||
|
$this->assertSame(64, strlen($token)); // 32 байта → 64 hex-символа
|
||||||
|
$this->assertMatchesRegularExpression('/^[0-9a-f]+$/', $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTokenIsStable(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(CSRF::token(), CSRF::token());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateAcceptsCorrectToken(): void
|
||||||
|
{
|
||||||
|
$token = CSRF::token();
|
||||||
|
$this->assertTrue(CSRF::validate($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateRejectsWrongToken(): void
|
||||||
|
{
|
||||||
|
CSRF::token();
|
||||||
|
$this->assertFalse(CSRF::validate('deadbeef'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateRejectsNullAndEmpty(): void
|
||||||
|
{
|
||||||
|
CSRF::token();
|
||||||
|
$this->assertFalse(CSRF::validate(null));
|
||||||
|
$this->assertFalse(CSRF::validate(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateFailsWhenNoTokenInSession(): void
|
||||||
|
{
|
||||||
|
// Токен ещё не создавали — в сессии его нет.
|
||||||
|
$this->assertFalse(CSRF::validate('whatever'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFieldContainsToken(): void
|
||||||
|
{
|
||||||
|
$field = CSRF::field();
|
||||||
|
$this->assertStringContainsString('name="' . CSRF::$key . '"', $field);
|
||||||
|
$this->assertStringContainsString('value="' . CSRF::token() . '"', $field);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
tests/Unit/ControllerTest.php
Normal file
60
tests/Unit/ControllerTest.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use System\Classes\BaseController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тестовый контроллер: фиксирует порядок вызовов before/action/after.
|
||||||
|
*/
|
||||||
|
class LifecycleController extends BaseController
|
||||||
|
{
|
||||||
|
public array $log = [];
|
||||||
|
|
||||||
|
protected function before(): void
|
||||||
|
{
|
||||||
|
$this->log[] = 'before';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function after(): void
|
||||||
|
{
|
||||||
|
$this->log[] = 'after';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function indexAction(): string
|
||||||
|
{
|
||||||
|
$this->log[] = 'action';
|
||||||
|
return 'body';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testExecuteActionReturnsActionBody(): void
|
||||||
|
{
|
||||||
|
$controller = new LifecycleController();
|
||||||
|
$this->assertSame('body', $controller->executeAction('indexAction'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHooksRunAroundActionInOrder(): void
|
||||||
|
{
|
||||||
|
$controller = new LifecycleController();
|
||||||
|
$controller->executeAction('indexAction');
|
||||||
|
|
||||||
|
$this->assertSame(['before', 'action', 'after'], $controller->log);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultHooksAreNoop(): void
|
||||||
|
{
|
||||||
|
// Голое ядро без переопределения хуков просто возвращает тело.
|
||||||
|
$controller = new class extends BaseController {
|
||||||
|
public function pingAction(): string
|
||||||
|
{
|
||||||
|
return 'pong';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->assertSame('pong', $controller->executeAction('pingAction'));
|
||||||
|
}
|
||||||
|
}
|
||||||
137
tests/Unit/LogTest.php
Normal file
137
tests/Unit/LogTest.php
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use System\Classes\Log;
|
||||||
|
|
||||||
|
class LogTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $dir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->dir = sys_get_temp_dir() . '/bicycle_log_' . uniqid();
|
||||||
|
Log::$directory = $this->dir;
|
||||||
|
Log::$threshold = Log::DEBUG;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if (is_dir($this->dir)) {
|
||||||
|
foreach (glob($this->dir . '/*') as $file) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
rmdir($this->dir);
|
||||||
|
}
|
||||||
|
Log::$directory = null;
|
||||||
|
Log::$threshold = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logFile(string $channel): string
|
||||||
|
{
|
||||||
|
return $this->dir . '/' . $channel . '-' . date('Y-m-d') . '.log';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWriteCreatesFileWithFormattedLine(): void
|
||||||
|
{
|
||||||
|
Log::info('Привет');
|
||||||
|
|
||||||
|
$this->assertFileExists($this->logFile('action'));
|
||||||
|
$content = file_get_contents($this->logFile('action'));
|
||||||
|
$this->assertMatchesRegularExpression('/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] INFO: Привет\n/', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChannelsSplitErrorsFromActions(): void
|
||||||
|
{
|
||||||
|
Log::info('действие');
|
||||||
|
Log::error('сбой');
|
||||||
|
|
||||||
|
// Действия — в action-файле, ошибки — в error-файле, не смешаны.
|
||||||
|
$action = file_get_contents($this->logFile('action'));
|
||||||
|
$error = file_get_contents($this->logFile('error'));
|
||||||
|
|
||||||
|
$this->assertStringContainsString('INFO: действие', $action);
|
||||||
|
$this->assertStringNotContainsString('сбой', $action);
|
||||||
|
$this->assertStringContainsString('ERROR: сбой', $error);
|
||||||
|
$this->assertStringNotContainsString('действие', $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWarningGoesToErrorChannel(): void
|
||||||
|
{
|
||||||
|
Log::warning('предупреждение');
|
||||||
|
$this->assertFileDoesNotExist($this->logFile('action'));
|
||||||
|
$this->assertStringContainsString('WARNING: предупреждение', file_get_contents($this->logFile('error')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThresholdFiltersLowerLevels(): void
|
||||||
|
{
|
||||||
|
Log::$threshold = Log::WARNING;
|
||||||
|
|
||||||
|
Log::debug('низкий');
|
||||||
|
Log::info('низкий');
|
||||||
|
$this->assertFileDoesNotExist($this->logFile('action'));
|
||||||
|
|
||||||
|
Log::warning('высокий');
|
||||||
|
Log::error('высокий');
|
||||||
|
$content = file_get_contents($this->logFile('error'));
|
||||||
|
$this->assertStringContainsString('WARNING: высокий', $content);
|
||||||
|
$this->assertStringContainsString('ERROR: высокий', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShortcutsUseCorrectLevelLabel(): void
|
||||||
|
{
|
||||||
|
Log::debug('d');
|
||||||
|
Log::info('i');
|
||||||
|
Log::warning('w');
|
||||||
|
Log::error('e');
|
||||||
|
|
||||||
|
$action = file_get_contents($this->logFile('action'));
|
||||||
|
$error = file_get_contents($this->logFile('error'));
|
||||||
|
$this->assertStringContainsString('DEBUG: d', $action);
|
||||||
|
$this->assertStringContainsString('INFO: i', $action);
|
||||||
|
$this->assertStringContainsString('WARNING: w', $error);
|
||||||
|
$this->assertStringContainsString('ERROR: e', $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStrtrSubstitution(): void
|
||||||
|
{
|
||||||
|
Log::info('Пользователь :id вошёл', [':id' => 42]);
|
||||||
|
$this->assertStringContainsString('Пользователь 42 вошёл', file_get_contents($this->logFile('action')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAppendsWithoutOverwriting(): void
|
||||||
|
{
|
||||||
|
Log::info('первая');
|
||||||
|
Log::info('вторая');
|
||||||
|
|
||||||
|
$content = file_get_contents($this->logFile('action'));
|
||||||
|
$this->assertStringContainsString('первая', $content);
|
||||||
|
$this->assertStringContainsString('вторая', $content);
|
||||||
|
$this->assertSame(2, substr_count($content, "\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequestInfoEmptyWithoutCurrentRequest(): void
|
||||||
|
{
|
||||||
|
// Request::$current не установлен в юнит-окружении.
|
||||||
|
$this->assertSame('', Log::requestInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMaskHidesSensitiveKeys(): void
|
||||||
|
{
|
||||||
|
$method = new \ReflectionMethod(Log::class, 'mask');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invoke(null, [
|
||||||
|
'email' => 'a@b.com',
|
||||||
|
'password' => 'secret',
|
||||||
|
'csrf_token' => 'abc123',
|
||||||
|
'CSRF_TOKEN' => 'xyz', // регистр не важен
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('a@b.com', $result['email']);
|
||||||
|
$this->assertSame('***', $result['password']);
|
||||||
|
$this->assertSame('***', $result['csrf_token']);
|
||||||
|
$this->assertSame('***', $result['CSRF_TOKEN']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user