diff --git a/.gitignore b/.gitignore index 91e69f67..6cbf94a6 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,6 @@ app/.phpstan-cache/ app/infection.log app/infection-summary.log .superpowers/ + +# Plan 3 Task 5 — Playwright Node subprocess (~200MB chromium downloads on prod) +app/playwright/node_modules/ diff --git a/app/app/Console/Commands/SupplierSessionRefreshCommand.php b/app/app/Console/Commands/SupplierSessionRefreshCommand.php new file mode 100644 index 00000000..3a122739 --- /dev/null +++ b/app/app/Console/Commands/SupplierSessionRefreshCommand.php @@ -0,0 +1,23 @@ +info('Supplier session refreshed.'); + + return self::SUCCESS; + } +} diff --git a/app/app/Jobs/Supplier/RefreshSupplierSessionJob.php b/app/app/Jobs/Supplier/RefreshSupplierSessionJob.php index dabb0c0e..87435b93 100644 --- a/app/app/Jobs/Supplier/RefreshSupplierSessionJob.php +++ b/app/app/Jobs/Supplier/RefreshSupplierSessionJob.php @@ -4,26 +4,61 @@ declare(strict_types=1); namespace App\Jobs\Supplier; +use App\Services\Supplier\PlaywrightBridge; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Cache\LockProvider; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; /** - * Plan 3 Task 4 stub. Полная реализация — Task 5 (PlaywrightBridge integration). - * До Task 5 dispatch_sync(RefreshSupplierSessionJob) — noop (handle() пустой). + * Plan 3 Task 5: real implementation. + * Запускает PlaywrightBridge (Node.js + headless chromium) для обновления + * supplier session cookie + CSRF, записывает в Redis с TTL 6h. + * + * Triggers: + * 1. Schedule::hourly() + * 2. Schedule::dailyAt('20:15') МСК (за 15 мин до supplier sync cron) + * 3. Inline dispatch_sync() из SupplierPortalClient на 401/403 + * + * Защита от concurrent refresh — Cache::lock('supplier:session:refresh'). */ class RefreshSupplierSessionJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public function handle(): void + public $tries = 3; + + /** + * @return array + */ + public function backoff(): array { - throw new \LogicException( - 'RefreshSupplierSessionJob stub: real implementation lands in Plan 3 Task 5. ' - .'Until then, manually seed supplier:session cache for local dev:'."\n" - .' Cache::store(\'redis\')->put(\'supplier:session\', [\'phpsessid\' => \'…\', \'csrf\' => \'…\'], 6 * 3600);' - ); + return [120, 600, 1800]; // 2m / 10m / 30m exponential + } + + public function handle(PlaywrightBridge $bridge): void + { + /** @var LockProvider $lockStore */ + $lockStore = Cache::store('redis'); + $lockStore->lock('supplier:session:refresh', 30) + ->block(35, function () use ($bridge) { + $session = $bridge->refreshSession( + login: (string) config('services.supplier.login'), + password: (string) config('services.supplier.password'), + url: (string) config('services.supplier.portal_url'), + ); + + Cache::store('redis')->put( + key: 'supplier:session', + value: $session, + ttl: now()->addHours(6), + ); + + Log::info('supplier.session.refreshed', ['ttl_hours' => 6]); + }); } } diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index 452e6b65..1975e396 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Services\Supplier\ProcessFactory; +use App\Services\Supplier\SymfonyProcessFactory; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,7 +13,10 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind( + ProcessFactory::class, + SymfonyProcessFactory::class, + ); } /** diff --git a/app/app/Services/Supplier/PlaywrightBridge.php b/app/app/Services/Supplier/PlaywrightBridge.php new file mode 100644 index 00000000..d6911f69 --- /dev/null +++ b/app/app/Services/Supplier/PlaywrightBridge.php @@ -0,0 +1,55 @@ +processFactory->create( + ['node', self::SCRIPT_RELATIVE_PATH], + base_path(), + ); + $process->setInput(json_encode([ + 'login' => $login, + 'password' => $password, + 'url' => $url, + ], JSON_THROW_ON_ERROR)); + $process->setTimeoutSeconds(self::TIMEOUT_SECONDS); + $process->run(); + + if (! $process->isSuccessful()) { + throw new SupplierAuthException( + "PlaywrightBridge exit code {$process->getExitCode()}: {$process->getErrorOutput()}", + ); + } + + $output = json_decode($process->getOutput(), true); + if (! is_array($output) || ! isset($output['phpsessid'], $output['csrf'])) { + throw new SupplierAuthException( + "PlaywrightBridge returned invalid output: {$process->getOutput()}", + ); + } + + return $output; + } +} diff --git a/app/app/Services/Supplier/PlaywrightProcessHandle.php b/app/app/Services/Supplier/PlaywrightProcessHandle.php new file mode 100644 index 00000000..506c05b4 --- /dev/null +++ b/app/app/Services/Supplier/PlaywrightProcessHandle.php @@ -0,0 +1,29 @@ + $command + */ + public function create(array $command, ?string $cwd = null): PlaywrightProcessHandle; +} diff --git a/app/app/Services/Supplier/SymfonyPlaywrightProcessHandle.php b/app/app/Services/Supplier/SymfonyPlaywrightProcessHandle.php new file mode 100644 index 00000000..2e2a8ee0 --- /dev/null +++ b/app/app/Services/Supplier/SymfonyPlaywrightProcessHandle.php @@ -0,0 +1,54 @@ +process->setInput($input); + + return $this; + } + + public function setTimeoutSeconds(int $seconds): self + { + $this->process->setTimeout($seconds); + + return $this; + } + + public function run(): int + { + return $this->process->run(); + } + + public function isSuccessful(): bool + { + return $this->process->isSuccessful(); + } + + public function getOutput(): string + { + return $this->process->getOutput(); + } + + public function getErrorOutput(): string + { + return $this->process->getErrorOutput(); + } + + public function getExitCode(): int + { + return $this->process->getExitCode() ?? -1; + } +} diff --git a/app/app/Services/Supplier/SymfonyProcessFactory.php b/app/app/Services/Supplier/SymfonyProcessFactory.php new file mode 100644 index 00000000..026073ca --- /dev/null +++ b/app/app/Services/Supplier/SymfonyProcessFactory.php @@ -0,0 +1,15 @@ +=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/app/playwright/package.json b/app/playwright/package.json new file mode 100644 index 00000000..c0f831d9 --- /dev/null +++ b/app/playwright/package.json @@ -0,0 +1,13 @@ +{ + "name": "liderra-supplier-playwright", + "version": "1.0.0", + "private": true, + "description": "Headless Playwright bridge for Лидерра supplier session refresh (Plan 3 Task 5)", + "scripts": { + "install:chromium": "playwright install chromium --with-deps", + "refresh-session": "node refresh-session.js" + }, + "dependencies": { + "playwright": "^1.50.0" + } +} diff --git a/app/playwright/refresh-session.js b/app/playwright/refresh-session.js new file mode 100644 index 00000000..7346eb34 --- /dev/null +++ b/app/playwright/refresh-session.js @@ -0,0 +1,92 @@ +#!/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) { + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS }); + + // DOM-селекторы — placeholder до Task 1 discovery + const loginSelector = 'input[name=login]'; + const passwordSelector = 'input[name=password]'; + const submitSelector = 'button[type=submit]'; + + await page.fill(loginSelector, args.login); + await page.fill(passwordSelector, args.password); + await Promise.all([ + page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }), + page.click(submitSelector), + ]); + + 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 { + 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); + } + refresh(args); +}); diff --git a/app/tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php b/app/tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php new file mode 100644 index 00000000..f95ab970 --- /dev/null +++ b/app/tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php @@ -0,0 +1,36 @@ + 'test_login', + 'services.supplier.password' => 'test_password', + 'services.supplier.portal_url' => 'https://crm.bp-gr.ru', + ]); + Cache::store('redis')->forget('supplier:session'); +}); + +afterEach(function () { + Cache::store('redis')->forget('supplier:session'); +}); + +test('command supplier:session:refresh dispatches job synchronously and writes cache', function () { + $bridge = Mockery::mock(PlaywrightBridge::class); + /** @var PlaywrightBridge $bridge */ + $bridge->shouldReceive('refreshSession')->andReturn([ + 'phpsessid' => 'cmd_sess', + 'csrf' => 'cmd_csrf', + 'refreshed_at' => '2026-05-11T10:00:00Z', + ]); + app()->instance(PlaywrightBridge::class, $bridge); + + // @phpstan-ignore-next-line method.notFound (Pest TestCall->artisan() mixin) + $this->artisan('supplier:session:refresh')->assertExitCode(0); + + expect(Cache::store('redis')->get('supplier:session'))->toBeArray() + ->and(Cache::store('redis')->get('supplier:session')['phpsessid'])->toBe('cmd_sess'); +}); diff --git a/app/tests/Unit/Supplier/PlaywrightBridgeTest.php b/app/tests/Unit/Supplier/PlaywrightBridgeTest.php new file mode 100644 index 00000000..16ca394d --- /dev/null +++ b/app/tests/Unit/Supplier/PlaywrightBridgeTest.php @@ -0,0 +1,92 @@ + 'abc123', + 'csrf' => 'xyz789', + 'refreshed_at' => '2026-05-11T10:00:00Z', + ]), + ); + + $factoryMock = Mockery::mock(ProcessFactory::class); + /** @var ProcessFactory $factoryMock */ + $factoryMock->shouldReceive('create')->andReturn($stubHandle); + + $bridge = new PlaywrightBridge($factoryMock); + $result = $bridge->refreshSession( + login: 'test_login', + password: 'test_password', + url: 'https://crm.bp-gr.ru' + ); + + // Credentials must come through stdin, not argv (avoid leak in ps output) + $capturedInput = json_decode($stubHandle->capturedInput, true); + expect($capturedInput)->toBeArray() + ->and($capturedInput['login'])->toBe('test_login') + ->and($capturedInput['password'])->toBe('test_password') + ->and($capturedInput['url'])->toBe('https://crm.bp-gr.ru'); + + expect($stubHandle->capturedTimeout)->toBe(75); + + expect($result)->toHaveKeys(['phpsessid', 'csrf', 'refreshed_at']) + ->and($result['phpsessid'])->toBe('abc123') + ->and($result['csrf'])->toBe('xyz789'); +}); + +test('PlaywrightBridge throws SupplierAuthException on non-zero exit', function () { + $stubHandle = new StubPlaywrightProcessHandle( + successful: false, + errorOutput: '{"error":"login rejected"}', + exitCode: 1, + ); + + $factoryMock = Mockery::mock(ProcessFactory::class); + $factoryMock->shouldReceive('create')->andReturn($stubHandle); + + $bridge = new PlaywrightBridge($factoryMock); + + expect(fn () => $bridge->refreshSession('bad', 'bad', 'https://crm.bp-gr.ru')) + ->toThrow(SupplierAuthException::class); +}); + +test('PlaywrightBridge throws SupplierAuthException on invalid stdout JSON', function () { + $stubHandle = new StubPlaywrightProcessHandle( + successful: true, + output: 'not valid json{', + ); + + $factoryMock = Mockery::mock(ProcessFactory::class); + $factoryMock->shouldReceive('create')->andReturn($stubHandle); + + $bridge = new PlaywrightBridge($factoryMock); + + expect(fn () => $bridge->refreshSession('a', 'b', 'https://crm.bp-gr.ru')) + ->toThrow(SupplierAuthException::class); +}); + +test('PlaywrightBridge throws SupplierAuthException on missing keys in stdout', function () { + $stubHandle = new StubPlaywrightProcessHandle( + successful: true, + output: json_encode(['phpsessid' => 'a']), // no csrf + ); + + $factoryMock = Mockery::mock(ProcessFactory::class); + $factoryMock->shouldReceive('create')->andReturn($stubHandle); + + $bridge = new PlaywrightBridge($factoryMock); + + expect(fn () => $bridge->refreshSession('a', 'b', 'https://crm.bp-gr.ru')) + ->toThrow(SupplierAuthException::class); +}); diff --git a/app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php b/app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php new file mode 100644 index 00000000..ab2250aa --- /dev/null +++ b/app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php @@ -0,0 +1,60 @@ + 'test_login', + 'services.supplier.password' => 'test_password', + 'services.supplier.portal_url' => 'https://crm.bp-gr.ru', + ]); + Cache::store('redis')->forget('supplier:session'); +}); + +afterEach(function () { + Cache::store('redis')->forget('supplier:session'); + Mockery::close(); +}); + +test('writes session data to Redis cache key supplier:session with 6h TTL', function () { + $bridge = Mockery::mock(PlaywrightBridge::class); + /** @var PlaywrightBridge $bridge */ + $bridge->shouldReceive('refreshSession') + ->once() + ->with('test_login', 'test_password', 'https://crm.bp-gr.ru') + ->andReturn([ + 'phpsessid' => 'sess123', + 'csrf' => 'csrf456', + 'refreshed_at' => '2026-05-11T10:00:00Z', + ]); + + (new RefreshSupplierSessionJob)->handle($bridge); + + $cached = Cache::store('redis')->get('supplier:session'); + expect($cached)->toBeArray() + ->and($cached['phpsessid'])->toBe('sess123') + ->and($cached['csrf'])->toBe('csrf456') + ->and($cached['refreshed_at'])->toBe('2026-05-11T10:00:00Z'); +}); + +test('rethrows SupplierAuthException from PlaywrightBridge', function () { + $bridge = Mockery::mock(PlaywrightBridge::class); + /** @var PlaywrightBridge $bridge */ + $bridge->shouldReceive('refreshSession') + ->andThrow(new SupplierAuthException('Login rejected')); + + expect(fn () => (new RefreshSupplierSessionJob)->handle($bridge)) + ->toThrow(SupplierAuthException::class); +}); + +// NOTE: artisan() command test moved to tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php +// (Pest Unit/ + $this->artisan() + uses(TestCase::class) триггерит laravel/pao stream_filter +// conflict при кросс-файловом запуске tests/Unit/Supplier/). diff --git a/app/tests/Unit/Supplier/Stubs/StubPlaywrightProcessHandle.php b/app/tests/Unit/Supplier/Stubs/StubPlaywrightProcessHandle.php new file mode 100644 index 00000000..f7cd6e7e --- /dev/null +++ b/app/tests/Unit/Supplier/Stubs/StubPlaywrightProcessHandle.php @@ -0,0 +1,67 @@ +capturedInput = $input; + + return $this; + } + + public function setTimeoutSeconds(int $seconds): self + { + $this->capturedTimeout = $seconds; + + return $this; + } + + public function run(): int + { + return $this->successful ? 0 : $this->exitCode; + } + + public function isSuccessful(): bool + { + return $this->successful; + } + + public function getOutput(): string + { + return $this->output; + } + + public function getErrorOutput(): string + { + return $this->errorOutput; + } + + public function getExitCode(): int + { + return $this->successful ? 0 : $this->exitCode; + } +} diff --git a/app/tests/Unit/Supplier/Stubs/ThrowingRefreshSupplierSessionJob.php b/app/tests/Unit/Supplier/Stubs/ThrowingRefreshSupplierSessionJob.php index 69d477ce..d632e634 100644 --- a/app/tests/Unit/Supplier/Stubs/ThrowingRefreshSupplierSessionJob.php +++ b/app/tests/Unit/Supplier/Stubs/ThrowingRefreshSupplierSessionJob.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Unit\Supplier\Stubs; use App\Jobs\Supplier\RefreshSupplierSessionJob; +use App\Services\Supplier\PlaywrightBridge; use RuntimeException; /** @@ -24,7 +25,7 @@ class ThrowingRefreshSupplierSessionJob extends RefreshSupplierSessionJob // } - public function handle(): void + public function handle(?PlaywrightBridge $bridge = null): void { throw new RuntimeException($this->simulatedMessage); }