diff --git a/frontend/src/utils/serverRoute.ts b/frontend/src/utils/serverRoute.ts index b55ea1986..2b1a9799a 100644 --- a/frontend/src/utils/serverRoute.ts +++ b/frontend/src/utils/serverRoute.ts @@ -10,6 +10,18 @@ export function serverDetailPath(name: string, tab?: string): string { return tab ? `${base}?tab=${encodeURIComponent(tab)}` : base } +// MCP-2125 (#643 Defect B): scan ids embed the raw upstream server name, so an +// official-registry server whose name contains '/' (e.g. +// "com.pulsemcp/google-flights") yields a scan id like +// "scan-com.pulsemcp/google-flights-1781284446323229000". The scan-report route +// is a single `:jobId` segment, so the id MUST be percent-encoded — otherwise +// the '/' splits the path and the link falls through to the catch-all 404. +// vue-router decodes the param back to the original id on read (same class as +// serverDetailPath above / MCP-1112). +export function scanReportPath(jobId: string): string { + return `/security/scans/${encodeURIComponent(jobId)}` +} + // serverDisplayName prefers the registry-provided human-friendly `title` over // the raw reverse-DNS `name` identifier (e.g. "io.github.owner/repo"). The // `name` remains the stable key used for API calls and routing; only the diff --git a/frontend/src/views/Security.vue b/frontend/src/views/Security.vue index 7d100ab81..ef99b734c 100644 --- a/frontend/src/views/Security.vue +++ b/frontend/src/views/Security.vue @@ -339,7 +339,7 @@ Details → @@ -448,6 +448,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import api from '@/services/api' import { refreshSecurityScannerStatus } from '@/composables/useSecurityScannerStatus' import { useSystemStore } from '@/stores/system' +import { scanReportPath } from '@/utils/serverRoute' const systemStore = useSystemStore() diff --git a/frontend/src/views/ServerDetail.vue b/frontend/src/views/ServerDetail.vue index f95e1d241..0f67e46e2 100644 --- a/frontend/src/views/ServerDetail.vue +++ b/frontend/src/views/ServerDetail.vue @@ -1148,7 +1148,7 @@
- + View Full Report →
@@ -1223,7 +1223,7 @@ import type { Hint } from '@/components/CollapsibleHintsPanel.vue' import type { Server, Tool, ToolApproval, SecurityScanReport } from '@/types' import api from '@/services/api' import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus' -import { serverDisplayName } from '@/utils/serverRoute' +import { serverDisplayName, scanReportPath } from '@/utils/serverRoute' import { selectQuarantinedTools } from '@/utils/toolQuarantine' import { oauthSignInState } from '@/utils/health' import { computeToolDiffSections } from '@/utils/toolDiff' diff --git a/frontend/tests/unit/scan-report-route.spec.ts b/frontend/tests/unit/scan-report-route.spec.ts new file mode 100644 index 000000000..93038a953 --- /dev/null +++ b/frontend/tests/unit/scan-report-route.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { createRouter, createWebHistory } from 'vue-router' +import { scanReportPath } from '@/utils/serverRoute' + +// MCP-2125 (Defect B of MCP-2123): scan ids embed the raw upstream server name, +// so official-registry servers whose names contain '/' (e.g. +// "com.pulsemcp/google-flights") produce a scan id like +// "scan-com.pulsemcp/google-flights-1781284446323229000". The scan-report route +// is a single `:jobId` segment, so an unencoded '/' splits the path and falls +// through to the catch-all 404. The id MUST be percent-encoded; vue-router v4 +// decodes the param back on read (same class as MCP-1112 / serverDetailPath). + +const SLASH_SCAN_ID = 'scan-com.pulsemcp/google-flights-1781284446323229000' + +describe('scanReportPath (MCP-2125)', () => { + it('percent-encodes a "/"-containing scan id into a single path segment', () => { + expect(scanReportPath(SLASH_SCAN_ID)).toBe( + '/security/scans/scan-com.pulsemcp%2Fgoogle-flights-1781284446323229000' + ) + }) + + it('leaves a plain scan id untouched (no "/" to encode)', () => { + expect(scanReportPath('scan-github-123')).toBe('/security/scans/scan-github-123') + }) +}) + +describe('scan-report route round-trip (MCP-2125)', () => { + it('decodes the encoded "/" back into the jobId param (no 404)', async () => { + const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/security/scans/:jobId', name: 'scan-report', component: { template: '
' } }, + { path: '/:pathMatch(.*)*', name: 'not-found', component: { template: '
404
' } }, + ], + }) + await router.push(scanReportPath(SLASH_SCAN_ID)) + await router.isReady() + // It must match scan-report (NOT the catch-all 404)... + expect(router.currentRoute.value.name).toBe('scan-report') + // ...and the param must be decoded back to the original scan id. + expect(router.currentRoute.value.params.jobId).toBe(SLASH_SCAN_ID) + }) +})