This commit is contained in:
Stephen Zhou 2026-03-19 11:14:20 +08:00
parent 60bae05d0f
commit b80e944665
No known key found for this signature in database
8 changed files with 226 additions and 18 deletions

View File

@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import WebSSOForm from '../page'
const mockReplace = vi.fn()
let mockRedirectUrl = '/share/test-share-code'
let mockWebAppAccessMode: AccessMode | null = null
let mockSystemFeaturesEnabled = true
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
useSearchParams: () => ({
get: (key: string) => key === 'redirect_url' ? mockRedirectUrl : null,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) =>
selector({
systemFeatures: {
webapp_auth: {
enabled: mockSystemFeaturesEnabled,
},
},
}),
}))
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: { webAppAccessMode: AccessMode | null, shareCode: string | null }) => unknown) =>
selector({
webAppAccessMode: mockWebAppAccessMode,
shareCode: 'test-share-code',
}),
}))
vi.mock('@/service/webapp-auth', () => ({
webAppLogout: vi.fn(),
}))
vi.mock('../normalForm', () => ({
default: () => <div data-testid="normal-form" />,
}))
vi.mock('../components/external-member-sso-auth', () => ({
default: () => <div data-testid="external-member-sso-auth" />,
}))
describe('WebSSOForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRedirectUrl = '/share/test-share-code'
mockWebAppAccessMode = null
mockSystemFeaturesEnabled = true
})
describe('Access Mode Resolution', () => {
it('should avoid rendering auth variants before the access mode query resolves', () => {
render(<WebSSOForm />)
expect(screen.queryByTestId('normal-form')).not.toBeInTheDocument()
expect(screen.queryByTestId('external-member-sso-auth')).not.toBeInTheDocument()
expect(screen.queryByText('share.login.backToHome')).not.toBeInTheDocument()
})
it('should render the normal form for organization-backed access modes', () => {
mockWebAppAccessMode = AccessMode.ORGANIZATION
render(<WebSSOForm />)
expect(screen.getByTestId('normal-form')).toBeInTheDocument()
})
})
})

View File

@ -45,10 +45,13 @@ const WebSSOForm: FC = () => {
if (!systemFeatures.webapp_auth.enabled) {
return (
<div className="flex h-full items-center justify-center">
<p className="system-xs-regular text-text-tertiary">{t('webapp.disabled', { ns: 'login' })}</p>
<p className="text-text-tertiary system-xs-regular">{t('webapp.disabled', { ns: 'login' })}</p>
</div>
)
}
if (webAppAccessMode === null)
return <div className="w-full max-w-[400px]" />
if (webAppAccessMode && (webAppAccessMode === AccessMode.ORGANIZATION || webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS)) {
return (
<div className="w-full max-w-[400px]">
@ -63,7 +66,7 @@ const WebSSOForm: FC = () => {
return (
<div className="flex h-full flex-col items-center justify-center gap-y-4">
<AppUnavailable className="h-auto w-auto" isUnknownReason={true} />
<span className="system-sm-regular cursor-pointer text-text-tertiary" onClick={backToHome}>{t('login.backToHome', { ns: 'share' })}</span>
<span className="cursor-pointer text-text-tertiary system-sm-regular" onClick={backToHome}>{t('login.backToHome', { ns: 'share' })}</span>
</div>
)
}

View File

@ -1,10 +1,12 @@
import type { SiteInfo } from '@/models/share'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AccessMode } from '@/models/access-control'
import MenuDropdown from '../menu-dropdown'
const mockReplace = vi.fn()
const mockPathname = '/test-path'
let mockWebAppAccessMode: AccessMode | null = AccessMode.SPECIFIC_GROUPS_MEMBERS
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
@ -16,7 +18,7 @@ const mockShareCode = 'test-share-code'
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
webAppAccessMode: 'code',
webAppAccessMode: mockWebAppAccessMode,
shareCode: mockShareCode,
}
return selector(state)
@ -41,6 +43,7 @@ describe('MenuDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWebAppAccessMode = AccessMode.SPECIFIC_GROUPS_MEMBERS
})
describe('rendering', () => {
@ -151,6 +154,19 @@ describe('MenuDropdown', () => {
})
})
it('should hide logout option when access mode is unknown', async () => {
mockWebAppAccessMode = null
render(<MenuDropdown data={baseSiteInfo} hideLogout={false} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.queryByText('common.userProfile.logout')).not.toBeInTheDocument()
})
})
it('should call webAppLogout and redirect when logout is clicked', async () => {
render(<MenuDropdown data={baseSiteInfo} hideLogout={false} />)

View File

@ -1,4 +1,5 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { AppSourceType } from '@/service/share'
import { useTextGenerationAppState } from '../use-text-generation-app-state'
@ -118,13 +119,13 @@ const defaultAppParams = {
type MockWebAppState = {
appInfo: MockAppInfo | null
appParams: typeof defaultAppParams | null
webAppAccessMode: string
webAppAccessMode: AccessMode | null
}
const mockWebAppState: MockWebAppState = {
appInfo: defaultAppInfo,
appParams: defaultAppParams,
webAppAccessMode: 'public',
webAppAccessMode: AccessMode.PUBLIC,
}
const resetMockWebAppState = () => {
@ -154,7 +155,7 @@ const resetMockWebAppState = () => {
image_file_size_limit: 10,
},
}
mockWebAppState.webAppAccessMode = 'public'
mockWebAppState.webAppAccessMode = AccessMode.PUBLIC
}
vi.mock('@/context/global-public-context', () => ({

View File

@ -58,6 +58,7 @@ const MenuDropdown: FC<Props> = ({
}, [router, pathname, webAppLogout, shareCode])
const [show, setShow] = useState(false)
const showLogout = !hideLogout && webAppAccessMode !== null && webAppAccessMode !== AccessMode.EXTERNAL_MEMBERS && webAppAccessMode !== AccessMode.PUBLIC
useEffect(() => {
if (forceClose)
@ -85,7 +86,7 @@ const MenuDropdown: FC<Props> = ({
<PortalToFollowElemContent className="z-50">
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<div className="p-1">
<div className={cn('system-md-regular flex cursor-pointer items-center rounded-lg py-1.5 pl-3 pr-2 text-text-secondary')}>
<div className={cn('flex cursor-pointer items-center rounded-lg py-1.5 pl-3 pr-2 text-text-secondary system-md-regular')}>
<div className="grow">{t('theme.theme', { ns: 'common' })}</div>
<ThemeSwitcher />
</div>
@ -93,7 +94,7 @@ const MenuDropdown: FC<Props> = ({
<Divider type="horizontal" className="my-0" />
<div className="p-1">
{data?.privacy_policy && (
<a href={data.privacy_policy} target="_blank" className="system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover">
<a href={data.privacy_policy} target="_blank" className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover">
<span className="grow">{t('chat.privacyPolicyMiddle', { ns: 'share' })}</span>
</a>
)}
@ -102,16 +103,16 @@ const MenuDropdown: FC<Props> = ({
handleTrigger()
setShow(true)
}}
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
className="cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover"
>
{t('userProfile.about', { ns: 'common' })}
</div>
</div>
{!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && (
{showLogout && (
<div className="p-1">
<div
onClick={handleLogout}
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
className="cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover"
>
{t('userProfile.logout', { ns: 'common' })}
</div>

View File

@ -18,7 +18,7 @@ import RunBatch from './run-batch'
import RunOnce from './run-once'
type TextGenerationSidebarProps = {
accessMode: AccessMode
accessMode: AccessMode | null
allTasksRun: boolean
currentTab: string
customConfig: TextGenerationCustomConfig | null

View File

@ -0,0 +1,108 @@
import { render, screen, waitFor } from '@testing-library/react'
import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
let mockPathname = '/share/test-share-code'
let mockRedirectUrl: string | null = null
let mockAccessModeResult: { accessMode: AccessMode } | undefined
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
useSearchParams: () => ({
get: (key: string) => key === 'redirect_url' ? mockRedirectUrl : null,
toString: () => {
const params = new URLSearchParams()
if (mockRedirectUrl)
params.set('redirect_url', mockRedirectUrl)
return params.toString()
},
}),
}))
vi.mock('@/app/components/base/chat/utils', () => ({
getProcessedSystemVariablesFromUrlParams: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/use-share', () => ({
useGetWebAppAccessModeByCode: vi.fn(() => ({
data: mockAccessModeResult,
})),
}))
const StoreSnapshot = () => {
const shareCode = useWebAppStore(s => s.shareCode)
const accessMode = useWebAppStore(s => s.webAppAccessMode)
return (
<div>
<span data-testid="share-code">{shareCode ?? 'none'}</span>
<span data-testid="access-mode">{accessMode ?? 'unknown'}</span>
</div>
)
}
describe('WebAppStoreProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/share/test-share-code'
mockRedirectUrl = null
mockAccessModeResult = undefined
useWebAppStore.setState({
shareCode: null,
appInfo: null,
appParams: null,
webAppAccessMode: null,
appMeta: null,
userCanAccessApp: false,
embeddedUserId: null,
embeddedConversationId: null,
})
})
describe('Access Mode State', () => {
it('should keep the access mode unknown until the query resolves', async () => {
render(
<WebAppStoreProvider>
<StoreSnapshot />
</WebAppStoreProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('share-code')).toHaveTextContent('test-share-code')
})
expect(screen.getByTestId('access-mode')).toHaveTextContent('unknown')
})
it('should reset the access mode when the share code changes before the next result arrives', async () => {
const { rerender } = render(
<WebAppStoreProvider>
<StoreSnapshot />
</WebAppStoreProvider>,
)
mockAccessModeResult = { accessMode: AccessMode.PUBLIC }
rerender(
<WebAppStoreProvider>
<StoreSnapshot />
</WebAppStoreProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('access-mode')).toHaveTextContent(AccessMode.PUBLIC)
})
mockPathname = '/share/next-share-code'
mockAccessModeResult = undefined
rerender(
<WebAppStoreProvider>
<StoreSnapshot />
</WebAppStoreProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('share-code')).toHaveTextContent('next-share-code')
})
expect(screen.getByTestId('access-mode')).toHaveTextContent('unknown')
})
})
})

View File

@ -2,11 +2,11 @@
import type { FC, PropsWithChildren } from 'react'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AccessMode } from '@/models/access-control'
import type { AppData, AppMeta } from '@/models/share'
import { useEffect } from 'react'
import { create } from 'zustand'
import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/chat/utils'
import { AccessMode } from '@/models/access-control'
import { usePathname, useSearchParams } from '@/next/navigation'
import { useGetWebAppAccessModeByCode } from '@/service/use-share'
@ -17,8 +17,8 @@ type WebAppStore = {
updateAppInfo: (appInfo: AppData | null) => void
appParams: ChatConfig | null
updateAppParams: (appParams: ChatConfig | null) => void
webAppAccessMode: AccessMode
updateWebAppAccessMode: (accessMode: AccessMode) => void
webAppAccessMode: AccessMode | null
updateWebAppAccessMode: (accessMode: AccessMode | null) => void
appMeta: AppMeta | null
updateWebAppMeta: (appMeta: AppMeta | null) => void
userCanAccessApp: boolean
@ -36,8 +36,8 @@ export const useWebAppStore = create<WebAppStore>(set => ({
updateAppInfo: (appInfo: AppData | null) => set(() => ({ appInfo })),
appParams: null,
updateAppParams: (appParams: ChatConfig | null) => set(() => ({ appParams })),
webAppAccessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
updateWebAppAccessMode: (accessMode: AccessMode) => set(() => ({ webAppAccessMode: accessMode })),
webAppAccessMode: null,
updateWebAppAccessMode: (accessMode: AccessMode | null) => set(() => ({ webAppAccessMode: accessMode })),
appMeta: null,
updateWebAppMeta: (appMeta: AppMeta | null) => set(() => ({ appMeta })),
userCanAccessApp: false,
@ -78,6 +78,10 @@ const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
updateShareCode(shareCode)
}, [shareCode, updateShareCode])
useEffect(() => {
updateWebAppAccessMode(null)
}, [shareCode, updateWebAppAccessMode])
useEffect(() => {
let cancelled = false
const syncEmbeddedUserId = async () => {
@ -106,7 +110,7 @@ const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
useEffect(() => {
if (accessModeResult?.accessMode)
updateWebAppAccessMode(accessModeResult.accessMode)
}, [accessModeResult, updateWebAppAccessMode, shareCode])
}, [accessModeResult, updateWebAppAccessMode])
return <>{children}</>
}