diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index 5234ee04..c5442c85 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -17,6 +17,7 @@ use App\Services\Supplier\Channel\FormProjectChannel; use App\Services\Supplier\Channel\SupplierProjectChannel; use App\Services\Supplier\ProcessFactory; use App\Services\Supplier\SymfonyProcessFactory; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Http\Request; @@ -76,6 +77,20 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + // FN-RESET (приёмка 22.06.2026): дефолтное Laravel-уведомление ResetPassword + // строит ссылку через route('password.reset'), которого в SPA нет — роут + // сброса объявлен только на фронте (Vue Router /reset/:token, name + // 'reset-password'; backend знает лишь Route::view('/reset','welcome')). + // Без этого toMail() бросает «Route [password.reset] not defined» → письмо + // со ссылкой не уходит никому, сброс пароля сломан для всех. + // Формат ссылки — ровно тот, что разбирает ResetPasswordView.vue: + // {app.url}/reset/{token}?email={urlencoded}. + ResetPassword::createUrlUsing( + fn (User $user, string $token): string => rtrim((string) config('app.url'), '/') + .'/reset/'.$token + .'?email='.urlencode($user->getEmailForPasswordReset()), + ); + // P1 go-live: per-IP route-throttle поверх прикладного per-credential // rate-limit в auth-контроллерах. Именованные лимитеры изолируют счётчики // login / 2fa / password. Применение — throttle: в routes/web.php. diff --git a/app/bootstrap/app.php b/app/bootstrap/app.php index 0434f708..9dae21ac 100644 --- a/app/bootstrap/app.php +++ b/app/bootstrap/app.php @@ -4,6 +4,7 @@ use App\Http\Middleware\ApiKeyAuth; use App\Http\Middleware\EnsureSaasAdmin; use App\Http\Middleware\ImpersonationContext; use App\Http\Middleware\SetTenantContext; +use Illuminate\Auth\AuthenticationException; use Illuminate\Database\QueryException; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; @@ -33,6 +34,15 @@ return Application::configure(basePath: dirname(__DIR__)) ImpersonationContext::class, ]); + // FN-LOGIN-ROUTE (приёмка 22.06.2026): по умолчанию Laravel при неаутент. + // не-JSON запросе зовёт route('login') для guest-редиректа. У SPA нет + // backend-роута с именем 'login' (логин — клиентский Vue-роут), поэтому + // дефолт бросал «Route [login] not defined» (500) на прямых заходах + // браузера/бота к /api/*. Возвращаем строковый путь SPA-логина — никакого + // route() вызова. Для /api/* финальный ответ — 401 JSON (render ниже во + // withExceptions), сюда не доходит. + $middleware->redirectGuestsTo(fn (): string => '/login'); + // Защитные HTTP-заголовки (CSP, X-Frame-Options, X-Content-Type-Options, // Referrer-Policy, HSTS, Permissions-Policy, COOP/CORP) ставит nginx — // единый источник: /etc/nginx/sites-available/liderra (add_header ... always). @@ -48,6 +58,23 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { + // FN-LOGIN-ROUTE (приёмка 22.06.2026): неаутентифицированный запрос к + // auth:sanctum-роуту, который НЕ просит JSON (прямой заход браузером/ботом + // без Accept: application/json), уводил Laravel в Authenticate::redirectTo() + // → route('login'), которого в SPA нет (логин — клиентский Vue-роут) → + // «Route [login] not defined» (500). Все защищённые маршруты тут — /api/*, + // поэтому для них всегда отдаём 401 JSON вместо редиректа на несуществующий + // именованный роут. Наблюдалось в проде 08.06 и 21.06. + $exceptions->render(function (AuthenticationException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json([ + 'message' => 'Требуется авторизация.', + ], 401); + } + + return null; // не-API: поведение по умолчанию + }); + // Reduce verbosity of constraint-violation logging (SQLSTATE 23xxx): // data-validity errors do not need a full stack trace в laravel.log. // Incident 2026-05-29: 420k повторов B1+SMS check_violation накопили diff --git a/app/playwright/manage-project.js b/app/playwright/manage-project.js index 3cbf6014..9b4011a9 100644 --- a/app/playwright/manage-project.js +++ b/app/playwright/manage-project.js @@ -359,8 +359,12 @@ async function listOp(page, args) { // --------------------------------------------------------------------------- async function run(args) { - const browser = await chromium.launch({ headless: true }); + // browser.launch ВНУТРИ try: отказ запуска браузера классифицируется как + // exit 4 + JSON stderr, а не уходит unhandled-rejection'ом в exit 1 + // (двойник «login rejected»). FN-SESSION, приёмка 22.06.2026. + let browser = null; try { + browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext(); const page = await ctx.newPage(); let out; @@ -385,7 +389,9 @@ async function run(args) { ) process.exit(1); process.exit(4); } finally { - await browser.close(); + if (browser) { + await browser.close(); + } } } @@ -401,5 +407,10 @@ process.stdin.on('end', () => { process.stderr.write(JSON.stringify({ error: 'missing required: operation, url' })); process.exit(4); } - run(args); + // Top-level guard: rejection не пойманная в run() → чистый exit 4 + JSON stderr. + run(args).catch((err) => { + const message = err && err.message ? err.message : String(err); + process.stderr.write(JSON.stringify({ error: message })); + process.exit(4); + }); }); diff --git a/app/playwright/manage-project.test.js b/app/playwright/manage-project.test.js index 451743a5..37d038f9 100644 --- a/app/playwright/manage-project.test.js +++ b/app/playwright/manage-project.test.js @@ -43,12 +43,12 @@ function startFixtureServer() { } /** Спавнить manage-project.js, подать JSON на stdin, вернуть {code, stdout, stderr}. */ -function runScript(input) { +function runScript(input, extraEnv) { return new Promise((resolve, reject) => { const child = execFile( 'node', [SCRIPT], - { timeout: 90_000 }, + { timeout: 90_000, env: { ...process.env, ...extraEnv } }, (err, stdout, stderr) => { if (err && err.killed) return reject(new Error('Process killed / timed out')); // err.code — exit code; treat as expected (tests assert on code) @@ -135,3 +135,25 @@ test('listProjects returns array (skipLogin mode, fixture page)', async () => { server.close(); } }); + +// --------------------------------------------------------------------------- +// Test 3 — отказ запуска браузера → чистый exit 4 + JSON {error}, не опасный exit 1 +// FN-SESSION (приёмка 22.06.2026): launch вне try/catch уводил отказ запуска в +// unhandled-rejection exit 1 — двойник «login rejected». Фикс: launch в try → exit 4. +// --------------------------------------------------------------------------- +test('browser launch failure → exit 4 + JSON {error} (not unhandled-rejection exit 1)', async () => { + const result = await runScript( + { operation: 'list', url: 'http://127.0.0.1:1/', skipLogin: true }, + { PLAYWRIGHT_BROWSERS_PATH: path.resolve(__dirname, '__nonexistent_browsers__') }, + ); + + assert.strictEqual(result.code, 4, `Expected exit 4, got ${result.code}. stderr: ${result.stderr}`); + + let parsed; + try { + parsed = JSON.parse(result.stderr.trim()); + } catch (e) { + assert.fail(`stderr не валидный JSON (сырой стек?): ${result.stderr}`); + } + assert.ok(typeof parsed.error === 'string' && parsed.error.length > 0, `expected {error}, got ${result.stderr}`); +}); diff --git a/app/playwright/refresh-session.js b/app/playwright/refresh-session.js index 785a73d7..98636b4a 100644 --- a/app/playwright/refresh-session.js +++ b/app/playwright/refresh-session.js @@ -20,8 +20,12 @@ const { chromium } = require('playwright'); const TIMEOUT_MS = 60_000; async function refresh(args) { - const browser = await chromium.launch({ headless: true }); + // browser.launch ВНУТРИ try: отказ запуска (нет исполняемого файла браузера, + // sandbox, нехватка libs) должен классифицироваться как exit 4 + JSON stderr, + // а не уходить unhandled-rejection'ом в опасный exit 1 (FN-SESSION, приёмка 22.06). + let browser = null; try { + browser = await chromium.launch({ headless: true }); const context = await browser.newContext(); const page = await context.newPage(); @@ -88,7 +92,9 @@ async function refresh(args) { process.stderr.write(JSON.stringify({ error: err.message })); process.exit(err.message.includes('Timeout') ? 3 : 4); } finally { - await browser.close(); + if (browser) { + await browser.close(); + } } } @@ -107,5 +113,11 @@ process.stdin.on('end', () => { process.stderr.write(JSON.stringify({ error: 'missing required keys: login, password, url' })); process.exit(4); } - refresh(args); + // Top-level guard: любая rejection, не пойманная внутри refresh(), всё равно + // даёт чистый exit 4 + JSON stderr, а не unhandled-rejection exit 1. + refresh(args).catch((err) => { + const message = err && err.message ? err.message : String(err); + process.stderr.write(JSON.stringify({ error: message })); + process.exit(4); + }); }); diff --git a/app/playwright/refresh-session.test.js b/app/playwright/refresh-session.test.js new file mode 100644 index 00000000..3bcbef22 --- /dev/null +++ b/app/playwright/refresh-session.test.js @@ -0,0 +1,61 @@ +/** + * Тест refresh-session.js — поведение при ОТКАЗЕ запуска браузера. + * + * FN-SESSION (приёмка 22.06.2026): на проде browserType.launch падал (не было + * исполняемого файла headless-shell в кэше www-data). Из-за того, что + * chromium.launch() стоял ВНЕ try/catch, отказ становился unhandled promise + * rejection → Node выходил с кодом 1 + сырой стек в stderr — неотличимо от + * честного «login rejected» (тоже exit 1). Этот тест фиксирует контракт: + * отказ запуска браузера = exit 4 + структурированный JSON {error} в stderr. + * + * Runner: встроенный node:test (Node 18+). Запуск: `node --test refresh-session.test.js`. + */ +const { test } = require('node:test'); +const assert = require('node:assert'); +const { execFile } = require('node:child_process'); +const path = require('node:path'); + +const SCRIPT = path.resolve(__dirname, 'refresh-session.js'); + +/** Спавнить refresh-session.js с заданным env, подать JSON на stdin, вернуть {code, stdout, stderr}. */ +function runScript(input, extraEnv) { + return new Promise((resolve, reject) => { + const child = execFile( + 'node', + [SCRIPT], + { timeout: 90_000, env: { ...process.env, ...extraEnv } }, + (err, stdout, stderr) => { + if (err && err.killed) return reject(new Error('Process killed / timed out')); + resolve({ + code: err ? err.code : 0, + stdout: stdout.toString(), + stderr: stderr.toString(), + }); + }, + ); + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + }); +} + +test('отказ запуска браузера → чистый exit 4 + JSON {error} в stderr (не опасный exit 1)', async () => { + // Форсируем отказ chromium.launch: указываем кэш браузеров в несуществующий + // путь — Playwright не найдёт исполняемый файл (та же ошибка, что была на проде). + const result = await runScript( + { login: 'x', password: 'y', url: 'http://127.0.0.1:1/' }, + { PLAYWRIGHT_BROWSERS_PATH: path.resolve(__dirname, '__nonexistent_browsers__') }, + ); + + // Отказ запуска должен классифицироваться как exit 4 (другая ошибка), + // а НЕ как exit 1 (login rejected) и не как unhandled-rejection exit 1. + assert.strictEqual(result.code, 4, `Expected exit 4, got ${result.code}. stderr: ${result.stderr}`); + + // stderr должен быть валидным JSON с ключом error (а не сырым стеком Node). + let parsed; + try { + parsed = JSON.parse(result.stderr.trim()); + } catch (e) { + assert.fail(`stderr не валидный JSON (сырой стек?): ${result.stderr}`); + } + assert.ok(typeof parsed.error === 'string' && parsed.error.length > 0, `expected {error}, got ${result.stderr}`); +}); diff --git a/app/tests/Feature/Auth/PasswordResetUrlTest.php b/app/tests/Feature/Auth/PasswordResetUrlTest.php new file mode 100644 index 00000000..387998f0 --- /dev/null +++ b/app/tests/Feature/Auth/PasswordResetUrlTest.php @@ -0,0 +1,37 @@ +tenant = Tenant::factory()->create(); +}); + +// FN-RESET (приёмка 22.06.2026): дефолтное Laravel-уведомление ResetPassword +// строит URL через route('password.reset'), которого в SPA нет (роут сброса — +// /reset/{token}). Без фикса toMail() бросает «Route [password.reset] not defined», +// письмо не уходит. Фикс: ResetPassword::createUrlUsing(...) в AppServiceProvider::boot. +test('ResetPassword notification строит ссылку на SPA-роут /reset/{token}?email= без route-error', function () { + $user = User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'reset-link@example.ru', + 'password_hash' => Hash::make('old-pass-1234'), + ]); + + $token = 'test-token-abc123'; + + // Без фикса этот вызов бросит «Route [password.reset] not defined». + $mail = (new ResetPassword($token))->toMail($user); + + $url = (string) $mail->actionUrl; + + expect($url)->toContain('/reset/'.$token); + expect($url)->toContain('email='.urlencode('reset-link@example.ru')); +}); diff --git a/app/tests/Feature/Auth/UnauthenticatedApiResponseTest.php b/app/tests/Feature/Auth/UnauthenticatedApiResponseTest.php new file mode 100644 index 00000000..b43522c5 --- /dev/null +++ b/app/tests/Feature/Auth/UnauthenticatedApiResponseTest.php @@ -0,0 +1,27 @@ +get('/api/auth/me', ['Accept' => 'text/html']); + + $r->assertStatus(401); + $r->assertHeader('Content-Type', 'application/json'); + expect($r->json('message'))->not->toBeEmpty(); +}); + +test('гость c Accept:json на /api/auth/me получает 401 JSON (регрессия — уже работало)', function () { + $r = $this->getJson('/api/auth/me'); + + $r->assertStatus(401); +}); diff --git a/docs/superpowers/runbooks/2026-06-22-FN-SESSION-diagnosis-and-ops-fix.md b/docs/superpowers/runbooks/2026-06-22-FN-SESSION-diagnosis-and-ops-fix.md new file mode 100644 index 00000000..1d309fff --- /dev/null +++ b/docs/superpowers/runbooks/2026-06-22-FN-SESSION-diagnosis-and-ops-fix.md @@ -0,0 +1,95 @@ +# FN-SESSION — диагностика и фикс (22.06.2026) + +> Разбор находки **FN-SESSION** из приёмки боевого 22.06. Диагностика на проде — +> строго read-only (`ssh liderra-prod`). Кодовая фраза стены: «роутер-наставник». + +## Вердикт + +**FN-SESSION — это была проблема окружения (отсутствовал исполняемый файл браузера), +уже устранённая переустановкой 21.06, ПЛЮС код-баг диагностируемости (исправлен этой +сессией по TDD).** К «лиды не идут с 3 июня» отношения **не имеет** — то бизнес-решение +омеги (сама выключила проекты), не дефект. + +## Что происходило (14–21.06, ~121×/день) + +Playwright-скрипт `app/playwright/refresh-session.js` падал, потому что в кэше +браузеров пользователя www-data **не было исполняемого файла** headless-shell: + +``` +browserType.launch: Executable doesn't exist at +/var/www/.cache/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell +``` + +`chromium.launch()` отклонялся. И вот **почему это было трудно диагностировать**: в коде +`const browser = await chromium.launch()` стоял **вне** `try/catch`, а `refresh(args)` +вызывался **без `.catch()`**. Поэтому отказ запуска браузера превращался в +**unhandled promise rejection → Node выходил с кодом 1** и сырым стеком в stderr — +**неотличимо** от честного «login rejected» (тоже exit 1). По «exit code 1» нельзя было +понять: сломан браузер или отклонён логин. + +## Каденс (почему 121×/день) + +`RefreshSupplierSessionJob`: `Schedule::hourly()` + `dailyAt('17:45')` + инлайн +`dispatch_sync()` из `SupplierPortalClient` на 401/403/HTML-логин-страницу. +`$tries=3` с backoff. Итого ~72/день от hourly + инлайн-ретраи. + +## Текущее состояние (после переустановки 21.06 12:36) — read-only улики + +| Проверка | Результат | +|---|---| +| Исполняемый файл браузера | **на месте** (`chrome-headless-shell` 185 МБ, `-rwxr-xr-x`, www-data, точный ожидаемый путь) | +| Ошибки «PlaywrightBridge exit code» 21.06 после 12:36 и за всё 22.06 | **НЕТ** — missing-binary прекратился | +| Воркер | systemd `liderra-queue.service`, `User=www-data`, HOME консистентен с кэшем | +| Supplier-интеграция 22.06 | **жива** — в 02:55 идут аутентифицированные вызовы `SyncSupplierProjectJob` к `/admin/visit/rt-project-save` | + +### Разъяснение «`supplier.session.refreshed` = 0 за весь лог» + +Это **не сбой**. `supplier.session.refreshed` — это `Log::info`. На проде +`production.INFO`-строк в логе **0** (`LOG_LEVEL >= warning`) → INFO просто не пишется. +Что refresh работает — доказано тем, что tier-1 AJAX `rt-project-save` 22.06 доходит до +поставщика и получает бизнес-конверт (`status=Doubles`, HTTP 200): это возможно только +с валидной сессией из Redis (`SupplierPortalClient::loadSession()`). + +## Что исправлено этой сессией (код, по TDD, проверено глазами) + +Сделана **диагностируемость**: отказ запуска браузера теперь даёт **чистый exit 4 + +JSON `{error}` в stderr** вместо опасного unhandled-rejection exit 1. + +- `app/playwright/refresh-session.js` — `chromium.launch()` внесён в `try`, `browser.close()` + под guard, добавлен top-level `.catch()`. +- `app/playwright/manage-project.js` — тот же баг, тот же фикс. +- Тесты (`node:test`): `app/playwright/refresh-session.test.js` (новый), + `app/playwright/manage-project.test.js` (+тест launch-failure). Все зелёные. +- Живая проверка: форсированный отказ запуска (битый `PLAYWRIGHT_BROWSERS_PATH`) → + **exit 4 + JSON** (было exit 1 + сырой стек). PHP Supplier-тесты 71/71. + +После фикса `SupplierAuthException` примет вид +`PlaywrightBridge exit code 4: {"error":"browserType.launch: Executable doesn't exist…"}` +— сразу ясно, что это отказ запуска браузера, а не отклонённый логин. + +## Ops-рекомендация (корень рецидива) — действие ВЛАДЕЛЬЦА на проде + +Браузер пропал из кэша www-data почти наверняка из-за деплоя, который обновлял/чистил +`node_modules` без переустановки браузеров под www-data. Чтобы не повторялось: + +1. В пайплайн деплоя добавить шаг **от имени www-data** (HOME=/var/www): + `npx playwright install chromium-headless-shell` (или `chromium`) — после `npm ci`. +2. Версия браузера привязана к версии npm-пакета `playwright` (сейчас headless-shell + `-1217`). При обновлении `playwright` обязательно переустановить браузеры. +3. Желательно — health-check: алёрт, если `RefreshSupplierSessionJob` падает N раз подряд + (сейчас падения видны только как рост `failed_jobs` + ERROR-строки). + +## Поправки к отчёту приёмки + +В `docs/superpowers/specs/2026-06-21-acceptance-RUN-report.md` (находки FN-SESSION): + +- «Переустановка браузера 21.06 НЕ вылечила» — **неверно**: вылечила (ошибок после + 21.06 12:36 нет). Отчёт смотрел на ошибки ДО переустановки. +- «Вероятно связана с лиды не идут» — **опровергнуто**: лиды не идут — бизнес-решение + омеги (выключила проекты), не дефект. + +## Не сделано (вне объёма) + +- Прод не трогался (только read-only диагностика). Код-фикс — локально, не закоммичен, + не выкачен (ждёт слова владельца; пуш — в gitea). +- Ops-шаг (playwright install в деплое) — действие владельца на проде. diff --git a/docs/superpowers/specs/2026-06-21-acceptance-RUN-report.md b/docs/superpowers/specs/2026-06-21-acceptance-RUN-report.md index 252ee091..2f99b5ca 100644 --- a/docs/superpowers/specs/2026-06-21-acceptance-RUN-report.md +++ b/docs/superpowers/specs/2026-06-21-acceptance-RUN-report.md @@ -95,6 +95,32 @@ > **Вывод по логам:** основной шум (collision 2001× / B1+SMS 490k / routing_failed_permanently 667×) — **исторический майский шторм** (вашиденьги24), корень починен, нужна только уборка `failed_jobs`. Единственная **живая** операционная проблема — **FN-SESSION** (Playwright-заход к поставщику валится 121×/день → CsvReconcile не работает). Почта/деньги/раздача сейчас здоровы. +## Поправка от 22.06.2026 (разбор FN-SESSION) + +> Дописано после диагностики на проде (read-only). Запись выше сохранена как была; +> здесь — уточнения. Полный разбор: [docs/superpowers/runbooks/2026-06-22-FN-SESSION-diagnosis-and-ops-fix.md](../runbooks/2026-06-22-FN-SESSION-diagnosis-and-ops-fix.md). + +- **FN-SESSION**: корень — отсутствовал исполняемый файл браузера в кэше www-data + (`chromium.launch` падал). Утверждение «переустановка 21.06 НЕ вылечила» — **неверно**: + вылечила (ошибок «PlaywrightBridge exit code» после 21.06 12:36 нет). Дополнительно + исправлен код-баг диагностируемости (launch вне try/catch → опасный exit-1-двойник + «login rejected»); теперь отказ запуска = exit 4 + JSON. Остаётся ops-шаг владельца: + `playwright install` под www-data в деплое (профилактика рецидива). +- **«Лиды не идут с 3 июня»** — **не дефект**: бизнес-решение омеги (сама выключила + проекты). С FN-SESSION не связано. +- **`supplier.session.refreshed`=0 в логе** — артефакт уровня логирования (`Log::info` + отфильтрован, на проде `LOG_LEVEL>=warning`), не сбой: refresh работает. + +## Новые находки после прогона + +- **FN-INN-LOOKUP** (🟡, найдено владельцем 22.06 на проде): Настройки → Реквизиты → + Тип лица «ЮРЛИЦО», кнопка **«Найти по ИНН» не находит организацию** по валидному ИНН + юрлица (пример: `2460090423` — реальное ООО) → «Организация не найдена по ИНН». + Это DaData party-lookup (`TenantRequisitesController::lookupInn` / `PartyLookup`, + флаг `services.dadata.party_enabled` + ключ DaData). Вероятно на проде lookup выключен + или мисконфиг ключа/эндпоинта. Заполнение названия вручную работает — на гейт реквизитов + не блокирует. **Разобрать позже** (read-only диагностика: флаг + ключ DaData на проде). + ## Критдефекты - **Нет.**