e8e5c82b86
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>
124 lines
5.2 KiB
JavaScript
124 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Headless Playwright login на crm.bp-gr.ru.
|
||
*
|
||
* Input (JSON через stdin):
|
||
* {login, password, url}
|
||
*
|
||
* Output (JSON через stdout):
|
||
* {phpsessid, csrf, refreshed_at}
|
||
*
|
||
* Exit codes:
|
||
* 0 — success
|
||
* 1 — auth failed (login/password rejected, или session cookie missing)
|
||
* 2 — DOM не найден (CSRF token не найден)
|
||
* 3 — timeout (60s)
|
||
* 4 — invalid input или другая ошибка
|
||
*/
|
||
const { chromium } = require('playwright');
|
||
|
||
const TIMEOUT_MS = 60_000;
|
||
|
||
async function refresh(args) {
|
||
// 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();
|
||
|
||
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||
|
||
// DOM-селекторы crm.bp-gr.ru/login (Yii2 LoginForm) — verified live 2026-05-19 через Playwright MCP.
|
||
const loginSelector = '#loginform-username';
|
||
const passwordSelector = '#loginform-password';
|
||
const submitSelector = 'button[type=submit]';
|
||
|
||
await page.fill(loginSelector, args.login);
|
||
await page.fill(passwordSelector, args.password);
|
||
|
||
// Сабмит + ОЖИДАНИЕ пост-логин перехода.
|
||
// Старый Promise.all([waitForLoadState('networkidle'), click]) — гонка:
|
||
// логин-страница уже в состоянии networkidle, поэтому waitForLoadState
|
||
// резолвился мгновенно (ДО редиректа), и скрипт хватал PHPSESSID
|
||
// неаутентифицированной логин-страницы. Ждём, пока логин-форма исчезнет
|
||
// из DOM — waitForFunction опрашивает и переживает навигацию.
|
||
await page.click(submitSelector);
|
||
await page
|
||
.waitForFunction(
|
||
(sel) => !document.querySelector(sel),
|
||
loginSelector,
|
||
{ timeout: TIMEOUT_MS },
|
||
)
|
||
.catch(() => { /* форма осталась — логин отклонён, ловится guard'ом ниже */ });
|
||
await page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }).catch(() => {});
|
||
|
||
// Verify: логин-форма всё ещё на странице → вход НЕ удался. Не возвращаем
|
||
// мусорную (неаутентифицированную) сессию как «успех» (exit 0).
|
||
if ((await page.locator(loginSelector).count()) > 0) {
|
||
process.stderr.write(JSON.stringify({ error: 'login rejected: still on login page after submit' }));
|
||
process.exit(1);
|
||
}
|
||
|
||
let csrf = null;
|
||
try {
|
||
csrf = await page.locator('meta[name=csrf-token]').first().getAttribute('content', { timeout: 5000 });
|
||
} catch (e) {
|
||
// CSRF meta tag not found — try other patterns в Task 1 discovery
|
||
}
|
||
|
||
const cookies = await context.cookies();
|
||
const sessionCookie = cookies.find(c => c.name === 'PHPSESSID' || c.name === 'JSESSIONID');
|
||
|
||
if (!sessionCookie) {
|
||
process.stderr.write(JSON.stringify({ error: 'session cookie not found in response' }));
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!csrf) {
|
||
process.stderr.write(JSON.stringify({ error: 'CSRF token not found in DOM' }));
|
||
process.exit(2);
|
||
}
|
||
|
||
process.stdout.write(JSON.stringify({
|
||
phpsessid: sessionCookie.value,
|
||
csrf: csrf,
|
||
refreshed_at: new Date().toISOString(),
|
||
}));
|
||
process.exit(0);
|
||
} catch (err) {
|
||
process.stderr.write(JSON.stringify({ error: err.message }));
|
||
process.exit(err.message.includes('Timeout') ? 3 : 4);
|
||
} finally {
|
||
if (browser) {
|
||
await browser.close();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Read stdin
|
||
let input = '';
|
||
process.stdin.on('data', chunk => { input += chunk; });
|
||
process.stdin.on('end', () => {
|
||
let args;
|
||
try {
|
||
args = JSON.parse(input);
|
||
} catch (e) {
|
||
process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' }));
|
||
process.exit(4);
|
||
}
|
||
if (!args.login || !args.password || !args.url) {
|
||
process.stderr.write(JSON.stringify({ error: 'missing required keys: login, password, url' }));
|
||
process.exit(4);
|
||
}
|
||
// 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);
|
||
});
|
||
});
|