fix(web): prevent local cloud analytics script errors (#36420)

This commit is contained in:
yyh 2026-05-20 11:23:21 +08:00 committed by GitHub
parent 77333e57a7
commit 468cc19e68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 226 additions and 147 deletions

View File

@ -1023,14 +1023,6 @@
"count": 2
}
},
"web/app/components/base/ga/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
},
"web/app/components/base/icons/src/public/avatar/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2

View File

@ -3,7 +3,7 @@ import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { GotoAnything } from '@/app/components/goto-anything'
import Header from '@/app/components/header'
@ -19,7 +19,7 @@ import RoleRouteGuard from './role-route-guard'
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GA gaType={GaType.admin} />
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<AppInitializer>
<AppContextProvider>

View File

@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@ -13,7 +13,7 @@ import Header from './header'
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GA gaType={GaType.admin} />
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<AppInitializer>
<AppContextProvider>

View File

@ -0,0 +1,141 @@
import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app'
import { renderHook } from '@testing-library/react'
import { useWorkflowOnlineUsers } from '../use-workflow-online-users'
type QueryOptions = {
input: {
body: {
app_ids: string[]
}
}
enabled: boolean
select: (response?: WorkflowOnlineUsersResponse) => Record<string, WorkflowOnlineUser[]>
refetchInterval: false | number
}
const { mockUseQuery, mockQueryOptions } = vi.hoisted(() => ({
mockUseQuery: vi.fn(),
mockQueryOptions: vi.fn((options: QueryOptions) => options),
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: mockUseQuery,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
workflowOnlineUsers: {
queryOptions: mockQueryOptions,
},
},
},
}))
const getLastQueryOptions = () => {
const lastCall = mockQueryOptions.mock.lastCall
if (!lastCall)
throw new Error('workflowOnlineUsers.queryOptions was not called')
return lastCall[0] as QueryOptions
}
describe('useWorkflowOnlineUsers', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockReturnValue({ data: undefined })
})
describe('Query Options', () => {
it('should disable query with a valid empty input when app ids are empty', () => {
renderHook(() => useWorkflowOnlineUsers({
appIds: [],
enabled: true,
}))
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
input: { body: { app_ids: [] } },
enabled: false,
refetchInterval: false,
}))
expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({
input: { body: { app_ids: [] } },
enabled: false,
}))
})
it('should enable query and polling when collaboration is enabled with app ids', () => {
renderHook(() => useWorkflowOnlineUsers({
appIds: ['app-1', 'app-2'],
enabled: true,
}))
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
input: { body: { app_ids: ['app-1', 'app-2'] } },
enabled: true,
refetchInterval: 10000,
}))
})
it('should disable query while preserving valid input when collaboration is disabled', () => {
renderHook(() => useWorkflowOnlineUsers({
appIds: ['app-1'],
enabled: false,
}))
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
input: { body: { app_ids: ['app-1'] } },
enabled: false,
refetchInterval: false,
}))
})
})
describe('Response Mapping', () => {
it('should normalize array response data by app id', () => {
renderHook(() => useWorkflowOnlineUsers({
appIds: ['app-1'],
enabled: true,
}))
const options = getLastQueryOptions()
const user = { user_id: 'user-1', username: 'Alice' }
expect(options.select({
data: [
{ app_id: 'app-1', users: [user] },
],
})).toEqual({
'app-1': [user],
})
})
it('should normalize record response data without changing user arrays', () => {
renderHook(() => useWorkflowOnlineUsers({
appIds: ['app-1'],
enabled: true,
}))
const options = getLastQueryOptions()
const users = [{ user_id: 'user-1', username: 'Alice' }]
expect(options.select({
data: {
'app-1': users,
},
})).toEqual({
'app-1': users,
})
})
})
describe('Return Value', () => {
it('should return an empty map when query data is unavailable', () => {
const { result } = renderHook(() => useWorkflowOnlineUsers({
appIds: ['app-1'],
enabled: true,
}))
expect(result.current.onlineUsersMap).toEqual({})
})
})
})

View File

@ -1,5 +1,5 @@
import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
type WorkflowOnlineUsersMap = Record<string, WorkflowOnlineUser[]>
@ -36,9 +36,8 @@ export const useWorkflowOnlineUsers = ({
}: UseWorkflowOnlineUsersParams) => {
const shouldFetch = enabled && appIds.length > 0
const { data: onlineUsersMap = {} } = useQuery(consoleQuery.apps.workflowOnlineUsers.queryOptions({
input: shouldFetch
? { body: { app_ids: appIds } }
: skipToken,
input: { body: { app_ids: appIds } },
enabled: shouldFetch,
select: normalizeWorkflowOnlineUsers,
refetchInterval: shouldFetch ? 10000 : false,
}))

View File

@ -2,29 +2,24 @@ import type { ReactElement, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
type ConfigState = {
isCeEdition: boolean
isCloudEdition: boolean
isProd: boolean
}
type GaProps = {
gaType: string
}
type GaRenderFn = (props: GaProps) => Promise<ReactNode>
type GaTypeValue = 'admin' | 'webapp'
type GoogleAnalyticsScriptsRenderFn = () => Promise<ReactNode>
const { mockHeaders, mockHeadersGet, configState } = vi.hoisted(() => ({
mockHeaders: vi.fn(),
mockHeadersGet: vi.fn(),
configState: ({
isCeEdition: false,
isCloudEdition: true,
isProd: true,
}) as ConfigState,
}))
vi.mock('@/config', () => ({
get IS_CE_EDITION() {
return configState.isCeEdition
get IS_CLOUD_EDITION() {
return configState.isCloudEdition
},
get IS_PROD() {
return configState.isProd
@ -41,18 +36,18 @@ vi.mock('@/next/script', () => ({
strategy,
src,
nonce,
dangerouslySetInnerHTML,
children,
}: {
id?: string
strategy?: string
src?: string
nonce?: string
dangerouslySetInnerHTML?: { __html?: string }
children?: ReactNode
}) => (
<script
data-testid="mock-next-script"
data-id={id ?? ''}
data-inline={dangerouslySetInnerHTML?.__html ?? ''}
data-inline={typeof children === 'string' ? children : ''}
data-nonce={nonce ?? ''}
data-src={src ?? ''}
data-strategy={strategy ?? ''}
@ -62,24 +57,15 @@ vi.mock('@/next/script', () => ({
const loadComponent = async () => {
const mod = await import('../index')
// mod.default is either an async function (server component) or
// a React.memo object whose .type is the async function.
const rawExport = mod.default as unknown
const renderer: GaRenderFn | undefined
= typeof rawExport === 'function' ? (rawExport as GaRenderFn) : (rawExport as { type?: GaRenderFn }).type
if (!renderer)
throw new Error('GA component is not callable in tests')
return {
renderer,
GaType: mod.GaType,
renderer: mod.GoogleAnalyticsScripts as GoogleAnalyticsScriptsRenderFn,
}
}
const renderGA = async (gaType: GaTypeValue) => {
const renderGoogleAnalyticsScripts = async () => {
const { renderer } = await loadComponent()
const element = await renderer({ gaType })
const element = await renderer()
if (!element)
return { element }
@ -92,43 +78,57 @@ describe('GA', () => {
vi.clearAllMocks()
vi.resetModules()
configState.isCeEdition = false
configState.isCloudEdition = true
configState.isProd = true
mockHeadersGet.mockReturnValue(`default-src 'self'; script-src 'self' 'nonce-test-nonce'`)
mockHeadersGet.mockImplementation((name: string) => name === 'x-nonce' ? 'test-nonce' : null)
mockHeaders.mockResolvedValue({
get: mockHeadersGet,
})
})
describe('Rendering', () => {
it('should return null when CE edition is enabled', async () => {
configState.isCeEdition = true
const { element } = await renderGA('admin')
it('should return null when cloud edition is disabled', async () => {
configState.isCloudEdition = false
const { element } = await renderGoogleAnalyticsScripts()
expect(element).toBeNull()
expect(mockHeaders).not.toHaveBeenCalled()
})
it('should render three script tags with admin GA id in production', async () => {
await renderGA('admin')
it('should return null when not in production', async () => {
configState.isProd = false
const { element } = await renderGoogleAnalyticsScripts()
expect(element).toBeNull()
expect(mockHeaders).not.toHaveBeenCalled()
})
it('should render consent, CookieYes, and Google Analytics scripts in production', async () => {
await renderGoogleAnalyticsScripts()
const scripts = screen.getAllByTestId('mock-next-script')
expect(scripts).toHaveLength(3)
expect(scripts).toHaveLength(4)
expect(mockHeaders).toHaveBeenCalledTimes(1)
expect(mockHeadersGet).toHaveBeenCalledWith('content-security-policy')
expect(mockHeadersGet).toHaveBeenCalledWith('x-nonce')
expect(scripts[0]).toHaveAttribute('data-id', 'ga-init')
expect(scripts[0]).toHaveAttribute('data-id', 'google-consent-defaults')
expect(scripts[0]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('config', 'G-DM9497FN4V');`))
expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('consent', 'default'`))
expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`analytics_storage: 'denied'`))
expect(scripts[1]).toHaveAttribute('data-id', 'cookieyes')
expect(scripts[1]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[1]).toHaveAttribute('data-src', 'https://www.googletagmanager.com/gtag/js?id=G-DM9497FN4V')
expect(scripts[1]).toHaveAttribute('data-src', 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js')
expect(scripts[2]).toHaveAttribute('data-id', 'cookieyes')
expect(scripts[2]).toHaveAttribute('data-strategy', 'lazyOnload')
expect(scripts[2]).toHaveAttribute('data-src', 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js')
expect(scripts[2]).toHaveAttribute('data-id', 'google-analytics')
expect(scripts[2]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[2]).toHaveAttribute('data-src', 'https://www.googletagmanager.com/gtag/js?id=G-DM9497FN4V')
expect(scripts[3]).toHaveAttribute('data-id', 'google-analytics-init')
expect(scripts[3]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[3]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('config', 'G-DM9497FN4V');`))
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', 'test-nonce')
@ -136,45 +136,10 @@ describe('GA', () => {
})
})
describe('Props', () => {
it('should use webapp GA id when gaType is webapp', async () => {
await renderGA('webapp')
const scripts = screen.getAllByTestId('mock-next-script')
expect(scripts[0]).toHaveAttribute('data-inline', expect.stringContaining(`window.gtag('config', 'G-2MFWXK7WYT');`))
expect(scripts[1]).toHaveAttribute('data-src', 'https://www.googletagmanager.com/gtag/js?id=G-2MFWXK7WYT')
})
})
describe('Edge Cases', () => {
it('should not read headers and should omit nonce when not in production', async () => {
configState.isProd = false
await renderGA('admin')
const scripts = screen.getAllByTestId('mock-next-script')
expect(mockHeaders).not.toHaveBeenCalled()
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', '')
})
})
it('should omit nonce when CSP header does not contain nonce token', async () => {
mockHeadersGet.mockReturnValue(`default-src 'self'; script-src 'self'`)
await renderGA('admin')
const scripts = screen.getAllByTestId('mock-next-script')
expect(mockHeaders).toHaveBeenCalledTimes(1)
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', '')
})
})
it('should omit nonce when CSP header is null', async () => {
it('should omit nonce when x-nonce header is missing', async () => {
mockHeadersGet.mockReturnValue(null)
await renderGA('admin')
await renderGoogleAnalyticsScripts()
const scripts = screen.getAllByTestId('mock-next-script')

View File

@ -1,75 +1,57 @@
import type { FC } from 'react'
import * as React from 'react'
import { IS_CE_EDITION, IS_PROD } from '@/config'
import { IS_CLOUD_EDITION, IS_PROD } from '@/config'
import { headers } from '@/next/headers'
import Script from '@/next/script'
export enum GaType {
admin = 'admin',
webapp = 'webapp',
}
const GA_MEASUREMENT_ID_ADMIN = 'G-DM9497FN4V'
const GA_MEASUREMENT_ID_WEBAPP = 'G-2MFWXK7WYT'
const GOOGLE_ANALYTICS_ID = 'G-DM9497FN4V'
const GOOGLE_TAG_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ANALYTICS_ID}`
const COOKIEYES_SCRIPT_SRC = 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js'
const gaIdMaps = {
[GaType.admin]: GA_MEASUREMENT_ID_ADMIN,
[GaType.webapp]: GA_MEASUREMENT_ID_WEBAPP,
}
type IGAProps = {
gaType: GaType
}
const extractNonceFromCSP = (cspHeader: string | null): string | undefined => {
if (!cspHeader)
return undefined
const nonceMatch = /'nonce-([^']+)'/.exec(cspHeader)
return nonceMatch ? nonceMatch[1] : undefined
}
const GA: FC<IGAProps> = async ({
gaType,
}) => {
if (IS_CE_EDITION)
export async function GoogleAnalyticsScripts() {
if (!IS_CLOUD_EDITION || !IS_PROD)
return null
const cspHeader = IS_PROD
? (await headers()).get('content-security-policy')
: null
const nonce = extractNonceFromCSP(cspHeader)
const nonce = (await headers()).get('x-nonce') ?? undefined
return (
<>
{/* Initialize dataLayer first */}
<Script
id="ga-init"
id="google-consent-defaults"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag(){window.dataLayer.push(arguments);};
window.gtag('js', new Date());
window.gtag('config', '${gaIdMaps[gaType]}');
`,
}}
nonce={nonce}
/>
{/* Load GA script */}
<Script
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${gaIdMaps[gaType]}`}
nonce={nonce}
/>
{/* Cookie banner */}
>
{`
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function gtag(){window.dataLayer.push(arguments);};
window.gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
});
`}
</Script>
<Script
id="cookieyes"
strategy="lazyOnload"
strategy="afterInteractive"
src={COOKIEYES_SCRIPT_SRC}
nonce={nonce}
/>
<Script
id="google-analytics"
strategy="afterInteractive"
src={GOOGLE_TAG_SCRIPT_SRC}
nonce={nonce}
/>
<Script
id="google-analytics-init"
strategy="afterInteractive"
nonce={nonce}
>
{`
window.gtag('js', new Date());
window.gtag('config', '${GOOGLE_ANALYTICS_ID}');
`}
</Script>
</>
)
}
export default React.memo(GA)