fix(приёмка): FN-RESET + FN-LOGIN-ROUTE + диагностируемость FN-SESSION

FN-RESET: письмо сброса строило именованный роут password.reset которого нет в SPA.
ResetPassword::createUrlUsing → /reset/{token}?email= в AppServiceProvider boot.

FN-LOGIN-ROUTE: гость без Accept json на auth:sanctum уводил в именованный роут
login которого нет → 500. redirectGuestsTo /login + render AuthenticationException
→ 401 JSON для api/*.

FN-SESSION: chromium.launch стоял вне try/catch — отказ запуска браузера маскировался
unhandled-rejection в opaque exit 1 двойник login-rejected. launch в try + top-level
catch → чистый exit 4 + JSON stderr в refresh-session.js и manage-project.js.

Тесты: PasswordResetUrlTest, UnauthenticatedApiResponseTest, node:test launch-failure
в обоих playwright-скриптах. Разбор FN-SESSION + ops-долг playwright install под
www-data + поправки отчёта приёмки + новая находка FN-INN-LOOKUP.

Прод не трогался. Накат — позже вместе с остальным.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-22 08:49:19 +03:00
parent bd7c39b58d
commit 545bebdabb
10 changed files with 341 additions and 8 deletions
+15
View File
@@ -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:<name> в routes/web.php.
+27
View File
@@ -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 накопили
+14 -3
View File
@@ -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);
});
});
+24 -2
View File
@@ -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}`);
});
+15 -3
View File
@@ -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);
});
});
+61
View File
@@ -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}`);
});
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->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'));
});
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
// FN-LOGIN-ROUTE (приёмка 22.06.2026): неаутентифицированный запрос к auth:sanctum
// роуту, который НЕ просит JSON (нет Accept: application/json — прямой заход
// браузером/ботом), уводит Laravel в редирект на route('login'), которого в SPA
// нет → «Route [login] not defined» (500). Должно отдавать 401 JSON для /api/*.
test('гость без Accept:json на /api/auth/me получает 401 JSON, не route(login)-ошибку', function () {
// get() без Accept:application/json — expectsJson() === false, как у прямого
// браузерного/ботового захода. Заголовок Accept по умолчанию не JSON.
$r = $this->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);
});
@@ -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 в деплое) — действие владельца на проде.
@@ -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 на проде).
## Критдефекты
- **Нет.**