diff --git a/.gitignore b/.gitignore index 1dcd5516..2a9e2cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,11 @@ dist *.sw? .cursor -.claude \ No newline at end of file +.claude + +# Playwright artifacts +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ +e2e/.storage/ \ No newline at end of file diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..94020cca --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,73 @@ +# Auth dapp E2E tests + +Playwright tests organised into three projects. + +## Projects + +| Project | Specs | Target | When to run | +|---|---|---|---| +| `local` (default) | everything except `request-types-*` and `magic-google-real-oauth` | local Vite preview on `localhost:5174` (CI=true) or `npm start` (dev) | every PR | +| `real-auth` | `request-types-*.spec.ts` | dev deployment `https://decentraland.zone/auth` + live `auth-api.decentraland.zone` | every PR | +| `real-oauth` | `magic-google-real-oauth.spec.ts` | dev deployment + Google login | nightly | + +## Running + +```bash +# Default suite (mock-driven, fast) +CI=true npx playwright test --project=local + +# Real auth-server (no /v2/requests/** mocking — creates real requests) +SKIP_WEBSERVER=1 npx playwright test --project=real-auth + +# Nightly: real OAuth round-trip through Google +SKIP_WEBSERVER=1 E2E_GOOGLE_EMAIL=... E2E_GOOGLE_PASSWORD=... npx playwright test --project=real-oauth +``` + +`SKIP_WEBSERVER=1` disables Playwright's local Vite webServer for projects that +target the deployed dapp at `decentraland.zone/auth`. + +## Required env vars (real-oauth project) + +| Var | Purpose | Source | +|---|---|---| +| `E2E_GOOGLE_EMAIL` | Test Google account email | dedicated test account; never a real employee account | +| `E2E_GOOGLE_PASSWORD` | Test Google account password | same | +| `E2E_PERSIST_STORAGE_STATE` (optional) | When set, writes `e2e/.storage/google-session.json` after first successful login | CI job that persists artifacts | + +Missing env vars cause the spec to auto-skip (`test.skip`), so it's safe to run +the suite without configuring secrets. + +### Provisioning the test Google account + +1. Create a dedicated Google account (do NOT reuse a real one). +2. In the Magic dashboard, ensure the staging publishable key has + `https://decentraland.zone/auth/callback` in its allowed redirect URIs. +3. Add the email/password to CI secrets: + ``` + gh secret set E2E_GOOGLE_EMAIL --body "" + gh secret set E2E_GOOGLE_PASSWORD --body "" + ``` + +## Known caveats + +- **CAPTCHA**: Google occasionally challenges headless Playwright sessions with + reCAPTCHA. The first run after long inactivity is the riskiest. Wire as a + nightly job and don't gate PRs on it. +- **`storageState` reuse**: Persist `e2e/.storage/google-session.json` between + runs to skip Google's email/password steps and avoid bot detection. +- **2FA**: The test Google account must NOT have 2FA enabled or the password + step will hang. + +## Helpers + +- `injectMockWallet(context)` — mock Ethereum provider (legacy, fixed `MOCK_WALLET`). +- `injectFullSession(context)` — fresh wallet keypair + real AuthIdentity in + localStorage; the same identity can be used to sign auth-server payloads. +- `mockApiRoutes(page, options)` — auth-server, profile, feature-flag, segment + mocks. Skip its `/v2/requests/**` routes via `page.route('**/v2/requests/**', + route => route.continue())` to mix mocked profile with real auth-server. +- `createAuthServerRequest({ method, params, identity? })` — POST to the real + auth-server (`auth-api.decentraland.zone/requests`) to obtain a `requestId` + for `eth_sendTransaction` (auth-chain required) or `dcl_personal_sign`. +- `mockCatalystDeployRoute`, `mockNewsletterRoute`, `mockReferralRoute` — + setup-page deploy chain. diff --git a/e2e/fixtures/ethereum-provider.ts b/e2e/fixtures/ethereum-provider.ts index 45d97b02..dff156ab 100644 --- a/e2e/fixtures/ethereum-provider.ts +++ b/e2e/fixtures/ethereum-provider.ts @@ -92,6 +92,16 @@ export function createMockProviderScript(walletAddress: string): string { return '0xDE0B6B3A7640000'; // 1 ETH case 'eth_estimateGas': return '0x5208'; + case 'eth_sendTransaction': + case 'eth_sendRawTransaction': { + // Journal the submission so tests can assert that the dapp + // forwarded the same tx params it received from the auth-server, + // without ever broadcasting. Returns a deterministic fake hash. + if (typeof window.__e2eRecordTx === 'function') { + try { await window.__e2eRecordTx(method, params); } catch (_) { /* non-fatal */ } + } + return '0x' + 'ab'.repeat(32); + } default: return null; } diff --git a/e2e/fixtures/mock-responses.ts b/e2e/fixtures/mock-responses.ts index 96637d9e..5a4fefdf 100644 --- a/e2e/fixtures/mock-responses.ts +++ b/e2e/fixtures/mock-responses.ts @@ -73,6 +73,35 @@ export const emptyProfileResponse = { avatars: [] } +/** + * Active-entities response for a `default` profile pointer. + * Used by `SetupPage`'s `deployProfileFromDefault` flow when it calls + * `client.fetchEntitiesByPointers(['defaultN'])` to seed the deployed entity. + */ +export const defaultProfileEntityResponse = { + id: 'default-entity', + type: 'profile', + pointers: ['default1'], + timestamp: Date.now(), + content: [], + metadata: { + avatars: [ + { + name: 'default1', + description: '', + avatar: { + bodyShape: 'urn:decentraland:off-chain:base-avatars:BaseMale', + eyes: { color: { r: 0.125, g: 0.703, b: 0.964 } }, + hair: { color: { r: 0.234, g: 0.128, b: 0.065 } }, + skin: { color: { r: 0.8, g: 0.608, b: 0.465 } }, + wearables: [], + snapshots: {} + } + } + ] + } +} + /** Profile: returns existing user */ export const existingProfileResponse = { avatars: [ diff --git a/e2e/helpers/setup.ts b/e2e/helpers/setup.ts index ce2d0ac8..e920fda2 100644 --- a/e2e/helpers/setup.ts +++ b/e2e/helpers/setup.ts @@ -1,4 +1,6 @@ import { BrowserContext, Page } from '@playwright/test' +import { Authenticator, AuthIdentity } from '@dcl/crypto' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { createMockProviderScript } from '../fixtures/ethereum-provider' import { MOCK_WALLET, @@ -12,7 +14,8 @@ import { featureFlagsResponse, explorerFeatureFlagsResponse, emptyProfileResponse, - existingProfileResponse + existingProfileResponse, + defaultProfileEntityResponse } from '../fixtures/mock-responses' type SetupOptions = { @@ -306,4 +309,276 @@ export async function injectPreviousConnection(context: BrowserContext, provider await context.addInitScript(createPreviousConnectionScript(providerType, chainId)) } +const ONE_MONTH_IN_MINUTES = 60 * 24 * 30 + +/** + * Pre-populate everything the app needs to consider the user already logged in: + * mock Ethereum provider bound to a fresh address, decentraland-connect storage + * (so `tryPreviousConnection` finds the provider), and a structurally-valid + * AuthIdentity in the SSO storage. The ephemeral private key is real, so + * `Authenticator.signPayload` can produce valid signatures for deploys. + * + * Returns the generated wallet address so the caller can wire mocks that + * depend on it. + */ +export async function injectFullSession( + context: BrowserContext, + options: { providerType?: string; chainId?: number } = {} +): Promise<{ address: string; identity: AuthIdentity }> { + const { providerType = 'injected', chainId = 1 } = options + + const userPk = generatePrivateKey() + const userAccount = privateKeyToAccount(userPk) + const ephemeralPk = generatePrivateKey() + const ephemeralAccount = privateKeyToAccount(ephemeralPk) + + const identity = await Authenticator.initializeAuthChain( + userAccount.address, + { + address: ephemeralAccount.address, + publicKey: ephemeralAccount.publicKey, + privateKey: ephemeralPk + }, + ONE_MONTH_IN_MINUTES, + async message => userAccount.signMessage({ message }) + ) + + await context.addInitScript(createMockProviderScript(userAccount.address)) + + await context.exposeFunction('__e2eSign', async (message: string) => { + return userAccount.signMessage({ message }) + }) + + await context.addInitScript( + ({ providerType, chainId }) => { + localStorage.setItem('decentraland-connect-storage-key', JSON.stringify({ providerType, chainId })) + }, + { providerType, chainId } + ) + + await context.addInitScript( + ({ key, identity }) => { + localStorage.setItem(key, JSON.stringify(identity)) + }, + { key: `single-sign-on-${userAccount.address.toLowerCase()}`, identity } + ) + + return { address: userAccount.address, identity } +} + +/** + * Auth-server URL for the dev environment (decentraland.zone). Requests created + * here are visible to the auth dapp deployed at https://decentraland.zone/auth. + */ +export const DEV_AUTH_SERVER_URL = 'https://auth-api.decentraland.zone' + +/** + * Create a real auth-server request and return its `requestId`. The request + * lives on the real auth-server (no mocking) so the auth dapp's RequestPage + * can recover it normally over HTTP. + * + * For `dcl_personal_sign` the auth-server allows anonymous creation. For + * `eth_sendTransaction` it requires an `authChain` in the body — pass the + * `identity` returned by `injectFullSession` and the helper extracts the + * SIGNER + ECDSA_EPHEMERAL links the server expects. + */ +export async function createAuthServerRequest( + options: { + method: string + params: unknown[] + identity?: AuthIdentity + authServerUrl?: string + } +): Promise<{ requestId: string; code?: number; expiration: string }> { + const { method, params, identity, authServerUrl = DEV_AUTH_SERVER_URL } = options + const body: Record = { method, params } + + if (identity) { + // The auth-server expects the first two links of the auth chain + // (SIGNER + ECDSA_EPHEMERAL) in the request body — not the full + // signPayload chain. + body.authChain = identity.authChain + } + + const response = await fetch(`${authServerUrl}/requests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + + if (!response.ok) { + const respBody = await response.text() + throw new Error(`auth-server POST /requests ${response.status}: ${respBody}`) + } + + return response.json() as Promise<{ requestId: string; code?: number; expiration: string }> +} + +/** + * Mock the Catalyst content endpoints used by `SetupPage`'s deploy flow: + * - POST /content/entities/active with a `default` pointer → + * returns a fake default profile entity so `fetchEntitiesByPointers` resolves. + * - POST /content/entities (deploy) → returns 200 (or `status`) after `delayMs`. + * + * Call this AFTER `mockApiRoutes`. Playwright matches the most-recently-added + * route first, so this override wins for the deploy POST. + */ +export async function mockCatalystDeployRoute( + page: Page, + options: { status?: number; delayMs?: number } = {} +) { + const { status = 200, delayMs = 0 } = options + + await page.route('**/content/entities/active**', async (route, request) => { + if (request.method() === 'POST') { + const body = request.postData() ?? '' + if (/default\d+/.test(body)) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([defaultProfileEntityResponse]) + }) + } + } + return route.fallback() + }) + + await page.route('**/content/entities', async (route, request) => { + if (request.method() === 'POST' && !request.url().includes('active')) { + if (delayMs > 0) { + await new Promise(resolve => setTimeout(resolve, delayMs)) + } + if (status >= 200 && status < 300) { + return route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify({ creationTimestamp: Date.now() }) + }) + } + return route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify({ error: 'Deployment failed' }) + }) + } + return route.fallback() + }) +} + +/** + * Mock the newsletter subscription endpoint (POST {BUILDER_SERVER_URL}/v1/newsletter) + * used by `SetupPage`. Captures POSTed bodies so tests can assert on them. + */ +export function mockNewsletterRoute(page: Page, options: { status?: number } = {}): { + posts: Array<{ email?: string; source?: string }> +} { + const { status = 200 } = options + const posts: Array<{ email?: string; source?: string }> = [] + + page.route('**/v1/newsletter', async (route, request) => { + if (request.method() === 'POST') { + try { + posts.push(JSON.parse(request.postData() ?? '{}')) + } catch { + posts.push({}) + } + return route.fulfill({ + status, + contentType: 'application/json', + body: status >= 200 && status < 300 ? '{"ok":true}' : '{"error":"newsletter failure"}' + }) + } + return route.fallback() + }) + + return { posts } +} + +/** + * Mock the referral tracking endpoint (POST/PATCH {REFERRAL_SERVER_URL}/referral-progress) + * used by `useTrackReferral`. Captures requests so tests can assert on method + body. + */ +export function mockReferralRoute(page: Page, options: { status?: number } = {}): { + requests: Array<{ method: string; body: { referrer?: string } | null }> +} { + const { status = 200 } = options + const requests: Array<{ method: string; body: { referrer?: string } | null }> = [] + + page.route('**/referral-progress', async (route, request) => { + const method = request.method() + if (method === 'POST' || method === 'PATCH') { + let body: { referrer?: string } | null = null + try { + const raw = request.postData() + body = raw ? JSON.parse(raw) : null + } catch { + body = null + } + requests.push({ method, body }) + return route.fulfill({ + status, + contentType: 'application/json', + body: status >= 200 && status < 300 ? '{"ok":true}' : '{"error":"referral failure"}' + }) + } + return route.fallback() + }) + + return { requests } +} + +/** + * A single wallet write the mock provider observed (eth_sendTransaction or + * eth_sendRawTransaction). The `params[0]` shape mirrors what the dapp's + * walletClient forwarded — same fields a real wallet would have received. + */ +export type CapturedWalletTx = { method: 'eth_sendTransaction' | 'eth_sendRawTransaction'; params: unknown[]; ts: number } + +/** + * Hook the mock Ethereum provider so every `eth_sendTransaction` / + * `eth_sendRawTransaction` submission lands in a Node-side journal. Returned + * array is mutated as the dapp makes calls, so tests can assert on it after + * clicking Approve. + * + * Must be called BEFORE `page.goto`, so the exposed function is registered + * before any user code runs. + */ +export async function captureWalletTransactions(context: BrowserContext): Promise { + const log: CapturedWalletTx[] = [] + await context.exposeFunction('__e2eRecordTx', (method: string, params: unknown[]) => { + log.push({ method: method as CapturedWalletTx['method'], params, ts: Date.now() }) + }) + return log +} + +/** + * Poll the real auth-server's outcome endpoint (`GET /requests/{requestId}`) + * until the dapp submits the signed result (signature or tx hash). + * + * The endpoint returns 204 No Content while the request is pending; once the + * outcome is POSTed by the dapp it switches to 200 with `{ sender, result }`. + * Distinct from the dapp's `/v2/requests/{id}` recover endpoint, which returns + * the unfulfilled request payload. + */ +export async function pollForOutcome( + requestId: string, + options: { timeoutMs?: number; intervalMs?: number; authServerUrl?: string } = {} +): Promise<{ sender: string; result: unknown }> { + const { timeoutMs = 15_000, intervalMs = 500, authServerUrl = DEV_AUTH_SERVER_URL } = options + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + const response = await fetch(`${authServerUrl}/requests/${requestId}`) + if (response.status === 204) { + await new Promise(r => setTimeout(r, intervalMs)) + continue + } + if (!response.ok) { + throw new Error(`auth-server GET /requests/${requestId} ${response.status}: ${await response.text()}`) + } + return response.json() as Promise<{ sender: string; result: unknown }> + } + throw new Error(`pollForOutcome timed out after ${timeoutMs}ms for ${requestId}`) +} + export { MOCK_WALLET, MOCK_REQUEST_ID } diff --git a/e2e/tests/cached-session-edge-cases.spec.ts b/e2e/tests/cached-session-edge-cases.spec.ts new file mode 100644 index 00000000..3b58a79b --- /dev/null +++ b/e2e/tests/cached-session-edge-cases.spec.ts @@ -0,0 +1,91 @@ +import { test, expect } from '@playwright/test' +import { injectMockWallet, injectPreviousConnection, mockApiRoutes, MOCK_WALLET } from '../helpers/setup' + +const MARKETPLACE_URL = 'https://decentraland.org/marketplace' + +/** + * Defends the cached-session restore path against malformed / expired payloads + * in localStorage. `connection-persistence.spec.ts` covers the happy SSO path; + * these specs ensure a poisoned localStorage doesn't break the dapp UX. + */ +test.describe('Cached session: edge cases', () => { + test.beforeEach(async ({ context }) => { + await injectMockWallet(context) + }) + + test('decentraland-connect storage with malformed JSON → app falls back to login', async ({ context, page }) => { + await context.addInitScript(() => { + localStorage.setItem('decentraland-connect-storage-key', 'this is not json {') + }) + await mockApiRoutes(page, { hasProfile: true }) + + await page.goto(`/auth/login?redirectTo=${encodeURIComponent(MARKETPLACE_URL)}`) + + // App must render the login page (or initiate auto-connect), NOT crash with + // an unhandled JSON.parse error. + await page.waitForLoadState('networkidle') + await expect(page.locator('body')).toBeVisible() + const errors: string[] = [] + page.on('pageerror', e => errors.push(e.message)) + await page.waitForTimeout(500) + expect(errors).toEqual([]) + }) + + test('expired SSO identity for connected wallet → purged on read, page works', async ({ context, page }) => { + // Pre-populate the SSO key for MOCK_WALLET (the address the mock provider + // returns) with an expired identity. `localStorageGetIdentity` rejects it + // and clears it the first time the app tries to restore the session. + const address = MOCK_WALLET.toLowerCase() + await context.addInitScript(addr => { + const expiredIdentity = { + ephemeralIdentity: { + address: '0x0000000000000000000000000000000000000002', + publicKey: '0x' + '00'.repeat(64), + privateKey: '0x' + '00'.repeat(32) + }, + expiration: new Date(Date.now() - 60_000).toISOString(), + authChain: [] + } + localStorage.setItem(`single-sign-on-${addr}`, JSON.stringify(expiredIdentity)) + }, address) + + await injectPreviousConnection(context) + await mockApiRoutes(page, { hasProfile: false }) + + await page.goto('/auth/login') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(2000) + + const stillThere = await page.evaluate(addr => localStorage.getItem(`single-sign-on-${addr}`), address) + expect(stillThere).toBeNull() + }) + + test('localStorage.setItem throws (quota exceeded simulation) → page does not crash', async ({ context, page }) => { + await context.addInitScript(() => { + const original = window.localStorage.setItem.bind(window.localStorage) + let calls = 0 + window.localStorage.setItem = (key: string, value: string) => { + calls += 1 + // Allow the first few writes (page init, mocks) then start throwing + // to simulate quota exceeded mid-flow. + if (calls > 10 && key.startsWith('decentraland-connect')) { + throw new DOMException('QuotaExceededError', 'QuotaExceededError') + } + return original(key, value) + } + }) + await mockApiRoutes(page, { hasProfile: true }) + + const pageErrors: string[] = [] + page.on('pageerror', e => pageErrors.push(e.message)) + + await page.goto(`/auth/login?redirectTo=${encodeURIComponent(MARKETPLACE_URL)}`) + await page.waitForLoadState('networkidle') + await page.waitForTimeout(2000) + + // QuotaExceeded shouldn't crash the React tree — uncaught pageerror is the + // failure signal. `decentraland-connect` writes are write-through, errors + // in setItem should be caught/swallowed by the library. + expect(pageErrors.filter(e => e.includes('QuotaExceededError'))).toEqual([]) + }) +}) diff --git a/e2e/tests/feature-flag-onboarding-to-explorer.spec.ts b/e2e/tests/feature-flag-onboarding-to-explorer.spec.ts new file mode 100644 index 00000000..ad8296f2 --- /dev/null +++ b/e2e/tests/feature-flag-onboarding-to-explorer.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test' +import { injectMockWallet, mockApiRoutes } from '../helpers/setup' + +const MARKETPLACE_URL = 'https://decentraland.org/marketplace' + +/** + * Locks in the production-default routing when `dapps-onboarding-to-explorer = true`. + * + * The existing suite implicitly exercises this flag (most specs pass it true), + * but no test asserts the *URL* the user lands on after MetaMask login. These + * tests catch silent routing regressions where, say, a refactor sends new + * users back to `/auth/setup` or `/auth/avatar-setup` instead of `/quick-setup`. + * + * Today's routing with onboardingToExplorer=true (per src/hooks/useSetupNavigation.ts): + * - new user → /auth/quick-setup + * - existing user → redirect off-origin to redirectTo + */ +test.describe('Feature flag: dapps-onboarding-to-explorer = true (production default)', () => { + test.beforeEach(async ({ context }) => { + await injectMockWallet(context) + }) + + test('MetaMask new user → lands on /auth/quick-setup', async ({ page }) => { + await mockApiRoutes(page, { hasProfile: false, onboardingToExplorer: true }) + + await page.goto(`/auth/login?redirectTo=${encodeURIComponent(MARKETPLACE_URL)}&loginMethod=METAMASK`) + + await expect(page.getByPlaceholder(/enter your username/i)).toBeVisible({ timeout: 15_000 }) + await expect(page).toHaveURL(/\/auth\/quick-setup/) + }) + + test('MetaMask existing user → skips setup, navigates away from /auth', async ({ page }) => { + await mockApiRoutes(page, { hasProfile: true, onboardingToExplorer: true }) + + await page.goto(`/auth/login?redirectTo=${encodeURIComponent(MARKETPLACE_URL)}&loginMethod=METAMASK`) + + // Existing user → no QuickSetup form. URL leaves /auth because redirectTo is off-origin. + await page.waitForURL( + url => !url.pathname.startsWith('/auth/login') && !url.pathname.startsWith('/auth/quick-setup'), + { timeout: 15_000 } + ) + await expect(page.getByPlaceholder(/enter your username/i)).not.toBeVisible() + }) +}) diff --git a/e2e/tests/magic-google-real-oauth.spec.ts b/e2e/tests/magic-google-real-oauth.spec.ts new file mode 100644 index 00000000..39c83a42 --- /dev/null +++ b/e2e/tests/magic-google-real-oauth.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test' + +/** + * Real Magic + Google OAuth E2E. + * + * Unlike `web-social-flow.spec.ts` which mocks the Magic SDK, this spec drives + * the actual Google login form and consent screen. Catches the failures that + * mocks can't: popup blockers, redirect URI mismatches, consent screen layout + * changes, third-party-cookie regressions. + * + * REQUIREMENTS to run (auto-skipped otherwise): + * - E2E_GOOGLE_EMAIL — test Google account email + * - E2E_GOOGLE_PASSWORD — test Google account password + * + * RECOMMENDED extras: + * - Run with `--headed` for first execution so a captured `storageState` + * can replay Google's "remember this device" cookies on subsequent runs. + * - Wire as a nightly CI job — Google's bot detection occasionally trips + * CAPTCHA, so this should NOT gate main-line PR merges. + * + * See e2e/README.md for env-var wiring and known caveats. + */ +const E2E_GOOGLE_EMAIL = process.env.E2E_GOOGLE_EMAIL +const E2E_GOOGLE_PASSWORD = process.env.E2E_GOOGLE_PASSWORD + +test.describe('Real Magic + Google OAuth (nightly)', () => { + test.skip( + !E2E_GOOGLE_EMAIL || !E2E_GOOGLE_PASSWORD, + 'Set E2E_GOOGLE_EMAIL + E2E_GOOGLE_PASSWORD to run this spec (see e2e/README.md).' + ) + + test('Continue with Google → Google login → consent → returns to /auth/callback → identity established', async ({ + page, + context + }) => { + test.setTimeout(120_000) + + await page.goto('/auth/login') + + // 1. Click the Google CTA. The dapp delegates to Magic SDK which redirects + // to https://accounts.google.com/... + await page.getByRole('button', { name: /continue with google/i }).click() + await page.waitForURL(/accounts\.google\.com/, { timeout: 30_000 }) + + // 2. Google login: email step. + const emailInput = page.locator('input[type="email"]').first() + await expect(emailInput).toBeVisible({ timeout: 15_000 }) + await emailInput.fill(E2E_GOOGLE_EMAIL as string) + await page.getByRole('button', { name: /^next$/i }).first().click() + + // 3. Password step. + const passwordInput = page.locator('input[type="password"]').first() + await expect(passwordInput).toBeVisible({ timeout: 15_000 }) + await passwordInput.fill(E2E_GOOGLE_PASSWORD as string) + await page.getByRole('button', { name: /^next$/i }).first().click() + + // 4. Consent screen may or may not appear depending on prior grant. Click + // Continue/Allow if present, otherwise fall through. + const consentButton = page.getByRole('button', { name: /^(continue|allow)$/i }).first() + await consentButton.click({ timeout: 10_000 }).catch(() => undefined) + + // 5. Back on the dapp at /auth/callback. + await page.waitForURL(/\/auth\/callback/, { timeout: 30_000 }) + + // 6. New user → routed to setup (/quick-setup or /setup). Existing user → + // away from /auth entirely. + await page.waitForURL(url => !url.pathname.startsWith('/auth/callback'), { timeout: 30_000 }) + expect(page.url()).not.toContain('/auth/callback') + + // 7. Persist Google's "remember this device" cookies so subsequent runs + // skip the password step. + if (process.env.E2E_PERSIST_STORAGE_STATE) { + await context.storageState({ path: 'e2e/.storage/google-session.json' }) + } + }) +}) diff --git a/e2e/tests/otp-edge-cases.spec.ts b/e2e/tests/otp-edge-cases.spec.ts new file mode 100644 index 00000000..8f522b06 --- /dev/null +++ b/e2e/tests/otp-edge-cases.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '@playwright/test' +import { injectMockWallet, mockApiRoutes, mockThirdwebRoutes } from '../helpers/setup' + +/** + * Extends `web-otp-flow.spec.ts` beyond the happy-path modal-open coverage. + * Exercises behaviour the modal MUST get right for production OTP login: + * wrong code, network failures, paste-distribute across inputs, keyboard + * navigation. All scenarios mock the Thirdweb backend via `mockThirdwebRoutes`. + */ +test.describe('OTP modal: edge cases', () => { + test.beforeEach(async ({ context, page }) => { + await injectMockWallet(context) + await mockApiRoutes(page, { hasProfile: true }) + }) + + async function openOtpModal(page: import('@playwright/test').Page, email = 'test@example.com') { + await page.goto('/auth/login') + + const emailInput = page.getByPlaceholder(/email/i) + await expect(emailInput).toBeVisible({ timeout: 15_000 }) + await emailInput.fill(email) + await page.locator('[data-testid="email-submit-button"]').click() + + await expect(page.locator('[data-testid="otp-input-0"]')).toBeVisible({ timeout: 10_000 }) + } + + test('wrong OTP → verification rejected, modal stays open', async ({ page }) => { + await mockThirdwebRoutes(page, { failVerify: true }) + await openOtpModal(page) + + for (let i = 0; i < 6; i++) { + await page.locator(`[data-testid="otp-input-${i}"]`).fill(String(i + 1)) + } + + // Modal remains open after rejected verification — first input should + // still exist (regardless of clear vs. preserved value, the user is not + // navigated away). + await page.waitForTimeout(2000) + await expect(page.locator('[data-testid="otp-input-0"]')).toBeVisible() + }) + + test('paste 6-digit code distributes across inputs', async ({ page }) => { + await mockThirdwebRoutes(page) + await openOtpModal(page) + + const otpInput0 = page.locator('[data-testid="otp-input-0"]') + await otpInput0.focus() + + // Type the 6 digits as a single string; OTP components typically split a + // paste/keystrokes burst across inputs. Use page.keyboard for sequential + // input rather than fill (which targets a single input). + await page.keyboard.type('123456') + + for (let i = 0; i < 6; i++) { + await expect(page.locator(`[data-testid="otp-input-${i}"]`)).toHaveValue(String(i + 1)) + } + }) + + test('backspace on a populated input clears it and shifts focus backwards', async ({ page }) => { + await mockThirdwebRoutes(page) + await openOtpModal(page) + + await page.locator('[data-testid="otp-input-0"]').fill('1') + await expect(page.locator('[data-testid="otp-input-1"]')).toBeFocused() + await page.locator('[data-testid="otp-input-1"]').fill('2') + + await page.keyboard.press('Backspace') + await page.keyboard.press('Backspace') + + // Backspace from input 2 (empty) should land focus on input 1. Backspace + // again clears it. The exact final focused index depends on the impl + // detail; what matters is that at least input 0 or 1 retains focus. + const focusedTestId = await page.evaluate(() => + (document.activeElement as HTMLElement | null)?.getAttribute('data-testid') + ) + expect(focusedTestId).toMatch(/^otp-input-[01]$/) + }) + + test('OTP send endpoint failure → does not advance to modal', async ({ page }) => { + await mockThirdwebRoutes(page, { failSend: true }) + await page.goto('/auth/login') + + const emailInput = page.getByPlaceholder(/email/i) + await expect(emailInput).toBeVisible({ timeout: 15_000 }) + await emailInput.fill('test@example.com') + await page.locator('[data-testid="email-submit-button"]').click() + + // With send failing, the OTP modal must NOT appear. We wait a bit then + // assert input 0 is still absent. + await page.waitForTimeout(3000) + await expect(page.locator('[data-testid="otp-input-0"]')).not.toBeVisible() + }) +}) diff --git a/e2e/tests/request-types-generic-transaction.spec.ts b/e2e/tests/request-types-generic-transaction.spec.ts new file mode 100644 index 00000000..4098d9fe --- /dev/null +++ b/e2e/tests/request-types-generic-transaction.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test' +import { captureWalletTransactions, createAuthServerRequest, injectFullSession, pollForOutcome } from '../helpers/setup' + +/** + * RequestPage's fallback `View.WALLET_INTERACTION` for `eth_sendTransaction` + * requests whose data decodes as neither an ERC20 transfer (MANA) nor an + * ERC721 transferFrom (NFT). The safety-net path — any new dapp-initiated + * transaction lands here when the dapp can't classify it more specifically. + * + * Verifies three layers of correctness: + * (1) Display — interactive view (Deny/Allow) renders. + * (2) Forwarding — the tx the dapp hands to the wallet on Allow is byte-equal + * to the tx params we originally POSTed to the auth-server. + * (3) Outcome — the dapp reports the (stubbed) tx hash back to the auth-server + * under the connected wallet's address. + */ +test.describe('RequestPage: eth_sendTransaction (generic fallback)', () => { + test('arbitrary tx data → renders, forwards unmangled, reports outcome', async ({ context, page }) => { + const { address, identity } = await injectFullSession(context) + const txLog = await captureWalletTransactions(context) + + const txParams = { + from: address.toLowerCase(), + to: '0x1111111111111111111111111111111111111111', + value: '0x0', + data: '0xdeadbeefcafebabe' + } + + const { requestId } = await createAuthServerRequest({ + identity, + method: 'eth_sendTransaction', + params: [txParams] + }) + + await page.goto(`/auth/requests/${requestId}`) + + // (1) Display + await expect(page.locator('[data-testid="wallet-interaction-allow-button"]').first()).toBeVisible({ + timeout: 25_000 + }) + + // User approval + await page.locator('[data-testid="wallet-interaction-allow-button"]').first().click() + + // (2) Forwarding + await expect.poll(() => txLog.length, { timeout: 15_000 }).toBeGreaterThan(0) + expect(txLog[0].method).toBe('eth_sendTransaction') + expect(txLog[0].params[0]).toMatchObject(txParams) + + // (3) Outcome + const outcome = await pollForOutcome(requestId) + expect(outcome.sender.toLowerCase()).toBe(address.toLowerCase()) + expect(outcome.result).toMatch(/^0x[a-f0-9]{64}$/) + }) +}) diff --git a/e2e/tests/request-types-mana-donation.spec.ts b/e2e/tests/request-types-mana-donation.spec.ts new file mode 100644 index 00000000..9263a3e0 --- /dev/null +++ b/e2e/tests/request-types-mana-donation.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test' +import { encodeFunctionData, parseUnits } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { captureWalletTransactions, createAuthServerRequest, injectFullSession, pollForOutcome } from '../helpers/setup' + +const ERC20_TRANSFER_ABI = [ + { + type: 'function', + name: 'transfer', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' } + ], + outputs: [{ type: 'bool' }] + } +] as const + +const encodeManaTransfer = (recipient: `0x${string}`, mana: bigint) => + encodeFunctionData({ + abi: ERC20_TRANSFER_ABI, + functionName: 'transfer', + args: [recipient, mana] + }) + +/** + * RequestPage's `View.WALLET_MANA_INTERACTION` branch for `eth_sendTransaction` + * payloads whose data decodes as ERC20 `transfer(address,uint256)`. + * + * Verifies three layers: + * (1) Display — the dapp decodes "5 MANA" and renders the tip confirmation. + * (2) Forwarding — clicking Confirm sends the same tx to the wallet. + * (3) Outcome — the dapp reports back the tx hash to the auth-server. + */ +test.describe('RequestPage: eth_sendTransaction (MANA transfer)', () => { + test('ERC20 transfer of MANA → renders, forwards unmangled, reports outcome', async ({ context, page }) => { + const { address, identity } = await injectFullSession(context) + const txLog = await captureWalletTransactions(context) + + const recipient = privateKeyToAccount(generatePrivateKey()).address + const data = encodeManaTransfer(recipient, parseUnits('5', 18)) + const txParams = { + from: address.toLowerCase(), + // Truly random `to`; the dapp routes by data selector (ERC20 transfer → + // 0xa9059cbb), not by contract address, so this still hits the MANA view. + // Using the real MANA address would push us into the meta-tx path. + to: '0x1111111111111111111111111111111111111111', + value: '0x0', + data + } + + const { requestId } = await createAuthServerRequest({ + identity, + method: 'eth_sendTransaction', + params: [txParams] + }) + + await page.goto(`/auth/requests/${requestId}`) + + // (1) Display — title text contains the decoded MANA amount. + await expect(page.getByText(/Confirm 5 MANA Tip for/i)).toBeVisible({ timeout: 25_000 }) + await expect(page.locator('[data-testid="transfer-confirm-button"]')).toBeVisible() + + await page.locator('[data-testid="transfer-confirm-button"]').click() + + // (2) Forwarding + await expect.poll(() => txLog.length, { timeout: 15_000 }).toBeGreaterThan(0) + expect(txLog[0].method).toBe('eth_sendTransaction') + expect(txLog[0].params[0]).toMatchObject(txParams) + + // (3) Outcome + const outcome = await pollForOutcome(requestId) + expect(outcome.sender.toLowerCase()).toBe(address.toLowerCase()) + expect(outcome.result).toMatch(/^0x[a-f0-9]{64}$/) + }) +}) diff --git a/e2e/tests/request-types-nft-gift.spec.ts b/e2e/tests/request-types-nft-gift.spec.ts new file mode 100644 index 00000000..0ee3cb2f --- /dev/null +++ b/e2e/tests/request-types-nft-gift.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test' +import { encodeFunctionData } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { captureWalletTransactions, createAuthServerRequest, injectFullSession, pollForOutcome } from '../helpers/setup' + +const ERC721_TRANSFER_FROM_ABI = [ + { + type: 'function', + name: 'transferFrom', + stateMutability: 'nonpayable', + inputs: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'tokenId', type: 'uint256' } + ], + outputs: [] + } +] as const + +const encodeNftTransferFrom = (from: `0x${string}`, to: `0x${string}`, tokenId: bigint) => + encodeFunctionData({ + abi: ERC721_TRANSFER_FROM_ABI, + functionName: 'transferFrom', + args: [from, to, tokenId] + }) + +/** + * RequestPage handling for `eth_sendTransaction` whose data decodes as ERC721 + * `transferFrom`. With an arbitrary contract address the on-chain `tokenURI` + * lookup fails and the page falls back from `View.WALLET_NFT_INTERACTION` to + * `View.WALLET_INTERACTION` — the test accepts either terminal Allow CTA. + * + * Verifies three layers: + * (1) Display — interactive view (NFT or generic fallback) renders. + * (2) Forwarding — clicking Allow sends the same tx to the wallet. + * (3) Outcome — the dapp reports back the tx hash to the auth-server. + */ +test.describe('RequestPage: eth_sendTransaction (NFT transfer)', () => { + test('ERC721 transferFrom → renders, forwards unmangled, reports outcome', async ({ context, page }) => { + const { address, identity } = await injectFullSession(context) + const txLog = await captureWalletTransactions(context) + + const recipient = privateKeyToAccount(generatePrivateKey()).address + const data = encodeNftTransferFrom(address.toLowerCase() as `0x${string}`, recipient, 1n) + const txParams = { + from: address.toLowerCase(), + to: '0x1111111111111111111111111111111111111111', + value: '0x0', + data + } + + const { requestId } = await createAuthServerRequest({ + identity, + method: 'eth_sendTransaction', + params: [txParams] + }) + + await page.goto(`/auth/requests/${requestId}`) + + // (1) Display — accept either NFT-view confirm button OR generic-fallback allow button. + const allowButton = page.locator( + '[data-testid="transfer-confirm-button"], [data-testid="wallet-interaction-allow-button"]' + ) + await expect(allowButton.first()).toBeVisible({ timeout: 25_000 }) + await allowButton.first().click() + + // (2) Forwarding + await expect.poll(() => txLog.length, { timeout: 15_000 }).toBeGreaterThan(0) + expect(txLog[0].method).toBe('eth_sendTransaction') + expect(txLog[0].params[0]).toMatchObject(txParams) + + // (3) Outcome + const outcome = await pollForOutcome(requestId) + expect(outcome.sender.toLowerCase()).toBe(address.toLowerCase()) + expect(outcome.result).toMatch(/^0x[a-f0-9]{64}$/) + }) +}) diff --git a/e2e/tests/request-types-sign-message.spec.ts b/e2e/tests/request-types-sign-message.spec.ts new file mode 100644 index 00000000..43e08bc2 --- /dev/null +++ b/e2e/tests/request-types-sign-message.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test' +import { createAuthServerRequest, injectFullSession, mockApiRoutes, pollForOutcome } from '../helpers/setup' + +/** + * Exercises RequestPage rendering for `dcl_personal_sign` requests against the + * real auth-server (no /v2/requests/** mock). Each test creates a fresh request + * via POST https://auth-api.decentraland.zone/requests and navigates the dapp + * deployed at decentraland.zone/auth to /auth/requests/{requestId}. + * + * Two distinct paths: + * - `skipSetup=true` (Explorer flow, no redirectTo) + new user → auto-sign + * transitions directly to VERIFY_SIGN_IN_COMPLETE. We additionally poll + * the auth-server's outcome endpoint to confirm the signature was reported + * under the connected wallet. + * - `skipSetup=false` (returning user) → shows VERIFY_SIGN_IN with the + * auth-server verification code visible. + * + * The forwarding layer used in the eth_sendTransaction specs doesn't apply + * here — personal_sign signatures go through `__e2eSign` (already verified by + * the auth-server's outcome check below). + */ +test.describe('RequestPage: dcl_personal_sign (arbitrary message)', () => { + test.beforeEach(async ({ context }) => { + await injectFullSession(context) + }) + + test('Explorer flow (no redirectTo) + new user → auto-signs, reports signature outcome', async ({ page }) => { + const { requestId } = await createAuthServerRequest({ + method: 'dcl_personal_sign', + params: ['Please sign this dapp-specific message to authorise action.'] + }) + + await page.goto(`/auth/requests/${requestId}`) + + // (1) Display + await expect(page.getByRole('heading', { name: /sign in successful/i })).toBeVisible({ timeout: 25_000 }) + + // (3) Outcome — the auto-sign path also reports back to the auth-server. + // Result is the EIP-191 signature (130 hex chars after 0x). + const outcome = await pollForOutcome(requestId) + expect(outcome.sender).toBeTruthy() + expect(outcome.result).toMatch(/^0x[a-f0-9]{130}$/) + }) + + test('Explorer flow + returning user → verification code from auth-server is rendered', async ({ page }) => { + // Mock the Catalyst profile lookup so the dapp treats this random wallet as a + // returning user — that's the branch that routes to the verification screen + // (skipSetup=true + profile exists → VERIFY_SIGN_IN). Profile mocking is fine; + // the auth-server calls (/v2/requests/**) are left to hit the real backend + // via the route.continue() override below. + await mockApiRoutes(page, { hasProfile: true }) + await page.route('**/v2/requests/**', route => route.continue()) + + const { requestId, code } = await createAuthServerRequest({ + method: 'dcl_personal_sign', + params: ['Sign in to acme dapp'] + }) + + expect(code).toBeDefined() + + await page.goto(`/auth/requests/${requestId}`) + + // The auth-server returns a numeric code; the dapp's verification screen + // surfaces it so the user can match it against what their original app shows. + await expect(page.getByText(String(code))).toBeVisible({ timeout: 25_000 }) + }) +}) diff --git a/e2e/tests/setup-flow.spec.ts b/e2e/tests/setup-flow.spec.ts new file mode 100644 index 00000000..1fbc8cbd --- /dev/null +++ b/e2e/tests/setup-flow.spec.ts @@ -0,0 +1,155 @@ +import { test, expect } from '@playwright/test' +import { + injectFullSession, + mockApiRoutes, + mockCatalystDeployRoute, + mockNewsletterRoute, + mockReferralRoute +} from '../helpers/setup' + +const MARKETPLACE_URL = 'https://decentraland.org/marketplace' +const VALID_REFERRER = '0x1234567890123456789012345678901234567890' + +const PAGE_LOAD_TIMEOUT = 15_000 + +test.describe('SetupPage: /setup onboarding flow', () => { + test.beforeEach(async ({ context, page }) => { + await injectFullSession(context) + await mockApiRoutes(page, { hasProfile: false, onboardingToExplorer: true }) + }) + + async function gotoFormView(page: import('@playwright/test').Page, search = '') { + await page.goto(`/auth/setup${search}`) + await expect(page.getByText('Welcome to Decentraland!')).toBeVisible({ timeout: PAGE_LOAD_TIMEOUT }) + await page.getByTestId('setup-continue-button').click() + await expect(page.getByText('Complete your Profile')).toBeVisible() + } + + test('randomize view → continue → form view renders', async ({ page }) => { + await page.goto('/auth/setup') + await expect(page.getByText('Welcome to Decentraland!')).toBeVisible({ timeout: PAGE_LOAD_TIMEOUT }) + await expect(page.getByTestId('setup-randomize-button')).toBeVisible() + + await page.getByTestId('setup-continue-button').click() + await expect(page.getByText('Complete your Profile')).toBeVisible() + await expect(page.getByPlaceholder(/enter your username/i)).toBeVisible() + }) + + test('submit with empty name → username_empty error shown', async ({ page }) => { + await gotoFormView(page) + + await page.getByRole('checkbox').check() + await page.getByTestId('setup-submit-button').click() + + await expect(page.getByText('Please enter your username.')).toBeVisible() + }) + + test('submit with 15-char name → username_max_length error shown', async ({ page }) => { + await gotoFormView(page) + + await page.getByPlaceholder(/enter your username/i).fill('Abcdefghijklmno') // exactly 15 chars; >= 15 triggers + await page.getByRole('checkbox').check() + await page.getByTestId('setup-submit-button').click() + + await expect(page.getByText('Sorry, usernames can have a maximum of 15 characters.')).toBeVisible() + }) + + test('submit with spaces in name → username_no_spaces error shown', async ({ page }) => { + await gotoFormView(page) + + await page.getByPlaceholder(/enter your username/i).fill('abc def') + await page.getByRole('checkbox').check() + await page.getByTestId('setup-submit-button').click() + + await expect(page.getByText('Sorry, spaces are not permitted.')).toBeVisible() + }) + + test('submit with special chars in name → username_no_special_chars error shown', async ({ page }) => { + await gotoFormView(page) + + await page.getByPlaceholder(/enter your username/i).fill('abc!def') + await page.getByRole('checkbox').check() + await page.getByTestId('setup-submit-button').click() + + await expect(page.getByText(/special characters/i)).toBeVisible() + }) + + test('ToS unchecked → submit button disabled even with valid name', async ({ page }) => { + await gotoFormView(page) + + await page.getByPlaceholder(/enter your username/i).fill('ValidUser') + await expect(page.getByTestId('setup-submit-button')).toBeDisabled() + }) + + test('valid name + invalid email → email_invalid error shown', async ({ page }) => { + await gotoFormView(page) + + await page.getByPlaceholder(/enter your username/i).fill('ValidUser') + await page.getByPlaceholder(/enter your email/i).fill('not-an-email') + await page.getByRole('checkbox').check() + await page.getByTestId('setup-submit-button').click() + + await expect(page.getByText('Invalid email, please try again.')).toBeVisible() + }) + + test('happy path: valid form → deploy → redirect', async ({ page }) => { + await mockCatalystDeployRoute(page) + + await gotoFormView(page, `?redirectTo=${encodeURIComponent(MARKETPLACE_URL)}`) + + await page.getByPlaceholder(/enter your username/i).fill('ValidUser') + await page.getByRole('checkbox').check() + await page.getByTestId('setup-submit-button').click() + + // After successful deploy the SetupPage calls redirect(). The redirectTo + // points off-origin (marketplace), so we just assert we're no longer on /setup. + await page.waitForURL(url => !url.pathname.endsWith('/setup'), { timeout: 15_000 }) + }) + + test('with email + valid form → newsletter subscription POST fires', async ({ page }) => { + await mockCatalystDeployRoute(page) + const newsletter = mockNewsletterRoute(page) + + await gotoFormView(page, `?redirectTo=${encodeURIComponent(MARKETPLACE_URL)}`) + + await page.getByPlaceholder(/enter your username/i).fill('ValidUser') + await page.getByPlaceholder(/enter your email/i).fill('test@example.com') + await page.getByRole('checkbox').check() + await page.getByTestId('setup-submit-button').click() + + await page.waitForURL(url => !url.pathname.endsWith('/setup'), { timeout: 15_000 }) + + expect(newsletter.posts).toHaveLength(1) + expect(newsletter.posts[0]).toMatchObject({ email: 'test@example.com', source: 'auth' }) + }) + + test('with valid referrer query param → referral POST on init and PATCH on submit', async ({ page }) => { + await mockCatalystDeployRoute(page) + const referral = mockReferralRoute(page) + + await gotoFormView(page, `?referrer=${VALID_REFERRER}&redirectTo=${encodeURIComponent(MARKETPLACE_URL)}`) + + await page.getByPlaceholder(/enter your username/i).fill('ValidUser') + await page.getByRole('checkbox').check() + await page.getByTestId('setup-submit-button').click() + + await page.waitForURL(url => !url.pathname.endsWith('/setup'), { timeout: 15_000 }) + + const methods = referral.requests.map(r => r.method) + expect(methods).toContain('POST') + expect(methods).toContain('PATCH') + }) + + test('catalyst deploy returns 500 → deploy error UI is rendered', async ({ page }) => { + await mockCatalystDeployRoute(page, { status: 500 }) + + await gotoFormView(page, `?redirectTo=${encodeURIComponent(MARKETPLACE_URL)}`) + + await page.getByPlaceholder(/enter your username/i).fill('ValidUser') + await page.getByRole('checkbox').check() + await page.getByTestId('setup-submit-button').click() + + await expect(page.getByText('An error occurred while creating your account')).toBeVisible({ timeout: 10_000 }) + expect(page.url()).toContain('/setup') + }) +}) diff --git a/e2e/tests/social-callback-edge-cases.spec.ts b/e2e/tests/social-callback-edge-cases.spec.ts new file mode 100644 index 00000000..1cdcfbd5 --- /dev/null +++ b/e2e/tests/social-callback-edge-cases.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test' +import { injectMockWallet, mockApiRoutes } from '../helpers/setup' + +const MARKETPLACE_URL = 'https://decentraland.org/marketplace' + +/** + * `/auth/callback` is reached after a social-OAuth redirect (Google, Discord, + * Apple, X). Existing `web-social-flow.spec.ts` covers a handful of error + * routes; this spec fills the remaining holes — direct navigation to callback + * without preceding state, and the no-redirectTo guard. + */ +test.describe('Social callback: edge cases', () => { + test.beforeEach(async ({ context, page }) => { + await injectMockWallet(context) + await mockApiRoutes(page, { hasProfile: true }) + // Block Magic SDK network calls so getRedirectResult fails fast rather + // than hanging on a real OAuth round-trip. + await page.route('**/magic.link/**', route => route.abort()) + await page.route('**/api.toaster.magic.link/**', route => route.abort()) + }) + + test('direct navigation to /auth/callback without OAuth state → does not stay stuck on callback', async ({ page }) => { + await page.goto(`/auth/callback?redirectTo=${encodeURIComponent(MARKETPLACE_URL)}`) + + // Eventually the page redirects away (to login or shows an error). + await page.waitForTimeout(7000) + const url = page.url() + // Either escaped to /login OR rendered an error message — never just hangs on /callback. + const onLogin = url.includes('/auth/login') + const hasError = await page.getByText(/went wrong|error|try again/i).isVisible().catch(() => false) + expect(onLogin || hasError).toBe(true) + }) + + test('callback with error=server_error → redirects to login', async ({ page }) => { + await page.goto(`/auth/callback?error=server_error&redirectTo=${encodeURIComponent(MARKETPLACE_URL)}`) + + // Whatever the error code, callback page must route the user back to login + // (possibly via invalidRedirection if the redirectTo is cross-origin and the + // dapp's safeguard rewrites it — also acceptable for this test). + await page.waitForURL(/\/auth\/(login|invalidRedirection)/, { timeout: 10_000 }) + expect(page.url()).not.toContain('/auth/callback') + }) + + test('callback with malformed state param → does not crash and does not land on quick-setup', async ({ page }) => { + const pageErrors: string[] = [] + page.on('pageerror', e => pageErrors.push(e.message)) + + await page.goto(`/auth/callback?state=%FF%FE-garbage-not-base64-or-json`) + await page.waitForTimeout(5000) + + expect(page.url()).not.toContain('/quick-setup') + expect(pageErrors).toEqual([]) + }) +}) diff --git a/e2e/tests/wallet-runtime-events.spec.ts b/e2e/tests/wallet-runtime-events.spec.ts new file mode 100644 index 00000000..f3fe087d --- /dev/null +++ b/e2e/tests/wallet-runtime-events.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from '@playwright/test' +import { injectFullSession, mockApiRoutes, MOCK_REQUEST_ID } from '../helpers/setup' + +const MARKETPLACE_URL = 'https://decentraland.org/marketplace' + +/** + * Exercises ConnectionProvider's response to mid-session wallet events that + * happen all the time in production (user switches accounts in MetaMask, + * disconnects, switches networks) but are uncovered by the existing suite. + * + * The mock provider exposes `emit(event, payload)` (see fixtures/ethereum- + * provider.ts:57). Tests trigger events from `page.evaluate` and assert the + * dapp's reaction. + */ +test.describe('Wallet runtime events', () => { + test.beforeEach(async ({ context, page }) => { + await injectFullSession(context) + await mockApiRoutes(page, { hasProfile: true }) + }) + + test('accountsChanged with empty array → connection state is cleared', async ({ page }) => { + await page.goto(`/auth/requests/${MOCK_REQUEST_ID}`) + await page.waitForLoadState('networkidle') + + // ConnectionProvider's accountsChanged listener clears account/identity/provider/ + // providerType/chainId when accounts[] is empty (wallet disconnected via extension). + await page.evaluate(() => { + ;(window as unknown as { ethereum: { emit: (e: string, p: unknown) => void } }).ethereum.emit( + 'accountsChanged', + [] + ) + }) + + // App reacts by reloading the request — verify the storage key is now empty + // for the disconnected provider. Since the test wallet was just disconnected + // there should be no active account anymore. + await page.waitForTimeout(1000) + const stillConnected = await page.evaluate(() => { + const item = localStorage.getItem('decentraland-connect-storage-key') + return !!item + }) + // decentraland-connect's storage key persists by design (so future visits can + // try-previous-connection). What matters is that the in-app state is cleared. + // Sanity: the storage key is at least still parsable as JSON (no corruption). + if (stillConnected) { + const parsed = await page.evaluate(() => + JSON.parse(localStorage.getItem('decentraland-connect-storage-key') ?? 'null') + ) + expect(parsed).toMatchObject({ providerType: expect.any(String) }) + } + }) + + test('chainChanged → ConnectionContext updates chainId via wallet event', async ({ page }) => { + await page.goto(`/auth/requests/${MOCK_REQUEST_ID}?redirectTo=${encodeURIComponent(MARKETPLACE_URL)}`) + await page.waitForLoadState('networkidle') + + // Emit chainChanged with Polygon mainnet (0x89 = 137). The ConnectionProvider + // listener parses the hex string and updates context.chainId. We don't have + // a direct selector for chainId state, but we can verify the page doesn't + // crash and remains interactive. + const errorThrown = await page.evaluate(() => { + try { + ;(window as unknown as { ethereum: { emit: (e: string, p: unknown) => void } }).ethereum.emit( + 'chainChanged', + '0x89' + ) + return null + } catch (e) { + return (e as Error).message + } + }) + + expect(errorThrown).toBeNull() + // Survival assertion: no JS error was thrown into window. We don't assert + // on any specific DOM state because chainChanged in the auth dapp simply + // updates context.chainId without re-rendering visible chrome. + }) + + test('disconnect event mid-session → page handles event without uncaught error', async ({ page }) => { + await page.goto(`/auth/requests/${MOCK_REQUEST_ID}?redirectTo=${encodeURIComponent(MARKETPLACE_URL)}`) + await page.waitForLoadState('networkidle') + + const consoleErrors: string[] = [] + page.on('pageerror', error => consoleErrors.push(error.message)) + + await page.evaluate(() => { + const eth = (window as unknown as { ethereum: { emit: (e: string, p: unknown) => void } }).ethereum + eth.emit('disconnect', { code: 4900, message: 'Disconnected' }) + }) + + await page.waitForTimeout(1500) + + // No unhandled exceptions reached the window error handler. + expect(consoleErrors).toEqual([]) + }) +}) diff --git a/playwright.config.ts b/playwright.config.ts index 2a7c0a8c..a464cdb1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,10 +10,32 @@ export default defineConfig({ headless: true, screenshot: 'only-on-failure' }, - webServer: { - command: process.env.CI ? 'npx vite preview --port 5174 --base /auth' : 'npm start', - port: 5174, - reuseExistingServer: !process.env.CI, - timeout: 30_000 - } + webServer: process.env.SKIP_WEBSERVER + ? undefined + : { + command: process.env.CI ? 'npx vite preview --port 5174 --base /auth' : 'npm start', + port: 5174, + reuseExistingServer: !process.env.CI, + timeout: 180_000 + }, + // Real-auth specs run against the dev deployment so HTTP traffic to the live + // auth-server (decentraland.zone/auth) is same-origin and CORS-clean. The + // request-types specs need the real /v2/requests/{id} backend so they don't + // mock the auth-server. + projects: [ + { + name: 'local', + testIgnore: /(request-types-.*|magic-google-real-oauth)\.spec\.ts$/ + }, + { + name: 'real-auth', + testMatch: /request-types-.*\.spec\.ts$/, + use: { baseURL: 'https://decentraland.zone' } + }, + { + name: 'real-oauth', + testMatch: /magic-google-real-oauth\.spec\.ts$/, + use: { baseURL: 'https://decentraland.zone' } + } + ] })