test(web): align auth e2e with console home (#38538)

This commit is contained in:
yyh
2026-07-08 14:17:14 +08:00
committed by GitHub
parent 41fb479957
commit 10cf9be3c7
12 changed files with 171 additions and 28 deletions
@@ -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
+2 -2
View File
@@ -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
@@ -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
@@ -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
@@ -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')
})
@@ -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 })
})
@@ -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(?:\?.*)?$/)
})
+4 -4
View File
@@ -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 })
+7 -2
View File
@@ -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,
)
}
+12
View File
@@ -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)
}
+52 -1
View File
@@ -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')
})
})
+24 -5
View File
@@ -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<typeof useSuspenseQuery>)
})
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<typeof useQuery>)
.mockReturnValueOnce(nonInviteQueryResult as unknown as ReturnType<typeof useQuery>)
render(<NormalForm />)
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<typeof useQuery>)
.mockReturnValueOnce(invitationQueryResult as unknown as ReturnType<typeof useQuery>)