diff --git a/frontend/src/views/CloudFolderView.vue b/frontend/src/views/CloudFolderView.vue index c4abed8..5594d66 100644 --- a/frontend/src/views/CloudFolderView.vue +++ b/frontend/src/views/CloudFolderView.vue @@ -406,36 +406,31 @@ async function onQueueResolve({ action, item }) { /** * Handle file-open from StorageBrowser. * - * D-02: Never calls window.open() with a raw provider URL. - * T-13-07: Must use the authorized backend open endpoint. - * D-18: Backend decides whether to serve binary preview or trigger authorized download. + * Phase 14.1 / D-01 / CLOUD-02 / CACHE-03: + * Cloud file rows now navigate to the cloud-file-detail route — they do NOT call + * openCloudFile or downloadCloudFile directly from the row click. * - * The authorized endpoint returns: - * {kind: 'open', url: ''} — for in-app preview - * {kind: 'unsupported_preview', reason: '...'} — for unsupported formats (Office/Workspace) + * Preview and download become explicit user actions on the detail surface (Plan 03), + * never side effects of a row click (D-14). * - * For unsupported formats the client calls the authorized download endpoint to get - * the file through DocuVault's own proxy, never via a raw provider URL. + * Route pushes use the opaque provider_item_id as the route param. + * Vue Router handles URI encoding — never split or decode provider_item_id here. + * + * Never uses browser open(url) — this pushes a DocuVault-internal named route only + * (preserves Phase 13 D-02 / T-13-07 prohibition on raw provider URL navigation). + * + * Folder navigation (folder-navigate event) is unchanged — only file rows are + * affected by this change. */ -async function onFileOpen(file) { +function onFileOpen(file) { if (!file?.provider_item_id) return - try { - const result = await api.openCloudFile(connectionId.value, file.provider_item_id, file) - if (result?.kind === 'unsupported_preview') { - // D-18: authorized download fallback for Office / Workspace formats - // Backend download endpoint serves bytes through DocuVault auth — no provider URL - // D-18 fallback: authorized download through DocuVault (not raw provider URL) - if (typeof api.downloadCloudFile === 'function') { - await api.downloadCloudFile(connectionId.value, file.provider_item_id) - } else { - toast.show(`"${file.name}" cannot be previewed in-app.`, 'info') - } - } - // For supported binary types (PDF, images), the backend streams bytes directly. - // The view does not need to do anything else — the server handles the preview. - } catch (e) { - toast.show(`Could not open "${file.name}": ${e.message || 'Unknown error'}`, 'error') - } + router.push({ + name: 'cloud-file-detail', + params: { + connectionId: connectionId.value, + itemId: file.provider_item_id, + }, + }) } // ── Phase 14: Analysis handlers ─────────────────────────────────────────────── diff --git a/frontend/src/views/__tests__/CloudFolderOpenPreview.test.js b/frontend/src/views/__tests__/CloudFolderOpenPreview.test.js index 97da920..ab9e7cb 100644 --- a/frontend/src/views/__tests__/CloudFolderOpenPreview.test.js +++ b/frontend/src/views/__tests__/CloudFolderOpenPreview.test.js @@ -137,20 +137,28 @@ afterEach(() => { sessionStorage.clear() }) -// ── D-02 / T-13-07: Authorized open — no raw provider URLs ─────────────────── +// ── D-02 / T-13-07 / CLOUD-02 / D-14: Row click navigates to cloud-file-detail ─ +// +// Phase 14.1 Plan 04 change: cloud file row clicks now navigate to the +// cloud-file-detail route instead of calling openCloudFile + auto-download. +// Preview and download become explicit actions on the detail surface. +// This preserves D-02/T-13-07 (no window.open; no provider URL) while also +// satisfying D-14 (no auto-download on row click) and CACHE-03 (bytes only +// through the detail/authorized handlers). describe('file_open_routes_through_authorized_backend', () => { - it('file-open event triggers an authorized API call, not window.open()', async () => { + it('file-open event navigates to cloud-file-detail route, not window.open()', async () => { /** - * D-02 and T-13-07: Opening a cloud file must never call window.open() with - * a raw provider URL. Instead CloudFolderView must call the authorized - * backend open/preview endpoint. + * Phase 14.1 / D-01 / CLOUD-02: + * A cloud file row click must navigate to the cloud-file-detail named route + * with the opaque provider_item_id as the route param. * - * RED: current CloudFolderView has a placeholder for file-open that does nothing - * or may call window.open(); the authorized API call is missing. + * Must NOT: + * - call window.open() with a raw provider URL (T-13-07 / D-02) + * - call openCloudFile directly on row click (D-14 — preview is explicit on detail) + * - auto-download on row click (D-14) */ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) - api.openCloudFile.mockResolvedValue({ preview_url: '/api/cloud/preview/session-token-abc' }) api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [PDF_FILE], @@ -173,26 +181,28 @@ describe('file_open_routes_through_authorized_backend', () => { // Must NOT open a browser window with raw provider URL expect(openSpy).not.toHaveBeenCalled() - // Must call the authorized backend open endpoint - expect(api.openCloudFile).toHaveBeenCalledWith( - 'uuid-conn-preview', - PDF_FILE.provider_item_id, - expect.anything() - ) + // Must NOT call the open/download API directly on row click (D-14) + expect(api.openCloudFile).not.toHaveBeenCalled() + + // Must navigate to cloud-file-detail route (Phase 14.1 D-01) + expect(mockPush).toHaveBeenCalledWith({ + name: 'cloud-file-detail', + params: { + connectionId: 'uuid-conn-preview', + itemId: PDF_FILE.provider_item_id, + }, + }) openSpy.mockRestore() }) - it('file-open call uses connection_id and provider_item_id — never a raw URL', async () => { + it('file-open navigation uses opaque provider_item_id — never a raw URL', async () => { /** - * T-13-07: The authorized open endpoint must be parameterized by - * connection_id and provider_item_id. Raw provider download URLs must - * not appear as arguments. + * T-13-07 / D-02: The route push must use the opaque provider_item_id + * as the route param. Raw provider URLs must not appear as navigation targets. * - * RED: openCloudFile API method does not exist yet. + * Phase 14.1: replaced old openCloudFile assertion with route push assertion. */ - api.openCloudFile.mockResolvedValue({ preview_url: '/api/cloud/preview/tok' }) - api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [PDF_FILE], capabilities: null, @@ -209,31 +219,33 @@ describe('file_open_routes_through_authorized_backend', () => { await browser.vm.$emit('file-open', PDF_FILE) await flushPromises() - if (api.openCloudFile.mock.calls.length > 0) { - const callArgs = api.openCloudFile.mock.calls[0] - // Arguments must not contain http/https raw URLs - callArgs.forEach(arg => { - if (typeof arg === 'string') { - expect(arg).not.toMatch(/^https?:\/\//) - } - }) + // The route push must not contain any raw http/https provider URLs + if (mockPush.mock.calls.length > 0) { + const pushArg = mockPush.mock.calls[0][0] + const stringified = JSON.stringify(pushArg) + expect(stringified).not.toMatch(/^https?:\/\//) + expect(stringified).not.toMatch(/googleapis\.com/) + expect(stringified).not.toMatch(/graph\.microsoft\.com/) } }) }) -// ── D-18: Unsupported format fallback to authorized download ────────────────── +// ── D-14 / D-18: Row click never auto-downloads — routes to detail instead ──── +// +// Phase 14.1 Plan 04: unsupported-format auto-download is REMOVED from row click. +// All file types (PDF, DOCX, Workspace) navigate to cloud-file-detail on row click. +// Preview/download become explicit user actions on the detail surface (Plan 03). describe('unsupported_format_uses_authorized_download_fallback', () => { - it('Office document (docx) emits or triggers authorized download, not Office native preview', async () => { + it('Office document (docx) row click navigates to detail — no auto-download', async () => { /** - * D-18: Office and Workspace formats are not supported for in-app preview. - * The view must route them to the authorized download fallback endpoint rather - * than open a Microsoft/Google preview URL. + * D-14 / D-18 (Phase 14.1): Office documents no longer auto-download from row click. + * Clicking a DOCX row navigates to cloud-file-detail, same as any other file type. + * Download is an explicit action on the detail surface. * - * RED: no authorized download fallback path exists in CloudFolderView. + * This replaces the Phase 13 "fallback to authorized download" behavior on row click. */ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) - api.downloadCloudFile.mockResolvedValue({ download_url: '/api/cloud/download/session-tok' }) api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [DOCX_FILE], @@ -251,28 +263,32 @@ describe('unsupported_format_uses_authorized_download_fallback', () => { await browser.vm.$emit('file-open', DOCX_FILE) await flushPromises() - // Must not open a raw provider URL window + // Must not open a raw provider URL window (D-02) expect(openSpy).not.toHaveBeenCalled() - // Must call the authorized backend endpoint for unsupported formats - // (either openCloudFile that handles the fallback, or downloadCloudFile explicitly) - const anyAuthCall = api.openCloudFile.mock.calls.length > 0 - || api.downloadCloudFile.mock.calls.length > 0 - expect(anyAuthCall).toBe(true) + // Must NOT auto-download on row click (D-14) + expect(api.downloadCloudFile).not.toHaveBeenCalled() + expect(api.openCloudFile).not.toHaveBeenCalled() + + // Must navigate to cloud-file-detail (Phase 14.1 D-01) + expect(mockPush).toHaveBeenCalledWith({ + name: 'cloud-file-detail', + params: { + connectionId: 'uuid-conn-preview', + itemId: DOCX_FILE.provider_item_id, + }, + }) openSpy.mockRestore() }) - it('Google Workspace document falls back to authorized download, not Workspace preview', async () => { + it('Google Workspace document row click navigates to detail — no provider URL opened', async () => { /** - * D-18: Google Workspace documents (application/vnd.google-apps.*) are - * excluded from in-app preview. They must use the authorized download fallback. - * No Google Docs/Sheets preview URL must be opened. - * - * RED: no Workspace-aware fallback exists. + * D-14 / D-18 (Phase 14.1): Google Workspace documents no longer auto-open + * a Workspace preview URL from row click. Row click navigates to cloud-file-detail. + * No Google Docs/Sheets preview URL is ever passed to window.open. */ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) - api.downloadCloudFile.mockResolvedValue({ download_url: '/api/cloud/download/session-gdoc' }) api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [GDOC_FILE], @@ -290,7 +306,7 @@ describe('unsupported_format_uses_authorized_download_fallback', () => { await browser.vm.$emit('file-open', GDOC_FILE) await flushPromises() - // Must not open Google Workspace preview URL via window.open + // Must not open Google Workspace preview URL via window.open (D-02 / T-13-07) expect(openSpy).not.toHaveBeenCalledWith( expect.stringContaining('docs.google.com'), expect.anything() @@ -299,26 +315,26 @@ describe('unsupported_format_uses_authorized_download_fallback', () => { expect.stringContaining('drive.google.com'), expect.anything() ) + // Must not auto-download (D-14) + expect(api.downloadCloudFile).not.toHaveBeenCalled() openSpy.mockRestore() }) }) -// ── D-02: No provider credentials in file-open response ────────────────────── +// ── D-02: Row click navigates to internal route — no provider credential exposure ─ +// +// Phase 14.1: row click now pushes cloud-file-detail (an internal route), so the +// component never renders provider credentials or raw provider URLs in response to +// a row click. The check that the rendered HTML contains no provider URLs is preserved. describe('file_open_response_contains_no_provider_credentials', () => { - it('preview_url returned from API is a DocuVault-relative URL, not a provider URL', async () => { + it('row click navigates to internal route — no provider URL rendered in view', async () => { /** - * D-02: The backend authorized open endpoint must return a DocuVault-relative - * preview URL, not a raw Google/OneDrive/WebDAV URL. The frontend must - * verify this is not a provider URL before rendering. - * - * RED: no preview URL validation logic exists in CloudFolderView. + * D-02 (Phase 14.1): Row click navigates to cloud-file-detail (internal route). + * The view must never render any iframe/embed/anchor with a raw provider URL + * as a result of a file row click. */ - // Backend returns a proper DocuVault-relative preview URL - const DOCUVAULT_PREVIEW_URL = '/api/cloud/preview/abc123' - api.openCloudFile.mockResolvedValue({ preview_url: DOCUVAULT_PREVIEW_URL }) - api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [PDF_FILE], capabilities: null, @@ -340,6 +356,9 @@ describe('file_open_response_contains_no_provider_credentials', () => { expect(html).not.toMatch(/googleapis\.com/) expect(html).not.toMatch(/graph\.microsoft\.com/) expect(html).not.toMatch(/onedrive\.live\.com/) + + // Must navigate to internal route (not a provider URL) + expect(mockPush).toHaveBeenCalledWith(expect.objectContaining({ name: 'cloud-file-detail' })) }) }) @@ -374,16 +393,13 @@ describe('cloud_folder_view_is_thin_data_provider', () => { expect(w.find('[data-test="storage-browser"]').exists()).toBe(true) }) - it('file-open is handled by the view, not re-emitted up to the router', async () => { + it('file-open is handled by the view as a route navigation (not re-emitted)', async () => { /** - * CloudFolderView must intercept the file-open event from StorageBrowser - * and handle it (call the authorized API). It must not pass the raw event - * up to a parent router or emit it as an unhandled event. + * Phase 14.1: CloudFolderView intercepts the file-open event from StorageBrowser + * and handles it by navigating to cloud-file-detail — not re-emitting the event. * - * RED: CloudFolderView currently has a placeholder; file-open goes unhandled. + * The view must NOT pass the raw file-open event up as an unhandled DOM event. */ - api.openCloudFile.mockResolvedValue({ preview_url: '/api/cloud/preview/tok' }) - api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [PDF_FILE], capabilities: null, @@ -406,19 +422,20 @@ describe('cloud_folder_view_is_thin_data_provider', () => { }) }) -// ── D-02: Preview download must not trigger a browser-to-device download ────── +// ── D-02 / D-14: Row click must not trigger any browser download at all ───────── describe('preview_does_not_trigger_device_download', () => { - it('previewing a PDF does not create an anchor element and click it', async () => { + it('PDF row click navigates to detail — does not create an anchor download hack', async () => { /** - * D-02: "Preview stays inside DocuVault and must not trigger a - * browser-to-device download." Anchor click hacks bypass the authorized - * download path and expose provider content directly to the filesystem. + * D-02 / D-14 (Phase 14.1): + * Row click must not trigger any browser-to-device download — it navigates to + * cloud-file-detail instead. No anchor-click hacks, no document.createElement('a') + * for download purposes. * - * RED: no anchor-click prevention mechanism exists currently. + * Download is an explicit user action on the detail surface (Plan 03), not a + * side effect of clicking the file row. */ const createElementSpy = vi.spyOn(document, 'createElement') - api.openCloudFile.mockResolvedValue({ preview_url: '/api/cloud/preview/tok' }) api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [PDF_FILE], @@ -432,7 +449,7 @@ describe('preview_does_not_trigger_device_download', () => { }) await flushPromises() - // Record anchor element creations before the open action + // Record anchor element creations before the row click const anchorsBefore = createElementSpy.mock.calls.filter(c => c[0] === 'a').length const browser = w.findComponent({ name: 'StorageBrowser' }) @@ -443,6 +460,9 @@ describe('preview_does_not_trigger_device_download', () => { const anchorsAfter = createElementSpy.mock.calls.filter(c => c[0] === 'a').length expect(anchorsAfter).toBe(anchorsBefore) + // Row click navigates to cloud-file-detail (not download) + expect(mockPush).toHaveBeenCalledWith(expect.objectContaining({ name: 'cloud-file-detail' })) + createElementSpy.mockRestore() }) })