diff --git a/.gitignore b/.gitignore index 8425751..db85cee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ config.json App/config/config.local.php +App/config/auth_users.php /App/logs/ diff --git a/App/Controller/Admin/AdminController.php b/App/Controller/Admin/AdminController.php index 3f3165b..34af20e 100644 --- a/App/Controller/Admin/AdminController.php +++ b/App/Controller/Admin/AdminController.php @@ -9,24 +9,19 @@ namespace App\Controller\Admin; use System\Classes\Controller; -use System\Classes\HTTP\HTTPException; /** * Базовый контроллер админки. Рендерится в общем layout сайта * (App/view/layout.html), своего отдельного шаблона не имеет. + * Требует авторизацию с ролью admin (см. Controller::$_auth_protection/$_auth_roles) — + * неавторизованный редиректится на /login, авторизованный без роли admin получает 403. * Конкретные страницы (Logs и т.п.) наследуют его. */ abstract class AdminController extends Controller { - /** - * Точка для проверки прав администратора. - * - * @return void - * @throws HTTPException - */ - protected function before(): void - { - parent::before(); // авто-CSRF на небезопасных методах - // TODO: проверка прав администратора, когда появится логин. - } + /** @var bool Доступ в админку только авторизованным пользователям */ + protected bool $_auth_protection = true; + + /** @var array В админку пускаем только роль admin */ + protected array $_auth_roles = ['admin']; } diff --git a/App/Controller/LoginController.php b/App/Controller/LoginController.php new file mode 100644 index 0000000..eb348aa --- /dev/null +++ b/App/Controller/LoginController.php @@ -0,0 +1,62 @@ +method() === HTTPRequest::POST) { + $validation = Validation::factory($request->post()) + ->label('login', 'Логин') + ->label('password', 'Пароль') + ->rule('login', 'required') + ->rule('password', 'required'); + + if ($validation->check() && Auth::instance()->login($request->post('login'), $request->post('password'))) { + HTTPException::factory(302, '/admin/logs')->getResponse(); + } + + $error = 'Неверный логин или пароль'; + } + + return $this->render('login', ['error' => $error]); + } + + /** + * Разлогинивает пользователя и возвращает на главную. + * + * @return string + */ + public function logoutAction(): string + { + Auth::instance()->logout(); + + HTTPException::factory(302, '/')->getResponse(); + } +} diff --git a/App/config/config.php b/App/config/config.php index 6e4ec07..e2c815b 100644 --- a/App/config/config.php +++ b/App/config/config.php @@ -32,4 +32,10 @@ return [ 'threshold' => 'debug', ], + 'auth' => [ + 'driver' => 'file', + 'users_file' => APPPATH . '/config/auth_users.php', + 'session_key' => 'auth_user', + ], + ]; diff --git a/App/view/Login/login.html b/App/view/Login/login.html new file mode 100644 index 0000000..3f08324 --- /dev/null +++ b/App/view/Login/login.html @@ -0,0 +1,34 @@ + +

Вход

+ + +
+ + +
+ + +
+ +
+ +
+ +
+ + +
diff --git a/CLAUDE.md b/CLAUDE.md index 255a00b..19bf052 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,13 +41,14 @@ App/Controller/ — контроллеры приложени App/config/ — конфиги (config.php; config.local.php — в .gitignore) App/view/ — шаблоны приложения (.html файлы с PHP-кодом) App/media/ — статические ресурсы (js, css, img) +Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже) Services/DataBase/, Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PDO / PHPMailer / dompdf); кода пока нет System/view/ — системные шаблоны (ошибки 404/500/403, exception) tools/sort_html_attrs.php — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore) tests/ — PHPUnit тесты ``` -> Каталоги `App/Classes/` и наполнение `Services/*` ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы. +> Каталог `App/Classes/` и часть `Services/*` (DataBase, Mail, PDF) ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы. ### Autoload (composer.json) @@ -100,6 +101,9 @@ PhpStorm может показывать предупреждение «Namespac | `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL | | `System\Classes\HTTP\Client\Request` | Билдер исходящего запроса: `method()`, `header()`, `json()`, `timeout()` | | `System\Classes\HTTP\Client\Response` | Ответ внешнего запроса: `status()`, `body()`, `json()`, `isSuccess()` | +| `Services\Auth` | Точка входа в авторизацию: `instance(?string $driver = null)`, кэш по драйверу | +| `Services\Auth\AuthDriver` | Интерфейс: `login()`, `logout()`, `loggedIn()`, `getUser()`, `checkPassword()` | +| `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak | ### Request lifecycle @@ -243,6 +247,52 @@ if (!CSRF::validate(Request::$current->post(CSRF::$key))) { - `field()` — готовый `` с токеном. - `CSRF::$key` — имя поля/ключа (по умолчанию `csrf_token`). +### Auth (Services\Auth) + +Авторизация через сменный драйвер (`Services\Auth\AuthDriver`), как в Kohana Auth: по умолчанию +`FileAuthDriver` (логины/пароли из PHP-файла), позже — БД/LDAP/Keycloak как новые классы +`implements AuthDriver` без изменения остального кода (тот же принцип, что и `LogReader`/`FileLogReader`). + +- `Services\Auth::instance(?string $name = null)` — драйвер по имени (кэшируется); без аргумента — + драйвер из `Config::get('auth', 'driver')` (по умолчанию `'file'`). +- `AuthDriver::login($login, $password)` — проверяет и, если верно, авторизует (пишет в `Session`). +- `AuthDriver::logout()` / `loggedIn()` / `getUser()` (без пароля) / `checkPassword($password)` + (сверка пароля с текущим авторизованным пользователем, например перед сменой настроек). + +**Файл пользователей** — `App/config/auth_users.php` (в `.gitignore`, как `config.local.php`): +```php +return [ + 'admin' => [ + 'password' => '', + 'full_name' => 'ФИО', + 'email' => '...', + 'role' => 'admin', // 'admin' | 'manager' | 'user' | ... — своё для каждого проекта + ], +]; +``` +Путь берётся из `Config::get('auth', 'users_file')`. Поля, кроме `password`, произвольные — +`AuthDriver::getUser()` отдаёт их как есть (без `password`); `role` — единственное поле, которое +понимает framework-код (`Controller::$_auth_roles`), остальное (`full_name`, `email`, …) — просто +проброс для шаблонов/логов. + +**Контроллеры** включают проверку флагом `$_auth_protection = true` (по умолчанию `false`, +как `$_csrf_protection`, но с обратной полярностью — авторизация не обязательна по умолчанию). +`$_auth_driver` — имя конкретного драйвера, если контроллеру нужен не дефолтный (`null` = дефолт +из конфига). `$_auth_roles` — список разрешённых ролей (`['admin']`, `['admin', 'manager']`); +пустой массив (по умолчанию) — любой авторизованный, без проверки роли. + +Проверка — в `Controller::before()`: нет авторизации → редирект на `/login` (**не** `throw`: +неперехваченные исключения уходят в `MyException::handler()`, который не вызывает `getResponse()`, +поэтому для 302 вызывается `HTTPException::factory(302, url)->getResponse()` напрямую — она сама +делает `Location` + `exit`); авторизован, но роль не подходит → `throw HTTPException::factory(403)`. +`App\Controller\Admin\AdminController` включает `$_auth_protection = true` и `$_auth_roles = ['admin']` +для всей админки. + +**Вход/выход** — `App/Controller/LoginController.php` (`/login` — форма и обработка, +`/login/logout`), шаблон `App/view/Login/login.html`. Меню сайта (`Controller::menu()`) показывает +«Войти» либо «Выйти (логин)» в зависимости от `Auth::instance()->getUser()`; пункт «Админка» виден +только при `role === 'admin'`. + ### Log (System\Classes\Log) Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой: @@ -262,7 +312,8 @@ Log::debug(...); Log::warning(...); - Канал по уровню: `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`: + user=login params=… query=… post=…`) из `Request::$current`; `user` — логин из `Auth::provider()->user()` + (`'guest'`, если не авторизован); чувствительные ключи (`Log::$mask_keys`: `password`, `pass`, `csrf_token`, `token`) маскируются `***`. **Интеграция (автоматически):** @@ -307,8 +358,7 @@ $response = HTTPException::factory(403)->getResponse(); ### View path convention - Шаблон контента: `App/view/{Controller}/{template}.html` -- Layout: `App/view/views/{layout}.html` (app-специфичные шаблоны — не в `System`, там только нейтральные fallback-страницы вроде `errors/`, `exception/`) -- Общие CSS/JS-подключения (Bootstrap, Bootstrap Icons, jQuery) вынесены в партиалы `App/view/views/_styles.html` и `_scripts.html` — layout'ы (`layout.html`, `admin.html`) инклудят их через `(new View('_styles', 'view/views'))->render()`, чтобы не дублировать теги при появлении новых layout'ов +- Layout: `App/view/{layout}.html` — единый шаблон на весь сайт (app-специфичный — не в `System`, там только нейтральные fallback-страницы вроде `errors/`, `exception/`) - `Core::findFile($dir, $file, 'html')` ищет в `APPPATH/{dir}/{file}.html`, затем в `SYSPATH/{dir}/{file}.html` - `$_paths` инициализируется лениво при первом вызове `findFile()` - `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`) @@ -318,13 +368,13 @@ $response = HTTPException::factory(403)->getResponse(); Расстановка имён — как в проекте eoffice_v3: - `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API. -- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хуки `layoutData()` (доп. данные в layout помимо `content`; по умолчанию `['menu' => $this->menu()]`) и `menu()` (пункты главного меню сайта, активный — по текущему URI). Layout по умолчанию — `App/view/views/layout.html` (Bootstrap-навбар сверху, `$content` внутри `
`). **Контроллеры приложения наследуют его.** +- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хуки `layoutData()` (доп. данные в layout помимо `content`; по умолчанию `['menu' => $this->menu()]`) и `menu()` (пункты главного меню сайта, активный — по текущему URI). Layout — `App/view/layout.html` (Bootstrap-навбар сверху с меню сайта, `$content` внутри `
`; CSS/JS — Bootstrap, Bootstrap Icons, jQuery из `/vendor/`). Единый для всего сайта, включая админку. **Контроллеры приложения наследуют `Controller` напрямую.** API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3). -**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` задаёт свой layout `admin` (боковое меню, `App/view/views/admin.html`, Bootstrap из `/vendor/`) и переопределяет `layoutData()`/`menu()` под собственный список пунктов (пока статичный, задел под БД). `before()` — заготовка под проверку прав (логина пока нет, **админка открыта**). Конкретные страницы наследуют `AdminController`. +**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` своего layout/меню не задаёт — рендерится в том же `App/view/layout.html`, что и весь сайт (в меню есть пункт «Админка»). Включает `$_auth_protection = true` — доступ только авторизованным (см. [Auth](#auth-servicesauth)), неавторизованный редиректится на `/login`. Конкретные страницы (`LogsController` и т.п.) наследуют `AdminController`. -`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизацию добавлять в `before()` конкретных контроллеров (логина пока нет). +`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом `$_auth_protection` (см. раздел Auth), а не переопределением `before()` вручную. ### Environment @@ -361,8 +411,10 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap | `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки | | `tests/Unit/LogTest.php` | уровни/порог, каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` | | `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` | +| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла | +| `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение | -**162 теста, 270 assertion — все проходят.** +**177 тестов, 290 assertion — все проходят.** ### Frontend dependencies (через Composer) diff --git a/Services/Auth.php b/Services/Auth.php new file mode 100644 index 0000000..3658e4e --- /dev/null +++ b/Services/Auth.php @@ -0,0 +1,62 @@ +login($login, $password); + * Auth::instance()->loggedIn(); + * Auth::instance()->getUser(); + * Auth::instance()->logout(); + */ +class Auth +{ + /** @var array> Реализации по имени драйвера */ + protected static array $_drivers = [ + 'file' => FileAuthDriver::class, + // 'db' => \Services\Auth\DbAuthDriver::class, // позже + // 'ldap' => \Services\Auth\LdapAuthDriver::class, // позже + // 'keycloak' => \Services\Auth\KeycloakAuthDriver::class, // позже + ]; + + /** @var array Кэш созданных драйверов по имени */ + protected static array $_instances = []; + + /** + * Возвращает драйвер авторизации по имени (кэшируется). + * + * @param string|null $name Имя драйвера; null — значение по умолчанию из конфига + * @return AuthDriver + * @throws MyException Если драйвер не зарегистрирован + */ + public static function instance(?string $name = null): AuthDriver + { + $name ??= Config::get('auth', 'driver') ?? 'file'; + + if (!isset(self::$_instances[$name])) { + if (!isset(self::$_drivers[$name])) { + throw new MyException('Неизвестный драйвер авторизации: :name', [':name' => $name]); + } + + $class = self::$_drivers[$name]; + self::$_instances[$name] = new $class(); + } + + return self::$_instances[$name]; + } +} diff --git a/Services/Auth/AuthDriver.php b/Services/Auth/AuthDriver.php new file mode 100644 index 0000000..7ec6489 --- /dev/null +++ b/Services/Auth/AuthDriver.php @@ -0,0 +1,57 @@ + ['password' => '', ...доп. поля], ...]; + * + * Путь к файлу — Config::get('auth', 'users_file'). + * Состояние авторизации хранится в сессии (логин текущего пользователя), + * ключ сессии — Config::get('auth', 'session_key') (по умолчанию 'auth_user'). + */ +class FileAuthDriver implements AuthDriver +{ + /** @var string|null Путь к файлу пользователей (если не передан — берётся из конфига) */ + protected ?string $_users_file; + + /** @var string Ключ сессии, под которым хранится логин авторизованного пользователя */ + protected string $_session_key; + + /** + * @param string|null $users_file Путь к файлу пользователей (для тестов/переопределения) + * @param string|null $session_key Ключ сессии (для тестов/переопределения) + */ + public function __construct(?string $users_file = null, ?string $session_key = null) + { + $this->_users_file = $users_file ?? Config::get('auth', 'users_file') ?? null; + $this->_session_key = $session_key ?? Config::get('auth', 'session_key') ?? 'auth_user'; + } + + /** + * Загружает список пользователей из файла (с полем 'password'). + * + * @return array + */ + protected function users(): array + { + if ($this->_users_file === null || !file_exists($this->_users_file)) { + return []; + } + + return (array)(include $this->_users_file); + } + + /** + * Логин текущего авторизованного пользователя из сессии. + * + * @return string|null + */ + protected function currentLogin(): ?string + { + return Session::instance()->get($this->_session_key); + } + + /** + * @inheritDoc + */ + public function login(string $login, string $password): bool + { + $user = $this->users()[$login] ?? null; + + if ($user === null || !password_verify($password, $user['password'] ?? '')) { + return false; + } + + Session::instance()->set($this->_session_key, $login); + + return true; + } + + /** + * @inheritDoc + */ + public function logout(): void + { + Session::instance()->delete($this->_session_key); + } + + /** + * @inheritDoc + */ + public function loggedIn(): bool + { + return $this->getUser() !== null; + } + + /** + * @inheritDoc + */ + public function getUser(): ?array + { + $login = $this->currentLogin(); + + if ($login === null) { + return null; + } + + $user = $this->users()[$login] ?? null; + + if ($user === null) { + return null; + } + + unset($user['password']); + + return ['login' => $login] + $user; + } + + /** + * @inheritDoc + */ + public function checkPassword(string $password): bool + { + $login = $this->currentLogin(); + + if ($login === null) { + return false; + } + + $hash = $this->users()[$login]['password'] ?? null; + + return $hash !== null && password_verify($password, $hash); + } +} diff --git a/System/Classes/Controller.php b/System/Classes/Controller.php index 7983c1a..c9e08c3 100644 --- a/System/Classes/Controller.php +++ b/System/Classes/Controller.php @@ -8,6 +8,7 @@ namespace System\Classes; +use Services\Auth; use System\Classes\HTTP\HTTPException; use System\Classes\HTTP\Request as HTTPRequest; @@ -23,25 +24,48 @@ class Controller extends BaseController /** @var bool Проверять ли CSRF-токен на небезопасных методах */ protected bool $_csrf_protection = true; + /** @var bool Требовать ли авторизацию (см. {@see Auth}) для всех экшенов контроллера */ + protected bool $_auth_protection = false; + + /** @var string|null Имя драйвера авторизации; null — значение по умолчанию из конфига */ + protected ?string $_auth_driver = null; + + /** @var array Роли, которым разрешён доступ; пустой массив — любой авторизованный */ + protected array $_auth_roles = []; + /** - * Проверяет CSRF-токен на POST/PUT/PATCH/DELETE. - * Отключается флагом $_csrf_protection (например, для API/вебхуков). + * Проверяет CSRF-токен на POST/PUT/PATCH/DELETE и, если включено, + * авторизацию + роль (редирект на /login при отсутствии авторизации, + * 403 — при недостаточной роли). * * @return void - * @throws HTTPException 403, если токен не прошёл + * @throws HTTPException 403, если CSRF-токен не прошёл или роль не подходит */ protected function before(): void { - if (!$this->_csrf_protection) { - return; + if ($this->_csrf_protection) { + $request = Request::$current; + $method = $request?->method() ?? HTTPRequest::GET; + $unsafe = [HTTPRequest::POST, HTTPRequest::PUT, HTTPRequest::PATCH, HTTPRequest::DELETE]; + + if (in_array($method, $unsafe, true) && !CSRF::validate($request->post(CSRF::$key))) { + throw HTTPException::factory(403); + } } - $request = Request::$current; - $method = $request?->method() ?? HTTPRequest::GET; - $unsafe = [HTTPRequest::POST, HTTPRequest::PUT, HTTPRequest::PATCH, HTTPRequest::DELETE]; + if ($this->_auth_protection) { + $user = Auth::instance($this->_auth_driver)->getUser(); - if (in_array($method, $unsafe, true) && !CSRF::validate($request->post(CSRF::$key))) { - throw HTTPException::factory(403); + if ($user === null) { + // getResponse() 302 сам делает Location + exit — throw здесь не подходит: + // неперехваченные исключения уходят в MyException::handler(), который + // getResponse() не вызывает и настоящий редирект не отправит. + HTTPException::factory(302, '/login')->getResponse(); + } + + if ($this->_auth_roles && !in_array($user['role'] ?? null, $this->_auth_roles, true)) { + throw HTTPException::factory(403); + } } } @@ -88,12 +112,20 @@ class Controller extends BaseController protected function menu(): array { $uri = Request::$current?->uri() ?? ''; + $user = Auth::instance()->getUser(); $items = [ ['title' => 'Главная', 'url' => '/'], ['title' => 'Обратная связь', 'url' => '/feedback'], - ['title' => 'Админка', 'url' => '/admin/logs'], ]; + if (($user['role'] ?? null) === 'admin') { + $items[] = ['title' => 'Админка', 'url' => '/admin/logs']; + } + + $items[] = $user + ? ['title' => 'Выйти (' . $user['login'] . ')', 'url' => '/login/logout'] + : ['title' => 'Войти', 'url' => '/login']; + foreach ($items as &$item) { $item['active'] = trim($item['url'], '/') === $uri; } diff --git a/System/Classes/Log.php b/System/Classes/Log.php index 7e9c606..a37ba5f 100644 --- a/System/Classes/Log.php +++ b/System/Classes/Log.php @@ -8,6 +8,8 @@ namespace System\Classes; +use Services\Auth; + /** * Файловый логгер. Пишет в {directory}/{Y-m-d}.log с фильтрацией по уровню. * Используется для аудита запросов (info) и логирования ошибок (error). @@ -132,11 +134,12 @@ class Log $params = json_encode($request->params(), $flags); $query = json_encode(self::mask($request->query()), $flags); $post = json_encode(self::mask($request->post()), $flags); + $user = Auth::instance()->getUser()['login'] ?? 'guest'; return sprintf( - '%s /%s %s::%s params=%s query=%s post=%s', + '%s /%s %s::%s user=%s params=%s query=%s post=%s', $request->method(), $request->uri(), $request->controller(), $request->action(), - $params, $query, $post + $user, $params, $query, $post ); } diff --git a/tests/Unit/AuthTest.php b/tests/Unit/AuthTest.php new file mode 100644 index 0000000..17ff8dc --- /dev/null +++ b/tests/Unit/AuthTest.php @@ -0,0 +1,42 @@ +getProperty('_instances'); + $prop->setAccessible(true); + $prop->setValue(null, []); + } + + public function testDefaultDriverIsFile(): void + { + $this->assertInstanceOf(FileAuthDriver::class, Auth::instance()); + } + + public function testExplicitFileDriver(): void + { + $this->assertInstanceOf(FileAuthDriver::class, Auth::instance('file')); + } + + public function testInstanceIsCachedPerDriver(): void + { + $this->assertSame(Auth::instance('file'), Auth::instance('file')); + } + + public function testUnknownDriverThrows(): void + { + $this->expectException(MyException::class); + Auth::instance('ldap'); + } +} diff --git a/tests/Unit/FileAuthDriverTest.php b/tests/Unit/FileAuthDriverTest.php new file mode 100644 index 0000000..c2ace15 --- /dev/null +++ b/tests/Unit/FileAuthDriverTest.php @@ -0,0 +1,114 @@ +file = sys_get_temp_dir() . '/bicycle_auth_users_' . uniqid() . '.php'; + + file_put_contents($this->file, ' [ + 'password' => password_hash('secret', PASSWORD_DEFAULT), + 'full_name' => 'Bob Smith', + 'email' => 'bob@example.com', + 'role' => 'manager', + ], + ], true) . ';'); + } + + protected function tearDown(): void + { + unlink($this->file); + + Session::instance()->destroy(); + + $ref = new ReflectionClass(Session::class); + $prop = $ref->getProperty('instances'); + $prop->setAccessible(true); + $prop->setValue(null, []); + } + + public function testLoginWithCorrectCredentialsSucceeds(): void + { + $this->assertTrue((new FileAuthDriver($this->file))->login('bob', 'secret')); + } + + public function testLoginWithWrongPasswordFails(): void + { + $this->assertFalse((new FileAuthDriver($this->file))->login('bob', 'wrong')); + } + + public function testLoginWithUnknownLoginFails(): void + { + $this->assertFalse((new FileAuthDriver($this->file))->login('nobody', 'secret')); + } + + public function testLoggedInIsFalseBeforeLogin(): void + { + $this->assertFalse((new FileAuthDriver($this->file))->loggedIn()); + } + + public function testLoggedInIsTrueAfterSuccessfulLogin(): void + { + $driver = new FileAuthDriver($this->file); + $driver->login('bob', 'secret'); + + $this->assertTrue($driver->loggedIn()); + } + + public function testGetUserReturnsDataWithoutPassword(): void + { + $driver = new FileAuthDriver($this->file); + $driver->login('bob', 'secret'); + $user = $driver->getUser(); + + $this->assertSame('bob', $user['login']); + $this->assertSame('Bob Smith', $user['full_name']); + $this->assertSame('bob@example.com', $user['email']); + $this->assertSame('manager', $user['role']); + $this->assertArrayNotHasKey('password', $user); + } + + public function testGetUserIsNullWhenNotAuthorized(): void + { + $this->assertNull((new FileAuthDriver($this->file))->getUser()); + } + + public function testLogoutClearsAuthorization(): void + { + $driver = new FileAuthDriver($this->file); + $driver->login('bob', 'secret'); + $driver->logout(); + + $this->assertFalse($driver->loggedIn()); + } + + public function testCheckPasswordAgainstLoggedInUser(): void + { + $driver = new FileAuthDriver($this->file); + $driver->login('bob', 'secret'); + + $this->assertTrue($driver->checkPassword('secret')); + $this->assertFalse($driver->checkPassword('wrong')); + } + + public function testCheckPasswordIsFalseWhenNotAuthorized(): void + { + $this->assertFalse((new FileAuthDriver($this->file))->checkPassword('secret')); + } + + public function testMissingUsersFileBehavesAsEmptyList(): void + { + $driver = new FileAuthDriver(sys_get_temp_dir() . '/does_not_exist_' . uniqid() . '.php'); + $this->assertFalse($driver->login('bob', 'secret')); + } +}