feat(14.1-04): cloud row click navigates to cloud-file-detail route; no auto preview/download

- onFileOpen in CloudFolderView replaced: row click now pushes router.push({
  name: 'cloud-file-detail', params: {connectionId, itemId: file.provider_item_id} })
- Auto-download-on-unsupported_preview branch removed from row open path (D-14)
- No window.open / no provider URL navigation (preserves Phase 13 D-02 / T-13-07)
- Folder navigation (folder-navigate) is unchanged
- CloudFolderOpenPreview.test.js updated to assert new navigate-to-detail
  behavior instead of old openCloudFile/downloadCloudFile assertions (D-14):
  - All 3 describe blocks updated to assert mockPush to cloud-file-detail
  - Assertions for no provider URLs / no anchor downloads preserved
- All 489 frontend tests pass; 88 backend tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-26 22:25:31 +02:00
co-authored by Claude Sonnet 4.6
parent 935accc91f
commit 6152a52bca
2 changed files with 117 additions and 102 deletions
+21 -26
View File
@@ -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: '<docuvault-relative-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 ───────────────────────────────────────────────
@@ -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()
})
})