Files
openhuman/app/test/e2e/specs/auth-access-control.spec.ts
T

499 lines
17 KiB
TypeScript

/* eslint-disable */
// @ts-nocheck
/**
* E2E test: Authentication & Access Control + Billing & Subscriptions (Linux / tauri-driver).
*
* Covers:
* 1.1 User registration via deep link
* 1.1.1 Duplicate account handling (re-auth same user)
* 1.2 Multi-device sessions (second JWT accepted)
* 3.1.1 Billing dashboard handoff is available
* 3.2.1 Billing dashboard entry point is stable
* 3.3.1 Subscription management handoff is displayed
* 3.3.3 Manage subscription uses the web dashboard handoff
* 1.3 Logout via Settings menu
* 1.3.1 Revoked session auto-logout
*
* Onboarding steps:
* Welcome → Skills → optional Context. The shared helper accepts older
* onboarding copy as fallback so this spec keeps covering auth/billing.
*
* 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, waitForAuthBootstrap } from '../helpers/app-helpers';
import { triggerAuthDeepLink } from '../helpers/deep-link-helpers';
import {
clickButton,
clickText,
dumpAccessibilityTree,
hasAppChrome,
textExists,
waitForText,
waitForWebView,
waitForWindowVisible,
} from '../helpers/element-helpers';
import { resetApp } from '../helpers/reset-app';
import {
navigateToBilling,
navigateToHome,
navigateToSettings,
navigateViaHash,
waitForHomePage,
walkOnboarding,
} from '../helpers/shared-flows';
import {
clearRequestLog,
getRequestLog,
resetMockBehavior,
setMockBehavior,
startMockServer,
stopMockServer,
} from '../mock-server';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
// waitForHomePage imported from shared-flows
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;
}
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;
}
async function expectBillingMarkers(markers) {
const results = [];
for (const marker of markers) {
results.push([marker, await textExists(marker)]);
}
const missing = results.filter(([, found]) => !found).map(([marker]) => marker);
if (missing.length > 0) {
console.log('[AuthAccess] Billing request log:', JSON.stringify(getRequestLog(), null, 2));
const tree = await dumpAccessibilityTree();
console.log('[AuthAccess] Billing page tree:\n', tree.slice(0, 6000));
}
for (const [marker, found] of results) {
expect(found).toBe(true);
console.log(`[AuthAccess] Billing marker verified: ${marker}`);
}
}
// walkOnboarding, waitForHomePage imported from shared-flows
/**
* Perform full login via deep link. Walks onboarding. Leaves app on Home page.
*/
async function performFullLogin(token = 'e2e-test-token') {
await triggerAuthDeepLink(token);
await waitForWindowVisible(25_000);
await waitForWebView(15_000);
await waitForAppReady(15_000);
await waitForAuthBootstrap(15_000);
const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 20_000);
if (!consumeCall) {
console.log(
'[AuthAccess] Missing consume call. Request log:',
JSON.stringify(getRequestLog(), null, 2)
);
throw new Error('Auth consume call missing in performFullLogin');
}
// The app may call /auth/me or /settings for user profile
const meCall =
(await waitForRequest('GET', '/auth/me', 10_000)) ||
(await waitForRequest('GET', '/settings', 10_000));
if (!meCall) {
console.log(
'[AuthAccess] Missing user profile call. Request log:',
JSON.stringify(getRequestLog(), null, 2)
);
console.log('[AuthAccess] Continuing without user profile call confirmation');
}
// Walk real onboarding steps
await walkOnboarding('[AuthAccess]');
const homeText = await waitForHomePage(15_000);
if (!homeText) {
const tree = await dumpAccessibilityTree();
console.log('[AuthAccess] Home page not reached after login. Tree:\n', tree.slice(0, 4000));
throw new Error('Full login did not reach Home page');
}
console.log(`[AuthAccess] Home page confirmed: found "${homeText}"`);
}
// ===========================================================================
// Test suite
// ===========================================================================
describe('Auth & Access Control', () => {
before(async function beforeSuite() {
this.timeout(90_000);
await startMockServer();
resetMockBehavior();
setMockBehavior('composioConnections', '[]');
await waitForApp();
// Wipe prior-spec state but stop before auth — this spec drives the
// login flow itself via `performFullLogin`, so it has to start from
// a logged-out Welcome screen.
await resetApp('e2e-auth-access-reset', { skipAuth: true });
clearRequestLog();
});
after(async () => {
resetMockBehavior();
await stopMockServer();
});
// -------------------------------------------------------------------------
// 1. Authentication
// -------------------------------------------------------------------------
it('new user registers via deep link and reaches home', async function () {
this.timeout(120_000);
await performFullLogin('e2e-auth-token');
});
it('re-authenticating with a new token for the same user returns to home', async () => {
clearRequestLog();
await triggerAuthDeepLink('e2e-auth-reauth-token');
// Wait until the app has processed the deep-link and navigated away from
// any loading state — poll for a home marker or the auth token consume
// request, whichever comes first.
await browser.waitUntil(
async () => {
const homeText = await waitForHomePage(500);
if (homeText) return true;
const consumed = getRequestLog().find(
r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
);
return !!consumed;
},
{
timeout: 10_000,
interval: 500,
timeoutMsg: 'Timed out waiting for re-auth deep-link to be processed',
}
);
const homeText = await waitForHomePage(15_000);
if (!homeText) {
await navigateToHome();
}
const finalHome = homeText || (await waitForHomePage(10_000));
expect(finalHome).not.toBeNull();
console.log('[AuthAccess] Re-auth completed, on Home');
});
it('second device token is accepted and processed', async () => {
clearRequestLog();
await triggerAuthDeepLink('e2e-auth-device2-token');
// Wait for the deep-link to be consumed before asserting home state.
await browser.waitUntil(
async () => {
const consumed = getRequestLog().find(
r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
);
return !!consumed;
},
{
timeout: 10_000,
interval: 500,
timeoutMsg: 'Timed out waiting for device-2 token consume call',
}
);
const homeText = await waitForHomePage(15_000);
if (!homeText) {
await navigateToHome();
}
const finalHome = homeText || (await waitForHomePage(10_000));
expect(finalHome).not.toBeNull();
const consumeCall = getRequestLog().find(
r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
);
expect(consumeCall).toBeDefined();
console.log('[AuthAccess] Multi-device token accepted');
});
// -------------------------------------------------------------------------
// 2. Default Plan
// -------------------------------------------------------------------------
it('3.1.1 — billing dashboard handoff is available', async () => {
await navigateToBilling();
const hasHandoff =
(await textExists('Billing moved to the web')) ||
(await textExists('Open billing dashboard'));
if (!hasHandoff) {
console.log('[AuthAccess] Billing request log:', JSON.stringify(getRequestLog(), null, 2));
const tree = await dumpAccessibilityTree();
console.log('[AuthAccess] Billing page tree:\n', tree.slice(0, 6000));
}
expect(hasHandoff).toBe(true);
await expectBillingMarkers(['Open billing dashboard']);
console.log('[AuthAccess] 3.1.1 — Billing web handoff verified');
await navigateToHome();
});
// -------------------------------------------------------------------------
// 3. Upgrade Flow
// -------------------------------------------------------------------------
it('3.2.1 — billing dashboard entry point is stable', async () => {
await navigateToBilling();
clearRequestLog();
await expectBillingMarkers(['Open billing dashboard', 'TinyHumans on the web']);
console.log('[AuthAccess] 3.2.1 — Billing dashboard entry point verified');
await navigateToHome();
});
// -------------------------------------------------------------------------
// 4. Active Subscription Display
// -------------------------------------------------------------------------
it('3.3.1 — subscription management handoff is displayed correctly', async () => {
// Seed mock state explicitly so this test is self-contained
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString());
clearRequestLog();
await navigateToBilling();
await expectBillingMarkers([
'Billing moved to the web',
'Subscription changes',
'Open billing dashboard',
]);
console.log('[AuthAccess] 3.3.1 — Subscription management handoff verified');
});
it('3.3.3 — manage subscription uses the web dashboard handoff', async () => {
// Seed mock state explicitly so this test is self-contained
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString());
clearRequestLog();
await navigateToBilling();
await browser.pause(3_000);
await expectBillingMarkers(['Open billing dashboard']);
console.log('[AuthAccess] 3.3.3 — Dashboard handoff verified');
resetMockBehavior();
await navigateToHome();
});
// -------------------------------------------------------------------------
// 5. Logout
// -------------------------------------------------------------------------
it('user can log out via Settings and returns to Welcome', async () => {
// Re-auth to get a clean session for logout
clearRequestLog();
await triggerAuthDeepLink('e2e-pre-logout-token');
// Wait for the consume call rather than using a fixed delay.
await browser.waitUntil(
async () => {
const consumed = getRequestLog().find(
r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
);
return !!consumed;
},
{
timeout: 10_000,
interval: 500,
timeoutMsg: 'Timed out waiting for pre-logout token consume call',
}
);
const homeCheck = await waitForHomePage(10_000);
if (!homeCheck) {
await navigateToHome();
}
// Log out + Clear App Data moved out of the main /settings page and
// into the Account section in PR #2550 (LogoutAndClearActions footer
// on /settings/account).
await navigateViaHash('/settings/account');
// Click "Log out" via JS — the settings menu item text is "Log out"
// with description "Sign out of your account"
const loggedOut = await browser.execute(() => {
const allElements = document.querySelectorAll('*');
for (const el of allElements) {
const text = el.textContent?.trim() || '';
if (text === 'Log out') {
const clickable = el.closest(
'button, [role="button"], a, [class*="MenuItem"]'
) as HTMLElement;
if (clickable) {
clickable.click();
return 'clicked-parent';
}
(el as HTMLElement).click();
return 'clicked-self';
}
}
return null;
});
if (!loggedOut) {
// Fallback: try XPath text search
const logoutCandidates = ['Log out', 'Logout', 'Sign out'];
let found = false;
for (const text of logoutCandidates) {
if (await textExists(text)) {
await clickText(text, 10_000);
console.log(`[AuthAccess] Clicked "${text}" via XPath`);
found = true;
break;
}
}
if (!found) {
const tree = await dumpAccessibilityTree();
console.log('[AuthAccess] Logout button not found. Tree:\n', tree.slice(0, 4000));
throw new Error('Could not find logout button in Settings');
}
} else {
console.log(`[AuthAccess] Logout: ${loggedOut}`);
}
// If a confirmation dialog appears, confirm it
await browser.pause(2_000);
const hasConfirm =
(await textExists('Confirm')) || (await textExists('Yes')) || (await textExists('Log Out'));
if (hasConfirm) {
const confirmed = await browser.execute(() => {
const candidates = document.querySelectorAll('button, [role="button"], a');
for (const el of candidates) {
const text = el.textContent?.trim() || '';
const label = el.getAttribute('aria-label') || '';
if (['Confirm', 'Yes', 'Log Out'].some(t => text === t || label === t)) {
(el as HTMLElement).click();
return true;
}
}
return false;
});
expect(confirmed).toBe(true);
console.log('[AuthAccess] Confirmation dialog: clicked');
await browser.pause(2_000);
}
// Verify we landed on the logged-out state — assert a specific marker
await browser.pause(3_000);
const welcomeCandidates = ['Welcome', 'Sign in', 'Login', 'Get Started'];
let onWelcome = false;
for (const text of welcomeCandidates) {
if (await textExists(text)) {
console.log(`[AuthAccess] Logged-out state confirmed: found "${text}"`);
onWelcome = true;
break;
}
}
// Also verify auth token was cleared from localStorage
const hasToken = await browser.execute(() => {
const persisted = localStorage.getItem('persist:auth');
if (!persisted) return false;
try {
const parsed = JSON.parse(persisted);
const token = typeof parsed.token === 'string' ? parsed.token.replace(/^"|"$/g, '') : null;
return !!token && token !== 'null';
} catch {
return false;
}
});
// Must see logged-out UI or token must be cleared (or both)
expect(onWelcome || !hasToken).toBe(true);
console.log(`[AuthAccess] Logout verified: welcomeUI=${onWelcome}, tokenCleared=${!hasToken}`);
});
it('revoked session auto-logs out the user', async function () {
this.timeout(120_000);
// Login fresh
clearRequestLog();
resetMockBehavior();
setMockBehavior('composioConnections', '[]');
await performFullLogin('e2e-revoked-session-token');
// Set mock to return 401 for user profile requests (revoked session)
setMockBehavior('session', 'revoked');
// Trigger a re-auth which will fail with 401
await triggerAuthDeepLink('e2e-revoked-check-token');
// Wait for the app to process the revoked token. The app should either
// navigate away from Home (auto-logout) or the token consume call should
// arrive. Poll with a generous timeout since 401 handling involves an
// async auth state update.
await browser.waitUntil(
async () => {
// Either the app has logged us out (no home markers) or the
// consume request arrived so we can proceed to the assertion.
const homeText = await waitForHomePage(500);
if (!homeText) return true; // navigated away — auto-logout happened
const consumed = getRequestLog().find(
r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
);
return !!consumed;
},
{
timeout: 12_000,
interval: 500,
timeoutMsg: 'Timed out waiting for revoked-session response',
}
);
// The app should auto-log out when it gets a 401
const stillOnHome = await waitForHomePage(5_000);
if (!stillOnHome) {
console.log('[AuthAccess] Revoked session: user was logged out (no home page markers)');
}
// Verify the app is either on Welcome or not on Home
const welcomeCandidates = ['Welcome', 'Sign in', 'Login', 'Get Started', 'OpenHuman'];
let onWelcome = false;
for (const text of welcomeCandidates) {
if (await textExists(text)) {
onWelcome = true;
break;
}
}
expect(onWelcome || !stillOnHome).toBe(true);
console.log('[AuthAccess] Revoked session auto-logout verified');
});
});