diff --git a/package.json b/package.json index 7c67cd84a..83a8ef4b3 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,9 @@ "test:coverage": "vitest run --coverage", "test:rust": "source $HOME/.cargo/env 2>/dev/null; cargo test --manifest-path src-tauri/Cargo.toml", "test:e2e:build": "bash scripts/e2e-build.sh", - "test:e2e:run": "bash scripts/e2e.sh", - "test:e2e": "yarn test:e2e:build && yarn test:e2e:run", + "test:e2e:login": "bash scripts/e2e-login.sh", + "test:e2e:auth": "bash scripts/e2e-auth.sh", + "test:e2e": "yarn test:e2e:build && yarn test:e2e:login && yarn test:e2e:auth", "test:all": "yarn test:coverage && yarn test:rust && yarn test:e2e", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/scripts/e2e-auth.sh b/scripts/e2e-auth.sh new file mode 100755 index 000000000..d004011ff --- /dev/null +++ b/scripts/e2e-auth.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Run E2E auth & access control tests only. +# +# Starts Appium, cleans app caches, runs the auth-access-control spec, +# then tears everything down. Each flow script is self-contained so +# specs don't pollute each other's Redux Persist state. +# +# Usage: +# ./scripts/e2e-auth.sh +# APPIUM_PORT=4723 ./scripts/e2e-auth.sh +# +set -euo pipefail + +APPIUM_PORT="${APPIUM_PORT:-4723}" +E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}" +SPEC="test/e2e/specs/auth-access-control.spec.ts" + +export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" +export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" + +# Clean cached app data for a fresh state — Redux Persist would otherwise +# remember the JWT from a previous run and skip the login flow. +echo "Cleaning cached app data..." +rm -rf ~/Library/WebKit/com.alphahuman.app +rm -rf ~/Library/Caches/com.alphahuman.app +rm -rf "$HOME/Library/Application Support/com.alphahuman.app" + +# Verify the frontend dist has the mock server URL baked in. +DIST_JS="$(ls dist/assets/index-*.js 2>/dev/null | head -1)" +if [ -z "$DIST_JS" ]; then + echo "ERROR: No frontend bundle found at dist/assets/index-*.js." >&2 + echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2 + exit 1 +fi +if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then + echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2 + echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2 + exit 1 +fi +echo "Verified: frontend bundle contains mock server URL." + +# --- Resolve Node 24 via nvm --------------------------------------------------- +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +# shellcheck source=/dev/null +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + +NODE24="$(nvm which 24 2>/dev/null || true)" +if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then + echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2 + exit 1 +fi + +APPIUM_BIN="$(dirname "$NODE24")/appium" +if [ ! -x "$APPIUM_BIN" ]; then + echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2 + exit 1 +fi + +# --- Start Appium in the background ------------------------------------------- +APPIUM_LOG="/tmp/appium-e2e-auth.log" +NODE_VER=$("$NODE24" --version) +echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..." +echo " Appium logs: $APPIUM_LOG" +"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 & +APPIUM_PID=$! + +cleanup() { + echo "Stopping Appium (pid $APPIUM_PID)..." + kill "$APPIUM_PID" 2>/dev/null || true + wait "$APPIUM_PID" 2>/dev/null || true +} +trap cleanup EXIT + +# Wait for Appium to be ready +for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then + echo "Appium is ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "ERROR: Appium did not start within 30 seconds." >&2 + exit 1 + fi + sleep 1 +done + +# --- Run WebDriverIO ---------------------------------------------------------- +echo "Running E2E auth flow tests ($SPEC)..." +npx wdio run wdio.conf.ts --spec "$SPEC" diff --git a/scripts/e2e.sh b/scripts/e2e-login.sh similarity index 84% rename from scripts/e2e.sh rename to scripts/e2e-login.sh index 4b0fac4c6..bb00464cc 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e-login.sh @@ -1,16 +1,20 @@ #!/usr/bin/env bash # -# Start Appium under Node 24 (required by appium v3), run WebDriverIO E2E -# tests, then tear everything down. +# Run E2E login flow tests only. +# +# Starts Appium, cleans app caches, runs the login-flow spec, +# then tears everything down. Each flow script is self-contained so +# specs don't pollute each other's Redux Persist state. # # Usage: -# ./scripts/e2e.sh # run tests -# APPIUM_PORT=4723 ./scripts/e2e.sh # custom port +# ./scripts/e2e-login.sh +# APPIUM_PORT=4723 ./scripts/e2e-login.sh # set -euo pipefail APPIUM_PORT="${APPIUM_PORT:-4723}" E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}" +SPEC="test/e2e/specs/login-flow.spec.ts" # Point the app at the local mock server (baked into the build already, but # also exported here so the Rust backend / any env-based config picks it up). @@ -57,9 +61,11 @@ if [ ! -x "$APPIUM_BIN" ]; then fi # --- Start Appium in the background ------------------------------------------- +APPIUM_LOG="/tmp/appium-e2e-login.log" NODE_VER=$("$NODE24" --version) echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..." -"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security & +echo " Appium logs: $APPIUM_LOG" +"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 & APPIUM_PID=$! cleanup() { @@ -83,5 +89,5 @@ for i in $(seq 1 30); do done # --- Run WebDriverIO ---------------------------------------------------------- -echo "Running E2E tests..." -npx wdio run wdio.conf.ts +echo "Running E2E login flow tests ($SPEC)..." +npx wdio run wdio.conf.ts --spec "$SPEC" diff --git a/test/e2e/mock-server.ts b/test/e2e/mock-server.ts index 3802c95ce..9a5947bee 100644 --- a/test/e2e/mock-server.ts +++ b/test/e2e/mock-server.ts @@ -30,6 +30,20 @@ export function clearRequestLog() { requestLog = []; } +// --------------------------------------------------------------------------- +// Mock behavior toggles — tests can change responses at runtime +// --------------------------------------------------------------------------- + +let mockBehavior: Record = {}; + +export function setMockBehavior(key: string, value: string) { + mockBehavior[key] = value; +} + +export function resetMockBehavior() { + mockBehavior = {}; +} + // --------------------------------------------------------------------------- // Mock data — shapes taken from src/test/handlers.ts (MSW unit-test mocks) // --------------------------------------------------------------------------- @@ -66,6 +80,38 @@ const MOCK_USER = { autoDeleteThreadsAfterDays: 30, }; +// --------------------------------------------------------------------------- +// Dynamic mock data helpers +// --------------------------------------------------------------------------- + +/** + * Build a team object whose subscription reflects the current mockBehavior. + * mockBehavior['plan'] → 'FREE' | 'BASIC' | 'PRO' (default: 'FREE') + * mockBehavior['planActive'] → 'true' to mark subscription active + * mockBehavior['planExpiry'] → ISO date string for renewal display + */ +function getMockTeam() { + const plan = mockBehavior['plan'] || 'FREE'; + const isActive = mockBehavior['planActive'] === 'true'; + const expiry = mockBehavior['planExpiry'] || null; + + return { + team: { + _id: 'team-1', + name: 'Personal', + slug: 'personal', + createdBy: 'test-user-123', + isPersonal: true, + maxMembers: 1, + subscription: { plan, hasActiveSubscription: isActive, planExpiry: expiry }, + usage: { dailyTokenLimit: 1000, remainingTokens: 1000, activeSessionCount: 0 }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + role: 'ADMIN', + }; +} + // --------------------------------------------------------------------------- // CORS helpers // --------------------------------------------------------------------------- @@ -136,19 +182,32 @@ async function handleRequest(req, res) { // POST /telegram/login-tokens/:token/consume if (method === 'POST' && /^\/telegram\/login-tokens\/[^/]+\/consume\/?$/.test(url)) { - json(res, 200, { success: true, data: { jwtToken: MOCK_JWT } }); + if (mockBehavior['token'] === 'expired') { + json(res, 401, { success: false, error: 'Token expired or invalid' }); + return; + } + if (mockBehavior['token'] === 'invalid') { + json(res, 401, { success: false, error: 'Invalid token' }); + return; + } + const jwt = mockBehavior['jwt'] ? `${MOCK_JWT}-${mockBehavior['jwt']}` : MOCK_JWT; + json(res, 200, { success: true, data: { jwtToken: jwt } }); return; } // GET /telegram/me if (method === 'GET' && /^\/telegram\/me\/?(\?.*)?$/.test(url)) { + if (mockBehavior['session'] === 'revoked') { + json(res, 401, { success: false, error: 'Unauthorized' }); + return; + } json(res, 200, { success: true, data: MOCK_USER }); return; } // GET /teams if (method === 'GET' && /^\/teams\/?(\?.*)?$/.test(url)) { - json(res, 200, { success: true, data: [] }); + json(res, 200, { success: true, data: [getMockTeam()] }); return; } @@ -176,11 +235,61 @@ async function handleRequest(req, res) { return; } - // GET /billing/current-plan - if (method === 'GET' && /^\/billing\/current-plan\/?(\?.*)?$/.test(url)) { + // GET /billing/current-plan (legacy alias) + // GET /payments/stripe/currentPlan + if ( + (method === 'GET' && /^\/billing\/current-plan\/?(\?.*)?$/.test(url)) || + (method === 'GET' && /^\/payments\/stripe\/currentPlan\/?(\?.*)?$/.test(url)) + ) { + const plan = mockBehavior['plan'] || 'FREE'; + const isActive = mockBehavior['planActive'] === 'true'; + const expiry = mockBehavior['planExpiry'] || null; json(res, 200, { success: true, - data: { plan: 'FREE', hasActiveSubscription: false, planExpiry: null, subscription: null }, + data: { + plan, + hasActiveSubscription: isActive, + planExpiry: expiry, + subscription: isActive + ? { + id: 'sub_mock_123', + status: 'active', + currentPeriodEnd: expiry || new Date(Date.now() + 30 * 86400000).toISOString(), + } + : null, + }, + }); + return; + } + + // POST /payments/stripe/purchasePlan + if (method === 'POST' && /^\/payments\/stripe\/purchasePlan\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { + checkoutUrl: 'http://127.0.0.1:18473/mock-checkout', + sessionId: 'cs_mock_' + Date.now(), + }, + }); + return; + } + + // POST /payments/stripe/portal + if (method === 'POST' && /^\/payments\/stripe\/portal\/?$/.test(url)) { + json(res, 200, { success: true, data: { portalUrl: 'http://127.0.0.1:18473/mock-portal' } }); + return; + } + + // POST /payments/coinbase/charge + if (method === 'POST' && /^\/payments\/coinbase\/charge\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { + gatewayTransactionId: 'coinbase_mock_' + Date.now(), + hostedUrl: 'http://127.0.0.1:18473/mock-coinbase-checkout', + status: 'NEW', + expiresAt: new Date(Date.now() + 3600000).toISOString(), + }, }); return; } @@ -361,6 +470,7 @@ function sendWsFrame(socket, opcode, payload) { // --------------------------------------------------------------------------- let server = null; +const openSockets = new Set(); export function startMockServer(port = DEFAULT_PORT) { return new Promise((resolve, reject) => { @@ -371,6 +481,12 @@ export function startMockServer(port = DEFAULT_PORT) { }); }); + // Track all connections so stopMockServer can force-close them + server.on('connection', socket => { + openSockets.add(socket); + socket.on('close', () => openSockets.delete(socket)); + }); + // Handle WebSocket upgrades for Socket.IO server.on('upgrade', (req, socket, head) => { handleWebSocketUpgrade(req, socket, head); @@ -391,6 +507,11 @@ export function stopMockServer() { resolve(); return; } + // Destroy all open sockets so server.close() doesn't hang + for (const socket of openSockets) { + socket.destroy(); + } + openSockets.clear(); server.close(() => { console.log('[MockServer] Stopped'); server = null; diff --git a/test/e2e/specs/auth-access-control.spec.ts b/test/e2e/specs/auth-access-control.spec.ts new file mode 100644 index 000000000..1dec0bc83 --- /dev/null +++ b/test/e2e/specs/auth-access-control.spec.ts @@ -0,0 +1,696 @@ +/* eslint-disable */ +// @ts-nocheck +/** + * E2E test: Authentication & Access Control + Billing & Subscriptions. + * + * Covers: + * 1.1 User registration via deep link (verified by before() setup) + * 1.1.1 Duplicate account handling (re-auth same user) + * 1.2 Multi-device sessions (second JWT accepted) + * 3.1.1 Default plan allocation (FREE plan on registration) + * 3.2.1 Upgrade flow (purchase API call + polling) + * 3.2.2 Downgrade flow (lower tiers have no Upgrade button) + * 3.3.1 Subscription creation (active subscription display) + * 3.3.2 Renewal handling (renewal date display) + * 3.3.3 Cancellation handling (Stripe portal API call) + * 1.3 Logout via Settings menu + * 1.3.1 Revoked session auto-logout + * + * Each describe block is standalone — the before() hook performs a full + * login + onboarding cycle so specs don't depend on each other. + * + * The mock server runs on http://127.0.0.1:18473 and the .app bundle must + * have been built with VITE_BACKEND_URL pointing there. + */ +import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; +import { triggerAuthDeepLink } from '../helpers/deep-link-helpers'; +import { + clickButton, + clickText, + dumpAccessibilityTree, + textExists, + waitForText, + waitForWebView, + waitForWindowVisible, +} from '../helpers/element-helpers'; +import { + clearRequestLog, + getRequestLog, + resetMockBehavior, + setMockBehavior, + startMockServer, + stopMockServer, +} from '../mock-server'; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/** + * Click a native XCUIElementTypeButton by its label/title attribute. + * Unlike clickText which matches any element, this targets only buttons. + * Required when text labels sit next to (but outside) the button bounds. + */ +async function clickNativeButton(text, timeout = 10_000) { + const selector = + `//XCUIElementTypeButton[contains(@label, "${text}") or ` + `contains(@title, "${text}")]`; + const el = await browser.$(selector); + await el.waitForExist({ timeout, timeoutMsg: `Button "${text}" not found within ${timeout}ms` }); + + const location = await el.getLocation(); + const size = await el.getSize(); + const centerX = Math.round(location.x + size.width / 2); + const centerY = Math.round(location.y + size.height / 2); + + await browser.performActions([ + { + type: 'pointer', + id: 'mouse1', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', duration: 10, x: centerX, y: centerY }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 50 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]); + await browser.releaseActions(); +} + +/** + * Poll the mock server request log until a matching request appears. + */ +async function waitForRequest(method, urlFragment, timeout = 15_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const log = getRequestLog(); + const match = log.find(r => r.method === method && r.url.includes(urlFragment)); + if (match) return match; + await browser.pause(500); + } + return undefined; +} + +/** + * Wait until the given text disappears from the accessibility tree. + */ +async function waitForTextToDisappear(text, timeout = 10_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (!(await textExists(text))) return true; + await browser.pause(500); + } + return false; +} + +/** + * Wait until one of the candidate texts appears on screen (Home page markers). + * Returns the matched text or null. + */ +async function waitForHomePage(timeout = 15_000) { + const candidates = [ + 'Test', + 'Good morning', + 'Good afternoon', + 'Good evening', + 'Message AlphaHuman', + 'Upgrade to Premium', + ]; + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + for (const text of candidates) { + if (await textExists(text)) return text; + } + await browser.pause(1_000); + } + return null; +} + +/** + * Wait until one of the Welcome/public page markers appears. + * Returns the matched text or null. + */ +async function waitForPublicPage(timeout = 15_000) { + const candidates = ['Welcome', 'Log in', 'Sign in', 'Get Started', 'alphahuman']; + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + for (const text of candidates) { + if (await textExists(text)) return text; + } + await browser.pause(1_000); + } + return null; +} + +/** + * Click the first matching text from a list of candidates, with retry. + * Returns the text that was clicked, or null if none found. + */ +async function clickFirstCandidate(candidates, label, timeout = 10_000) { + // First attempt + for (const text of candidates) { + if (await textExists(text)) { + await clickText(text, timeout); + console.log(`[AuthAccess] ${label}: clicked "${text}"`); + + // Verify the click advanced (text should disappear) + const advanced = await waitForTextToDisappear(text, 8_000); + if (advanced) return text; + + // If text didn't disappear, retry the click + console.log(`[AuthAccess] ${label}: "${text}" still visible, retrying click...`); + await clickText(text, 5_000); + const retryAdvanced = await waitForTextToDisappear(text, 5_000); + if (retryAdvanced) return text; + + // Still stuck — log and return null so callers surface the failure + const tree = await dumpAccessibilityTree(); + console.log( + `[AuthAccess] ${label}: "${text}" still visible after retry. Tree:\n`, + tree.slice(0, 4000) + ); + return null; + } + } + + // If no candidate found, dump tree for debugging + const tree = await dumpAccessibilityTree(); + console.log(`[AuthAccess] ${label}: no candidates found. Tree:\n`, tree.slice(0, 4000)); + return null; +} + +/** + * Navigate to the Billing panel: Settings → Billing & Usage. + * Returns when billing page content is visible. + */ +async function navigateToBilling() { + await clickNativeButton('Settings', 10_000); + console.log('[AuthAccess] Clicked Settings nav'); + await browser.pause(2_000); + + // Click "Billing" or "Billing & Usage" menu item + const billingCandidates = ['Billing & Usage', 'Billing']; + let clicked = false; + for (const text of billingCandidates) { + if (await textExists(text)) { + await clickText(text, 10_000); + console.log(`[AuthAccess] Clicked "${text}" menu item`); + clicked = true; + break; + } + } + if (!clicked) { + const tree = await dumpAccessibilityTree(); + console.log('[AuthAccess] Billing menu item not found. Tree:\n', tree.slice(0, 6000)); + throw new Error('Billing menu item not found in Settings'); + } + + await browser.pause(2_000); +} + +/** + * Navigate from Settings/Billing back to Home via the sidebar Home button. + */ +async function navigateToHome() { + await clickNativeButton('Home', 10_000); + console.log('[AuthAccess] Clicked Home nav'); + await browser.pause(2_000); + const homeText = await waitForHomePage(10_000); + if (!homeText) { + const tree = await dumpAccessibilityTree(); + console.log('[AuthAccess] navigateToHome: Home page not reached. Tree:\n', tree.slice(0, 4000)); + throw new Error('navigateToHome: Home page not reached after clicking Home nav'); + } +} + +/** + * Perform the full login + onboarding flow via deep link. + * Leaves the app on the Home page. + */ +async function performFullLogin(token = 'e2e-test-token') { + await triggerAuthDeepLink(token); + + // Wait for window + WebView + accessibility tree + await waitForWindowVisible(25_000); + await waitForWebView(15_000); + await waitForAppReady(15_000); + + // Onboarding Step 1: InviteCodeStep — skip + await clickText('Skip for now', 10_000); + console.log('[AuthAccess] Clicked "Skip for now"'); + + const stepChanged = await waitForTextToDisappear('Skip for now', 8_000); + if (!stepChanged) { + console.log('[AuthAccess] Step did not advance, retrying...'); + await clickText('Skip', 5_000); + await waitForTextToDisappear('Skip', 5_000); + } + await browser.pause(2_000); + + // Onboarding Step 2: FeaturesStep + const featResult = await clickFirstCandidate(['Looks Amazing', 'Bring It On'], 'FeaturesStep'); + if (!featResult) throw new Error('FeaturesStep button not found'); + await browser.pause(2_000); + + // Onboarding Step 3: PrivacyStep + const privResult = await clickFirstCandidate(['Got it', 'Continue'], 'PrivacyStep'); + if (!privResult) throw new Error('PrivacyStep button not found'); + await browser.pause(2_000); + + // Onboarding Step 4: GetStartedStep + const startResult = await clickFirstCandidate(["Let's Go", "I'm Ready"], 'GetStartedStep'); + if (!startResult) throw new Error('GetStartedStep button not found'); + await browser.pause(3_000); + + // Verify we landed on Home + const homeText = await waitForHomePage(15_000); + if (!homeText) { + const tree = await dumpAccessibilityTree(); + console.log( + '[AuthAccess] Home page not reached after onboarding. Tree:\n', + tree.slice(0, 4000) + ); + throw new Error('Full login + onboarding did not reach Home page'); + } + console.log(`[AuthAccess] Home page confirmed: found "${homeText}"`); +} + +// =========================================================================== +// Test suite +// =========================================================================== + +describe('Auth & Access Control', () => { + before(async () => { + await startMockServer(); + await waitForApp(); + clearRequestLog(); + + // Perform full login + onboarding so subsequent tests start from Home + await performFullLogin('e2e-auth-token'); + }); + + after(async function () { + this.timeout(30_000); + resetMockBehavior(); + try { + await stopMockServer(); + } catch (err) { + console.log('[AuthAccess] stopMockServer error (non-fatal):', err); + } + }); + + // ------------------------------------------------------------------------- + // 1.1 User Registration + // ------------------------------------------------------------------------- + + it('new user registers via deep link and reaches home', async () => { + // This was already verified by before() — just assert the API calls + const consumeCall = getRequestLog().find( + r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/') + ); + expect(consumeCall).toBeDefined(); + + const meCall = getRequestLog().find(r => r.method === 'GET' && r.url.includes('/telegram/me')); + expect(meCall).toBeDefined(); + + // Confirm we're on Home + const homeText = await waitForHomePage(5_000); + expect(homeText).not.toBeNull(); + }); + + // ------------------------------------------------------------------------- + // 1.1.1 Duplicate Account Handling + // ------------------------------------------------------------------------- + + it('re-authenticating with a new token for the same user returns to home', async () => { + clearRequestLog(); + + // Trigger a second deep link — backend returns the same MOCK_USER + await triggerAuthDeepLink('e2e-dup-token'); + await browser.pause(5_000); + + // App should process the token and stay on / return to Home + // (already onboarded, so ProtectedRoute sends to /home) + const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 10_000); + if (!consumeCall) { + console.log('[AuthAccess] Dup request log:', JSON.stringify(getRequestLog(), null, 2)); + } + expect(consumeCall).toBeDefined(); + + const homeText = await waitForHomePage(10_000); + if (!homeText) { + const tree = await dumpAccessibilityTree(); + console.log('[AuthAccess] Dup: not on Home. Tree:\n', tree.slice(0, 4000)); + } + expect(homeText).not.toBeNull(); + }); + + // ------------------------------------------------------------------------- + // 1.2 Multi-Device Sessions + // ------------------------------------------------------------------------- + + it('second device token is accepted and processed', async () => { + clearRequestLog(); + + // Mock returns a different JWT for "device 2" + setMockBehavior('jwt', 'device2'); + + await triggerAuthDeepLink('e2e-device2-token'); + await browser.pause(5_000); + + const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 10_000); + if (!consumeCall) { + console.log('[AuthAccess] Device2 request log:', JSON.stringify(getRequestLog(), null, 2)); + } + expect(consumeCall).toBeDefined(); + + // App should land on Home (already onboarded) or onboarding + const homeText = await waitForHomePage(10_000); + expect(homeText).not.toBeNull(); + + // Reset for next tests + resetMockBehavior(); + }); + + // ------------------------------------------------------------------------- + // 3.1 Plan Assignment + // ------------------------------------------------------------------------- + + it('3.1.1 — new user is assigned FREE plan by default', async () => { + // Navigate to Settings → Billing & Usage + await navigateToBilling(); + + // Verify billing page loaded with plan info + const hasPlanText = await textExists('Your Current Plan'); + if (!hasPlanText) { + const tree = await dumpAccessibilityTree(); + console.log('[AuthAccess] Billing page tree:\n', tree.slice(0, 6000)); + } + expect(hasPlanText).toBe(true); + + // Verify FREE plan is shown + const hasFree = await textExists('FREE'); + expect(hasFree).toBe(true); + + // Verify "Current" badge is displayed (next to the Free tier card) + const hasCurrent = await textExists('Current'); + expect(hasCurrent).toBe(true); + + // Verify "Upgrade" button exists (for BASIC and/or PRO tiers) + const hasUpgrade = await textExists('Upgrade'); + expect(hasUpgrade).toBe(true); + + console.log('[AuthAccess] 3.1.1 — FREE plan verified in billing'); + + // Navigate back to Home for next tests + await navigateToHome(); + }); + + // ------------------------------------------------------------------------- + // 3.2 Plan Changes + // ------------------------------------------------------------------------- + + it('3.2.1 — upgrade initiates purchase flow via Stripe', async () => { + // Navigate to billing + await navigateToBilling(); + clearRequestLog(); + + // Click the first "Upgrade" button (BASIC tier, appears before PRO) + await clickText('Upgrade', 10_000); + console.log('[AuthAccess] Clicked Upgrade button'); + await browser.pause(3_000); + + // Verify POST /payments/stripe/purchasePlan was called + const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000); + if (!purchaseCall) { + console.log('[AuthAccess] Purchase request log:', JSON.stringify(getRequestLog(), null, 2)); + } + expect(purchaseCall).toBeDefined(); + + // Verify the request body contains a BASIC plan identifier + if (purchaseCall?.body) { + const bodyStr = typeof purchaseCall.body === 'string' ? purchaseCall.body : ''; + console.log('[AuthAccess] Purchase request body:', bodyStr); + expect(bodyStr).toContain('BASIC'); + } + + // Verify the app entered purchasing state ("Waiting..." button text) + const hasWaiting = + (await textExists('Waiting')) || (await textExists('Waiting for payment confirmation')); + console.log(`[AuthAccess] Purchasing state visible: ${hasWaiting}`); + expect(hasWaiting).toBe(true); + + // Switch mock to BASIC plan so polling succeeds + setMockBehavior('plan', 'BASIC'); + setMockBehavior('planActive', 'true'); + setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString()); + + // Wait for polling to detect the plan change (polls every 5s, give it 20s) + const waitingGone = await waitForTextToDisappear('Waiting', 20_000); + console.log(`[AuthAccess] Purchasing state cleared: ${waitingGone}`); + + console.log('[AuthAccess] 3.2.1 — Upgrade purchase flow verified'); + + // Navigate back to Home + await navigateToHome(); + }); + + it('3.2.2 — lower tier does not show Upgrade button (downgrade not available)', async () => { + // Mock is already set to BASIC plan from previous test. + // Trigger deep link re-auth to refresh team state with BASIC subscription. + clearRequestLog(); + await triggerAuthDeepLink('e2e-billing-refresh-token'); + await browser.pause(5_000); + + // Wait for the re-auth to complete and reach Home + const homeText = await waitForHomePage(15_000); + expect(homeText).not.toBeNull(); + console.log('[AuthAccess] Re-authed with BASIC plan, on Home'); + + // Navigate to billing + await navigateToBilling(); + + // Verify BASIC is the current plan + const hasBasic = (await textExists('BASIC')) || (await textExists('Basic')); + expect(hasBasic).toBe(true); + + // Verify "Current" badge is visible (next to BASIC tier) + const hasCurrent = await textExists('Current'); + expect(hasCurrent).toBe(true); + + // Count all elements containing "Upgrade" — only PRO should have the button. + // Free is a downgrade from BASIC so isUpgrade() returns false → no Upgrade button. + const upgradeSelector = `//*[contains(@label, "Upgrade") or contains(@value, "Upgrade") or contains(@title, "Upgrade")]`; + const upgradeElements = await browser.$$(upgradeSelector); + const upgradeCount = upgradeElements.length; + console.log(`[AuthAccess] Found ${upgradeCount} "Upgrade" element(s)`); + expect(upgradeCount).toBe(1); + + // Verify PRO plan is visible — the only tier above BASIC that should show Upgrade + const hasPro = (await textExists('PRO')) || (await textExists('Pro')); + expect(hasPro).toBe(true); + console.log('[AuthAccess] 3.2.2 — Exactly 1 Upgrade (PRO only), downgrade not offered'); + + // Stay on billing for the next subscription lifecycle tests + }); + + // ------------------------------------------------------------------------- + // 3.3 Subscription Lifecycle + // ------------------------------------------------------------------------- + + it('3.3.1 — active subscription is displayed correctly', async () => { + // Still on billing page from previous test with BASIC plan active. + // Verify subscription indicators are visible. + + // The mock has planActive='true', so "Manage Subscription" should appear + const hasManage = await textExists('Manage Subscription'); + if (!hasManage) { + const tree = await dumpAccessibilityTree(); + console.log('[AuthAccess] Manage Subscription not found. Tree:\n', tree.slice(0, 6000)); + } + expect(hasManage).toBe(true); + + // Verify the current plan API was called (BillingPanel fetches on mount) + const planCall = getRequestLog().find( + r => r.method === 'GET' && r.url.includes('/payments/stripe/currentPlan') + ); + expect(planCall).toBeDefined(); + + console.log('[AuthAccess] 3.3.1 — Active subscription display verified'); + }); + + it('3.3.2 — renewal date is displayed for active subscription', async () => { + // Still on billing page with BASIC plan active and planExpiry set. + // The mock has planExpiry set to ~30 days from now. + const hasRenews = await textExists('Renews'); + if (!hasRenews) { + const tree = await dumpAccessibilityTree(); + console.log('[AuthAccess] Renews text not found. Tree:\n', tree.slice(0, 6000)); + } + expect(hasRenews).toBe(true); + + console.log('[AuthAccess] 3.3.2 — Renewal date display verified'); + }); + + it('3.3.3 — manage subscription opens Stripe portal', async () => { + // Still on billing page with active subscription. + clearRequestLog(); + + // Click "Manage Subscription" + await clickText('Manage Subscription', 10_000); + console.log('[AuthAccess] Clicked Manage Subscription'); + await browser.pause(3_000); + + // Verify POST /payments/stripe/portal was called + const portalCall = await waitForRequest('POST', '/payments/stripe/portal', 10_000); + if (!portalCall) { + console.log('[AuthAccess] Portal request log:', JSON.stringify(getRequestLog(), null, 2)); + } + expect(portalCall).toBeDefined(); + + console.log('[AuthAccess] 3.3.3 — Stripe portal API call verified'); + + // Reset billing mock behavior and navigate back to Home for logout tests + resetMockBehavior(); + await navigateToHome(); + + // Re-auth to restore clean state (FREE plan) for logout tests + clearRequestLog(); + await triggerAuthDeepLink('e2e-pre-logout-token'); + await browser.pause(5_000); + const homeAfterReset = await waitForHomePage(15_000); + expect(homeAfterReset).not.toBeNull(); + console.log('[AuthAccess] Restored clean state for logout tests'); + }); + + // ------------------------------------------------------------------------- + // 1.3 Logout & Revocation + // ------------------------------------------------------------------------- + + it('user can log out via Settings and returns to Welcome', async () => { + // Open Settings — must click the actual Button element, not the text label + // next to it (they have separate bounding boxes in the sidebar). + await clickNativeButton('Settings', 10_000); + console.log('[AuthAccess] Clicked Settings button'); + await browser.pause(3_000); + + // Verify we navigated to the Settings page + const settingsTree = await dumpAccessibilityTree(); + console.log('[AuthAccess] Settings page tree:\n', settingsTree.slice(0, 6000)); + + // Look for "Log out" or related text — it's a