Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'

import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'

import { useTemplateWorkflows } from './useTemplateWorkflows'

Expand All @@ -13,9 +14,10 @@ import { useTemplateWorkflows } from './useTemplateWorkflows'
* Supports URLs like:
* - /?template=flux_simple (loads with default source)
* - /?template=flux_simple&source=custom (loads from custom source)
* - /?template=flux_simple&mode=linear (loads template in linear mode)
*
* Input validation:
* - Template and source parameters must match: ^[a-zA-Z0-9_-]+$
* - Template, source, and mode parameters must match: ^[a-zA-Z0-9_-]+$
* - Invalid formats are rejected with console warnings
*/
export function useTemplateUrlLoader() {
Expand All @@ -24,7 +26,10 @@ export function useTemplateUrlLoader() {
const { t } = useI18n()
const toast = useToast()
const templateWorkflows = useTemplateWorkflows()
const canvasStore = useCanvasStore()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const SUPPORTED_MODES = ['linear'] as const
type SupportedMode = (typeof SUPPORTED_MODES)[number]

/**
* Validates parameter format to prevent path traversal and injection attacks
Expand All @@ -34,12 +39,20 @@ export function useTemplateUrlLoader() {
}

/**
* Removes template and source parameters from URL
* Type guard to check if a value is a supported mode
*/
const isSupportedMode = (mode: string): mode is SupportedMode => {
return SUPPORTED_MODES.includes(mode as SupportedMode)
}

/**
* Removes template, source, and mode parameters from URL
*/
const cleanupUrlParams = () => {
const newQuery = { ...route.query }
delete newQuery.template
delete newQuery.source
delete newQuery.mode
void router.replace({ query: newQuery })
}

Expand Down Expand Up @@ -70,6 +83,24 @@ export function useTemplateUrlLoader() {
return
}

const modeParam = route.query.mode as string | undefined

if (
modeParam &&
(typeof modeParam !== 'string' || !isValidParameter(modeParam))
) {
console.warn(
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
)
return
}

if (modeParam && !isSupportedMode(modeParam)) {
console.warn(
`[useTemplateUrlLoader] Unsupported mode parameter: ${modeParam}. Supported modes: ${SUPPORTED_MODES.join(', ')}`
)
}

try {
await templateWorkflows.loadTemplates()

Expand All @@ -87,6 +118,9 @@ export function useTemplateUrlLoader() {
}),
life: 3000
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
canvasStore.linearMode = true
}
} catch (error) {
console.error(
Expand Down
2 changes: 1 addition & 1 deletion src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const router = createRouter({
installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
keys: ['template', 'source']
keys: ['template', 'source', 'mode']
}
])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/
* Tests the behavior of loading templates via URL query parameters:
* - ?template=flux_simple loads the template
* - ?template=flux_simple&source=custom loads from custom source
* - ?template=flux_simple&mode=linear loads template in linear mode
* - Invalid template shows error toast
* - Input validation for template and source parameters
* - Input validation for template, source, and mode parameters
*/

const preservedQueryMocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -70,10 +71,20 @@ vi.mock('vue-i18n', () => ({
})
}))

// Mock canvas store
const mockCanvasStore = {
linearMode: false
}

vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))

describe('useTemplateUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryParams = {}
mockCanvasStore.linearMode = false
})

it('does not load template when no query param present', () => {
Expand Down Expand Up @@ -236,6 +247,7 @@ describe('useTemplateUrlLoader', () => {
mockQueryParams = {
template: 'flux_simple',
source: 'custom',
mode: 'linear',
other: 'param'
}

Expand Down Expand Up @@ -270,4 +282,121 @@ describe('useTemplateUrlLoader', () => {
query: { other: 'param' }
})
})

it('sets linear mode when mode=linear and template loads successfully', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'linear' }

const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()

expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(true)
})

it('does not set linear mode when template loading fails', async () => {
mockQueryParams = { template: 'invalid-template', mode: 'linear' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)

const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()

expect(mockCanvasStore.linearMode).toBe(false)
})

it('does not set linear mode when mode parameter is not linear', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'graph' }

const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()

expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
})

it('rejects invalid mode parameter with special characters', () => {
mockQueryParams = { template: 'flux_simple', mode: '../malicious' }

const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()

expect(mockLoadTemplates).not.toHaveBeenCalled()
})

it('handles array mode params correctly', () => {
// Vue Router can return string[] for duplicate params
mockQueryParams = {
template: 'flux_simple',
mode: ['linear', 'graph'] as any
}

const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()

// Should not load when mode param is an array
expect(mockLoadTemplates).not.toHaveBeenCalled()
})

it('warns about unsupported mode values but continues loading', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockQueryParams = { template: 'flux_simple', mode: 'unsupported' }

const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()

expect(consoleSpy).toHaveBeenCalledWith(
'[useTemplateUrlLoader] Unsupported mode parameter: unsupported. Supported modes: linear'
)
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)

consoleSpy.mockRestore()
})

it('accepts supported mode parameter: linear', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'linear' }

const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()

expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(true)
})

it('accepts valid format but warns about unsupported modes', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const unsupportedModes = ['graph', 'mode123', 'my_mode-2']

for (const mode of unsupportedModes) {
vi.clearAllMocks()
consoleSpy.mockClear()
mockCanvasStore.linearMode = false
mockQueryParams = { template: 'flux_simple', mode }

const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()

expect(consoleSpy).toHaveBeenCalledWith(
`[useTemplateUrlLoader] Unsupported mode parameter: ${mode}. Supported modes: linear`
)
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
}

consoleSpy.mockRestore()
})
})
Loading