Explorar o código

Add auto-login to Booking.com credential refresh

When a session expires, refresh-cookies.ts now attempts a full headless
login using BOOKING_EMAIL and BOOKING_PASSWORD from booking.env before
falling back to the manual 5-minute wait.

- performLogin drives the account.booking.com sign-in SPA via DOM
  (email field + Next button, then password field + submit)
- handlePostLoginChallenge handles both auth-assurance and the
  account-recovery/options new-device verification flow via SMS gateway
- Rate limiter (3/hour, 10/day) persisted to booking-login-attempts.json
  prevents account lockout from repeated failed attempts
- loginDisabledReason circuit-breaker disables auto-login for the
  container lifetime on any credential rejection; cleared by restart
- Live integration test (BOOKING_LIVE_TEST=1) starts from an empty
  session and asserts fresh cookies and a working API call result
ZiB hai 4 meses
pai
achega
b5142d013f

+ 140 - 15
room_cal/service_api/typescript/booking/refresh-cookies.ts

@@ -7,6 +7,105 @@ import { handleOutput, fetchSmsCode } from '../shared/api-client';
 import { openPersistentContext, profileDir } from '../shared/browser';
 import type { CredentialsPayload } from '../shared/types';
 
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+const LOGIN_ATTEMPTS_PATH = path.join(DATA_HOME, 'booking-login-attempts.json');
+const LOGIN_HOURLY_LIMIT = 3;
+const LOGIN_DAILY_LIMIT = 10;
+
+// Set when Booking.com rejects credentials; cleared only by process restart.
+// Prevents repeated bad-credential attempts from triggering account lockout.
+let loginDisabledReason: string | null = null;
+
+function checkAndRecordLoginAttempt(): void {
+  const now = Date.now();
+  let attempts: number[] = [];
+  try {
+    if (fs.existsSync(LOGIN_ATTEMPTS_PATH)) {
+      attempts = (JSON.parse(fs.readFileSync(LOGIN_ATTEMPTS_PATH, 'utf8')) as { attempts?: number[] }).attempts ?? [];
+    }
+  } catch {}
+
+  const inLastHour = attempts.filter(t => t > now - 3_600_000);
+  const inLastDay = attempts.filter(t => t > now - 86_400_000);
+
+  if (inLastHour.length >= LOGIN_HOURLY_LIMIT) {
+    throw new Error(
+      `[booking] Auto-login rate limit: ${inLastHour.length} attempts in the last hour (max ${LOGIN_HOURLY_LIMIT}) — check credentials or wait`,
+    );
+  }
+  if (inLastDay.length >= LOGIN_DAILY_LIMIT) {
+    throw new Error(
+      `[booking] Auto-login rate limit: ${inLastDay.length} attempts in 24 h (max ${LOGIN_DAILY_LIMIT}) — check credentials`,
+    );
+  }
+
+  // Record before making any network calls so crashes/timeouts still count against the limit.
+  const pruned = attempts.filter(t => t > now - 86_400_000);
+  pruned.push(now);
+  fs.writeFileSync(LOGIN_ATTEMPTS_PATH, JSON.stringify({ attempts: pruned }));
+}
+
+async function performLogin(page: Page, email: string, password: string): Promise<void> {
+  if (loginDisabledReason) {
+    throw new Error(`[booking] Auto-login disabled for this session: ${loginDisabledReason}`);
+  }
+
+  checkAndRecordLoginAttempt();
+
+  // Dismiss cookie consent banner if present.
+  try {
+    await page.getByRole('button', { name: /accept|agree|got it|ok/i }).first().click({ timeout: 3_000 });
+    console.error('[booking] Dismissed cookie consent.');
+  } catch { /* no banner */ }
+
+  // Use Locators (not ElementHandles) — they re-query the DOM on every action
+  // and stay live through SPA re-renders.
+  const emailLocator = page.locator('input[type="email"], input[name="username"], input[autocomplete="username"]').first();
+  try {
+    await emailLocator.waitFor({ timeout: 15_000 });
+  } catch {
+    loginDisabledReason = 'email input not found on sign-in page';
+    throw new Error(`[booking] ${loginDisabledReason}`);
+  }
+  console.error('[booking] Auto-login: filling email...');
+  await emailLocator.click();
+  await emailLocator.pressSequentially(email, { delay: 50 });
+  await page.locator('button[type="submit"]').click();
+
+  const passwordLocator = page.locator('input[autocomplete="current-password"]:not([aria-hidden="true"])').first();
+  try {
+    await passwordLocator.waitFor({ state: 'visible', timeout: 15_000 });
+  } catch {
+    loginDisabledReason = 'password input not found — email may have been rejected';
+    throw new Error(`[booking] ${loginDisabledReason}`);
+  }
+  console.error('[booking] Auto-login: filling password...');
+  await passwordLocator.click();
+  await passwordLocator.fill(password);
+  await page.locator('button[type="submit"]').click();
+  console.error(`[booking] Password submitted — at: ${page.url().slice(0, 80)}`);
+}
+
+function needsChallenge(url: string): boolean {
+  return url.includes('auth-assurance') || url.includes('account-recovery');
+}
+
+async function handlePostLoginChallenge(page: Page, masked: string): Promise<void> {
+  const url = page.url();
+  if (url.includes('account-recovery')) {
+    console.error('[booking] New-device verification required (account-recovery)...');
+    // Choose SMS option if presented with a choice.
+    try {
+      await page.getByText(/sms|text message|phone/i).first().click({ timeout: 5_000 });
+      await page.waitForLoadState('domcontentloaded');
+    } catch {
+      // Already on code-entry page.
+    }
+  }
+  // Both auth-assurance and account-recovery end up at a PIN entry screen.
+  await handleAuthAssurance(page, masked);
+}
+
 async function handleAuthAssurance(page: Page, masked: string): Promise<void> {
   console.error(`[booking] 2FA required (${masked || 'unknown number'}) — attempting SMS automation`);
 
@@ -41,7 +140,6 @@ async function handleAuthAssurance(page: Page, masked: string): Promise<void> {
   await page.keyboard.press('Enter');
 }
 
-const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
 const ENV_PATH = path.join(DATA_HOME, 'booking.env');
 const ADMIN_BASE = 'admin.booking.com';
 const STRIP = new Set(['OptanonConsent', 'pcm_consent', 'uz']);
@@ -54,6 +152,8 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
   const envText = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
   const hotelId = envText.match(/^BOOKING_HOTEL_ID=(\S+)/m)?.[1] ?? '';
   const ses = envText.match(/^BOOKING_SESSION=(\S+)/m)?.[1] ?? '';
+  const email = envText.match(/^BOOKING_EMAIL=(\S+)/m)?.[1] ?? '';
+  const password = envText.match(/^BOOKING_PASSWORD=(\S+)/m)?.[1] ?? '';
   const monthlyUrl = `https://${ADMIN_BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/monthly.html?hotel_id=${hotelId}&lang=en&ses=${ses}&view_mode=MONTHLY`;
 
   let capturedCsrf: string | null = null;
@@ -90,15 +190,27 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
     await page.waitForLoadState('domcontentloaded');
 
     if (!page.url().includes('ses=')) {
-      if (FORCE_HEADLESS) {
+      if (email && password && page.url().includes('account.booking.com/sign-in')) {
+        console.error('Session expired — attempting auto-login (headless)...');
+        await performLogin(page, email, password);
+        await page.waitForURL(
+          url => url.toString().includes('ses=') || needsChallenge(url.toString()),
+          { timeout: 60_000 },
+        );
+        if (needsChallenge(page.url())) {
+          await handlePostLoginChallenge(page, smsMasked);
+          await page.waitForURL('**ses=**', { timeout: 120_000 });
+        }
+      } else if (FORCE_HEADLESS) {
         await context.close();
         throw new Error('Session expired — re-run without HEADLESS=1 to re-authenticate.');
+      } else {
+        console.error('Profile session expired — switching to headed browser for login...');
+        await context.close();
+        headless = false;
+        context = await openCtx(false);
+        page = context.pages()[0] ?? await context.newPage();
       }
-      console.error('Profile session expired — switching to headed browser for login...');
-      await context.close();
-      headless = false;
-      context = await openCtx(false);
-      page = context.pages()[0] ?? await context.newPage();
     } else {
       console.error('Session valid — continuing silently.');
     }
@@ -110,14 +222,27 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
     await page.waitForLoadState('domcontentloaded');
 
     if (!page.url().includes('ses=')) {
-      console.error('Not logged in — log in and the script will continue automatically (5 min timeout)...');
-      await page.waitForURL(
-        url => url.toString().includes('ses=') || url.toString().includes('auth-assurance'),
-        { timeout: 300_000 },
-      );
-      if (!page.url().includes('ses=')) {
-        await handleAuthAssurance(page, smsMasked);
-        await page.waitForURL('**ses=**', { timeout: 120_000 });
+      if (email && password && page.url().includes('account.booking.com/sign-in')) {
+        console.error('Session expired — attempting auto-login (headed)...');
+        await performLogin(page, email, password);
+        await page.waitForURL(
+          url => url.toString().includes('ses=') || needsChallenge(url.toString()),
+          { timeout: 60_000 },
+        );
+        if (needsChallenge(page.url())) {
+          await handlePostLoginChallenge(page, smsMasked);
+          await page.waitForURL('**ses=**', { timeout: 120_000 });
+        }
+      } else {
+        console.error('Not logged in — log in and the script will continue automatically (5 min timeout)...');
+        await page.waitForURL(
+          url => url.toString().includes('ses=') || url.toString().includes('auth-assurance'),
+          { timeout: 300_000 },
+        );
+        if (!page.url().includes('ses=')) {
+          await handleAuthAssurance(page, smsMasked);
+          await page.waitForURL('**ses=**', { timeout: 120_000 });
+        }
       }
     }
   }

+ 82 - 0
tests/test_refresh_cookies.py

@@ -1,7 +1,9 @@
 import asyncio
 import os
 import re
+import shutil
 import subprocess
+import tempfile
 import warnings
 from datetime import date, timedelta
 from importlib.resources import files
@@ -147,3 +149,83 @@ def test_refresh_cookies_headless(chrome_installed):
       UserWarning,
       stacklevel=1,
     )
+
+
+# ---------------------------------------------------------------------------
+# Live auto-login test — opt-in only, not run in normal CI
+# Set BOOKING_LIVE_TEST=1 to enable.
+# Requires BOOKING_EMAIL and BOOKING_PASSWORD in booking.env.
+# ---------------------------------------------------------------------------
+
+def _booking_env_credentials() -> tuple[str, str]:
+  """Return (email, password) from booking.env, or ('', '') if absent."""
+  env_path = _data_home() / 'booking.env'
+  if not env_path.exists():
+    return '', ''
+  text = env_path.read_text()
+  email = next((m.group(1) for m in [re.search(r'^BOOKING_EMAIL=(\S+)', text, re.M)] if m), '')
+  password = next((m.group(1) for m in [re.search(r'^BOOKING_PASSWORD=(\S+)', text, re.M)] if m), '')
+  return email, password
+
+
+@pytest.mark.skipif(
+  not os.environ.get('BOOKING_LIVE_TEST'),
+  reason='Set BOOKING_LIVE_TEST=1 to run live Booking.com auto-login test',
+)
+def test_booking_auto_login(chrome_installed):
+  """Full auto-login flow from an empty session.
+
+  Wipes the Booking.com Playwright profile and blanks the session credentials
+  in a temporary DATA_HOME, then runs refresh-booking. Asserts that fresh
+  cookies and a session token are written, and that the API can make a live
+  calendar call with them.
+  """
+  email, password = _booking_env_credentials()
+  if not email or not password:
+    pytest.skip('BOOKING_EMAIL and BOOKING_PASSWORD must be set in booking.env')
+
+  real_data = _data_home()
+  booking_env_src = real_data / 'booking.env'
+
+  with tempfile.TemporaryDirectory(prefix='room_cal_login_test_') as tmp:
+    tmp_path = Path(tmp)
+
+    # Copy booking.env into the temp DATA_HOME, stripping session credentials
+    # so the script has no valid session to reuse.
+    src_text = booking_env_src.read_text() if booking_env_src.exists() else ''
+    stripped = re.sub(r'^(BOOKING_COOKIES|BOOKING_SESSION|BOOKING_CSRF_TOKEN)=.*$', r'\1=', src_text, flags=re.M)
+    (tmp_path / 'booking.env').write_text(stripped)
+
+    # Copy browsers so the test doesn't re-download Chromium.
+    browsers_src = _browsers_path()
+    if browsers_src.exists():
+      shutil.copytree(browsers_src, tmp_path / '.playwright-browsers', symlinks=True)
+
+    # Clear the login-attempts counter so rate limiting doesn't interfere.
+    attempts_file = tmp_path / 'booking-login-attempts.json'
+    if attempts_file.exists():
+      attempts_file.unlink()
+
+    result = _npm_run('refresh-booking', timeout=180, extra_env={
+      'ROOM_CAL_DATA_HOME': str(tmp_path),
+      'PLAYWRIGHT_BROWSERS_PATH': str(tmp_path / '.playwright-browsers'),
+    })
+    output = f'stdout:\n{result.stdout}\nstderr:\n{result.stderr}'
+
+    assert result.returncode == 0, f'refresh-booking exited {result.returncode}\n{output}'
+
+    new_text = (tmp_path / 'booking.env').read_text()
+    new_cookies = re.search(r'^BOOKING_COOKIES=(\S+)', new_text, re.M)
+    new_session = re.search(r'^BOOKING_SESSION=(\S+)', new_text, re.M)
+
+    assert new_cookies and new_cookies.group(1), f'No BOOKING_COOKIES written\n{output}'
+    assert new_session and new_session.group(1), f'No BOOKING_SESSION written\n{output}'
+
+    # Copy fresh credentials into the real DATA_HOME so the API call below uses them.
+    booking_env_src.write_text(new_text)
+
+  from room_cal.service_api import ServiceAPI
+  api = ServiceAPI()
+  prices = asyncio.run(api.get_prices(date.today() + timedelta(days=1)))
+  booking_prices = {d: p for d, p in prices.items() if d}
+  assert booking_prices, 'API returned no prices after auto-login — session may not have been accepted'