mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: add E2E tests for auth access control and billing/subscription … (#122)
* feat: add E2E tests for auth access control and billing/subscription flows Add comprehensive E2E test coverage for authentication, access control, and billing/subscription lifecycle using Appium mac2 + WebDriverIO. New test cases (11 total): - 1.1 User registration via deep link - 1.1.1 Duplicate account handling - 1.2 Multi-device sessions - 3.1.1 Default FREE plan allocation - 3.2.1 Upgrade flow (Stripe purchase API + polling) - 3.2.2 Downgrade flow verification - 3.3.1 Active subscription display - 3.3.2 Renewal date handling - 3.3.3 Cancellation via Stripe portal - 1.3 Logout via Settings - 1.3.1 Revoked session auto-logout Mock server additions: - Dynamic team data with subscription controlled by mockBehavior - /payments/stripe/currentPlan, purchasePlan, portal routes - /payments/coinbase/charge route Split e2e.sh into per-flow scripts (e2e-login.sh, e2e-auth.sh) to prevent Redux Persist state leaking between specs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: fix Prettier formatting for E2E test files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review findings for E2E auth spec - clickFirstCandidate: check retry result, return null with tree dump on failure - navigateToHome: throw on failure instead of silently continuing - 3.2.2: assert exactly 1 Upgrade element (PRO only) and verify PRO visible Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ad9d001261
commit
c9bd1b7236
+3
-2
@@ -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 .",
|
||||
|
||||
Executable
+90
@@ -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"
|
||||
@@ -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"
|
||||
+126
-5
@@ -30,6 +30,20 @@ export function clearRequestLog() {
|
||||
requestLog = [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock behavior toggles — tests can change responses at runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mockBehavior: Record<string, string> = {};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 <button> element in the
|
||||
// settings page with title "Log out" rendered as text inside it.
|
||||
const logoutCandidates = ['Log out', 'Sign out', 'Logout', 'log out'];
|
||||
let logoutFound = false;
|
||||
|
||||
for (const text of logoutCandidates) {
|
||||
if (await textExists(text)) {
|
||||
await clickText(text, 5_000);
|
||||
console.log(`[AuthAccess] Clicked logout: "${text}"`);
|
||||
logoutFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!logoutFound) {
|
||||
// Settings page may need scrolling — "Log out" is at the bottom.
|
||||
// Use mouse wheel scroll inside the content area.
|
||||
console.log('[AuthAccess] Log out not visible, attempting scroll...');
|
||||
try {
|
||||
const webView = await browser.$('//XCUIElementTypeWebView');
|
||||
const loc = await webView.getLocation();
|
||||
const size = await webView.getSize();
|
||||
// Scroll inside the right content area (not the sidebar)
|
||||
const scrollX = Math.round(loc.x + size.width * 0.65);
|
||||
const startY = Math.round(loc.y + size.height * 0.8);
|
||||
const endY = Math.round(loc.y + size.height * 0.2);
|
||||
|
||||
await browser.performActions([
|
||||
{
|
||||
type: 'pointer',
|
||||
id: 'scroll1',
|
||||
parameters: { pointerType: 'mouse' },
|
||||
actions: [
|
||||
{ type: 'pointerMove', duration: 10, x: scrollX, y: startY },
|
||||
{ type: 'pointerDown', button: 0 },
|
||||
{ type: 'pointerMove', duration: 300, x: scrollX, y: endY },
|
||||
{ type: 'pointerUp', button: 0 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
await browser.releaseActions();
|
||||
await browser.pause(2_000);
|
||||
} catch (scrollErr) {
|
||||
console.log('[AuthAccess] Scroll attempt failed:', scrollErr);
|
||||
}
|
||||
|
||||
// Dump tree after scroll to see what's now visible
|
||||
const afterScrollTree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] After scroll tree:\n', afterScrollTree.slice(0, 6000));
|
||||
|
||||
// Try again after scroll
|
||||
for (const text of logoutCandidates) {
|
||||
if (await textExists(text)) {
|
||||
await clickText(text, 5_000);
|
||||
console.log(`[AuthAccess] Clicked logout after scroll: "${text}"`);
|
||||
logoutFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!logoutFound) {
|
||||
throw new Error('Could not find logout button in Settings');
|
||||
}
|
||||
|
||||
await browser.pause(3_000);
|
||||
|
||||
// Should land on the public Welcome page
|
||||
const publicText = await waitForPublicPage(15_000);
|
||||
if (!publicText) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log('[AuthAccess] After logout, not on public page. Tree:\n', tree.slice(0, 4000));
|
||||
}
|
||||
expect(publicText).not.toBeNull();
|
||||
console.log(`[AuthAccess] Post-logout public page confirmed: found "${publicText}"`);
|
||||
|
||||
// Verify no protected content is visible
|
||||
const homeStillVisible = await textExists('Message AlphaHuman');
|
||||
expect(homeStillVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('revoked session auto-logs out the user', async () => {
|
||||
// After the previous test logged out, we're on the public page.
|
||||
// Set mock to return 401 on /telegram/me (session revoked)
|
||||
setMockBehavior('session', 'revoked');
|
||||
clearRequestLog();
|
||||
|
||||
// Login again via deep link — token consume succeeds, but /telegram/me returns 401
|
||||
await triggerAuthDeepLink('e2e-revoked-token');
|
||||
await browser.pause(5_000);
|
||||
|
||||
// The app gets a JWT, navigates to onboarding/home, UserProvider calls
|
||||
// /telegram/me -> 401 -> clearToken() -> redirect to Welcome
|
||||
const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 10_000);
|
||||
expect(consumeCall).toBeDefined();
|
||||
|
||||
// Wait for the auto-logout cycle: login -> fetch user -> 401 -> clearToken -> public page
|
||||
await browser.pause(5_000);
|
||||
|
||||
const publicText = await waitForPublicPage(15_000);
|
||||
if (!publicText) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log(
|
||||
'[AuthAccess] After revocation, not on public page. Tree:\n',
|
||||
tree.slice(0, 4000)
|
||||
);
|
||||
}
|
||||
expect(publicText).not.toBeNull();
|
||||
console.log(`[AuthAccess] Revoked session auto-logout confirmed: found "${publicText}"`);
|
||||
|
||||
resetMockBehavior();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user