mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
test(web): align auth e2e with console home (#38538)
This commit is contained in:
parent
41fb479957
commit
10cf9be3c7
9
e2e/features/auth/session-refresh.feature
Normal file
9
e2e/features/auth/session-refresh.feature
Normal file
@ -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
|
||||||
@ -1,8 +1,8 @@
|
|||||||
@auth @smoke @core @unauthenticated
|
@auth @smoke @core @unauthenticated
|
||||||
Feature: Sign in
|
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
|
Given I am not signed in
|
||||||
When I open the sign-in page
|
When I open the sign-in page
|
||||||
And I sign in as the default E2E admin
|
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
|
@smoke @authenticated
|
||||||
Feature: Authenticated app console
|
Feature: Authenticated console home
|
||||||
Scenario: Open the apps console with the shared authenticated state
|
Scenario: Open the default console entry with the shared authenticated state
|
||||||
Given I am signed in as the default E2E admin
|
Given I am signed in as the default E2E admin
|
||||||
When I open the apps console
|
When I open the default console entry
|
||||||
Then I should stay on the apps console
|
Then I should be on the console home
|
||||||
And I should not see the "Sign in" button
|
And I should not see the "Sign in" button
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
@smoke @unauthenticated
|
@smoke @unauthenticated
|
||||||
Feature: Unauthenticated app console entry
|
Feature: Unauthenticated console home entry
|
||||||
Scenario: Redirect to the sign-in page when opening the apps console without logging in
|
Scenario: Redirect to the sign-in page when opening the default console entry without logging in
|
||||||
Given I am not signed 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
|
Then I should be redirected to the signin page
|
||||||
And I should see the "Sign in" button
|
And I should see the "Sign in" button
|
||||||
|
|||||||
43
e2e/features/step-definitions/auth/session-refresh.steps.ts
Normal file
43
e2e/features/step-definitions/auth/session-refresh.steps.ts
Normal file
@ -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 type { DifyWorld } from '../../support/world'
|
||||||
import { Then, When } from '@cucumber/cucumber'
|
import { When } from '@cucumber/cucumber'
|
||||||
import { expect } from '@playwright/test'
|
|
||||||
import { adminCredentials } from '../../../fixtures/auth'
|
import { adminCredentials } from '../../../fixtures/auth'
|
||||||
|
|
||||||
When('I open the sign-in page', async function (this: DifyWorld) {
|
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) {
|
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.getByLabel('Password', { exact: true }).fill(adminCredentials.password)
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
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 { Then, When } from '@cucumber/cucumber'
|
||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
import { waitForAppsConsole } from '../../../support/apps'
|
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) {
|
When('I open the apps console', async function (this: DifyWorld) {
|
||||||
await this.getPage().goto('/apps')
|
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())
|
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) {
|
Then('I should be redirected to the signin page', async function (this: DifyWorld) {
|
||||||
await expect(this.getPage()).toHaveURL(/\/signin(?:\?.*)?$/)
|
await expect(this.getPage()).toHaveURL(/\/signin(?:\?.*)?$/)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Buffer } from 'node:buffer'
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { waitForAppsConsole } from '../support/apps'
|
import { waitForConsoleHome } from '../support/home'
|
||||||
import { apiURL, defaultBaseURL, defaultLocale } from '../test-env'
|
import { apiURL, defaultBaseURL, defaultLocale } from '../test-env'
|
||||||
|
|
||||||
export type AuthSessionMetadata = {
|
export type AuthSessionMetadata = {
|
||||||
@ -150,12 +150,12 @@ export const ensureAuthenticatedState = async (browser: Browser, configuredBaseU
|
|||||||
const { mode, usedInitPassword } = await ensureAdminAccount(context, deadline)
|
const { mode, usedInitPassword } = await ensureAdminAccount(context, deadline)
|
||||||
await loginAdmin(context, deadline)
|
await loginAdmin(context, deadline)
|
||||||
|
|
||||||
console.warn('[e2e] auth bootstrap: verifying apps console')
|
console.warn('[e2e] auth bootstrap: verifying console home')
|
||||||
await page.goto(appURL(baseURL, '/apps'), {
|
await page.goto(appURL(baseURL, '/'), {
|
||||||
timeout: getRemainingTimeout(deadline),
|
timeout: getRemainingTimeout(deadline),
|
||||||
waitUntil: 'domcontentloaded',
|
waitUntil: 'domcontentloaded',
|
||||||
})
|
})
|
||||||
await waitForAppsConsole(page, getRemainingTimeout(deadline))
|
await waitForConsoleHome(page, getRemainingTimeout(deadline))
|
||||||
|
|
||||||
await context.storageState({ path: authStatePath })
|
await context.storageState({ path: authStatePath })
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import type { Page } from '@playwright/test'
|
import type { Page } from '@playwright/test'
|
||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const getExpectOptions = (timeout?: number) =>
|
||||||
|
timeout === undefined ? undefined : { timeout }
|
||||||
|
|
||||||
export const waitForAppsConsole = async (page: Page, timeout?: number) => {
|
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(
|
await expect(page.getByRole('heading', { name: 'Studio' })).toBeVisible(
|
||||||
timeout === undefined ? undefined : { timeout },
|
options,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
e2e/support/home.ts
Normal file
12
e2e/support/home.ts
Normal 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)
|
||||||
|
}
|
||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
basePath: '',
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/config', () => ({
|
vi.mock('@/config', () => ({
|
||||||
API_PREFIX: 'http://localhost:5001/console/api',
|
API_PREFIX: 'http://localhost:5001/console/api',
|
||||||
CSRF_COOKIE_NAME: () => 'csrf_token',
|
CSRF_COOKIE_NAME: () => 'csrf_token',
|
||||||
@ -15,7 +19,9 @@ vi.mock('@/config/server', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/var', () => ({
|
vi.mock('@/utils/var', () => ({
|
||||||
basePath: '',
|
get basePath() {
|
||||||
|
return mocks.basePath
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const getSetCookieHeaders = (headers: Headers) => {
|
const getSetCookieHeaders = (headers: Headers) => {
|
||||||
@ -38,7 +44,9 @@ const createRequest = (url: string, cookie?: string) => ({
|
|||||||
describe('auth refresh route', () => {
|
describe('auth refresh route', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
vi.resetModules()
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
|
mocks.basePath = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should refresh cookies and redirect back to the requested path', async () => {
|
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.status).toBe(303)
|
||||||
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -41,10 +41,6 @@ vi.mock('@/service/common', async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('./utils/post-login-redirect', () => ({
|
|
||||||
resolvePostLoginRedirect: vi.fn(() => null),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const mockReplace = vi.fn()
|
const mockReplace = vi.fn()
|
||||||
const mockUseQuery = vi.mocked(useQuery)
|
const mockUseQuery = vi.mocked(useQuery)
|
||||||
const mockUseSuspenseQuery = vi.mocked(useSuspenseQuery)
|
const mockUseSuspenseQuery = vi.mocked(useSuspenseQuery)
|
||||||
@ -74,11 +70,17 @@ const invitationQueryResult = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nonInviteQueryResult = {
|
||||||
|
isPending: false,
|
||||||
|
isError: false,
|
||||||
|
data: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
describe('NormalForm', () => {
|
describe('NormalForm', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockUseRouter.mockReturnValue({ replace: mockReplace })
|
mockUseRouter.mockReturnValue({ replace: mockReplace })
|
||||||
mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token'))
|
mockUseSearchParams.mockReturnValue(new URLSearchParams())
|
||||||
mockUseSuspenseQuery.mockReturnValue({
|
mockUseSuspenseQuery.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
enable_social_oauth_login: false,
|
enable_social_oauth_login: false,
|
||||||
@ -97,8 +99,25 @@ describe('NormalForm', () => {
|
|||||||
} as unknown as ReturnType<typeof useSuspenseQuery>)
|
} 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', () => {
|
describe('Invite Redirects', () => {
|
||||||
it('should send logged-in invite visitors to the invite confirmation page', async () => {
|
it('should send logged-in invite visitors to the invite confirmation page', async () => {
|
||||||
|
mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token'))
|
||||||
mockUseQuery
|
mockUseQuery
|
||||||
.mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType<typeof useQuery>)
|
.mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||||
.mockReturnValueOnce(invitationQueryResult as unknown as ReturnType<typeof useQuery>)
|
.mockReturnValueOnce(invitationQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user