Files
openhuman/app/test/e2e/specs/auth-access-control.spec.ts
T
8381283c52 Fix/update api (#319)
* feat(webhooks): refactor webhook URL handling and enhance API endpoints

- Introduced a new utility function `buildWebhookIngressUrl` to standardize the construction of webhook URLs across components.
- Updated `WebhooksDebugPanel` and `TunnelList` components to utilize the new URL builder for improved consistency and maintainability.
- Refactored API endpoints in `tunnelsApi` to reflect changes in webhook routing, ensuring all references to tunnel management are aligned with the new structure.
- Adjusted user API calls to replace `/telegram/me` with `/auth/me` for better clarity and consistency in authentication flows.
- Enhanced socket service to normalize channel connection update payloads, improving error handling and data integrity.

These changes streamline webhook management and enhance the overall developer experience when working with webhooks.

* feat(messaging): enhance Telegram and Discord channel linking functionality

- Added support for managed DM linking with Telegram and OAuth flow for Discord.
- Introduced `createChannelLinkToken` API to generate short-lived link tokens for messaging channels.
- Updated `MessagingPanel` to handle managed linking flows, including building launch URLs and user instructions.
- Enhanced configuration to include a Telegram bot username for fallback linking.
- Updated tests to mock new configuration values for consistent testing.

These changes improve the user experience for linking messaging channels and streamline the authentication process.

* feat(api): introduce core command client and refactor API calls

- Added a new `coreCommandClient` to facilitate RPC calls to core services.
- Refactored existing API endpoints in `authApi`, `billingApi`, `creditsApi`, `tunnelsApi`, and `userApi` to utilize the new command client for improved consistency and maintainability.
- Updated the billing API to include new endpoints for fetching balance, transactions, and managing auto-recharge settings.
- Enhanced the user API to streamline user data retrieval.
- Improved error handling and logging across the refactored API calls.

These changes enhance the overall architecture of the API services, making them more modular and easier to maintain.

* refactor(api): streamline API calls and enhance error handling

- Refactored `creditsApi` and `tunnelsApi` to ensure consistent use of the new core command client.
- Updated API methods to improve error handling and response parsing.
- Introduced a new `authed_json` method in `BackendOAuthClient` for standardized authenticated requests.
- Cleaned up unused imports and optimized code structure across various modules.

These changes enhance the maintainability and reliability of the API services.

* refactor(api): streamline API calls and improve code readability

- Reorganized API calls in `authApi`, `billingApi`, `creditsApi`, and `tunnelsApi` to enhance consistency and maintainability.
- Updated function formatting for better readability, ensuring parameters are clearly defined.
- Cleaned up import statements and removed unnecessary line breaks to improve code clarity.

These changes contribute to a more maintainable codebase and enhance the overall developer experience.

* refactor(MessagingPanel): clean up imports and remove unused constants

- Reorganized import statements for clarity and consistency.
- Removed unused constants related to channel definitions and status styles to streamline the component.
- Simplified the `updateField` function call in the rendering logic for better readability.

These changes enhance the maintainability of the MessagingPanel component.

* fix: restore helper functions removed during merge cleanup

buildManagedChannelLaunchUrl and buildManagedChannelInstruction were
stripped by the linter after merge resolution, breaking the build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(messaging): enhance MessagingPanel with pending instruction handling

- Introduced a new state for pending instructions to improve user feedback during channel connection processes.
- Updated error handling to ensure users receive clear instructions, including URLs for manual access if automatic opening fails.
- Refactored connection status updates to streamline the dispatch process and improve code readability.
- Enhanced the rendering logic to display pending instructions in the UI, providing better context for users during authentication flows.

* fix(api): encode channel and ID parameters in webhook and OAuth URLs

- Updated the URL construction in the BackendOAuthClient to encode the channel parameter, ensuring proper formatting for requests.
- Modified the get_tunnel, update_tunnel, and delete_tunnel functions to encode the ID parameter in webhook URLs, enhancing security and compatibility with special characters.

* fix(auth): validate channel input for link token creation

- Added validation to ensure the channel is not empty and is one of the supported types (telegram, discord).
- Converted the channel string to lowercase for consistent matching.
- Updated the call to create_channel_link_token to use a reference to the channel variable.

* chore(todos): update TODO list with completed tasks and new feature requests

- Marked several tasks as completed, including integration of the custom memory engine and skills registry into the core.
- Added new items to the TODO list, including improvements for voiceover functionalities, screen intelligence, and Gmail skill enhancements.
- Included tasks for debugging skills from the UI and improving prompts to reduce hallucinations.

* refactor(webhooks): improve formatting of get_tunnel function for readability

- Reformatted the get_tunnel function to enhance code clarity by adjusting the indentation and line breaks in the call to get_authed_value.
- This change improves the maintainability of the code and aligns with the overall code style.

* test(userApi): enhance userApi tests with improved mocking and error handling

- Added a mock function for core command calls to streamline test setup.
- Introduced a helper function to generate mock user data for consistent test cases.
- Updated tests to use the mock function for simulating API responses, improving clarity and maintainability.
- Enhanced error handling in tests to cover various API error scenarios, ensuring robustness in userApi.getMe functionality.

* refactor(billingApi): update tests to use core command mocking and improve error handling

- Replaced apiClient mocks with core command mocks for better alignment with the new API structure.
- Updated test cases to reflect changes in API call expectations, enhancing clarity and maintainability.
- Improved error handling in tests to ensure proper propagation of errors from core command calls.

* refactor(billingApi): simplify test cases by consolidating mock function calls

- Streamlined mock function calls in billingApi tests for improved readability and consistency.
- Removed unnecessary line breaks in mock responses, enhancing clarity in test definitions.
- Ensured that error handling and API call expectations are clearly represented in the tests.

* refactor(api): rename settings endpoint to current_user for clarity

- Updated the endpoint handling authenticated profile fetches to improve clarity in the codebase.
- Adjusted the routing to reflect the new function name, enhancing maintainability and understanding of the API's purpose.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:59:31 -07:00

452 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 Default plan allocation (FREE plan on registration)
* 3.2.1 Upgrade flow (purchase API call)
* 3.3.1 Active subscription display
* 3.3.3 Manage subscription (Stripe portal API call)
* 1.3 Logout via Settings menu
* 1.3.1 Revoked session auto-logout
*
* Onboarding steps (Onboarding.tsx — 5 steps, indices 04):
* Welcome → Local AI → Screen & Accessibility → Enable Tools → Install Skills
* (each step: primary "Continue"; final step completes onboarding)
*
* 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 {
navigateToBilling,
navigateToHome,
navigateToSettings,
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;
}
// 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 () => {
await startMockServer();
await waitForApp();
clearRequestLog();
});
after(async () => {
resetMockBehavior();
await stopMockServer();
});
// -------------------------------------------------------------------------
// 1. Authentication
// -------------------------------------------------------------------------
it('new user registers via deep link and reaches home', async () => {
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');
await browser.pause(5_000);
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');
await browser.pause(5_000);
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 — new user is assigned FREE plan by default', async () => {
await navigateToBilling();
// BillingPanel heading: "Current Plan — FREE"
const hasPlan = (await textExists('Current Plan')) || (await textExists('FREE'));
if (!hasPlan) {
const tree = await dumpAccessibilityTree();
console.log('[AuthAccess] Billing page tree:\n', tree.slice(0, 6000));
}
expect(hasPlan).toBe(true);
const hasUpgrade = await textExists('Upgrade');
expect(hasUpgrade).toBe(true);
console.log('[AuthAccess] 3.1.1 — FREE plan verified in billing');
await navigateToHome();
});
// -------------------------------------------------------------------------
// 3. Upgrade Flow
// -------------------------------------------------------------------------
it('3.2.1 — upgrade initiates purchase flow via Stripe', async () => {
await navigateToBilling();
clearRequestLog();
await clickText('Upgrade', 10_000);
console.log('[AuthAccess] Clicked Upgrade button');
await browser.pause(3_000);
const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000);
expect(purchaseCall).toBeDefined();
if (purchaseCall?.body) {
const bodyStr = typeof purchaseCall.body === 'string' ? purchaseCall.body : '';
console.log('[AuthAccess] Purchase request body:', bodyStr);
}
// Verify purchasing state appears
const hasWaiting = (await textExists('Waiting')) || (await textExists('Waiting for payment'));
console.log(`[AuthAccess] Purchasing state visible: ${hasWaiting}`);
// Switch mock to BASIC plan so polling clears the waiting state
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString());
if (hasWaiting) {
const disappeared = await waitForTextToDisappear('Waiting', 20_000);
if (!disappeared) {
throw new Error(
'3.2.1 — "Waiting" spinner did not clear within 20s after mock plan was set to BASIC'
);
}
}
console.log('[AuthAccess] 3.2.1 — Upgrade purchase flow verified');
await navigateToHome();
});
// -------------------------------------------------------------------------
// 4. Active Subscription Display
// -------------------------------------------------------------------------
it('3.3.1 — active subscription 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();
// Wait for billing data to load
await browser.pause(3_000);
// Verify currentPlan was fetched
const planCall = getRequestLog().find(
r => r.method === 'GET' && r.url.includes('/payments/stripe/currentPlan')
);
expect(planCall).toBeDefined();
// Check that plan info is displayed (Current Plan heading or tier name)
const hasPlanInfo =
(await textExists('Current Plan')) ||
(await textExists('BASIC')) ||
(await textExists('Basic'));
expect(hasPlanInfo).toBe(true);
// "Manage" button appears when hasActiveSubscription is true in currentPlan response.
const hasManage = await textExists('Manage');
expect(hasManage).toBe(true);
console.log('[AuthAccess] 3.3.1 — Active subscription display verified (Manage visible)');
});
it('3.3.3 — manage subscription opens Stripe portal', 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);
const hasManage = await textExists('Manage');
expect(hasManage).toBe(true);
await clickText('Manage', 10_000);
console.log('[AuthAccess] Clicked Manage button');
await browser.pause(3_000);
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');
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');
await browser.pause(5_000);
const homeCheck = await waitForHomePage(10_000);
if (!homeCheck) {
await navigateToHome();
}
await navigateToSettings();
// 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 () => {
// Login fresh
clearRequestLog();
resetMockBehavior();
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');
await browser.pause(8_000);
// 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');
});
});