mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
fix: gate deployments in route layout (#38078)
This commit is contained in:
parent
484633d261
commit
7bb94cb6fe
@ -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()
|
||||
})
|
||||
})
|
||||
61
web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx
Normal file
61
web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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}</>
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user