mirror of
https://github.com/langgenius/dify.git
synced 2026-07-24 21:18:35 +08:00
fix(web): show unavailable state for disabled Web Apps (#39392)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
3f0f57c594
commit
04f9026724
@ -195,6 +195,24 @@ Open the HTML report locally with:
|
||||
open cucumber-report/report.html
|
||||
```
|
||||
|
||||
## Scenario admission and behavior ownership
|
||||
|
||||
Add an E2E scenario only when it protects a critical user journey and a cross-boundary result that
|
||||
cheaper owner-level tests do not already prove. A control changing its own label is not sufficient
|
||||
E2E evidence when component or integration tests can own that contract.
|
||||
|
||||
Start from product truth, including real defaults and actor roles. API fixtures may establish
|
||||
preconditions, but they must not manufacture an opposite state merely to make the intended action
|
||||
look meaningful. When a product default is part of the journey, make it explicit and observable.
|
||||
|
||||
For cross-actor journeys, isolate each actor's browser state, keep their pages in typed `DifyWorld`
|
||||
state, and include them in failure diagnostics and cleanup. Assert the downstream user-observable
|
||||
effect, not only the initiating control's local state.
|
||||
|
||||
When a run exposes behavior that conflicts with the intended product contract, identify the first
|
||||
layer that misclassifies the business state. Fix that owner or report the mismatch explicitly; do
|
||||
not make the E2E pass by encoding an accidental redirect, stale label, or misleading error state.
|
||||
|
||||
## Writing new scenarios
|
||||
|
||||
### Workflow
|
||||
|
||||
@ -47,8 +47,9 @@ export async function setAgentSiteAccessAndGetURL(
|
||||
if (!appId) throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`)
|
||||
|
||||
const appDetail = await setAppSiteEnabled(appId, enabled)
|
||||
const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site.access_token
|
||||
const baseURL = agent.site?.app_base_url ?? appDetail.site.app_base_url
|
||||
const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site?.access_token
|
||||
const baseURL = agent.site?.app_base_url ?? appDetail.site?.app_base_url
|
||||
if (!token || !baseURL) throw new Error(`Agent v2 ${agentId} does not expose a Web App URL.`)
|
||||
|
||||
return `${baseURL.replace(/\/$/, '')}/agent/${token}`
|
||||
}
|
||||
|
||||
@ -1,17 +1,5 @@
|
||||
@apps @core
|
||||
Feature: Share app publicly
|
||||
|
||||
@authenticated
|
||||
Scenario: Enable public share for a published workflow app
|
||||
Given I am signed in as the default E2E admin
|
||||
And a "workflow" app has been created via API
|
||||
And a minimal runnable workflow draft has been synced
|
||||
When I open the app from the app list
|
||||
And I open the publish panel
|
||||
And I publish the app
|
||||
And I navigate to the app overview page
|
||||
And I enable the Web App share
|
||||
Then the Web App should be in service
|
||||
Feature: Use a shared workflow app
|
||||
|
||||
@unauthenticated
|
||||
Scenario: Access a shared workflow app without authentication
|
||||
|
||||
19
e2e/features/apps/web-app-service.feature
Normal file
19
e2e/features/apps/web-app-service.feature
Normal file
@ -0,0 +1,19 @@
|
||||
@apps @authenticated @core
|
||||
Feature: Manage Web App service
|
||||
|
||||
Scenario: Disable and restore a published workflow Web App
|
||||
Given I am signed in as the default E2E admin
|
||||
And a new runnable workflow app has been published
|
||||
When I navigate to the app overview page
|
||||
And I open the app information panel
|
||||
Then the Web App should be in service
|
||||
When an anonymous visitor opens the Web App
|
||||
Then the published workflow Web App should be accessible
|
||||
When I disable the Web App
|
||||
Then the Web App should be disabled
|
||||
When the anonymous visitor reloads the Web App
|
||||
Then the published workflow Web App should be unavailable
|
||||
When I enable the Web App
|
||||
Then the Web App should be in service
|
||||
When the anonymous visitor reloads the Web App
|
||||
Then the published workflow Web App should be accessible
|
||||
@ -9,31 +9,6 @@ import {
|
||||
} from '../../../support/api'
|
||||
import { createE2EResourceName } from '../../../support/naming'
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
When('I enable the Web App share', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name available. Run "a \\"workflow\\" app has been created via API" first.',
|
||||
)
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: new RegExp(escapeRegExp(appName)) }).click()
|
||||
const webAppCard = page.getByRole('region', { name: 'Web App' })
|
||||
const webAppSwitch = webAppCard.getByRole('switch', { name: 'Web App' })
|
||||
await expect(webAppSwitch).toBeEnabled({ timeout: 15_000 })
|
||||
await webAppSwitch.click()
|
||||
})
|
||||
|
||||
Then('the Web App should be in service', async function (this: DifyWorld) {
|
||||
const webAppCard = this.getPage().getByRole('region', { name: 'Web App' })
|
||||
await expect(webAppCard.getByText('In Service', { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
|
||||
Given('a workflow app has been published and shared via API', async function (this: DifyWorld) {
|
||||
const app = await createTestApp(createE2EResourceName('App', 'Share'), 'workflow')
|
||||
this.createdAppIds.push(app.id)
|
||||
|
||||
101
e2e/features/step-definitions/apps/web-app-service.steps.ts
Normal file
101
e2e/features/step-definitions/apps/web-app-service.steps.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import {
|
||||
createTestApp,
|
||||
getAppSiteDetail,
|
||||
getAppSiteURL,
|
||||
publishWorkflowApp,
|
||||
syncRunnableWorkflowDraft,
|
||||
} from '../../../support/api'
|
||||
import { createE2EResourceName } from '../../../support/naming'
|
||||
import { baseURL, defaultLocale } from '../../../test-env'
|
||||
|
||||
Given('a new runnable workflow app has been published', async function (this: DifyWorld) {
|
||||
const app = await createTestApp(createE2EResourceName('App', 'WebApp'), 'workflow')
|
||||
this.createdAppIds.push(app.id)
|
||||
this.lastCreatedAppName = app.name
|
||||
await syncRunnableWorkflowDraft(app.id)
|
||||
await publishWorkflowApp(app.id)
|
||||
|
||||
const appDetail = await getAppSiteDetail(app.id)
|
||||
expect(appDetail.enable_site).toBe(true)
|
||||
this.shareURL = getAppSiteURL(appDetail)
|
||||
})
|
||||
|
||||
When('I open the app information panel', async function (this: DifyWorld) {
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) {
|
||||
throw new Error('No app name available. Create an app before opening its information panel.')
|
||||
}
|
||||
|
||||
await this.getPage().getByRole('button', { name: appName }).click()
|
||||
})
|
||||
|
||||
const getWebAppSwitch = (world: DifyWorld) => {
|
||||
const webAppCard = world.getPage().getByRole('region', { name: 'Web App' })
|
||||
return webAppCard.getByRole('switch', { name: 'Web App' })
|
||||
}
|
||||
|
||||
When('an anonymous visitor opens the Web App', async function (this: DifyWorld) {
|
||||
if (!this.shareURL) throw new Error('No Web App URL is available.')
|
||||
if (!this.context) throw new Error('Playwright browser context has not been initialized.')
|
||||
|
||||
const browser = this.context.browser()
|
||||
if (!browser) throw new Error('Playwright browser has not been initialized.')
|
||||
|
||||
const anonymousContext = await browser.newContext({ baseURL, locale: defaultLocale })
|
||||
this.registerCleanup(() => anonymousContext.close())
|
||||
this.sharedAppPage = await anonymousContext.newPage()
|
||||
await this.sharedAppPage.goto(this.shareURL, { timeout: 20_000 })
|
||||
})
|
||||
|
||||
When('the anonymous visitor reloads the Web App', async function (this: DifyWorld) {
|
||||
if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.')
|
||||
await this.sharedAppPage.reload({ timeout: 20_000 })
|
||||
})
|
||||
|
||||
When('I disable the Web App', async function (this: DifyWorld) {
|
||||
const webAppSwitch = getWebAppSwitch(this)
|
||||
|
||||
await expect(webAppSwitch).not.toHaveAttribute('aria-disabled', 'true', { timeout: 15_000 })
|
||||
await expect(webAppSwitch).toHaveAttribute('aria-checked', 'true')
|
||||
await webAppSwitch.click()
|
||||
})
|
||||
|
||||
When('I enable the Web App', async function (this: DifyWorld) {
|
||||
const webAppSwitch = getWebAppSwitch(this)
|
||||
|
||||
await expect(webAppSwitch).not.toHaveAttribute('aria-disabled', 'true', { timeout: 15_000 })
|
||||
await expect(webAppSwitch).toHaveAttribute('aria-checked', 'false')
|
||||
await webAppSwitch.click()
|
||||
})
|
||||
|
||||
Then('the Web App should be in service', async function (this: DifyWorld) {
|
||||
const webAppCard = this.getPage().getByRole('region', { name: 'Web App' })
|
||||
await expect(webAppCard.getByText('In Service', { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
|
||||
Then('the Web App should be disabled', async function (this: DifyWorld) {
|
||||
const webAppCard = this.getPage().getByRole('region', { name: 'Web App' })
|
||||
await expect(webAppCard.getByText('Disabled', { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
|
||||
Then('the published workflow Web App should be accessible', async function (this: DifyWorld) {
|
||||
if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.')
|
||||
await expect(this.sharedAppPage.getByRole('button', { name: 'Execute' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
})
|
||||
|
||||
Then('the published workflow Web App should be unavailable', async function (this: DifyWorld) {
|
||||
if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.')
|
||||
await expect(this.sharedAppPage.getByRole('heading', { name: '404' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
await expect(this.sharedAppPage.getByText('App is unavailable', { exact: true })).toBeVisible()
|
||||
})
|
||||
@ -12,15 +12,19 @@ Given('a {string} app has been created via API', async function (this: DifyWorld
|
||||
})
|
||||
|
||||
Given('a minimal workflow draft has been synced', async function (this: DifyWorld) {
|
||||
const appId = this.createdAppIds.at(-1)!
|
||||
const appId = this.createdAppIds.at(-1)
|
||||
if (!appId) throw new Error('No app is available for workflow draft setup.')
|
||||
await syncMinimalWorkflowDraft(appId)
|
||||
})
|
||||
|
||||
When('I open the app from the app list', async function (this: DifyWorld) {
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) throw new Error('No app is available to open from the app list.')
|
||||
|
||||
const page = this.getPage()
|
||||
await page.goto('/apps')
|
||||
await waitForAppsConsole(page)
|
||||
const appLink = page.getByRole('link', { name: this.lastCreatedAppName!, exact: true })
|
||||
const appLink = page.getByRole('link', { name: appName, exact: true })
|
||||
await expect(appLink).toBeVisible()
|
||||
await appLink.click()
|
||||
})
|
||||
|
||||
@ -220,6 +220,7 @@ After(
|
||||
const artifactErrors: string[] = []
|
||||
const diagnosticPages = uniqueDiagnosticPages([
|
||||
{ label: 'main-page', page: this.page },
|
||||
{ label: 'shared-app', page: this.sharedAppPage },
|
||||
{ label: 'agent-v2-web-app', page: this.agentBuilder.accessPoint.webAppPage },
|
||||
{ label: 'agent-v2-api-reference', page: this.agentBuilder.accessPoint.apiReferencePage },
|
||||
{
|
||||
|
||||
@ -96,6 +96,7 @@ export class DifyWorld extends World {
|
||||
scenarioCleanups: ScenarioCleanup[] = []
|
||||
capturedDownloads: Download[] = []
|
||||
shareURL: string | undefined
|
||||
sharedAppPage: Page | undefined
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options)
|
||||
@ -120,6 +121,7 @@ export class DifyWorld extends World {
|
||||
this.scenarioCleanups = []
|
||||
this.capturedDownloads = []
|
||||
this.shareURL = undefined
|
||||
this.sharedAppPage = undefined
|
||||
}
|
||||
|
||||
async startSession(browser: Browser, authenticated: boolean) {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { APIResponse } from '@playwright/test'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { zAppDetailWithSite } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import { request } from '@playwright/test'
|
||||
import { authStatePath } from '../fixtures/auth'
|
||||
import { apiURL } from '../test-env'
|
||||
@ -82,7 +84,7 @@ export async function getWorkflowDraft(appId: string): Promise<WorkflowDraft> {
|
||||
export async function syncMinimalWorkflowDraft(appId: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
await ctx.post(`/console/api/apps/${appId}/workflows/draft`, {
|
||||
const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, {
|
||||
data: {
|
||||
graph: {
|
||||
nodes: [
|
||||
@ -101,6 +103,7 @@ export async function syncMinimalWorkflowDraft(appId: string): Promise<void> {
|
||||
conversation_variables: [],
|
||||
},
|
||||
})
|
||||
await expectApiResponseOK(response, `Sync minimal workflow draft for ${appId}`)
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
@ -164,7 +167,7 @@ export async function deleteTestApp(id: string): Promise<void> {
|
||||
export async function syncRunnableWorkflowDraft(appId: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
await ctx.post(`/console/api/apps/${appId}/workflows/draft`, {
|
||||
const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, {
|
||||
data: {
|
||||
graph: {
|
||||
nodes: [
|
||||
@ -203,6 +206,7 @@ export async function syncRunnableWorkflowDraft(appId: string): Promise<void> {
|
||||
conversation_variables: [],
|
||||
},
|
||||
})
|
||||
await expectApiResponseOK(response, `Sync runnable workflow draft for ${appId}`)
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
@ -211,22 +215,37 @@ export async function syncRunnableWorkflowDraft(appId: string): Promise<void> {
|
||||
export async function publishWorkflowApp(appId: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
await ctx.post(`/console/api/apps/${appId}/workflows/publish`, {
|
||||
const response = await ctx.post(`/console/api/apps/${appId}/workflows/publish`, {
|
||||
data: { marked_name: '', marked_comment: '' },
|
||||
})
|
||||
await expectApiResponseOK(response, `Publish workflow app ${appId}`)
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export type AppDetailWithSite = {
|
||||
mode?: string
|
||||
site: { access_token: string; app_base_url: string; enable_site: boolean }
|
||||
export function getAppSiteURL({ mode, site }: AppDetailWithSite): string {
|
||||
if (!site?.app_base_url || !site.access_token)
|
||||
throw new Error('App detail does not include a Web App URL.')
|
||||
|
||||
const webAppMode = (() => {
|
||||
if (mode === 'completion' || mode === 'workflow') return mode
|
||||
if (mode === 'advanced-chat' || mode === 'agent-chat' || mode === 'chat') return 'chat'
|
||||
throw new Error(`Unsupported Web App mode: ${mode}`)
|
||||
})()
|
||||
|
||||
return `${site.app_base_url}/${webAppMode}/${site.access_token}`
|
||||
}
|
||||
|
||||
export function getAppSiteURL({ mode, site }: AppDetailWithSite): string {
|
||||
const webAppMode = mode === 'completion' || mode === 'workflow' ? mode : 'chat'
|
||||
return `${site.app_base_url}/${webAppMode}/${site.access_token}`
|
||||
export async function getAppSiteDetail(appId: string): Promise<AppDetailWithSite> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.get(`/console/api/apps/${appId}`)
|
||||
await expectApiResponseOK(response, `Get app site detail for ${appId}`)
|
||||
return zAppDetailWithSite.parse(await response.json())
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableAppSiteAndGetURL(appId: string): Promise<string> {
|
||||
@ -243,11 +262,9 @@ export async function setAppSiteEnabled(
|
||||
data: { enable_site: enabled },
|
||||
})
|
||||
await expectApiResponseOK(enableResponse, `${enabled ? 'Enable' : 'Disable'} app site ${appId}`)
|
||||
|
||||
const detailResponse = await ctx.get(`/console/api/apps/${appId}`)
|
||||
await expectApiResponseOK(detailResponse, `Get app site detail for ${appId}`)
|
||||
return (await detailResponse.json()) as AppDetailWithSite
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
|
||||
return getAppSiteDetail(appId)
|
||||
}
|
||||
|
||||
@ -157,14 +157,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(shareLayout)/components/splash.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import Splash from '../splash'
|
||||
|
||||
const navigationMocks = vi.hoisted(() => ({
|
||||
@ -42,7 +42,7 @@ vi.mock('@/service/share', () => ({
|
||||
|
||||
vi.mock('@/service/webapp-auth', () => webAppAuthMocks)
|
||||
|
||||
describe('Splash redirect security', () => {
|
||||
describe('Splash', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
webAppState.shareCode = 'share-app'
|
||||
@ -121,4 +121,36 @@ describe('Splash redirect security', () => {
|
||||
expect(webAppAuthMocks.webAppLoginStatus).not.toHaveBeenCalled()
|
||||
expect(fetchAccessTokenMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show the app unavailable state when a public Web App passport is not found', async () => {
|
||||
navigationMocks.searchParams = new URLSearchParams()
|
||||
webAppAuthMocks.webAppLoginStatus.mockResolvedValue({
|
||||
userLoggedIn: true,
|
||||
appLoggedIn: false,
|
||||
})
|
||||
fetchAccessTokenMock.mockRejectedValue(new Response(null, { status: 404 }))
|
||||
|
||||
render(
|
||||
<Splash>
|
||||
<div>share application</div>
|
||||
</Splash>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('share.common.appUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose the unavailable-state action as a button', () => {
|
||||
navigationMocks.searchParams = new URLSearchParams({
|
||||
code: '404',
|
||||
message: 'The Web App is unavailable.',
|
||||
})
|
||||
|
||||
render(
|
||||
<Splash>
|
||||
<div>share application</div>
|
||||
</Splash>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'share.login.backToHome' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -57,6 +57,7 @@ function Splash({ children }: PropsWithChildren) {
|
||||
}, [getSigninUrl, pathname, redirectUrl, router, shareCode])
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [unavailableShareCode, setUnavailableShareCode] = useState<string>()
|
||||
useEffect(() => {
|
||||
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
|
||||
const isSigninRoute = isWebAppSigninPath(pathname)
|
||||
@ -101,7 +102,12 @@ function Splash({ children }: PropsWithChildren) {
|
||||
})
|
||||
setWebAppPassport(effectiveShareCode, access_token)
|
||||
redirectOrFinish()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof Response && error.status === 404) {
|
||||
setUnavailableShareCode(effectiveShareCode)
|
||||
await webAppLogout(effectiveShareCode)
|
||||
return
|
||||
}
|
||||
await webAppLogout(effectiveShareCode)
|
||||
proceedToAuth()
|
||||
}
|
||||
@ -126,11 +132,23 @@ function Splash({ children }: PropsWithChildren) {
|
||||
code={code || t(($) => $['common.appUnavailable'], { ns: 'share' })}
|
||||
unknownReason={message}
|
||||
/>
|
||||
<span className="cursor-pointer system-sm-regular text-text-tertiary" onClick={backToHome}>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer system-sm-regular text-text-tertiary"
|
||||
onClick={backToHome}
|
||||
>
|
||||
{code === '403'
|
||||
? t(($) => $['userProfile.logout'], { ns: 'common' })
|
||||
: t(($) => $['login.backToHome'], { ns: 'share' })}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (unavailableShareCode === shareCode) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<AppUnavailable />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user