diff --git a/e2e/invite-signup-verification.spec.ts b/e2e/invite-signup-verification.spec.ts index 612d7dc056..a4d586e3e8 100644 --- a/e2e/invite-signup-verification.spec.ts +++ b/e2e/invite-signup-verification.spec.ts @@ -130,7 +130,6 @@ test('admin invite signup and email verification happy path', async ({ ).toBeVisible({ timeout: 1_000 }) }).toPass({ timeout: 15_000 }) - await expect(page.getByText(/MCP access is now available/i)).toBeVisible() const continueAuthorization = page.getByRole('link', { name: 'Continue authorization', }) diff --git a/packages/worker/client/routes/email-verification-flow.node.test.ts b/packages/worker/client/routes/email-verification-flow.node.test.ts new file mode 100644 index 0000000000..ad611715cb --- /dev/null +++ b/packages/worker/client/routes/email-verification-flow.node.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from 'vitest' +import { resolveAuthorizeEmailVerified } from '#client/routes/oauth-authorize-email-verified.ts' +import { + buildOnboardingPath, + onboardingPath, + resolveOnboardingLoginPath, + resolveOnboardingPendingVerificationPath, +} from '#client/routes/onboarding-redirect.ts' +import { resolveContinueVerificationFeedback } from '#client/routes/pending-verification-continue.ts' +import { buildPendingVerificationPath } from '#client/routes/pending-verification-path.ts' +import { resolvePasswordAuthRedirect } from '#client/routes/resolve-password-auth-redirect.ts' + +test('email verification redirect helpers preserve safe targets and reject open redirects', () => { + const oauthResume = + '/oauth/authorize?response_type=code&client_id=demo&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&scope=profile&state=abc' + + expect( + resolveAuthorizeEmailVerified({ + isSessionReady: true, + sessionEmailVerified: false, + infoEmailVerified: true, + }), + ).toBe(false) + expect( + resolveAuthorizeEmailVerified({ + isSessionReady: true, + sessionEmailVerified: true, + infoEmailVerified: false, + }), + ).toBe(true) + expect( + resolveAuthorizeEmailVerified({ + isSessionReady: false, + sessionEmailVerified: false, + infoEmailVerified: true, + }), + ).toBe(true) + expect( + resolveAuthorizeEmailVerified({ + isSessionReady: false, + sessionEmailVerified: true, + infoEmailVerified: false, + }), + ).toBe(false) + + expect(buildOnboardingPath(null)).toBe(onboardingPath) + expect(buildOnboardingPath(oauthResume)).toBe( + `/onboarding?redirectTo=${encodeURIComponent(oauthResume)}`, + ) + expect(buildOnboardingPath('https://evil.example')).toBe(onboardingPath) + expect(buildOnboardingPath('/\\evil.example')).toBe(onboardingPath) + + expect(resolveOnboardingPendingVerificationPath(null)).toBe( + '/pending-verification', + ) + expect(resolveOnboardingPendingVerificationPath(oauthResume)).toBe( + `/pending-verification?redirectTo=${encodeURIComponent(oauthResume)}`, + ) + expect(resolveOnboardingPendingVerificationPath('https://evil.example')).toBe( + '/pending-verification', + ) + + expect(resolveOnboardingLoginPath(null)).toBe( + '/login?redirectTo=%2Fonboarding', + ) + expect(resolveOnboardingLoginPath(oauthResume)).toBe( + `/login?redirectTo=${encodeURIComponent(buildOnboardingPath(oauthResume))}`, + ) + + expect(buildPendingVerificationPath(null)).toBe('/pending-verification') + expect(buildPendingVerificationPath('/onboarding')).toBe( + '/pending-verification?redirectTo=%2Fonboarding', + ) + expect(buildPendingVerificationPath('https://evil.example')).toBe( + '/pending-verification', + ) + + expect( + resolvePasswordAuthRedirect({ + mode: 'signup', + requiresTwoFactor: true, + emailVerificationRequired: true, + redirectTo: '/onboarding', + }), + ).toBe('/verify?redirectTo=%2Fonboarding') + expect( + resolvePasswordAuthRedirect({ + mode: 'signup', + emailVerificationRequired: true, + redirectTo: '/account', + }), + ).toBe('/pending-verification?redirectTo=%2Faccount') + expect( + resolvePasswordAuthRedirect({ + mode: 'signup', + emailVerificationRequired: true, + redirectTo: oauthResume, + }), + ).toBe(`/pending-verification?redirectTo=${encodeURIComponent(oauthResume)}`) + expect( + resolvePasswordAuthRedirect({ + mode: 'signup', + emailVerificationRequired: true, + redirectTo: 'https://evil.example/phish', + }), + ).toBe('/pending-verification') + expect( + resolvePasswordAuthRedirect({ + mode: 'login', + emailVerificationRequired: true, + redirectTo: '/onboarding', + }), + ).toBe('/onboarding') + expect( + resolvePasswordAuthRedirect({ + mode: 'login', + }), + ).toBe('/account') + expect( + resolvePasswordAuthRedirect({ + mode: 'signup', + emailVerificationRequired: false, + redirectTo: '/secrets', + }), + ).toBe('/secrets') +}) + +test('continue-after-verify feedback reflects session state without pinning copy', () => { + expect(resolveContinueVerificationFeedback({ emailVerified: true })).toEqual({ + status: 'verified', + tone: 'info', + message: null, + }) + expect( + resolveContinueVerificationFeedback({ emailVerified: false }), + ).toMatchObject({ + status: 'pending', + tone: 'info', + }) + expect( + resolveContinueVerificationFeedback({ emailVerified: false }).message, + ).toBeTruthy() + expect(resolveContinueVerificationFeedback(null)).toMatchObject({ + status: 'error', + tone: 'error', + }) + expect(resolveContinueVerificationFeedback(null).message).toBeTruthy() +}) diff --git a/packages/worker/client/routes/oauth-authorize-email-verified.node.test.ts b/packages/worker/client/routes/oauth-authorize-email-verified.node.test.ts deleted file mode 100644 index edd1ed8b58..0000000000 --- a/packages/worker/client/routes/oauth-authorize-email-verified.node.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, test } from 'vitest' -import { resolveAuthorizeEmailVerified } from '#client/routes/oauth-authorize-email-verified.ts' - -test('authorize emailVerified prefers a ready session over stale authorize-info', () => { - expect( - resolveAuthorizeEmailVerified({ - isSessionReady: true, - sessionEmailVerified: false, - infoEmailVerified: true, - }), - ).toBe(false) - expect( - resolveAuthorizeEmailVerified({ - isSessionReady: true, - sessionEmailVerified: true, - infoEmailVerified: false, - }), - ).toBe(true) - expect( - resolveAuthorizeEmailVerified({ - isSessionReady: false, - sessionEmailVerified: false, - infoEmailVerified: true, - }), - ).toBe(true) - expect( - resolveAuthorizeEmailVerified({ - isSessionReady: false, - sessionEmailVerified: true, - infoEmailVerified: false, - }), - ).toBe(false) -}) diff --git a/packages/worker/client/routes/onboarding-redirect.node.test.ts b/packages/worker/client/routes/onboarding-redirect.node.test.ts deleted file mode 100644 index d3d37683d2..0000000000 --- a/packages/worker/client/routes/onboarding-redirect.node.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, test } from 'vitest' -import { - buildOnboardingPath, - onboardingPath, - resolveOnboardingLoginPath, - resolveOnboardingPendingVerificationPath, -} from '#client/routes/onboarding-redirect.ts' - -test('onboarding redirect helpers preserve safe redirectTo and reject open redirects', () => { - const oauthResume = '/oauth/authorize?client_id=demo&state=abc' - - expect(buildOnboardingPath(null)).toBe(onboardingPath) - expect(buildOnboardingPath(oauthResume)).toBe( - `/onboarding?redirectTo=${encodeURIComponent(oauthResume)}`, - ) - expect(buildOnboardingPath('https://evil.example')).toBe(onboardingPath) - expect(buildOnboardingPath('/\\evil.example')).toBe(onboardingPath) - - expect(resolveOnboardingPendingVerificationPath(null)).toBe( - '/pending-verification', - ) - expect(resolveOnboardingPendingVerificationPath(oauthResume)).toBe( - `/pending-verification?redirectTo=${encodeURIComponent(oauthResume)}`, - ) - expect(resolveOnboardingPendingVerificationPath('https://evil.example')).toBe( - '/pending-verification', - ) - expect(resolveOnboardingPendingVerificationPath('/\\evil.example')).toBe( - '/pending-verification', - ) - - expect(resolveOnboardingLoginPath(null)).toBe( - '/login?redirectTo=%2Fonboarding', - ) - expect(resolveOnboardingLoginPath(oauthResume)).toBe( - `/login?redirectTo=${encodeURIComponent(buildOnboardingPath(oauthResume))}`, - ) - expect(resolveOnboardingLoginPath('https://evil.example')).toBe( - '/login?redirectTo=%2Fonboarding', - ) -}) diff --git a/packages/worker/client/routes/pending-verification-continue.node.test.ts b/packages/worker/client/routes/pending-verification-continue.node.test.ts deleted file mode 100644 index e80040da13..0000000000 --- a/packages/worker/client/routes/pending-verification-continue.node.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, test } from 'vitest' -import { resolveContinueVerificationFeedback } from '#client/routes/pending-verification-continue.ts' - -test('continue-after-verify treats missing session as an actionable error', () => { - expect(resolveContinueVerificationFeedback({ emailVerified: true })).toEqual({ - status: 'verified', - tone: 'info', - message: null, - }) - expect(resolveContinueVerificationFeedback({ emailVerified: false })).toEqual( - { - status: 'pending', - tone: 'info', - message: - 'Still waiting on verification. Open the link from your email, then try again.', - }, - ) - expect(resolveContinueVerificationFeedback(null)).toEqual({ - status: 'error', - tone: 'error', - message: 'Unable to check verification status. Try again.', - }) -}) diff --git a/packages/worker/client/routes/pending-verification-path.node.test.ts b/packages/worker/client/routes/pending-verification-path.node.test.ts deleted file mode 100644 index 0eb21c7cc8..0000000000 --- a/packages/worker/client/routes/pending-verification-path.node.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect, test } from 'vitest' -import { - buildPendingVerificationPath, - resolvePostVerificationRedirect, -} from '#client/routes/pending-verification-path.ts' -import { - normalizeRedirectTo, - resolveVerifyEmailSuccessCta, -} from '#app/safe-redirect.ts' - -test('safe redirect helpers reject open redirects and preserve same-origin paths', () => { - expect(normalizeRedirectTo('/oauth/authorize?client_id=1')).toBe( - '/oauth/authorize?client_id=1', - ) - expect(normalizeRedirectTo('/account#security')).toBe('/account#security') - expect(normalizeRedirectTo('/path%20with%20spaces')).toBe( - '/path%20with%20spaces', - ) - expect(normalizeRedirectTo('https://evil.example')).toBeNull() - expect(normalizeRedirectTo('//evil.example')).toBeNull() - expect(normalizeRedirectTo('/\\evil.example')).toBeNull() - expect(normalizeRedirectTo('/\\\\evil.example')).toBeNull() - expect(normalizeRedirectTo('/%5cevil.example')).toBeNull() - expect(normalizeRedirectTo('/%5Cevil.example')).toBeNull() - expect(normalizeRedirectTo('/\tevil.example')).toBeNull() - expect(normalizeRedirectTo('/evil\n.example')).toBeNull() - expect(normalizeRedirectTo('/%00evil')).toBeNull() - expect(normalizeRedirectTo('/%0aevil')).toBeNull() - expect(normalizeRedirectTo('/%2f%2fevil.example')).toBe('/%2f%2fevil.example') - expect(normalizeRedirectTo(null)).toBeNull() - expect(normalizeRedirectTo('')).toBeNull() - - expect(buildPendingVerificationPath(null)).toBe('/pending-verification') - expect(buildPendingVerificationPath('/onboarding')).toBe( - '/pending-verification?redirectTo=%2Fonboarding', - ) - expect(buildPendingVerificationPath('https://evil.example')).toBe( - '/pending-verification', - ) - expect(buildPendingVerificationPath('/\\evil.example')).toBe( - '/pending-verification', - ) - - expect(resolvePostVerificationRedirect(null)).toBe('/onboarding') - expect(resolvePostVerificationRedirect('/oauth/authorize?x=1')).toBe( - '/oauth/authorize?x=1', - ) - expect(resolvePostVerificationRedirect('https://evil.example')).toBe( - '/onboarding', - ) - - expect(resolveVerifyEmailSuccessCta('/oauth/authorize?client_id=1')).toEqual({ - href: '/oauth/authorize?client_id=1', - label: 'Continue authorization', - message: - 'Your email address has been verified. MCP access is now available. Continue authorization to finish connecting your AI agent.', - }) - expect(resolveVerifyEmailSuccessCta('https://evil.example')).toEqual({ - href: '/onboarding', - label: 'Continue to onboarding', - message: - 'Your email address has been verified. MCP access is now available. Continue onboarding to connect your AI agent.', - }) -}) diff --git a/packages/worker/client/routes/resolve-password-auth-redirect.node.test.ts b/packages/worker/client/routes/resolve-password-auth-redirect.node.test.ts deleted file mode 100644 index bbf128a274..0000000000 --- a/packages/worker/client/routes/resolve-password-auth-redirect.node.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { expect, test } from 'vitest' -import { resolvePasswordAuthRedirect } from '#client/routes/resolve-password-auth-redirect.ts' - -test('password auth redirect prefers 2FA, then signup verification with preserved redirectTo, then account', () => { - expect( - resolvePasswordAuthRedirect({ - mode: 'signup', - requiresTwoFactor: true, - emailVerificationRequired: true, - redirectTo: '/onboarding', - }), - ).toBe('/verify?redirectTo=%2Fonboarding') - - expect( - resolvePasswordAuthRedirect({ - mode: 'signup', - emailVerificationRequired: true, - redirectTo: '/account', - }), - ).toBe('/pending-verification?redirectTo=%2Faccount') - - expect( - resolvePasswordAuthRedirect({ - mode: 'signup', - emailVerificationRequired: true, - redirectTo: - '/oauth/authorize?response_type=code&client_id=demo&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&scope=profile&state=abc', - }), - ).toBe( - '/pending-verification?redirectTo=%2Foauth%2Fauthorize%3Fresponse_type%3Dcode%26client_id%3Ddemo%26redirect_uri%3Dhttps%253A%252F%252Fexample.com%252Fcallback%26scope%3Dprofile%26state%3Dabc', - ) - - expect( - resolvePasswordAuthRedirect({ - mode: 'signup', - emailVerificationRequired: true, - redirectTo: 'https://evil.example/phish', - }), - ).toBe('/pending-verification') - - expect( - resolvePasswordAuthRedirect({ - mode: 'login', - emailVerificationRequired: true, - redirectTo: '/onboarding', - }), - ).toBe('/onboarding') - - expect( - resolvePasswordAuthRedirect({ - mode: 'login', - }), - ).toBe('/account') - - expect( - resolvePasswordAuthRedirect({ - mode: 'signup', - emailVerificationRequired: false, - redirectTo: '/secrets', - }), - ).toBe('/secrets') -}) diff --git a/packages/worker/src/app/email-verification.node.test.ts b/packages/worker/src/app/email-verification.node.test.ts index 66a243896c..f723830a48 100644 --- a/packages/worker/src/app/email-verification.node.test.ts +++ b/packages/worker/src/app/email-verification.node.test.ts @@ -1,10 +1,5 @@ import { expect, test } from 'vitest' import { buildEmailVerificationUrl } from '#app/email-verification.ts' -import { - normalizeRedirectTo, - resolvePostVerificationRedirect, - resolveVerifyEmailSuccessCta, -} from '#app/safe-redirect.ts' test('email verification links preserve safe resume targets and reject open redirects', () => { const oauthResume = '/oauth/authorize?client_id=demo&state=abc' @@ -24,35 +19,4 @@ test('email verification links preserve safe resume targets and reject open redi }) expect(withoutResume.searchParams.get('token')).toBe('verify-token') expect(withoutResume.searchParams.has('redirectTo')).toBe(false) - - expect(normalizeRedirectTo('//evil.example')).toBeNull() - expect(normalizeRedirectTo('/\\evil.example')).toBeNull() - expect(normalizeRedirectTo('/%5cevil.example')).toBeNull() - expect(resolvePostVerificationRedirect('https://evil.example')).toBe( - '/onboarding', - ) - expect(resolveVerifyEmailSuccessCta(null)).toEqual({ - href: '/onboarding', - label: 'Continue to onboarding', - message: - 'Your email address has been verified. MCP access is now available. Continue onboarding to connect your AI agent.', - }) - expect(resolveVerifyEmailSuccessCta(oauthResume)).toEqual({ - href: oauthResume, - label: 'Continue authorization', - message: - 'Your email address has been verified. MCP access is now available. Continue authorization to finish connecting your AI agent.', - }) - expect(resolveVerifyEmailSuccessCta('/account')).toEqual({ - href: '/account', - label: 'Continue', - message: - 'Your email address has been verified. MCP access is now available.', - }) - expect(resolveVerifyEmailSuccessCta('https://evil.example')).toEqual({ - href: '/onboarding', - label: 'Continue to onboarding', - message: - 'Your email address has been verified. MCP access is now available. Continue onboarding to connect your AI agent.', - }) }) diff --git a/packages/worker/src/app/handlers/verify-email.node.test.ts b/packages/worker/src/app/handlers/verify-email.node.test.ts index c1678f848b..71d0dd5ae8 100644 --- a/packages/worker/src/app/handlers/verify-email.node.test.ts +++ b/packages/worker/src/app/handlers/verify-email.node.test.ts @@ -13,7 +13,7 @@ vi.mock('#app/ssr-render.tsx', () => ({ ), })) -test('verify-email success CTA resumes a safe OAuth target and rejects open redirects', async () => { +test('verify-email handler wires success CTA from redirectTo and rejects open redirects', async () => { vi.mocked(verifyEmailToken).mockResolvedValue({ ok: true, userId: 1, @@ -39,8 +39,7 @@ test('verify-email success CTA resumes a safe OAuth target and rejects open redi emailVerification: { ok: true, kind: 'email_verify', - message: - 'Your email address has been verified. MCP access is now available. Continue authorization to finish connecting your AI agent.', + message: expect.any(String), ctaHref: oauthResume, ctaLabel: 'Continue authorization', }, @@ -62,31 +61,7 @@ test('verify-email success CTA resumes a safe OAuth target and rejects open redi emailVerification: { ok: true, kind: 'email_verify', - message: - 'Your email address has been verified. MCP access is now available. Continue onboarding to connect your AI agent.', - ctaHref: '/onboarding', - ctaLabel: 'Continue to onboarding', - }, - }, - }) - - const backslashResponse = await handler.handler({ - request: new Request( - 'https://example.com/verify-email?token=ok&redirectTo=%2F%5Cevil.example', - ), - url: new URL( - 'https://example.com/verify-email?token=ok&redirectTo=%2F%5Cevil.example', - ), - params: {}, - } as never) - expect(await backslashResponse.json()).toEqual({ - ok: true, - loaderData: { - emailVerification: { - ok: true, - kind: 'email_verify', - message: - 'Your email address has been verified. MCP access is now available. Continue onboarding to connect your AI agent.', + message: expect.any(String), ctaHref: '/onboarding', ctaLabel: 'Continue to onboarding', }, diff --git a/packages/worker/src/app/safe-redirect.node.test.ts b/packages/worker/src/app/safe-redirect.node.test.ts new file mode 100644 index 0000000000..089c315802 --- /dev/null +++ b/packages/worker/src/app/safe-redirect.node.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'vitest' +import { + defaultPostVerificationRedirect, + normalizeRedirectTo, + resolvePostVerificationRedirect, + resolveVerifyEmailSuccessCta, +} from '#app/safe-redirect.ts' + +test('normalizeRedirectTo accepts same-origin paths and rejects open redirects', () => { + expect(normalizeRedirectTo('/oauth/authorize?client_id=1')).toBe( + '/oauth/authorize?client_id=1', + ) + expect(normalizeRedirectTo('/account#security')).toBe('/account#security') + expect(normalizeRedirectTo('/path%20with%20spaces')).toBe( + '/path%20with%20spaces', + ) + expect(normalizeRedirectTo('/%2f%2fevil.example')).toBe('/%2f%2fevil.example') + + for (const rejected of [ + 'https://evil.example', + '//evil.example', + '/\\evil.example', + '/\\\\evil.example', + '/%5cevil.example', + '/%5Cevil.example', + '/\tevil.example', + '/evil\n.example', + '/%00evil', + '/%0aevil', + null, + '', + ]) { + expect(normalizeRedirectTo(rejected)).toBeNull() + } +}) + +test('post-verification redirect and success CTA preserve safe targets', () => { + const oauthResume = '/oauth/authorize?client_id=demo&state=abc' + + expect(resolvePostVerificationRedirect(null)).toBe( + defaultPostVerificationRedirect, + ) + expect(resolvePostVerificationRedirect(oauthResume)).toBe(oauthResume) + expect(resolvePostVerificationRedirect('https://evil.example')).toBe( + defaultPostVerificationRedirect, + ) + + expect(resolveVerifyEmailSuccessCta(oauthResume)).toMatchObject({ + href: oauthResume, + label: 'Continue authorization', + }) + expect(resolveVerifyEmailSuccessCta(null)).toMatchObject({ + href: defaultPostVerificationRedirect, + label: 'Continue to onboarding', + }) + expect(resolveVerifyEmailSuccessCta('/account')).toMatchObject({ + href: '/account', + label: 'Continue', + }) + expect(resolveVerifyEmailSuccessCta('https://evil.example')).toMatchObject({ + href: defaultPostVerificationRedirect, + label: 'Continue to onboarding', + }) +})