diff --git a/e2e/features/auth/session-refresh.feature b/e2e/features/auth/session-refresh.feature new file mode 100644 index 00000000000..f565a6aff69 --- /dev/null +++ b/e2e/features/auth/session-refresh.feature @@ -0,0 +1,9 @@ +@auth @core @authenticated +Feature: Console session refresh + + Scenario: Refresh the console session during server-side navigation + Given I am signed in as the default E2E admin + And my console session requires token refresh + When I open the default console entry after the access token expires + Then I should be on the console home + And I should not see the "Sign in" button diff --git a/e2e/features/auth/sign-in.feature b/e2e/features/auth/sign-in.feature index a9a1e13626d..ffa8a02ad71 100644 --- a/e2e/features/auth/sign-in.feature +++ b/e2e/features/auth/sign-in.feature @@ -1,8 +1,8 @@ @auth @smoke @core @unauthenticated Feature: Sign in - Scenario: Sign in with valid credentials and reach the apps console + Scenario: Sign in with valid credentials and reach the console home Given I am not signed in When I open the sign-in page And I sign in as the default E2E admin - Then I should be on the apps console + Then I should be on the console home diff --git a/e2e/features/smoke/authenticated-entry.feature b/e2e/features/smoke/authenticated-entry.feature index 53d72bd667c..955279bd247 100644 --- a/e2e/features/smoke/authenticated-entry.feature +++ b/e2e/features/smoke/authenticated-entry.feature @@ -1,7 +1,7 @@ @smoke @authenticated -Feature: Authenticated app console - Scenario: Open the apps console with the shared authenticated state +Feature: Authenticated console home + Scenario: Open the default console entry with the shared authenticated state Given I am signed in as the default E2E admin - When I open the apps console - Then I should stay on the apps console + When I open the default console entry + Then I should be on the console home And I should not see the "Sign in" button diff --git a/e2e/features/smoke/unauthenticated-entry.feature b/e2e/features/smoke/unauthenticated-entry.feature index a2783c1cba2..604ea4970dd 100644 --- a/e2e/features/smoke/unauthenticated-entry.feature +++ b/e2e/features/smoke/unauthenticated-entry.feature @@ -1,7 +1,7 @@ @smoke @unauthenticated -Feature: Unauthenticated app console entry - Scenario: Redirect to the sign-in page when opening the apps console without logging in +Feature: Unauthenticated console home entry + Scenario: Redirect to the sign-in page when opening the default console entry without logging in Given I am not signed in - When I open the apps console + When I open the default console entry Then I should be redirected to the signin page And I should see the "Sign in" button diff --git a/e2e/features/step-definitions/auth/session-refresh.steps.ts b/e2e/features/step-definitions/auth/session-refresh.steps.ts new file mode 100644 index 00000000000..f6468bfaf3d --- /dev/null +++ b/e2e/features/step-definitions/auth/session-refresh.steps.ts @@ -0,0 +1,43 @@ +import type { DifyWorld } from '../../support/world' +import { Given, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' + +const consoleAccessTokenCookieName = /^(?:__Host-)?access_token$/ +const consoleRefreshTokenCookieName = /^(?:__Host-)?refresh_token$/ + +Given('my console session requires token refresh', async function (this: DifyWorld) { + if (!this.context) + throw new Error('Playwright browser context has not been initialized for this scenario.') + + const cookies = await this.context.cookies() + const hasAccessToken = cookies.some(cookie => consoleAccessTokenCookieName.test(cookie.name)) + const hasRefreshToken = cookies.some(cookie => consoleRefreshTokenCookieName.test(cookie.name)) + + expect(hasAccessToken, 'Expected the authenticated E2E session to include a console access token.').toBe(true) + expect(hasRefreshToken, 'Expected the authenticated E2E session to include a console refresh token.').toBe(true) + + await this.context.clearCookies({ name: consoleAccessTokenCookieName }) + + const remainingCookies = await this.context.cookies() + expect( + remainingCookies.some(cookie => consoleAccessTokenCookieName.test(cookie.name)), + 'Expected the console access token to be removed before opening the default console entry.', + ).toBe(false) + expect( + remainingCookies.some(cookie => consoleRefreshTokenCookieName.test(cookie.name)), + 'Expected the console refresh token to remain available for server-side refresh.', + ).toBe(true) +}) + +When('I open the default console entry after the access token expires', async function (this: DifyWorld) { + const page = this.getPage() + const refreshRequestPromise = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname.endsWith('/auth/refresh') && url.searchParams.get('redirect_url') === '/' + }) + + await page.goto('/') + + const refreshRequest = await refreshRequestPromise + this.attach(`Session refresh request: ${refreshRequest.url()}`, 'text/plain') +}) diff --git a/e2e/features/step-definitions/auth/sign-in.steps.ts b/e2e/features/step-definitions/auth/sign-in.steps.ts index 469203d8bfd..fcf106048b9 100644 --- a/e2e/features/step-definitions/auth/sign-in.steps.ts +++ b/e2e/features/step-definitions/auth/sign-in.steps.ts @@ -1,10 +1,9 @@ import type { DifyWorld } from '../../support/world' -import { Then, When } from '@cucumber/cucumber' -import { expect } from '@playwright/test' +import { When } from '@cucumber/cucumber' import { adminCredentials } from '../../../fixtures/auth' When('I open the sign-in page', async function (this: DifyWorld) { - await this.getPage().goto('/signin?redirect_url=%2Fapps') + await this.getPage().goto('/signin') }) When('I sign in as the default E2E admin', async function (this: DifyWorld) { @@ -14,7 +13,3 @@ When('I sign in as the default E2E admin', async function (this: DifyWorld) { await page.getByLabel('Password', { exact: true }).fill(adminCredentials.password) await page.getByRole('button', { name: 'Sign in' }).click() }) - -Then('I should be on the apps console', async function (this: DifyWorld) { - await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/, { timeout: 30_000 }) -}) diff --git a/e2e/features/step-definitions/common/navigation.steps.ts b/e2e/features/step-definitions/common/navigation.steps.ts index a558d96f937..10ee7b789f0 100644 --- a/e2e/features/step-definitions/common/navigation.steps.ts +++ b/e2e/features/step-definitions/common/navigation.steps.ts @@ -2,6 +2,11 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { waitForAppsConsole } from '../../../support/apps' +import { waitForConsoleHome } from '../../../support/home' + +When('I open the default console entry', async function (this: DifyWorld) { + await this.getPage().goto('/') +}) When('I open the apps console', async function (this: DifyWorld) { await this.getPage().goto('/apps') @@ -15,6 +20,10 @@ Then('I should stay on the apps console', async function (this: DifyWorld) { await waitForAppsConsole(this.getPage()) }) +Then('I should be on the console home', async function (this: DifyWorld) { + await waitForConsoleHome(this.getPage()) +}) + Then('I should be redirected to the signin page', async function (this: DifyWorld) { await expect(this.getPage()).toHaveURL(/\/signin(?:\?.*)?$/) }) diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index 9039f97483d..62d2880a465 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -3,7 +3,7 @@ import { Buffer } from 'node:buffer' import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { waitForAppsConsole } from '../support/apps' +import { waitForConsoleHome } from '../support/home' import { apiURL, defaultBaseURL, defaultLocale } from '../test-env' export type AuthSessionMetadata = { @@ -150,12 +150,12 @@ export const ensureAuthenticatedState = async (browser: Browser, configuredBaseU const { mode, usedInitPassword } = await ensureAdminAccount(context, deadline) await loginAdmin(context, deadline) - console.warn('[e2e] auth bootstrap: verifying apps console') - await page.goto(appURL(baseURL, '/apps'), { + console.warn('[e2e] auth bootstrap: verifying console home') + await page.goto(appURL(baseURL, '/'), { timeout: getRemainingTimeout(deadline), waitUntil: 'domcontentloaded', }) - await waitForAppsConsole(page, getRemainingTimeout(deadline)) + await waitForConsoleHome(page, getRemainingTimeout(deadline)) await context.storageState({ path: authStatePath }) diff --git a/e2e/support/apps.ts b/e2e/support/apps.ts index 3c3af547a35..07d0f15716a 100644 --- a/e2e/support/apps.ts +++ b/e2e/support/apps.ts @@ -1,10 +1,15 @@ import type { Page } from '@playwright/test' import { expect } from '@playwright/test' +const getExpectOptions = (timeout?: number) => + timeout === undefined ? undefined : { timeout } + export const waitForAppsConsole = async (page: Page, timeout?: number) => { - await expect(page).toHaveURL(/\/apps(?:\?.*)?$/, timeout === undefined ? undefined : { timeout }) + const options = getExpectOptions(timeout) + + await expect(page).toHaveURL(/\/apps(?:\?.*)?$/, options) await expect(page.getByRole('heading', { name: 'Studio' })).toBeVisible( - timeout === undefined ? undefined : { timeout }, + options, ) } diff --git a/e2e/support/home.ts b/e2e/support/home.ts new file mode 100644 index 00000000000..83fe9b475c0 --- /dev/null +++ b/e2e/support/home.ts @@ -0,0 +1,12 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +const getExpectOptions = (timeout?: number) => + timeout === undefined ? undefined : { timeout } + +export const waitForConsoleHome = async (page: Page, timeout?: number) => { + const options = getExpectOptions(timeout) + + await expect.poll(() => new URL(page.url()).pathname, options).toBe('/') + await expect(page.getByRole('link', { name: 'Home' })).toHaveAttribute('aria-current', 'page', options) +} diff --git a/web/app/auth/refresh/__tests__/route.spec.ts b/web/app/auth/refresh/__tests__/route.spec.ts index 82e8d402884..ac1b5e6747f 100644 --- a/web/app/auth/refresh/__tests__/route.spec.ts +++ b/web/app/auth/refresh/__tests__/route.spec.ts @@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => ({ + basePath: '', +})) + vi.mock('@/config', () => ({ API_PREFIX: 'http://localhost:5001/console/api', CSRF_COOKIE_NAME: () => 'csrf_token', @@ -15,7 +19,9 @@ vi.mock('@/config/server', () => ({ })) vi.mock('@/utils/var', () => ({ - basePath: '', + get basePath() { + return mocks.basePath + }, })) const getSetCookieHeaders = (headers: Headers) => { @@ -38,7 +44,9 @@ const createRequest = (url: string, cookie?: string) => ({ describe('auth refresh route', () => { beforeEach(() => { vi.clearAllMocks() + vi.resetModules() vi.unstubAllGlobals() + mocks.basePath = '' }) it('should refresh cookies and redirect back to the requested path', async () => { @@ -131,4 +139,47 @@ describe('auth refresh route', () => { expect(response.status).toBe(303) expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F') }) + + it('should preserve base path when refreshing and redirecting back', async () => { + mocks.basePath = '/console' + const headers = new Headers() + Object.defineProperty(headers, 'getSetCookie', { + value: () => [ + 'access_token=new-access; Path=/console; HttpOnly', + 'refresh_token=new-refresh; Path=/console; HttpOnly', + ], + }) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers, + } as Response) + vi.stubGlobal('fetch', fetchMock) + const { GET } = await import('../route') + + const response = await GET(createRequest( + 'http://localhost:3000/console/auth/refresh?redirect_url=%2Fconsole%2Fapps%3Fcategory%3Dworkflow', + 'refresh_token=old-refresh', + )) + + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe('/console/apps?category=workflow') + expect(getSetCookieHeaders(response.headers)).toEqual([ + 'access_token=new-access; Path=/console; HttpOnly', + 'refresh_token=new-refresh; Path=/console; HttpOnly', + ]) + }) + + it('should fall back to the base path home when base path refresh redirects to itself', async () => { + mocks.basePath = '/console' + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))) + const { GET } = await import('../route') + + const response = await GET(createRequest( + 'http://localhost:3000/console/auth/refresh?redirect_url=%2Fconsole%2Fauth%2Frefresh', + 'refresh_token=expired', + )) + + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe('/console/signin?redirect_url=%2Fconsole%2F') + }) }) diff --git a/web/app/signin/__tests__/normal-form.spec.tsx b/web/app/signin/__tests__/normal-form.spec.tsx index 6dfdfbb372f..0e03b7c3270 100644 --- a/web/app/signin/__tests__/normal-form.spec.tsx +++ b/web/app/signin/__tests__/normal-form.spec.tsx @@ -41,10 +41,6 @@ vi.mock('@/service/common', async () => { } }) -vi.mock('./utils/post-login-redirect', () => ({ - resolvePostLoginRedirect: vi.fn(() => null), -})) - const mockReplace = vi.fn() const mockUseQuery = vi.mocked(useQuery) const mockUseSuspenseQuery = vi.mocked(useSuspenseQuery) @@ -74,11 +70,17 @@ const invitationQueryResult = { }, } +const nonInviteQueryResult = { + isPending: false, + isError: false, + data: undefined, +} + describe('NormalForm', () => { beforeEach(() => { vi.clearAllMocks() mockUseRouter.mockReturnValue({ replace: mockReplace }) - mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token')) + mockUseSearchParams.mockReturnValue(new URLSearchParams()) mockUseSuspenseQuery.mockReturnValue({ data: { enable_social_oauth_login: false, @@ -97,8 +99,25 @@ describe('NormalForm', () => { } as unknown as ReturnType) }) + describe('Default Redirects', () => { + it('should send logged-in visitors without a redirect target to the console home', async () => { + const searchParams = new URLSearchParams() + mockUseSearchParams.mockReturnValue(searchParams) + mockUseQuery + .mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType) + .mockReturnValueOnce(nonInviteQueryResult as unknown as ReturnType) + + render() + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/') + }) + }) + }) + describe('Invite Redirects', () => { it('should send logged-in invite visitors to the invite confirmation page', async () => { + mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token')) mockUseQuery .mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType) .mockReturnValueOnce(invitationQueryResult as unknown as ReturnType)