From c4987b7f05a2f312ac35f1744c24cd6af265f0cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 9 Jun 2026 11:58:50 +0200 Subject: [PATCH] test(open-banking): e2e coverage for Enable Banking connection flows (#509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Bank connection via Enable Banking is a critical flow that must keep working in **both onboarding and settings**, including reconnecting an expired consent. This adds regression coverage so it never silently breaks. ## What Two complementary layers: ### 1. CI regression — mocked Pest browser tests `tests/Browser/BankConnectionFlowTest.php` drives the full UI → `authorize` → `callback` → account mapping → sync flow for all three scenarios, with the banking provider faked (`tests/Support/FakeBankingProvider.php`). Deterministic, no external calls. - ✅ connect from onboarding (accounts auto-created) - ✅ connect from settings (+ account mapping) - ✅ reconnect an expired connection (re-matches accounts by IBAN) **Runs on CI** in the existing `browser-tests` job (distributed across shards automatically as a new test; `shards.json` timing entry will be added next time `update-browser-shards` runs). ### 2. Live-sandbox verification — standalone Playwright script `tests/Browser/live/connect-bank.mjs` runs the same three flows against the **real Enable Banking sandbox** (Banco de Sabadell + BBVA) via the dev server, for on-demand / nightly checks. Backed by a local-only helper command `app/Console/Commands/E2eBankingFixtureCommand.php`. **Does not run on CI** (needs the dev server, live sandbox, and secrets). See `tests/Browser/live/README.md`. #### Why the live flow can't be a normal Pest test Enable Banking only redirects to the fixed registered URI (`https://whisper.money.local/open-banking/callback`). A `RefreshDatabase` Pest test runs an ephemeral server on a random port with a transactional DB the external redirect can never reach. The script instead drives the persistent dev server, captures the `code`+`state` the bank redirects with, and replays the callback against the dev server (the callback is stateless — keyed by `state_token`). ## Verification - Mocked Pest tests: **3 passing**. - Live script: **all 3 scenarios PASS** against the real sandbox (Sabadell → 5 accounts + 328 tx synced; BBVA onboarding → 7 accounts; expired → reconnect refreshes `valid_until` and retains accounts). - `pint --test`, eslint, prettier clean. > Note: while verifying locally, the dev DB was missing the `state_token` migration (`2026_06_05_185212`), which made `/open-banking/authorize` 500. Worth confirming it's applied in other environments. --- .../Commands/E2eBankingFixtureCommand.php | 164 ++++++ composer.json | 4 + tests/Browser/BankConnectionFlowTest.php | 177 ++++++ tests/Browser/live/.gitignore | 2 + tests/Browser/live/README.md | 76 +++ tests/Browser/live/connect-bank.mjs | 532 ++++++++++++++++++ tests/Support/FakeBankingProvider.php | 152 +++++ 7 files changed, 1107 insertions(+) create mode 100644 app/Console/Commands/E2eBankingFixtureCommand.php create mode 100644 tests/Browser/BankConnectionFlowTest.php create mode 100644 tests/Browser/live/.gitignore create mode 100644 tests/Browser/live/README.md create mode 100644 tests/Browser/live/connect-bank.mjs create mode 100644 tests/Support/FakeBankingProvider.php diff --git a/app/Console/Commands/E2eBankingFixtureCommand.php b/app/Console/Commands/E2eBankingFixtureCommand.php new file mode 100644 index 00000000..819b229a --- /dev/null +++ b/app/Console/Commands/E2eBankingFixtureCommand.php @@ -0,0 +1,164 @@ +environment('local', 'testing')) { + $this->error('e2e:banking-fixture is only available in local/testing environments.'); + + return self::FAILURE; + } + + return match ($this->argument('action')) { + 'seed' => $this->seed(), + 'inspect' => $this->inspect(), + 'expire' => $this->expire(), + default => $this->invalidAction(), + }; + } + + private function seed(): int + { + $onboarding = $this->resetUser(self::ONBOARDING_EMAIL, onboarded: false, pro: false); + $settings = $this->resetUser(self::SETTINGS_EMAIL, onboarded: true, pro: true); + + $this->line((string) json_encode([ + 'onboarding' => ['email' => $onboarding->email, 'password' => self::PASSWORD], + 'settings' => ['email' => $settings->email, 'password' => self::PASSWORD], + ], JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } + + private function inspect(): int + { + $connection = $this->latestConnection(); + + if (! $connection) { + $this->line((string) json_encode(['connection' => null])); + + return self::SUCCESS; + } + + $accountIds = $connection->accounts()->pluck('id'); + + $this->line((string) json_encode([ + 'connection' => [ + 'id' => $connection->id, + 'status' => $connection->status->value, + 'aspsp_name' => $connection->aspsp_name, + 'session_id' => $connection->session_id !== null, + 'valid_until' => $connection->valid_until?->toIso8601String(), + 'accounts_count' => $accountIds->count(), + 'accounts_without_external_id' => $connection->accounts()->whereNull('external_account_id')->count(), + 'transactions_count' => Transaction::whereIn('account_id', $accountIds)->count(), + ], + ], JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } + + private function expire(): int + { + $connection = $this->latestConnection(); + + if (! $connection) { + $this->error('No connection found for '.$this->argument('email')); + + return self::FAILURE; + } + + $connection->update([ + 'status' => BankingConnectionStatus::Expired, + 'valid_until' => now()->subDay(), + ]); + + $this->line((string) json_encode(['expired' => $connection->id])); + + return self::SUCCESS; + } + + private function resetUser(string $email, bool $onboarded, bool $pro): User + { + // Reuse the user across runs (the model soft-deletes, so recreating the same + // email would collide). Clear everything the flows produce so each run starts + // from a clean slate. + $user = User::withTrashed()->firstWhere('email', $email) + ?? User::factory()->create(['email' => $email]); + + $user->accounts()->forceDelete(); + $user->bankingConnections()->forceDelete(); + $user->subscriptions()->delete(); + + $user->restore(); + $user->forceFill([ + 'email_verified_at' => now(), + 'password' => Hash::make(self::PASSWORD), + 'onboarded_at' => $onboarded ? now() : null, + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + 'two_factor_confirmed_at' => null, + ])->save(); + + if ($pro) { + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_e2e_'.$user->id, + 'stripe_status' => 'active', + 'stripe_price' => 'price_e2e', + 'quantity' => 1, + ]); + } + + return $user; + } + + private function latestConnection(): ?BankingConnection + { + $email = $this->argument('email'); + + $user = User::where('email', $email)->first(); + + if (! $user) { + return null; + } + + return $user->bankingConnections()->latest()->first(); + } + + private function invalidAction(): int + { + $this->error('Unknown action. Use one of: seed, inspect, expire.'); + + return self::FAILURE; + } +} diff --git a/composer.json b/composer.json index c6d645e0..092cd086 100644 --- a/composer.json +++ b/composer.json @@ -74,6 +74,10 @@ "@php artisan config:clear --ansi", "@php artisan test" ], + "e2e:banking": [ + "Composer\\Config::disableProcessTimeout", + "node tests/Browser/live/connect-bank.mjs" + ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" diff --git a/tests/Browser/BankConnectionFlowTest.php b/tests/Browser/BankConnectionFlowTest.php new file mode 100644 index 00000000..61d2006b --- /dev/null +++ b/tests/Browser/BankConnectionFlowTest.php @@ -0,0 +1,177 @@ + false]); + + $this->fakeProvider = new FakeBankingProvider; + app()->instance(BankingProviderInterface::class, $this->fakeProvider); +}); + +it('connects a bank during onboarding', function () { + $user = User::factory()->notOnboarded()->create([ + 'email_verified_at' => now(), + ]); + + actingAs($user); + + $page = visit('/onboarding'); + + $page->assertSee('Welcome to') + ->click("Let's Get Started") + ->waitForText('Account Types', 5) + ->click('Create Your First Account') + ->waitForText('How would you like to set up this account?', 5) + ->click('Connected') + ->click('Continue') + ->waitForText('Connect Your Bank', 5) + ->click('[role="combobox"]') + ->wait(0.5) + ->click('[role="option"]:has-text("Spain")') + ->wait(0.3) + ->click('button:has-text("Continue")') + ->waitForText('Banco de Sabadell', 5) + ->click('button:has-text("Banco de Sabadell")') + ->click('button:has-text("Continue")') + ->waitForText('You will be redirected', 5) + ->click('button:has-text("Connect")') + ->wait(3); + + // The faked provider redirects straight to our callback, which auto-creates the + // accounts for a not-yet-onboarded user and marks the connection active. + $connection = $user->bankingConnections()->sole(); + + expect($connection->status)->toBe(BankingConnectionStatus::Active) + ->and($connection->aspsp_name)->toBe('Banco de Sabadell') + ->and($connection->accounts()->count())->toBe(2); + + $page->assertNoJavascriptErrors(); +}); + +it('connects a bank from settings and maps accounts', function () { + $user = User::factory()->create([ + 'email_verified_at' => now(), + 'onboarded_at' => now(), + ]); + + actingAs($user); + + $page = visit('/settings/connections'); + + $page->assertSee('Bank Connections') + ->click('Connect Bank') + ->waitForText('Select the country', 5) + ->click('[role="dialog"] [role="combobox"]') + ->wait(0.5) + ->click('[role="option"]:has-text("Spain")') + ->wait(0.3) + ->click('button:has-text("Continue")') + ->waitForText('Banco de Sabadell', 5) + ->click('[role="dialog"] button:has-text("Banco de Sabadell")') + ->click('[role="dialog"] button:has-text("Continue")') + ->waitForText('You will be redirected', 5) + ->click('[role="dialog"] button:has-text("Connect")') + ->wait(3); + + // An onboarded user lands on the account-mapping screen (status awaiting_mapping). + $connection = $user->bankingConnections()->sole(); + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping) + ->and($connection->pending_accounts_data)->toHaveCount(2); + + $page->assertPathBeginsWith('/open-banking/connections/') + ->assertSee('Map Bank Accounts') + ->click('button:has-text("Save & Sync")') + ->wait(3); + + expect($connection->fresh()->status)->toBe(BankingConnectionStatus::Active) + ->and($connection->accounts()->count())->toBe(2); + + $page->assertNoJavascriptErrors(); +}); + +it('shows the connected confirmation when the session is lost on return', function () { + $user = User::factory()->create([ + 'email_verified_at' => now(), + 'onboarded_at' => now(), + ]); + + // A pending connection mid-authorization, identified by its state token. + $connection = BankingConnection::factory()->pending()->for($user)->create([ + 'provider' => 'enablebanking', + 'aspsp_name' => 'Banco de Sabadell', + 'aspsp_country' => 'ES', + 'state_token' => 'session-lost-token', + ]); + + // Hit the callback WITHOUT acting as the user — this is the iOS-PWA case where the + // bank redirect lands in a browser that has no app session. + $page = visit('/open-banking/callback?code=fake&state=session-lost-token'); + + $page->assertSee('Bank account connected') + ->assertSee('go back to the app') + ->assertNoJavascriptErrors(); + + // The connection is still finalized server-side (resolved from the state token). + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping) + ->and($connection->session_id)->not->toBeNull(); +}); + +it('reconnects an expired connection from settings', function () { + $user = User::factory()->create([ + 'email_verified_at' => now(), + 'onboarded_at' => now(), + ]); + + $connection = BankingConnection::factory()->expired()->for($user)->create([ + 'provider' => 'enablebanking', + 'aspsp_name' => 'Banco de Sabadell', + 'aspsp_country' => 'ES', + ]); + + // Existing accounts carry the IBANs the faked provider returns, so the reconnect + // re-matches them by IBAN and refreshes their external ids instead of duplicating. + foreach (['ES1800810602610001111120', 'ES6200810602620003333338'] as $iban) { + Account::factory()->for($user)->create([ + 'banking_connection_id' => $connection->id, + 'iban' => $iban, + 'currency_code' => 'EUR', + ]); + } + + actingAs($user); + + $page = visit('/settings/connections'); + + $page->assertSee('Banco de Sabadell') + ->assertSee('Expired') + ->click('Reconnect') + ->wait(3); + + $connection->refresh(); + + expect($connection->status)->toBe(BankingConnectionStatus::Active) + ->and($connection->valid_until->isFuture())->toBeTrue() + ->and($connection->accounts()->count())->toBe(2) + ->and($connection->accounts()->whereNull('external_account_id')->count())->toBe(0); + + $page->assertNoJavascriptErrors(); +}); diff --git a/tests/Browser/live/.gitignore b/tests/Browser/live/.gitignore new file mode 100644 index 00000000..7f15a21a --- /dev/null +++ b/tests/Browser/live/.gitignore @@ -0,0 +1,2 @@ +failure-*.png +videos/ diff --git a/tests/Browser/live/README.md b/tests/Browser/live/README.md new file mode 100644 index 00000000..4f990ea9 --- /dev/null +++ b/tests/Browser/live/README.md @@ -0,0 +1,76 @@ +# Live Enable Banking e2e check + +`connect-bank.mjs` drives the **real Enable Banking sandbox** end-to-end against the +running dev server, covering the flows we must never regress: + +1. **Settings → connect** a bank (Banco de Sabadell), map accounts, and sync transactions. +2. **Onboarding → connect** a bank (BBVA) with auto-created accounts. +3. **Settings → reconnect** an expired connection. +4. **Session lost on return** — connect, then drop the app session before the bank + redirect lands, and confirm the user still sees the "connected — go back to your + app" screen (the iOS-PWA / Safari case) instead of being bounced to login. + +It uses the sandbox test credentials (`user1` / `1234`, OTP `012345`). + +## Why a standalone script and not a Pest test + +Enable Banking only redirects to the **fixed registered redirect URI** +(`https://whisper.money.local/open-banking/callback`). A Pest browser test runs an +ephemeral server on a random port with a `RefreshDatabase` transactional database that +the external redirect can never reach, so the live flow cannot complete inside one. + +This script instead drives the **persistent dev server**, lets the bank redirect to the +registered host (which is not served locally), captures the `code`+`state` it carries, +and replays the callback against the dev server. The callback is stateless — it resolves +the connection from the `state_token` — so the replay finalizes the connection correctly. + +For CI regression coverage of the same three flows with the provider faked, see +`tests/Browser/BankConnectionFlowTest.php`. + +## Prerequisites + +- `composer run dev` running. Note the app port it prints (default `8921`). +- Valid `ENABLEBANKING_*` config and private key (the same config the app uses), with + `ENABLEBANKING_REDIRECT_URL` pointing at the registered `whisper.money.local` callback. +- Pending migrations applied (`php artisan migrate`). + +## Run + +```bash +# one-liner (auto-detects the running dev server port) +composer e2e:banking + +# or directly +node tests/Browser/live/connect-bank.mjs + +# watch it run in a headed browser / override the base url +HEADLESS=0 node tests/Browser/live/connect-bank.mjs +APP_BASE_URL=http://127.0.0.1:8921 node tests/Browser/live/connect-bank.mjs +``` + +The script seeds its own users via `php artisan e2e:banking-fixture seed` +(local/testing only), reports `PASS`/`FAIL` per scenario, and exits non-zero on failure. + +### Videos & screenshots + +Everything is written to `tests/Browser/live/videos/` (cleared at the start of each run, +git-ignored): + +- `.mp4` — a recording of the whole flow. A visible cursor follows the mouse + and pulses red on every click so you can track what's happening. Encoded as mp4 + (H.264) so it plays in QuickTime / Preview / browsers. Needs `ffmpeg`; without it the + raw `.webm` is kept instead. +- `.png` — a still of the final screen (guaranteed clear, independent of video + timing). +- `failure-.png` — a full-page screenshot, written only when a scenario fails. + +Scenarios: `settings-connect`, `onboarding-connect`, `reconnect-expired`, +`session-lost-return`. + +## Notes + +- The Enable Banking and bank sandbox pages are driven in English; the script forces an + `en-US` browser locale so the button labels match. +- The transaction sync runs on the default queue (the dev worker only drains `emails`), + so the script triggers `php artisan banking:sync --connection= --sync` to verify + transactions pull through. diff --git a/tests/Browser/live/connect-bank.mjs b/tests/Browser/live/connect-bank.mjs new file mode 100644 index 00000000..7a8526c4 --- /dev/null +++ b/tests/Browser/live/connect-bank.mjs @@ -0,0 +1,532 @@ +// Live Enable Banking end-to-end check. +// +// Drives the REAL Enable Banking sandbox (Banco de Sabadell + BBVA) through the +// running dev server for the three flows we must never regress: +// 1. Connect a bank from settings (+ account mapping + sync). +// 2. Connect a bank during onboarding. +// 3. Reconnect an expired connection. +// +// Why this is a standalone script and not a Pest browser test: Enable Banking only +// redirects to the fixed registered URI (https://whisper.money.local/open-banking/ +// callback). A RefreshDatabase Pest test runs an ephemeral server on a random port +// with a transactional DB the external redirect can never reach. So we drive the +// persistent dev server, let the bank redirect to the registered host (which is not +// served locally), capture the `code`+`state` it carries, and replay the callback +// against the dev server (the callback is stateless — keyed by state_token). +// +// Prerequisites: +// - `composer run dev` running (APP at http://127.0.0.1:, default 8921). +// - Valid ENABLEBANKING_* config + key (the same config the app uses). +// +// Usage: +// APP_BASE_URL=http://127.0.0.1:8921 node tests/Browser/live/connect-bank.mjs +// HEADLESS=0 node tests/Browser/live/connect-bank.mjs # watch it run +// +// See tests/Browser/live/README.md for details. + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, rmSync } from 'node:fs'; +import process from 'node:process'; +import { chromium } from 'playwright'; + +const VIDEO_DIR = 'tests/Browser/live/videos'; + +// `composer run dev` serves on a random port, so auto-detect the running +// `artisan serve --port=N` unless APP_BASE_URL is given explicitly. +function detectBaseUrl() { + if (process.env.APP_BASE_URL) { + return process.env.APP_BASE_URL.replace(/\/$/, ''); + } + try { + const port = execFileSync( + 'bash', + [ + '-c', + "ps ax -o command | grep -oE 'artisan serve --port=[0-9]+' | grep -oE '[0-9]+$' | head -1", + ], + { encoding: 'utf8' }, + ).trim(); + if (port) { + return `http://127.0.0.1:${port}`; + } + } catch { + // fall through to the default + } + return 'http://127.0.0.1:8921'; +} + +const BASE_URL = detectBaseUrl(); +const HEADLESS = process.env.HEADLESS !== '0'; +const OTP = '012345'; +const SABADELL_USER = 'user1'; +const SABADELL_PASS = '1234'; +const BBVA_USER = 'user1'; +const BBVA_PASS = '1234'; + +function artisan(args) { + const out = execFileSync('php', ['artisan', ...args], { encoding: 'utf8' }); + return out.trim(); +} + +function artisanJson(args) { + const out = artisan(args); + const start = out.indexOf('{'); + return JSON.parse(out.slice(start)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +function log(message) { + process.stdout.write(` ${message}\n`); +} + +// Capture the bank's redirect to the registered (non-local) callback host and replay +// it against the dev server so the connection is finalized there. +function trackCallback(page) { + const state = { url: null }; + page.on('request', (request) => { + const url = request.url(); + if ( + url.includes('/open-banking/callback') && + url.includes('code=') && + !url.startsWith(BASE_URL) + ) { + state.url = url; + } + }); + return state; +} + +async function replayCallback(page, captured) { + assert( + captured.url, + 'expected the bank to redirect to the callback with a code', + ); + const replay = captured.url.replace(/^https?:\/\/[^/]+/, BASE_URL); + captured.url = null; + await page.goto(replay, { waitUntil: 'domcontentloaded' }); +} + +async function login(page, email, password) { + // Each scenario starts from a clean session (the prior user is still logged in + // otherwise, and /login would bounce straight to /dashboard). + await page.context().clearCookies(); + await page.goto(`${BASE_URL}/login`, { waitUntil: 'domcontentloaded' }); + await page.getByRole('textbox', { name: 'Email address' }).fill(email); + await page.getByRole('textbox', { name: 'Password' }).fill(password); + await page.locator('[data-test="login-button"]').click(); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 15000, + }); +} + +const EB_CONTINUE = /Continue authentication|Continuar autenticación/; + +async function driveSabadell(page) { + await page.getByRole('button', { name: EB_CONTINUE }).click(); + await page.waitForTimeout(4000); + await page.getByPlaceholder('Ej: 47587441A').fill(SABADELL_USER); + await page.getByPlaceholder('Ej: 123456').first().fill(SABADELL_PASS); + await page.getByRole('button', { name: 'Entrar' }).click(); + await page.waitForTimeout(7000); + // Consent + SCA: re-enter password, then the 6-digit OTP via the on-screen keypad. + await page.getByPlaceholder('Ej: 123456').first().fill(SABADELL_PASS); + await page.waitForTimeout(400); + for (const digit of OTP.split('')) { + await page.getByText(digit, { exact: true }).last().click(); + await page.waitForTimeout(150); + } + await page.waitForTimeout(400); + await page.getByRole('button', { name: /Sign|Firmar/ }).click(); + await page.waitForTimeout(10000); +} + +async function driveBbva(page) { + await page.getByRole('button', { name: EB_CONTINUE }).click(); + await page.waitForTimeout(5000); + const textboxes = await page.getByRole('textbox').all(); + await textboxes[0].fill(BBVA_USER); + await textboxes[1].fill(BBVA_PASS); + await page.getByRole('button', { name: 'Submit' }).first().click(); + await page.waitForTimeout(6000); + await page.getByRole('textbox', { name: 'SMS Code' }).fill(OTP); + await page.waitForTimeout(300); + await page.getByRole('button', { name: 'Confirm' }).click(); + await page.waitForTimeout(10000); +} + +async function selectBankInModal(page, bankName) { + await page.locator('[role="dialog"] [role="combobox"]').click(); + await page.waitForTimeout(400); + await page.getByRole('option', { name: 'Spain' }).click(); + await page.waitForTimeout(300); + await page.locator('[role="dialog"] button:has-text("Continue")').click(); + await page.waitForTimeout(2800); + await page + .locator(`[role="dialog"] button:has-text("${bankName}")`) + .first() + .click(); + await page.waitForTimeout(300); + await page.locator('[role="dialog"] button:has-text("Continue")').click(); + await page.waitForTimeout(800); + await page.locator('[role="dialog"] button:has-text("Connect")').click(); + await page.waitForTimeout(6000); +} + +async function scenarioSettingsConnect(page, settings) { + log('Settings → connect Banco de Sabadell'); + const captured = trackCallback(page); + await login(page, settings.email, settings.password); + await page.goto(`${BASE_URL}/settings/connections`, { + waitUntil: 'domcontentloaded', + }); + await page.getByRole('button', { name: 'Connect Bank' }).click(); + await page.waitForTimeout(600); + await selectBankInModal(page, 'Banco de Sabadell'); + await driveSabadell(page); + await replayCallback(page, captured); + + // Onboarded user lands on account mapping; accept the defaults and sync. + await page.waitForTimeout(1500); + await page.getByRole('button', { name: 'Save & Sync' }).click(); + // Lands back on the connections list; wait for the connection to render. + await page + .getByText('Banco de Sabadell') + .first() + .waitFor({ timeout: 15000 }); + await page.waitForTimeout(2500); + + const mapped = artisanJson([ + 'e2e:banking-fixture', + 'inspect', + settings.email, + ]).connection; + assert(mapped, 'settings connection exists'); + assert( + mapped.status === 'active', + `settings status active (got ${mapped?.status})`, + ); + assert(mapped.accounts_count > 0, 'settings accounts created'); + + // The sync job runs on the default queue (the dev worker only drains "emails"), + // so drive it synchronously to confirm transactions pull from the live sandbox. + artisan(['banking:sync', '--connection=' + mapped.id, '--sync']); + + const { connection } = artisanJson([ + 'e2e:banking-fixture', + 'inspect', + settings.email, + ]); + assert(connection.transactions_count > 0, 'settings transactions synced'); + log( + `✓ active · ${connection.accounts_count} accounts · ${connection.transactions_count} transactions`, + ); +} + +async function scenarioOnboardingConnect(page, onboarding) { + log('Onboarding → connect BBVA'); + const captured = trackCallback(page); + await login(page, onboarding.email, onboarding.password); + await page.goto(`${BASE_URL}/onboarding`, { + waitUntil: 'domcontentloaded', + }); + await page.getByRole('button', { name: "Let's Get Started" }).click(); + await page.waitForTimeout(800); + await page + .getByRole('button', { name: 'Create Your First Account' }) + .click(); + await page.waitForTimeout(800); + await page.getByRole('button', { name: /Connected/ }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.waitForTimeout(1000); + // Inline (non-modal) connect on the onboarding step. + await page.getByRole('combobox').last().click(); + await page.waitForTimeout(400); + await page.getByRole('option', { name: 'Spain' }).click(); + await page.waitForTimeout(300); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.waitForTimeout(2800); + await page.getByRole('button', { name: /^BBVA$/ }).click(); + await page.waitForTimeout(300); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.waitForTimeout(800); + await page.getByRole('button', { name: 'Connect' }).click(); + await page.waitForTimeout(6000); + await driveBbva(page); + await replayCallback(page, captured); + + // The onboarding step hydrates its account list from the client store after + // navigation; wait for it to render so we assert the UI (not just the DB) and the + // recording ends on the real result rather than a mid-load frame. + await page.getByText('Your Accounts').waitFor({ timeout: 15000 }); + await page.waitForTimeout(2500); + + const { connection } = artisanJson([ + 'e2e:banking-fixture', + 'inspect', + onboarding.email, + ]); + assert(connection, 'onboarding connection exists'); + assert( + connection.status === 'active', + `onboarding status active (got ${connection?.status})`, + ); + assert(connection.accounts_count > 0, 'onboarding accounts auto-created'); + log(`✓ active · ${connection.accounts_count} accounts`); +} + +async function scenarioReconnect(page, settings) { + log('Settings → reconnect an expired connection'); + artisanJson(['e2e:banking-fixture', 'expire', settings.email]); + + const captured = trackCallback(page); + await login(page, settings.email, settings.password); + await page.goto(`${BASE_URL}/settings/connections`, { + waitUntil: 'domcontentloaded', + }); + await page.getByRole('button', { name: 'Reconnect' }).first().click(); + await page.waitForTimeout(6000); + await driveSabadell(page); + await replayCallback(page, captured); + + // Back on the connections list with the connection live again. + await page + .getByText('Banco de Sabadell') + .first() + .waitFor({ timeout: 15000 }); + await page.waitForTimeout(2500); + + const { connection } = artisanJson([ + 'e2e:banking-fixture', + 'inspect', + settings.email, + ]); + assert(connection, 'reconnect connection exists'); + assert( + connection.status === 'active', + `reconnect status active (got ${connection?.status})`, + ); + assert( + new Date(connection.valid_until) > new Date(), + 'reconnect refreshed valid_until', + ); + assert( + connection.accounts_without_external_id === 0, + 'reconnect refreshed all account ids', + ); + log( + `✓ active · valid until ${connection.valid_until} · ${connection.accounts_count} accounts retained`, + ); +} + +async function scenarioSessionLost(page, settings) { + log('Settings → connect, but the app session is lost on return'); + const captured = trackCallback(page); + await login(page, settings.email, settings.password); + await page.goto(`${BASE_URL}/settings/connections`, { + waitUntil: 'domcontentloaded', + }); + await page.getByRole('button', { name: 'Connect Bank' }).click(); + await page.waitForTimeout(600); + // Sabadell is already connected for this user from an earlier scenario; use BBVA. + await selectBankInModal(page, 'BBVA'); + await driveBbva(page); + + // Simulate the app session being lost while the user was away at the bank (e.g. an + // iOS PWA hands the redirect to Safari, where there is no session). The callback is + // therefore unauthenticated on return. + await page.context().clearCookies(); + await replayCallback(page, captured); + + // They must see the standalone "connected — go back to your app" confirmation + // rather than being bounced to the login screen. This screen renders late in the + // flow, so hold on it long enough to be clearly visible in the recording. + await page.getByText('Bank account connected').waitFor({ timeout: 15000 }); + await page.getByText('go back to the app').waitFor({ timeout: 5000 }); + await page.waitForTimeout(6000); + + const { connection } = artisanJson([ + 'e2e:banking-fixture', + 'inspect', + settings.email, + ]); + assert(connection, 'session-lost connection exists'); + assert( + connection.session_id === true, + 'connection was finalized despite the lost session', + ); + log( + `✓ confirmation screen shown · connection finalized (status ${connection.status})`, + ); +} + +// Force English so the Enable Banking / bank sandbox pages render with the labels +// the selectors expect (they otherwise follow the browser locale). +const CONTEXT_OPTIONS = { + ignoreHTTPSErrors: true, + locale: 'en-US', + extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' }, +}; + +// Injected into every page so the recordings show where the cursor is and when it +// clicks (Playwright drives a real but invisible mouse). Runs on each document, +// including the external bank pages. +function installMouseHelper() { + if (window.__mouseHelperInstalled) { + return; + } + window.__mouseHelperInstalled = true; + const attach = () => { + const dot = document.createElement('div'); + dot.style.cssText = + 'pointer-events:none;position:fixed;top:0;left:0;z-index:2147483647;' + + 'width:22px;height:22px;margin:-11px 0 0 -11px;border-radius:50%;' + + 'background:rgba(220,38,38,.35);border:2px solid rgba(220,38,38,.9);' + + 'transition:transform .08s ease,background .15s ease;'; + document.body.appendChild(dot); + document.addEventListener( + 'mousemove', + (e) => { + dot.style.left = e.clientX + 'px'; + dot.style.top = e.clientY + 'px'; + }, + true, + ); + document.addEventListener( + 'mousedown', + () => { + dot.style.transform = 'scale(.6)'; + dot.style.background = 'rgba(220,38,38,.7)'; + }, + true, + ); + document.addEventListener( + 'mouseup', + () => { + dot.style.transform = 'scale(1)'; + dot.style.background = 'rgba(220,38,38,.35)'; + }, + true, + ); + }; + if (document.body) { + attach(); + } else { + document.addEventListener('DOMContentLoaded', attach); + } +} + +// Convert the webm Playwright produces to mp4 (plays in QuickTime / Preview / most +// players). Falls back to the webm if ffmpeg isn't installed. +function toMp4(webmPath) { + const mp4Path = webmPath.replace(/\.webm$/, '.mp4'); + try { + execFileSync( + 'ffmpeg', + [ + '-y', + '-loglevel', + 'error', + '-i', + webmPath, + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + mp4Path, + ], + { stdio: 'ignore' }, + ); + rmSync(webmPath, { force: true }); + return mp4Path; + } catch { + return webmPath; + } +} + +async function main() { + log(`Seeding fixtures (base url: ${BASE_URL})`); + const fixtures = artisanJson(['e2e:banking-fixture', 'seed']); + + rmSync(VIDEO_DIR, { recursive: true, force: true }); + mkdirSync(VIDEO_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: HEADLESS }); + + const results = []; + // Each scenario gets its own recording context, so it produces one video and + // starts from a clean session. + const run = async (name, fn) => { + const context = await browser.newContext({ + ...CONTEXT_OPTIONS, + recordVideo: { dir: VIDEO_DIR, size: { width: 1280, height: 800 } }, + }); + await context.addInitScript(installMouseHelper); + const page = await context.newPage(); + const video = page.video(); + let ok = true; + let error = null; + + try { + await fn(page); + // A guaranteed clear still of the final screen, independent of video timing. + await page + .screenshot({ path: `${VIDEO_DIR}/${name}.png` }) + .catch(() => {}); + } catch (e) { + ok = false; + error = e.message; + await page + .screenshot({ + path: `${VIDEO_DIR}/failure-${name}.png`, + fullPage: true, + }) + .catch(() => {}); + log(`✗ ${e.message.split('\n')[0]} (at ${page.url()})`); + } finally { + await context.close(); + } + + const webmPath = `${VIDEO_DIR}/${name}.webm`; + await video?.saveAs(webmPath).catch(() => {}); + await video?.delete().catch(() => {}); + results.push({ name, ok, error, video: toMp4(webmPath) }); + }; + + try { + await run('settings-connect', (page) => + scenarioSettingsConnect(page, fixtures.settings), + ); + await run('onboarding-connect', (page) => + scenarioOnboardingConnect(page, fixtures.onboarding), + ); + await run('reconnect-expired', (page) => + scenarioReconnect(page, fixtures.settings), + ); + await run('session-lost-return', (page) => + scenarioSessionLost(page, fixtures.settings), + ); + } finally { + await browser.close(); + } + + process.stdout.write('\nLive Enable Banking e2e results:\n'); + for (const result of results) { + process.stdout.write( + ` ${result.ok ? 'PASS' : 'FAIL'} ${result.name} → ${result.video}${result.ok ? '' : ' — ' + result.error}\n`, + ); + } + + process.exit(results.every((r) => r.ok) ? 0 : 1); +} + +main().catch((error) => { + process.stderr.write(`\nFatal: ${error.stack ?? error.message}\n`); + process.exit(1); +}); diff --git a/tests/Support/FakeBankingProvider.php b/tests/Support/FakeBankingProvider.php new file mode 100644 index 00000000..25d8220f --- /dev/null +++ b/tests/Support/FakeBankingProvider.php @@ -0,0 +1,152 @@ +> + */ + private array $ibansByAspsp = [ + 'Banco de Sabadell' => ['ES1800810602610001111120', 'ES6200810602620003333338'], + 'BBVA' => ['ES2100750000000000000001', 'ES2100750000000000000002'], + ]; + + /** + * {@inheritDoc} + */ + public function getInstitutions(string $countryCode): array + { + return [ + ['name' => 'Banco de Sabadell', 'country' => $countryCode, 'logo' => null, 'maximum_consent_validity' => 7776000], + ['name' => 'BBVA', 'country' => $countryCode, 'logo' => null, 'maximum_consent_validity' => 7776000], + ]; + } + + /** + * {@inheritDoc} + */ + public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl, string $state): array + { + $this->pendingAuthorization = [ + 'aspsp_name' => $aspspName, + 'country' => $countryCode, + 'state' => $state, + ]; + + // Skip the real bank redirect: send the browser straight back to our own + // callback on the current host (the test server) with a synthetic code+state. + // route() resolves to the running browser-test server, so the flow stays + // in-process instead of navigating to the configured production redirect host. + return [ + 'url' => route('open-banking.callback', ['code' => 'fake-code-'.$state, 'state' => $state]), + 'authorization_id' => 'fake-auth-'.$state, + ]; + } + + /** + * {@inheritDoc} + */ + public function createSession(string $code): array + { + $this->sessionCounter++; + + $aspspName = $this->pendingAuthorization['aspsp_name'] ?? 'Banco de Sabadell'; + $country = $this->pendingAuthorization['country'] ?? 'ES'; + $ibans = $this->ibansByAspsp[$aspspName] ?? ['ES0000000000000000000001']; + + $accounts = []; + foreach ($ibans as $index => $iban) { + $accounts[] = [ + 'uid' => sprintf('fake-uid-%d-%d', $this->sessionCounter, $index), + 'currency' => 'EUR', + 'name' => sprintf('%s account %d', $aspspName, $index + 1), + 'account_id' => ['iban' => $iban], + ]; + } + + return [ + 'session_id' => 'fake-session-'.$this->sessionCounter, + 'aspsp' => ['name' => $aspspName, 'country' => $country], + 'accounts' => $accounts, + 'access' => ['valid_until' => now()->addDays(90)->toIso8601String()], + ]; + } + + /** + * {@inheritDoc} + */ + public function getTransactions(string $accountId, string $dateFrom, string $dateTo, ?string $continuationKey = null, ?string $strategy = null): array + { + return ['transactions' => [], 'continuation_key' => null]; + } + + /** + * {@inheritDoc} + */ + public function getBalances(string $accountId): array + { + return ['balances' => []]; + } + + /** + * {@inheritDoc} + */ + public function getSession(string $sessionId): array + { + return [ + 'status' => 'AUTHORIZED', + 'access' => ['valid_until' => now()->addDays(90)->toIso8601String()], + 'accounts' => [], + ]; + } + + /** + * {@inheritDoc} + */ + public function getAccount(string $accountId): array + { + return [ + 'uid' => $accountId, + 'account_id' => ['iban' => 'ES0000000000000000000000'], + 'currency' => 'EUR', + 'name' => 'Fake account', + ]; + } + + /** + * {@inheritDoc} + */ + public function revokeSession(string $sessionId): void + { + // No-op: nothing to revoke for the in-memory fake. + } +}