fix: gate deployments in route layout (#38078)

This commit is contained in:
yyh 2026-06-27 21:35:37 +08:00 committed by GitHub
parent 484633d261
commit 7bb94cb6fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 82 additions and 146 deletions

View File

@ -1,106 +0,0 @@
import type { ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { RoleRouteGuard } from '../role-route-guard'
const mocks = vi.hoisted(() => ({
redirect: vi.fn((url: string) => {
throw new Error(`NEXT_REDIRECT:${url}`)
}),
currentWorkspaceQueryOptions: vi.fn(() => ({ queryKey: ['console', 'workspaces', 'current', 'post'] })),
systemFeaturesQueryKey: vi.fn(() => ['console', 'systemFeatures', 'get']),
}))
let mockPathname = '/apps'
vi.mock('@/next/navigation', () => ({
redirect: (url: string) => mocks.redirect(url),
usePathname: () => mockPathname,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: vi.fn(),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
systemFeatures: {
get: {
queryKey: mocks.systemFeaturesQueryKey,
},
},
workspaces: {
current: {
post: {
queryOptions: mocks.currentWorkspaceQueryOptions,
},
},
},
},
}))
const mockUseQuery = vi.mocked(useQuery)
function renderGuard(children: ReactNode) {
return renderWithSystemFeatures(
<RoleRouteGuard>
{children}
</RoleRouteGuard>,
{
systemFeatures: {
enable_app_deploy: true,
},
},
)
}
const setCurrentWorkspaceQuery = (overrides: { role?: string, isPending?: boolean } = {}) => {
mockUseQuery.mockReturnValue({
data: overrides.role,
isPending: overrides.isPending ?? false,
} as ReturnType<typeof useQuery>)
}
describe('RoleRouteGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/app/app-1'
setCurrentWorkspaceQuery()
})
it.each(['/', '/apps', '/app/app-1', '/deployments/create', '/snippets', '/explore/apps', '/tools', '/integrations', '/datasets'])('should allow %s without workspace role checks', (pathname) => {
mockPathname = pathname
renderGuard(<div>content</div>)
expect(screen.getByText('content')).toBeInTheDocument()
expect(mocks.redirect).not.toHaveBeenCalled()
expect(mockUseQuery).not.toHaveBeenCalled()
expect(mocks.currentWorkspaceQueryOptions).not.toHaveBeenCalled()
})
it('should redirect deployments route when app deploy is disabled', () => {
mockPathname = '/deployments/create'
expect(() => renderWithSystemFeatures(
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>,
{
systemFeatures: {
enable_app_deploy: false,
},
},
)).toThrow('NEXT_REDIRECT:/apps')
expect(mocks.redirect).toHaveBeenCalledWith('/apps')
expect(mockUseQuery).not.toHaveBeenCalled()
expect(mocks.currentWorkspaceQueryOptions).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,61 @@
import type { ReactElement, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
const mocks = vi.hoisted(() => ({
ensureQueryData: vi.fn(),
systemFeaturesQueryOptions: { queryKey: ['console', 'system-features'] },
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND')
}),
}))
vi.mock('@/context/query-client-server', () => ({
getQueryClientServer: () => ({
ensureQueryData: mocks.ensureQueryData,
}),
}))
vi.mock('@/features/system-features/server', () => ({
serverSystemFeaturesQueryOptions: () => mocks.systemFeaturesQueryOptions,
}))
vi.mock('@/features/deployments/deploy-drawer', () => ({
DeployDrawer: () => <div>Deploy drawer</div>,
}))
vi.mock('@/next/navigation', () => ({
notFound: () => mocks.notFound(),
}))
const renderDeploymentsLayout = async (children: ReactNode) => {
const { default: DeploymentsLayout } = await import('../layout')
const element = await DeploymentsLayout({ children })
render(element as ReactElement)
}
describe('DeploymentsLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: true })
})
it('should render deployments content and drawer when app deploy is enabled', async () => {
await renderDeploymentsLayout(<div>Deployments content</div>)
expect(mocks.ensureQueryData).toHaveBeenCalledWith(mocks.systemFeaturesQueryOptions)
expect(screen.getByText('Deployments content')).toBeInTheDocument()
expect(screen.getByText('Deploy drawer')).toBeInTheDocument()
expect(mocks.notFound).not.toHaveBeenCalled()
})
it('should trigger notFound when app deploy is disabled', async () => {
mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: false })
const { default: DeploymentsLayout } = await import('../layout')
await expect(DeploymentsLayout({
children: <div>Deployments content</div>,
})).rejects.toThrow('NEXT_NOT_FOUND')
expect(mocks.notFound).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,9 +1,17 @@
import type { ReactNode } from 'react'
import { getQueryClientServer } from '@/context/query-client-server'
import { DeployDrawer } from '@/features/deployments/deploy-drawer'
import { serverSystemFeaturesQueryOptions } from '@/features/system-features/server'
import { notFound } from '@/next/navigation'
export default function DeploymentsLayout({ children }: {
export default async function DeploymentsLayout({ children }: {
children: ReactNode
}) {
const systemFeatures = await getQueryClientServer().ensureQueryData(serverSystemFeaturesQueryOptions())
if (!systemFeatures.enable_app_deploy)
notFound()
return (
<>
{children}

View File

@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
@ -17,7 +16,6 @@ import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import PartnerStack from '../components/billing/partner-stack'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
import { RoleRouteGuard } from './role-route-guard'
export default async function Layout({ children }: { children: ReactNode }) {
return (
@ -33,9 +31,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout>
<RoleRouteGuard>
{children}
</RoleRouteGuard>
{children}
</MainNavLayout>
<InSiteMessageNotification />
<PartnerStack />

View File

@ -1,21 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { redirect, usePathname } from '@/next/navigation'
function isPathUnderRoute(pathname: string, route: string) {
return pathname === route || pathname.startsWith(`${route}/`)
}
export function RoleRouteGuard({ children }: { children: ReactNode }) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const pathname = usePathname()
const shouldRedirectAppDeploy = isPathUnderRoute(pathname, '/deployments') && !systemFeatures.enable_app_deploy
if (shouldRedirectAppDeploy)
redirect('/apps')
return <>{children}</>
}

View File

@ -20,7 +20,7 @@ import { usePathname, useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import MainNav from '../index'
import { MainNav } from '../index'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
const activeGradientMaskClassName = 'aria-[current=page]:dify-blue-glass-surface'
@ -608,7 +608,7 @@ describe('MainNav', () => {
expect(screen.getByTestId('app-detail-section')).toBeInTheDocument()
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
expect(screen.getByRole('complementary')).toHaveClass('w-[248px]')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
@ -673,7 +673,7 @@ describe('MainNav', () => {
fireEvent.mouseEnter(screen.getByTestId('app-detail-top').parentElement!)
fireEvent.click(screen.getByTestId('app-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-[248px]', 'transition-none')
expect(screen.getByRole('complementary')).toHaveClass('w-62', 'transition-none')
expect(screen.getByRole('complementary')).not.toHaveClass('overflow-visible')
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
@ -689,7 +689,7 @@ describe('MainNav', () => {
expect(screen.getByTestId('dataset-detail-section')).toBeInTheDocument()
expect(screen.getByTestId('dataset-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'true')
expect(screen.getByRole('complementary')).toHaveClass('w-[248px]')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
@ -733,7 +733,7 @@ describe('MainNav', () => {
expect(screen.getByTestId('agent-detail-section')).toBeInTheDocument()
expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('agent-detail-section')).toHaveAttribute('data-expand', 'true')
expect(screen.getByRole('complementary')).toHaveClass('w-[248px]')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
@ -760,7 +760,7 @@ describe('MainNav', () => {
expect(screen.getByTestId('deployment-detail-section')).toBeInTheDocument()
expect(screen.getByTestId('deployment-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('deployment-detail-section')).toHaveAttribute('data-expand', 'true')
expect(screen.getByRole('complementary')).toHaveClass('w-[248px]')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()

View File

@ -17,7 +17,7 @@ vi.mock('react-i18next', () => ({
}))
vi.mock('../index', () => ({
default: ({ className }: { className?: string }) => <aside className={className} data-testid="main-nav">MainNav</aside>,
MainNav: ({ className }: { className?: string }) => <aside className={className} data-testid="main-nav">MainNav</aside>,
}))
describe('MainNavLayout', () => {

View File

@ -82,9 +82,9 @@ const isSnippetDetailPathname = (pathname: string) => {
return section === 'snippets' && !!snippetId
}
const MainNav = ({
export function MainNav({
className,
}: MainNavProps) => {
}: MainNavProps) {
const { t } = useTranslation()
const pathname = usePathname()
const { langGeniusVersionInfo, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor } = useAppContext()
@ -222,7 +222,7 @@ const MainNav = ({
isDetailNavigationHoverPreviewOpen ? 'overflow-visible' : 'overflow-hidden',
showDetailNavigation
? detailNavigationExpanded
? 'w-[248px] bg-background-body p-1'
? 'w-62 bg-background-body p-1'
: 'w-16 bg-background-body p-1'
: showSnippetDetailBottomNavigation
? 'w-16 bg-background-body p-1'
@ -356,5 +356,3 @@ const MainNav = ({
</aside>
)
}
export default MainNav

View File

@ -2,7 +2,7 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import MainNav from './index'
import { MainNav } from '.'
import { MAIN_CONTENT_ID, SkipNav } from './skip-nav'
type MainNavLayoutProps = {