mirror of
https://github.com/traefik/traefik.git
synced 2025-11-29 06:31:16 +01:00
Restore remote Upgrade to Hub button web component
This commit is contained in:
parent
77b1282570
commit
a01c73d506
@ -49,7 +49,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
"@testing-library/react": "^14.2.1",
|
"@testing-library/react": "^14.2.1",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@traefiklabs/faency": "11.1.4",
|
"@traefiklabs/faency": "12.0.4",
|
||||||
"@types/lodash": "^4.17.16",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/node": "^22.15.18",
|
"@types/node": "^22.15.18",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,6 +1,4 @@
|
|||||||
import { Box, Button, Flex, TextField } from '@traefiklabs/faency'
|
import { Box, Button, Flex, TextField, InputHandle } from '@traefiklabs/faency'
|
||||||
// eslint-disable-next-line import/no-unresolved
|
|
||||||
import { InputHandle } from '@traefiklabs/faency/dist/components/Input'
|
|
||||||
import { isUndefined, omitBy } from 'lodash'
|
import { isUndefined, omitBy } from 'lodash'
|
||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { FiSearch, FiXCircle } from 'react-icons/fi'
|
import { FiSearch, FiXCircle } from 'react-icons/fi'
|
||||||
|
|||||||
131
webui/src/hooks/use-hub-upgrade-button.spec.tsx
Normal file
131
webui/src/hooks/use-hub-upgrade-button.spec.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import useHubUpgradeButton from './use-hub-upgrade-button'
|
||||||
|
|
||||||
|
import { VersionContext } from 'contexts/version'
|
||||||
|
import verifySignature from 'utils/workers/scriptVerification'
|
||||||
|
|
||||||
|
vi.mock('utils/workers/scriptVerification')
|
||||||
|
|
||||||
|
const mockVerifySignature = vi.mocked(verifySignature)
|
||||||
|
|
||||||
|
const createWrapper = (showHubButton: boolean) => {
|
||||||
|
return ({ children }: { children: ReactNode }) => (
|
||||||
|
<VersionContext.Provider value={{ showHubButton, version: '1.0.0' }}>{children}</VersionContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useHubUpgradeButton Hook', () => {
|
||||||
|
let originalCreateObjectURL: typeof URL.createObjectURL
|
||||||
|
let originalRevokeObjectURL: typeof URL.revokeObjectURL
|
||||||
|
const mockBlobUrl = 'blob:http://localhost:3000/mock-blob-url'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalCreateObjectURL = URL.createObjectURL
|
||||||
|
originalRevokeObjectURL = URL.revokeObjectURL
|
||||||
|
URL.createObjectURL = vi.fn(() => mockBlobUrl)
|
||||||
|
URL.revokeObjectURL = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
URL.createObjectURL = originalCreateObjectURL
|
||||||
|
URL.revokeObjectURL = originalRevokeObjectURL
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not verify script when showHubButton is false', async () => {
|
||||||
|
renderHook(() => useHubUpgradeButton(), {
|
||||||
|
wrapper: createWrapper(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockVerifySignature).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should verify script and create blob URL when showHubButton is true and verification succeeds', async () => {
|
||||||
|
const mockScriptContent = new ArrayBuffer(8)
|
||||||
|
mockVerifySignature.mockResolvedValue({
|
||||||
|
verified: true,
|
||||||
|
scriptContent: mockScriptContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useHubUpgradeButton(), {
|
||||||
|
wrapper: createWrapper(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockVerifySignature).toHaveBeenCalledWith(
|
||||||
|
'https://traefik.github.io/traefiklabs-hub-button-app/main-v1.js',
|
||||||
|
'https://traefik.github.io/traefiklabs-hub-button-app/main-v1.js.sig',
|
||||||
|
'MCowBQYDK2VwAyEAY0OZFFE5kSuqYK6/UprTL5RmvQ+8dpPTGMCw1MiO/Gs=',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.signatureVerified).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.scriptBlobUrl).toBe(mockBlobUrl)
|
||||||
|
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set signatureVerified to false when verification fails', async () => {
|
||||||
|
mockVerifySignature.mockResolvedValue({
|
||||||
|
verified: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useHubUpgradeButton(), {
|
||||||
|
wrapper: createWrapper(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockVerifySignature).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.signatureVerified).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.scriptBlobUrl).toBeNull()
|
||||||
|
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle verification errors gracefully', async () => {
|
||||||
|
mockVerifySignature.mockRejectedValue(new Error('Verification failed'))
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useHubUpgradeButton(), {
|
||||||
|
wrapper: createWrapper(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockVerifySignature).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.signatureVerified).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.scriptBlobUrl).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create blob with correct MIME type', async () => {
|
||||||
|
const mockScriptContent = new ArrayBuffer(8)
|
||||||
|
mockVerifySignature.mockResolvedValue({
|
||||||
|
verified: true,
|
||||||
|
scriptContent: mockScriptContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useHubUpgradeButton(), {
|
||||||
|
wrapper: createWrapper(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(URL.createObjectURL).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
const blobCall = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob
|
||||||
|
expect(blobCall).toBeInstanceOf(Blob)
|
||||||
|
expect(blobCall.type).toBe('application/javascript')
|
||||||
|
})
|
||||||
|
})
|
||||||
61
webui/src/hooks/use-hub-upgrade-button.tsx
Normal file
61
webui/src/hooks/use-hub-upgrade-button.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { VersionContext } from 'contexts/version'
|
||||||
|
import verifySignature from 'utils/workers/scriptVerification'
|
||||||
|
|
||||||
|
const HUB_BUTTON_URL = 'https://traefik.github.io/traefiklabs-hub-button-app/main-v1.js'
|
||||||
|
const PUBLIC_KEY = 'MCowBQYDK2VwAyEAY0OZFFE5kSuqYK6/UprTL5RmvQ+8dpPTGMCw1MiO/Gs='
|
||||||
|
|
||||||
|
const useHubUpgradeButton = () => {
|
||||||
|
const [signatureVerified, setSignatureVerified] = useState(false)
|
||||||
|
const [scriptBlobUrl, setScriptBlobUrl] = useState<string | null>(null)
|
||||||
|
const [isCustomElementDefined, setIsCustomElementDefined] = useState(false)
|
||||||
|
|
||||||
|
const { showHubButton } = useContext(VersionContext)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showHubButton) {
|
||||||
|
if (customElements.get('hub-button-app')) {
|
||||||
|
setSignatureVerified(true)
|
||||||
|
setIsCustomElementDefined(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyAndLoadScript = async () => {
|
||||||
|
try {
|
||||||
|
const { verified, scriptContent: content } = await verifySignature(
|
||||||
|
HUB_BUTTON_URL,
|
||||||
|
`${HUB_BUTTON_URL}.sig`,
|
||||||
|
PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
if (!verified || !content) {
|
||||||
|
setSignatureVerified(false)
|
||||||
|
} else {
|
||||||
|
const blob = new Blob([content], { type: 'application/javascript' })
|
||||||
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
setScriptBlobUrl(blobUrl)
|
||||||
|
setSignatureVerified(true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSignatureVerified(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyAndLoadScript()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setScriptBlobUrl((currentUrl) => {
|
||||||
|
if (currentUrl) {
|
||||||
|
URL.revokeObjectURL(currentUrl)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showHubButton])
|
||||||
|
|
||||||
|
return { signatureVerified, scriptBlobUrl, isCustomElementDefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useHubUpgradeButton
|
||||||
@ -1,8 +1,27 @@
|
|||||||
|
import { waitFor } from '@testing-library/react'
|
||||||
|
|
||||||
import { SideNav, TopNav } from './Navigation'
|
import { SideNav, TopNav } from './Navigation'
|
||||||
|
|
||||||
|
import useHubUpgradeButton from 'hooks/use-hub-upgrade-button'
|
||||||
import { renderWithProviders } from 'utils/test'
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
|
vi.mock('hooks/use-hub-upgrade-button')
|
||||||
|
|
||||||
|
const mockUseHubUpgradeButton = vi.mocked(useHubUpgradeButton)
|
||||||
|
|
||||||
describe('Navigation', () => {
|
describe('Navigation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseHubUpgradeButton.mockReturnValue({
|
||||||
|
signatureVerified: false,
|
||||||
|
scriptBlobUrl: null,
|
||||||
|
isCustomElementDefined: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
it('should render the side navigation bar', async () => {
|
it('should render the side navigation bar', async () => {
|
||||||
const { container } = renderWithProviders(<SideNav isExpanded={false} onSidePanelToggle={() => {}} />)
|
const { container } = renderWithProviders(<SideNav isExpanded={false} onSidePanelToggle={() => {}} />)
|
||||||
|
|
||||||
@ -18,4 +37,60 @@ describe('Navigation', () => {
|
|||||||
expect(container.innerHTML).toContain('theme-switcher')
|
expect(container.innerHTML).toContain('theme-switcher')
|
||||||
expect(container.innerHTML).toContain('help-menu')
|
expect(container.innerHTML).toContain('help-menu')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('hub-button-app rendering', () => {
|
||||||
|
it('should NOT render hub-button-app when signatureVerified is false', async () => {
|
||||||
|
mockUseHubUpgradeButton.mockReturnValue({
|
||||||
|
signatureVerified: false,
|
||||||
|
scriptBlobUrl: null,
|
||||||
|
isCustomElementDefined: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = renderWithProviders(<TopNav />)
|
||||||
|
|
||||||
|
const hubButtonApp = container.querySelector('hub-button-app')
|
||||||
|
expect(hubButtonApp).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should NOT render hub-button-app when scriptBlobUrl is null', async () => {
|
||||||
|
mockUseHubUpgradeButton.mockReturnValue({
|
||||||
|
signatureVerified: true,
|
||||||
|
scriptBlobUrl: null,
|
||||||
|
isCustomElementDefined: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = renderWithProviders(<TopNav />)
|
||||||
|
|
||||||
|
const hubButtonApp = container.querySelector('hub-button-app')
|
||||||
|
expect(hubButtonApp).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render hub-button-app when signatureVerified is true and scriptBlobUrl exists', async () => {
|
||||||
|
mockUseHubUpgradeButton.mockReturnValue({
|
||||||
|
signatureVerified: true,
|
||||||
|
scriptBlobUrl: 'blob:http://localhost:3000/mock-blob-url',
|
||||||
|
isCustomElementDefined: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = renderWithProviders(<TopNav />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const hubButtonApp = container.querySelector('hub-button-app')
|
||||||
|
expect(hubButtonApp).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should NOT render hub-button-app when noHubButton prop is true', async () => {
|
||||||
|
mockUseHubUpgradeButton.mockReturnValue({
|
||||||
|
signatureVerified: true,
|
||||||
|
scriptBlobUrl: 'blob:http://localhost:3000/mock-blob-url',
|
||||||
|
isCustomElementDefined: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = renderWithProviders(<TopNav noHubButton={true} />)
|
||||||
|
|
||||||
|
const hubButtonApp = container.querySelector('hub-button-app')
|
||||||
|
expect(hubButtonApp).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
VisuallyHidden,
|
VisuallyHidden,
|
||||||
} from '@traefiklabs/faency'
|
} from '@traefiklabs/faency'
|
||||||
import { useContext, useEffect, useMemo, useState } from 'react'
|
import { useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { BsChevronDoubleRight, BsChevronDoubleLeft } from 'react-icons/bs'
|
import { BsChevronDoubleRight, BsChevronDoubleLeft } from 'react-icons/bs'
|
||||||
import { FiBookOpen, FiGithub, FiHelpCircle } from 'react-icons/fi'
|
import { FiBookOpen, FiGithub, FiHelpCircle } from 'react-icons/fi'
|
||||||
import { matchPath, useHref } from 'react-router'
|
import { matchPath, useHref } from 'react-router'
|
||||||
@ -36,6 +37,7 @@ import { PluginsIcon } from 'components/icons/PluginsIcon'
|
|||||||
import ThemeSwitcher from 'components/ThemeSwitcher'
|
import ThemeSwitcher from 'components/ThemeSwitcher'
|
||||||
import TooltipText from 'components/TooltipText'
|
import TooltipText from 'components/TooltipText'
|
||||||
import { VersionContext } from 'contexts/version'
|
import { VersionContext } from 'contexts/version'
|
||||||
|
import useHubUpgradeButton from 'hooks/use-hub-upgrade-button'
|
||||||
import useTotals from 'hooks/use-overview-totals'
|
import useTotals from 'hooks/use-overview-totals'
|
||||||
import { useIsDarkMode } from 'hooks/use-theme'
|
import { useIsDarkMode } from 'hooks/use-theme'
|
||||||
import ApimDemoNavMenu from 'pages/hub-demo/HubDemoNav'
|
import ApimDemoNavMenu from 'pages/hub-demo/HubDemoNav'
|
||||||
@ -293,8 +295,7 @@ export const SideNav = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
|
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
|
||||||
const [hasHubButtonComponent, setHasHubButtonComponent] = useState(false)
|
const { version } = useContext(VersionContext)
|
||||||
const { showHubButton, version } = useContext(VersionContext)
|
|
||||||
const isDarkMode = useIsDarkMode()
|
const isDarkMode = useIsDarkMode()
|
||||||
|
|
||||||
const parsedVersion = useMemo(() => {
|
const parsedVersion = useMemo(() => {
|
||||||
@ -308,91 +309,73 @@ export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?:
|
|||||||
return matches ? 'v' + matches[1] : 'master'
|
return matches ? 'v' + matches[1] : 'master'
|
||||||
}, [version])
|
}, [version])
|
||||||
|
|
||||||
useEffect(() => {
|
const { signatureVerified, scriptBlobUrl, isCustomElementDefined } = useHubUpgradeButton()
|
||||||
if (!showHubButton) {
|
|
||||||
setHasHubButtonComponent(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customElements.get('hub-button-app')) {
|
const displayUpgradeToHubButton = useMemo(
|
||||||
setHasHubButtonComponent(true)
|
() => !noHubButton && signatureVerified && (!!scriptBlobUrl || isCustomElementDefined),
|
||||||
return
|
[isCustomElementDefined, noHubButton, scriptBlobUrl, signatureVerified],
|
||||||
}
|
)
|
||||||
|
|
||||||
const scripts: HTMLScriptElement[] = []
|
|
||||||
const createScript = (scriptSrc: string): HTMLScriptElement => {
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = scriptSrc
|
|
||||||
script.async = true
|
|
||||||
script.onload = () => {
|
|
||||||
setHasHubButtonComponent(customElements.get('hub-button-app') !== undefined)
|
|
||||||
}
|
|
||||||
scripts.push(script)
|
|
||||||
return script
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source: https://github.com/traefik/traefiklabs-hub-button-app
|
|
||||||
document.head.appendChild(createScript('traefiklabs-hub-button-app/main-v1.js'))
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Remove the scripts on unmount.
|
|
||||||
scripts.forEach((script) => {
|
|
||||||
if (script.parentNode) {
|
|
||||||
script.parentNode.removeChild(script)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [showHubButton])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex as="nav" role="navigation" justify="end" align="center" css={{ gap: '$2', mb: '$6', ...css }}>
|
<>
|
||||||
{!noHubButton && hasHubButtonComponent && (
|
{displayUpgradeToHubButton && (
|
||||||
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
|
<Helmet>
|
||||||
<hub-button-app
|
<meta
|
||||||
key={`dark-mode-${isDarkMode}`}
|
httpEquiv="Content-Security-Policy"
|
||||||
style={{ backgroundColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR, fontWeight: 'inherit' }}
|
content="script-src 'self' blob: 'unsafe-inline'; object-src 'none'; base-uri 'self';"
|
||||||
/>
|
/>
|
||||||
</Box>
|
<script src={scriptBlobUrl as string} type="module"></script>
|
||||||
|
</Helmet>
|
||||||
)}
|
)}
|
||||||
<ThemeSwitcher />
|
<Flex as="nav" role="navigation" justify="end" align="center" css={{ gap: '$2', mb: '$6', ...css }}>
|
||||||
|
{displayUpgradeToHubButton && (
|
||||||
|
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
|
||||||
|
<hub-button-app
|
||||||
|
key={`dark-mode-${isDarkMode}`}
|
||||||
|
style={{ backgroundColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR, fontWeight: 'inherit' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<ThemeSwitcher />
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button ghost variant="secondary" css={{ px: '$2', boxShadow: 'none' }} data-testid="help-menu">
|
<Button ghost variant="secondary" css={{ px: '$2', boxShadow: 'none' }} data-testid="help-menu">
|
||||||
<FiHelpCircle size={20} />
|
<FiHelpCircle size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent align="end" css={{ zIndex: 9999 }}>
|
<DropdownMenuContent align="end" css={{ zIndex: 9999 }}>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
||||||
<Link
|
<Link
|
||||||
href={`https://doc.traefik.io/traefik/${parsedVersion}`}
|
href={`https://doc.traefik.io/traefik/${parsedVersion}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
||||||
>
|
>
|
||||||
<Flex align="center" gap={2}>
|
<Flex align="center" gap={2}>
|
||||||
<FiBookOpen size={20} />
|
<FiBookOpen size={20} />
|
||||||
<Text>Documentation</Text>
|
<Text>Documentation</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/traefik/traefik/"
|
href="https://github.com/traefik/traefik/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
||||||
>
|
>
|
||||||
<Flex align="center" gap={2}>
|
<Flex align="center" gap={2}>
|
||||||
<FiGithub size={20} />
|
<FiGithub size={20} />
|
||||||
<Text>Github Repository</Text>
|
<Text>Github Repository</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { waitFor } from '@testing-library/react'
|
import { waitFor } from '@testing-library/react'
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
import { PUBLIC_KEY } from './constants'
|
||||||
import HubDashboard, { resetCache } from './HubDashboard'
|
import HubDashboard, { resetCache } from './HubDashboard'
|
||||||
import verifySignature from './workers/scriptVerification'
|
|
||||||
|
|
||||||
import { renderWithProviders } from 'utils/test'
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
import verifySignature from 'utils/workers/scriptVerification'
|
||||||
|
|
||||||
vi.mock('./workers/scriptVerification', () => ({
|
vi.mock('utils/workers/scriptVerification', () => ({
|
||||||
default: vi.fn(),
|
default: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -34,7 +35,6 @@ describe('HubDashboard demo', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
// Mock URL.createObjectURL
|
|
||||||
mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
|
mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
|
||||||
globalThis.URL.createObjectURL = mockCreateObjectURL
|
globalThis.URL.createObjectURL = mockCreateObjectURL
|
||||||
})
|
})
|
||||||
@ -45,7 +45,6 @@ describe('HubDashboard demo', () => {
|
|||||||
|
|
||||||
describe('without cache', () => {
|
describe('without cache', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset cache before each test suites
|
|
||||||
resetCache()
|
resetCache()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -130,6 +129,7 @@ describe('HubDashboard demo', () => {
|
|||||||
expect(mockVerifyScriptSignature).toHaveBeenCalledWith(
|
expect(mockVerifyScriptSignature).toHaveBeenCalledWith(
|
||||||
'https://assets.traefik.io/hub-ui-demo.js',
|
'https://assets.traefik.io/hub-ui-demo.js',
|
||||||
'https://assets.traefik.io/hub-ui-demo.js.sig',
|
'https://assets.traefik.io/hub-ui-demo.js.sig',
|
||||||
|
PUBLIC_KEY,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import { useMemo, useEffect, useState } from 'react'
|
|||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import verifySignature from './workers/scriptVerification'
|
import verifySignature from '../../utils/workers/scriptVerification'
|
||||||
|
|
||||||
|
import { PUBLIC_KEY } from './constants'
|
||||||
|
|
||||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
import { useIsDarkMode } from 'hooks/use-theme'
|
import { useIsDarkMode } from 'hooks/use-theme'
|
||||||
@ -42,7 +44,7 @@ const HubDashboard = ({ path }: { path: string }) => {
|
|||||||
setVerificationInProgress(true)
|
setVerificationInProgress(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { verified, scriptContent: content } = await verifySignature(SCRIPT_URL, `${SCRIPT_URL}.sig`)
|
const { verified, scriptContent: content } = await verifySignature(SCRIPT_URL, `${SCRIPT_URL}.sig`, PUBLIC_KEY)
|
||||||
|
|
||||||
if (!verified || !content) {
|
if (!verified || !content) {
|
||||||
setScriptError(true)
|
setScriptError(true)
|
||||||
|
|||||||
1
webui/src/pages/hub-demo/constants.ts
Normal file
1
webui/src/pages/hub-demo/constants.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const PUBLIC_KEY = 'MCowBQYDK2VwAyEAWMBZ0pMBaL/s8gNXxpAPCIQ8bxjnuz6bQFwGYvjXDfg='
|
||||||
@ -3,9 +3,10 @@ import { ReactNode } from 'react'
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
import { useHubDemo } from './use-hub-demo'
|
import { useHubDemo } from './use-hub-demo'
|
||||||
import verifySignature from './workers/scriptVerification'
|
|
||||||
|
|
||||||
vi.mock('./workers/scriptVerification', () => ({
|
import verifySignature from 'utils/workers/scriptVerification'
|
||||||
|
|
||||||
|
vi.mock('utils/workers/scriptVerification', () => ({
|
||||||
default: vi.fn(),
|
default: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { ReactNode, useEffect, useMemo, useState } from 'react'
|
import { ReactNode, useEffect, useMemo, useState } from 'react'
|
||||||
import { RouteObject } from 'react-router-dom'
|
import { RouteObject } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { PUBLIC_KEY } from './constants'
|
||||||
|
|
||||||
import HubDashboard from 'pages/hub-demo/HubDashboard'
|
import HubDashboard from 'pages/hub-demo/HubDashboard'
|
||||||
import { ApiIcon, DashboardIcon, GatewayIcon, PortalIcon } from 'pages/hub-demo/icons'
|
import { ApiIcon, DashboardIcon, GatewayIcon, PortalIcon } from 'pages/hub-demo/icons'
|
||||||
import verifySignature from 'pages/hub-demo/workers/scriptVerification'
|
import verifySignature from 'utils/workers/scriptVerification'
|
||||||
|
|
||||||
const ROUTES_MANIFEST_URL = 'https://traefik.github.io/hub-ui-demo-app/config/routes.json'
|
const ROUTES_MANIFEST_URL = 'https://traefik.github.io/hub-ui-demo-app/config/routes.json'
|
||||||
|
|
||||||
@ -20,7 +22,11 @@ const useHubDemoRoutesManifest = (): HubDemo.Manifest | null => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchManifest = async () => {
|
const fetchManifest = async () => {
|
||||||
try {
|
try {
|
||||||
const { verified, scriptContent } = await verifySignature(ROUTES_MANIFEST_URL, `${ROUTES_MANIFEST_URL}.sig`)
|
const { verified, scriptContent } = await verifySignature(
|
||||||
|
ROUTES_MANIFEST_URL,
|
||||||
|
`${ROUTES_MANIFEST_URL}.sig`,
|
||||||
|
PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
if (!verified || !scriptContent) {
|
if (!verified || !scriptContent) {
|
||||||
setManifest(null)
|
setManifest(null)
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|||||||
|
|
||||||
import verifySignature from './scriptVerification'
|
import verifySignature from './scriptVerification'
|
||||||
|
|
||||||
|
const SCRIPT_PATH = 'https://example.com/script.js'
|
||||||
|
const MOCK_PUBLIC_KEY = 'MCowBQYDK2VwAyEAWH71OHphISjNK3mizCR/BawiDxc6IXT1vFHpBcxSIA0='
|
||||||
class MockWorker {
|
class MockWorker {
|
||||||
onmessage: ((event: MessageEvent) => void) | null = null
|
onmessage: ((event: MessageEvent) => void) | null = null
|
||||||
onerror: ((error: ErrorEvent) => void) | null = null
|
onerror: ((error: ErrorEvent) => void) | null = null
|
||||||
@ -46,17 +48,14 @@ describe('verifySignature', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return true when verification succeeds', async () => {
|
it('should return true when verification succeeds', async () => {
|
||||||
const scriptPath = 'https://example.com/script.js'
|
const promise = verifySignature(SCRIPT_PATH, `${SCRIPT_PATH}.sig`, MOCK_PUBLIC_KEY)
|
||||||
const signaturePath = 'https://example.com/script.js.sig'
|
|
||||||
|
|
||||||
const promise = verifySignature(scriptPath, signaturePath)
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
expect(mockWorkerInstance.postMessage).toHaveBeenCalledWith(
|
expect(mockWorkerInstance.postMessage).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
scriptUrl: scriptPath,
|
scriptUrl: SCRIPT_PATH,
|
||||||
signatureUrl: signaturePath,
|
signatureUrl: `${SCRIPT_PATH}.sig`,
|
||||||
requestId: expect.any(String),
|
requestId: expect.any(String),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ -76,12 +75,9 @@ describe('verifySignature', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return false when verification fails', async () => {
|
it('should return false when verification fails', async () => {
|
||||||
const scriptPath = 'https://example.com/script.js'
|
|
||||||
const signaturePath = 'https://example.com/script.js.sig'
|
|
||||||
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
const promise = verifySignature(scriptPath, signaturePath)
|
const promise = verifySignature(SCRIPT_PATH, `${SCRIPT_PATH}.sig`, MOCK_PUBLIC_KEY)
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
@ -101,16 +97,12 @@ describe('verifySignature', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return false when worker throws an error', async () => {
|
it('should return false when worker throws an error', async () => {
|
||||||
const scriptPath = 'https://example.com/script.js'
|
|
||||||
const signaturePath = 'https://example.com/script.js.sig'
|
|
||||||
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
const promise = verifySignature(scriptPath, signaturePath)
|
const promise = verifySignature(SCRIPT_PATH, `${SCRIPT_PATH}.sig`, MOCK_PUBLIC_KEY)
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
// Simulate worker onerror event
|
|
||||||
const error = new Error('Worker crashed')
|
const error = new Error('Worker crashed')
|
||||||
mockWorkerInstance.simulateError(error)
|
mockWorkerInstance.simulateError(error)
|
||||||
|
|
||||||
@ -3,12 +3,10 @@ export interface VerificationResult {
|
|||||||
scriptContent?: ArrayBuffer
|
scriptContent?: ArrayBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
const PUBLIC_KEY = 'MCowBQYDK2VwAyEAWMBZ0pMBaL/s8gNXxpAPCIQ8bxjnuz6bQFwGYvjXDfg='
|
|
||||||
|
|
||||||
async function verifySignature(
|
async function verifySignature(
|
||||||
contentPath: string,
|
contentPath: string,
|
||||||
signaturePath: string,
|
signaturePath: string,
|
||||||
publicKey: string = PUBLIC_KEY,
|
publicKey: string,
|
||||||
): Promise<VerificationResult> {
|
): Promise<VerificationResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const requestId = Math.random().toString(36).substring(2)
|
const requestId = Math.random().toString(36).substring(2)
|
||||||
1445
webui/yarn.lock
1445
webui/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user